1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-09-21 08:51:34 +02:00

Merge pull request #5373 from beganovich/v5-0704-billing-portal

V5 0704 billing portal
This commit is contained in:
Benjamin Beganović 2021-04-08 02:25:09 +02:00 committed by GitHub
commit 41dd337617
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 280 additions and 15 deletions

View File

@ -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,
]);
}
}

View File

@ -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,

View File

@ -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', [

View 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');
}
}

View File

@ -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 [
//
];
}
}

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -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",

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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">

View File

@ -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">

View File

@ -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

View File

@ -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

View File

@ -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*/