mirror of
https://github.com/invoiceninja/invoiceninja.git
synced 2024-11-11 05:32:39 +01:00
Merge pull request #5373 from beganovich/v5-0704-billing-portal
V5 0704 billing portal
This commit is contained in:
commit
41dd337617
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://opensource.org/licenses/AAL
|
||||
*/
|
||||
|
||||
namespace App\Http\Controllers\ClientPortal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ClientPortal\Subscriptions\ShowPlanSwitchRequest;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SubscriptionPlanSwitchController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the page for switching between plans.
|
||||
*
|
||||
* @param ShowPlanSwitchRequest $request
|
||||
* @param Subscription $subscription
|
||||
* @param string $target_subscription
|
||||
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
|
||||
*/
|
||||
public function index(ShowPlanSwitchRequest $request, Subscription $subscription, Subscription $target_subscription)
|
||||
{
|
||||
return render('subscriptions.switch', [
|
||||
'subscription' => $subscription,
|
||||
'target_subscription' => $target_subscription,
|
||||
]);
|
||||
}
|
||||
}
|
@ -109,6 +109,8 @@ class BillingPortalPurchase extends Component
|
||||
'passwordless_login_sent' => false,
|
||||
'started_payment' => false,
|
||||
'discount_applied' => false,
|
||||
'show_loading_bar' => false,
|
||||
'not_eligible' => null,
|
||||
];
|
||||
|
||||
/**
|
||||
@ -269,7 +271,7 @@ class BillingPortalPurchase extends Component
|
||||
|
||||
$this->steps['fetched_payment_methods'] = true;
|
||||
|
||||
$this->methods = $contact->client->service()->getPaymentMethods(1000);
|
||||
$this->methods = $contact->client->service()->getPaymentMethods($this->price);
|
||||
|
||||
$this->heading_text = ctrans('texts.payment_methods');
|
||||
|
||||
@ -299,6 +301,7 @@ class BillingPortalPurchase extends Component
|
||||
public function handleBeforePaymentEvents()
|
||||
{
|
||||
$this->steps['started_payment'] = true;
|
||||
$this->steps['show_loading_bar'] = true;
|
||||
|
||||
$data = [
|
||||
'client_id' => $this->contact->client->id,
|
||||
@ -320,6 +323,15 @@ class BillingPortalPurchase extends Component
|
||||
->fillDefaults()
|
||||
->save();
|
||||
|
||||
$is_eligible = $this->subscription->service()->isEligible($this->contact);
|
||||
|
||||
if (is_array($is_eligible)) {
|
||||
$this->steps['not_eligible'] = true;
|
||||
$this->steps['show_loading_bar'] = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Cache::put($this->hash, [
|
||||
'subscription_id' => $this->subscription->id,
|
||||
'email' => $this->email ?? $this->contact->email,
|
||||
|
@ -30,6 +30,7 @@ class SubscriptionInvoicesTable extends Component
|
||||
->where('client_id', auth('contact')->user()->client->id)
|
||||
->whereNotNull('subscription_id')
|
||||
->orderBy($this->sort_field, $this->sort_asc ? 'asc' : 'desc')
|
||||
->where('balance', '=', 0)
|
||||
->paginate($this->per_page);
|
||||
|
||||
return render('components.livewire.subscriptions-invoices-table', [
|
||||
|
45
app/Http/Livewire/SubscriptionPlanSwitch.php
Normal file
45
app/Http/Livewire/SubscriptionPlanSwitch.php
Normal file
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Invoice Ninja (https://invoiceninja.com).
|
||||
*
|
||||
* @link https://github.com/invoiceninja/invoiceninja source repository
|
||||
*
|
||||
* @copyright Copyright (c) 2021. Invoice Ninja LLC (https://invoiceninja.com)
|
||||
*
|
||||
* @license https://opensource.org/licenses/AAL
|
||||
*/
|
||||
|
||||
namespace App\Http\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class SubscriptionPlanSwitch extends Component
|
||||
{
|
||||
public $subscription;
|
||||
|
||||
public $target_subscription;
|
||||
|
||||
public $contact;
|
||||
|
||||
public $methods = [];
|
||||
|
||||
public $total;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->methods = $this->contact->client->service()->getPaymentMethods(100);
|
||||
|
||||
$this->total = 1;
|
||||
}
|
||||
|
||||
public function handleBeforePaymentEvents()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return render('components.livewire.subscription-plan-switch');
|
||||
}
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\ClientPortal\Subscriptions;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Utils\Traits\MakesHash;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ShowPlanSwitchRequest extends FormRequest
|
||||
{
|
||||
use MakesHash;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function authorize()
|
||||
{
|
||||
return (bool)$this->subscription->allow_plan_changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
@ -118,12 +118,12 @@ class SubscriptionService
|
||||
/* Hits the client endpoint to determine whether the user is able to access this subscription */
|
||||
public function isEligible($contact)
|
||||
{
|
||||
|
||||
$context = [
|
||||
'context' => 'is_eligible',
|
||||
'subscription' => $this->subscription->hashed_id,
|
||||
'contact' => $contact->hashed_id,
|
||||
'contact_email' => $contact->email
|
||||
'contact_email' => $contact->email,
|
||||
'client' => $contact->client->hashed_id,
|
||||
];
|
||||
|
||||
$response = $this->triggerWebhook($context);
|
||||
@ -131,7 +131,7 @@ class SubscriptionService
|
||||
return $response;
|
||||
}
|
||||
|
||||
/* Starts the process to create a trial
|
||||
/* Starts the process to create a trial
|
||||
- we create a recurring invoice, which is has its next_send_date as now() + trial_duration
|
||||
- we then hit the client API end point to advise the trial payload
|
||||
- we then return the user to either a predefined user endpoint, OR we return the user to the recurring invoice page.
|
||||
@ -229,10 +229,10 @@ class SubscriptionService
|
||||
$response = false;
|
||||
|
||||
$body = array_merge($context, [
|
||||
'company_key' => $this->subscription->company->company_key,
|
||||
'company_key' => $this->subscription->company->company_key,
|
||||
'account_key' => $this->subscription->company->account->key,
|
||||
'db' => $this->subscription->company->db,
|
||||
]);
|
||||
]);
|
||||
|
||||
$response = $this->sendLoad($this->subscription, $body);
|
||||
|
||||
@ -240,10 +240,10 @@ class SubscriptionService
|
||||
if(is_array($response)){
|
||||
|
||||
$body = $response;
|
||||
|
||||
|
||||
}
|
||||
else {
|
||||
|
||||
|
||||
$status = $response->getStatusCode();
|
||||
$response_body = $response->getBody();
|
||||
$body = array_merge($body, ['status' => $status, 'response_body' => $response_body]);
|
||||
@ -278,4 +278,28 @@ class SubscriptionService
|
||||
{
|
||||
return Product::whereIn('id', $this->transformKeys(explode(",", $this->subscription->recurring_product_ids)))->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available upgrades & downgrades for the plan.
|
||||
*
|
||||
* @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
|
||||
*/
|
||||
public function getPlans()
|
||||
{
|
||||
return Subscription::query()
|
||||
->where('company_id', $this->subscription->company_id)
|
||||
->where('group_id', $this->subscription->group_id)
|
||||
->where('id', '!=', $this->subscription->id)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function completePlanChange(PaymentHash $paymentHash)
|
||||
{
|
||||
// .. handle redirect, after upgrade redirects, etc..
|
||||
}
|
||||
|
||||
public function handleCancellation()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
}
|
||||
|
2
public/css/app.css
vendored
2
public/css/app.css
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"/js/app.js": "/js/app.js?id=696e8203d5e8e7cf5ff5",
|
||||
"/css/app.css": "/css/app.css?id=773c78b0cad68d2f1f2e",
|
||||
"/css/app.css": "/css/app.css?id=2163e6d43930f4ad9253",
|
||||
"/js/clients/invoices/action-selectors.js": "/js/clients/invoices/action-selectors.js?id=a09bb529b8e1826f13b4",
|
||||
"/js/clients/invoices/payment.js": "/js/clients/invoices/payment.js?id=8ce8955ba775ea5f47d1",
|
||||
"/js/clients/linkify-urls.js": "/js/clients/linkify-urls.js?id=0dc8c34010d09195d2f7",
|
||||
|
@ -4202,6 +4202,7 @@ $LANG = array(
|
||||
'invoice_task_datelog_help' => 'Add date details to the invoice line items',
|
||||
'promo_code' => 'Promo code',
|
||||
'recurring_invoice_issued_to' => 'Recurring invoice issued to',
|
||||
'subscription' => 'Subscription',
|
||||
);
|
||||
|
||||
return $LANG;
|
||||
|
@ -20,7 +20,6 @@
|
||||
@foreach($subscription->service()->products() as $product)
|
||||
<div class="flex items-center justify-between mb-4 bg-white rounded px-6 py-4 shadow-sm border">
|
||||
<div>
|
||||
<p class="text-sm text-xl">{{ $product->product_key }}</p>
|
||||
<p class="text-sm text-gray-800">{{ $product->notes }}</p>
|
||||
</div>
|
||||
<div data-ref="price-and-quantity-container">
|
||||
@ -124,7 +123,7 @@
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@if($steps['started_payment'])
|
||||
@if($steps['started_payment'] && $steps['show_loading_bar'])
|
||||
<svg class="animate-spin h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
|
||||
@ -209,6 +208,10 @@
|
||||
<button class="button button-primary bg-primary">Apply</button>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
@if($steps['not_eligible'] && !is_null($steps['not_eligible']))
|
||||
<h1>{{ ctrans('texts.payment_error') }}</h1>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -0,0 +1,31 @@
|
||||
<div class="grid grid-cols-12 gap-8 mt-8">
|
||||
<div class="col-span-12 md:col-span-5 md:col-start-4 px-4 py-5">
|
||||
<!-- Total price -->
|
||||
<div class="relative mt-8">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex justify-center text-sm leading-5">
|
||||
<h1 class="text-2xl font-bold tracking-wide bg-gray-100 px-6 py-0">
|
||||
{{ ctrans('texts.total') }}: {{ \App\Utils\Number::formatMoney($total, $subscription->company) }}
|
||||
{{-- <small class="ml-1 line-through text-gray-500">{{ \App\Utils\Number::formatMoney($subscription->price, $subscription->company) }}</small>--}}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment methods -->
|
||||
<div class="mt-8 flex flex-col items-center">
|
||||
<small class="block mb-4">Select a payment method:</small>
|
||||
<div>
|
||||
@foreach($this->methods as $method)
|
||||
<button
|
||||
{{-- wire:click="handleMethodSelectingEvent('{{ $method['company_gateway_id'] }}', '{{ $method['gateway_type_id'] }}')"--}}
|
||||
class="px-3 py-2 border bg-white rounded mr-4 hover:border-blue-600">
|
||||
{{ $method['label'] }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -19,6 +19,11 @@
|
||||
<table class="min-w-full shadow rounded border border-gray-200 mt-4 credits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-white uppercase border-b border-gray-200 bg-primary">
|
||||
<p role="button" wire:click="sortBy('number')" class="cursor-pointer">
|
||||
{{ ctrans('texts.subscription') }}
|
||||
</p>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-white uppercase border-b border-gray-200 bg-primary">
|
||||
<p role="button" wire:click="sortBy('number')" class="cursor-pointer">
|
||||
{{ ctrans('texts.invoice') }}
|
||||
@ -26,7 +31,7 @@
|
||||
</th>
|
||||
<th class="px-6 py-3 border-b border-gray-200 bg-primary text-left text-xs leading-4 font-medium text-white uppercase tracking-wider">
|
||||
<p role="button" wire:click="sortBy('amount')" class="cursor-pointer">
|
||||
{{ ctrans('texts.total') }}
|
||||
{{ ctrans('texts.amount') }}
|
||||
</p>
|
||||
</th>
|
||||
<th class="px-6 py-3 border-b border-gray-200 bg-primary text-left text-xs leading-4 font-medium text-white uppercase tracking-wider">
|
||||
@ -39,6 +44,9 @@
|
||||
<tbody>
|
||||
@forelse($invoices as $invoice)
|
||||
<tr class="bg-white group hover:bg-gray-100">
|
||||
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
|
||||
{{ $invoice->subscription->name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
|
||||
<a href="{{ route('client.invoice.show', $invoice->hashed_id) }}"
|
||||
class="button-link text-primary">
|
||||
|
@ -19,6 +19,11 @@
|
||||
<table class="min-w-full shadow rounded border border-gray-200 mt-4 credits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-white uppercase border-b border-gray-200 bg-primary">
|
||||
<p role="button" wire:click="sortBy('number')" class="cursor-pointer">
|
||||
{{ ctrans('texts.subscription') }}
|
||||
</p>
|
||||
</th>
|
||||
<th class="px-6 py-3 text-xs font-medium leading-4 tracking-wider text-left text-white uppercase border-b border-gray-200 bg-primary">
|
||||
<p role="button" wire:click="sortBy('number')" class="cursor-pointer">
|
||||
{{ ctrans('texts.invoice') }}
|
||||
@ -26,7 +31,7 @@
|
||||
</th>
|
||||
<th class="px-6 py-3 border-b border-gray-200 bg-primary text-left text-xs leading-4 font-medium text-white uppercase tracking-wider">
|
||||
<p role="button" wire:click="sortBy('amount')" class="cursor-pointer">
|
||||
{{ ctrans('texts.total') }}
|
||||
{{ ctrans('texts.amount') }}
|
||||
</p>
|
||||
</th>
|
||||
<th class="px-6 py-3 border-b border-gray-200 bg-primary text-left text-xs leading-4 font-medium text-white uppercase tracking-wider">
|
||||
@ -39,6 +44,9 @@
|
||||
<tbody>
|
||||
@forelse($recurring_invoices as $recurring_invoice)
|
||||
<tr class="bg-white group hover:bg-gray-100">
|
||||
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
|
||||
{{ $recurring_invoice->subscription->name }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-no-wrap text-sm leading-5 text-gray-500">
|
||||
<a href="{{ route('client.recurring_invoice.show', $recurring_invoice->hashed_id) }}"
|
||||
class="button-link text-primary">
|
||||
|
@ -85,5 +85,17 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($invoice->subscription->allow_plan_changes)
|
||||
<div class="bg-white shadow overflow-hidden px-4 py-5 lg:rounded-lg">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">Switch Plans:</h3>
|
||||
<p class="mt-1 max-w-2xl text-sm leading-5 text-gray-500">Upgrade or downgrade your current plan.</p>
|
||||
|
||||
<div class="flex mt-4">
|
||||
@foreach($invoice->subscription->service()->getPlans() as $subscription)
|
||||
<a href="{{ route('client.subscription.plan_switch', ['subscription' => $invoice->subscription->hashed_id, 'target_subscription' => $subscription->hashed_id]) }}" class="border rounded px-5 py-2 hover:border-gray-800 text-sm cursor-pointer">{{ $subscription->name }}</a>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
|
@ -0,0 +1,47 @@
|
||||
@extends('portal.ninja2020.layout.app')
|
||||
@section('meta_title', ctrans('texts.subscriptions'))
|
||||
|
||||
@section('body')
|
||||
<div class="container mx-auto">
|
||||
<!-- Top section showing details between plans -->
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<!-- 1) Subscription we're switching from -->
|
||||
<div
|
||||
class="col-span-12 md:col-start-2 md:col-span-4 bg-white rounded px-4 py-5 shadow hover:shadow-lg">
|
||||
<span class="text-sm uppercase text-gray-900">Current plan:</span>
|
||||
<p class="mt-4">Placeholder text for plan notes, some text.</p>
|
||||
<div class="flex justify-end mt-2">
|
||||
<span>$10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-1 flex justify-center items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="hidden md:block">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
class="md:hidden">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<polyline points="19 12 12 19 5 12"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 2) Subscription we're switching to -->
|
||||
<div class="col-span-12 md:col-span-4 bg-white rounded px-4 py-5 shadow border hover:shadow-lg group-hover:border-transparent">
|
||||
<span class="text-sm uppercase text-gray-900">Switching to:</span>
|
||||
<p class="mt-4">Placeholder text for plan notes, some text.</p>
|
||||
<div class="flex justify-end mt-2">
|
||||
<span>$10</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment box -->
|
||||
@livewire('subscription-plan-switch', compact('subscription', 'target_subscription', 'contact'))
|
||||
</div>
|
||||
@endsection
|
@ -72,6 +72,8 @@ Route::group(['middleware' => ['auth:contact', 'locale', 'check_client_existence
|
||||
Route::get('documents/{document}/download', 'ClientPortal\DocumentController@download')->name('documents.download');
|
||||
Route::resource('documents', 'ClientPortal\DocumentController')->only(['index', 'show']);
|
||||
|
||||
Route::get('subscriptions/{subscription}/plan_switch/{target_subscription}', 'ClientPortal\SubscriptionPlanSwitchController@index')->name('subscription.plan_switch');
|
||||
|
||||
Route::resource('subscriptions', 'ClientPortal\SubscriptionController')->only(['index']);
|
||||
|
||||
Route::post('upload', 'ClientPortal\UploadController')->name('upload.store');
|
||||
@ -79,7 +81,7 @@ Route::group(['middleware' => ['auth:contact', 'locale', 'check_client_existence
|
||||
Route::get('logout', 'Auth\ContactLoginController@logout')->name('logout');
|
||||
});
|
||||
|
||||
Route::get('client/subscription/{subscription}/purchase', 'ClientPortal\SubscriptionPurchaseController@index')->name('client.subscription.purchase');
|
||||
Route::get('client/subscription/{subscription}/purchase/', 'ClientPortal\SubscriptionPurchaseController@index')->name('client.subscription.purchase');
|
||||
|
||||
Route::group(['middleware' => ['invite_db'], 'prefix' => 'client', 'as' => 'client.'], function () {
|
||||
/*Invitation catches*/
|
||||
|
Loading…
Reference in New Issue
Block a user