Merge pull request #5179 from beganovich/v5-1803-billing

(v5) 18th March: Billing page
This commit is contained in:
Benjamin Beganović 2021-03-22 09:39:29 +01:00 committed by GitHub
commit 60800c9ea7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 250 additions and 36 deletions

View File

@ -15,16 +15,36 @@ namespace App\Http\Controllers\ClientPortal;
use App\Http\Controllers\Controller;
use App\Models\BillingSubscription;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class BillingSubscriptionPurchaseController extends Controller
{
public function index(BillingSubscription $billing_subscription)
public function index(BillingSubscription $billing_subscription, Request $request)
{
if ($request->has('locale')) {
$this->setLocale($request->query('locale'));
}
return view('billing-portal.purchase', [
'billing_subscription' => $billing_subscription,
'hash' => Str::uuid()->toString(),
'request_data' => $request->all(),
]);
}
/**
* Set locale for incoming request.
*
* @param string $locale
*/
private function setLocale(string $locale): void
{
$record = DB::table('languages')->where('locale', $locale)->first();
if ($record) {
App::setLocale($record->locale);
}
}
}

View File

@ -12,48 +12,137 @@
namespace App\Http\Livewire;
use App\Factory\ClientFactory;
use App\Models\BillingSubscription;
use App\Models\ClientContact;
use App\Models\Invoice;
use App\Repositories\ClientContactRepository;
use App\Repositories\ClientRepository;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class BillingPortalPurchase extends Component
{
/**
* Random hash generated by backend to handle the tracking of state.
*
* @var string
*/
public $hash;
public $heading_text = 'Log in';
/**
* Top level text on the left side of billing page.
*
* @var string
*/
public $heading_text;
/**
* E-mail address model for user input.
*
* @var string
*/
public $email;
/**
* Password model for user input.
*
* @var string
*/
public $password;
/**
* Instance of billing subscription.
*
* @var BillingSubscription
*/
public $billing_subscription;
/**
* Instance of client contact.
*
* @var null|ClientContact
*/
public $contact;
/**
* Rules for validating the form.
*
* @var \string[][]
*/
protected $rules = [
'email' => ['required', 'email'],
];
/**
* Id for CompanyGateway record.
*
* @var string|integer
*/
public $company_gateway_id;
/**
* Id for GatewayType.
*
* @var string|integer
*/
public $payment_method_id;
/**
* List of steps that frontend form follows.
*
* @var array
*/
public $steps = [
'passed_email' => false,
'existing_user' => false,
'fetched_payment_methods' => false,
'fetched_client' => false,
'show_start_trial' => false,
];
/**
* List of payment methods fetched from client.
*
* @var array
*/
public $methods = [];
/**
* Instance of \App\Models\Invoice
*
* @var Invoice
*/
public $invoice;
/**
* Coupon model for user input
*
* @var string
*/
public $coupon;
/**
* Quantity for seats
*
* @var int
*/
public $quantity = 1;
/**
* First-hit request data (queries, locales...).
*
* @var array
*/
public $request_data;
/**
* Handle user authentication
*
* @return $this|bool|void
*/
public function authenticate()
{
$this->validate();
@ -81,6 +170,12 @@ class BillingPortalPurchase extends Component
}
}
/**
* Create a blank client. Used for new customers purchasing.
*
* @return mixed
* @throws \Laracasts\Presenter\Exceptions\PresenterException
*/
protected function createBlankClient()
{
$company = $this->billing_subscription->company;
@ -88,23 +183,47 @@ class BillingPortalPurchase extends Component
$client_repo = new ClientRepository(new ClientContactRepository());
$client = $client_repo->save([
$data = [
'name' => 'Client Name',
'contacts' => [
['email' => $this->email],
]
], ClientFactory::create($company->id, $user->id));
],
'settings' => [],
];
if (array_key_exists('locale', $this->request_data)) {
$record = DB::table('languages')->where('locale', $this->request_data['locale'])->first();
if ($record) {
$data['settings']['language_id'] = (string)$record->id;
}
}
$client = $client_repo->save($data, ClientFactory::create($company->id, $user->id));
return $client->contacts->first();
}
/**
* Fetching payment methods from the client.
*
* @param ClientContact $contact
* @return $this
*/
protected function getPaymentMethods(ClientContact $contact): self
{
if ($this->billing_subscription->trial_enabled) {
$this->heading_text = ctrans('texts.plan_trial');
$this->steps['show_start_trial'] = true;
return $this;
}
$this->steps['fetched_payment_methods'] = true;
$this->methods = $contact->client->service()->getPaymentMethods(1000);
$this->heading_text = 'Pick a payment method';
$this->heading_text = ctrans('texts.payment_methods');
Auth::guard('contact')->login($contact);
@ -113,6 +232,13 @@ class BillingPortalPurchase extends Component
return $this;
}
/**
* Middle method between selecting payment method &
* submitting the from to the backend.
*
* @param $company_gateway_id
* @param $gateway_type_id
*/
public function handleMethodSelectingEvent($company_gateway_id, $gateway_type_id)
{
$this->company_gateway_id = $company_gateway_id;
@ -121,10 +247,13 @@ class BillingPortalPurchase extends Component
$this->handleBeforePaymentEvents();
}
/**
* Method to handle events before payments.
*
* @return void
*/
public function handleBeforePaymentEvents()
{
//stubs
$data = [
'client_id' => $this->contact->client->id,
'date' => now()->format('Y-m-d'),
@ -133,7 +262,7 @@ class BillingPortalPurchase extends Component
'client_contact_id' => $this->contact->hashed_id,
]],
'user_input_promo_code' => $this->coupon,
'quantity' => 1, // Option to increase quantity
'quantity' => $this->quantity,
];
$this->invoice = $this->billing_subscription
@ -146,18 +275,46 @@ class BillingPortalPurchase extends Component
Cache::put($this->hash, [
'email' => $this->email ?? $this->contact->email,
'client_id' => $this->contact->client->id,
'invoice_id' => $this->invoice->id],
'invoice_id' => $this->invoice->id,
'subscription_id' => $this->billing_subscription->id],
now()->addMinutes(60)
);
$this->emit('beforePaymentEventsCompleted');
}
//this isn't managed here - this is taken care of in the BS
public function applyCouponCode()
/**
* Proxy method for starting the trial.
*
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function handleTrial()
{
dd('Applying coupon code: ' . $this->coupon);
return $this->billing_subscription->service()->startTrial([
'email' => $this->email ?? $this->contact->email,
]);
}
/**
* Update quantity property.
*
* @param string $option
* @return int
*/
public function updateQuantity(string $option): int
{
if ($this->quantity == 1 && $option == 'decrement') {
return $this->quantity;
}
// TODO: Dave review.
if ($this->quantity >= $this->billing_subscription->max_seats_limit) {
return $this->quantity;
}
return $option == 'increment'
? $this->quantity++
: $this->quantity--;
}
public function render()

View File

@ -57,7 +57,11 @@ class BillingSubscriptionService
public function startTrial(array $data)
{
// Redirects from here work just fine. Livewire will respect it.
// Some magic here..
return redirect('/trial-started');
}
public function createInvoice($data): ?\App\Models\Invoice

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=e8d6d5e8cb60bc2f15b3",
"/css/app.css": "/css/app.css?id=1481aa442df903f3c38b",
"/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

@ -4177,6 +4177,7 @@ $LANG = array(
'migration_auth_label' => 'Let\'s continue by authenticating.',
'api_secret' => 'API secret',
'migration_api_secret_notice' => 'You can find API_SECRET in the .env file or Invoice Ninja v5. If property is missing, leave field blank.',
'billing_coupon_notice' => 'Your discount will be applied on the checkout.',
'use_last_email' => 'Use last email',
'activate_company' => 'Activate Company',
'activate_company_help' => 'Enable emails, recurring invoices and notifications',

View File

@ -2,7 +2,7 @@
@section('meta_title', $billing_subscription->product->product_key)
@section('body')
@livewire('billing-portal-purchase', ['billing_subscription' => $billing_subscription, 'contact' => auth('contact')->user(), 'hash' => $hash])
@livewire('billing-portal-purchase', ['billing_subscription' => $billing_subscription, 'contact' => auth('contact')->user(), 'hash' => $hash, 'request_data' => $request_data])
@stop
@push('footer')

View File

@ -10,10 +10,36 @@
<p class="my-6">{{ $billing_subscription->product->notes }}</p>
<span class="text-sm uppercase font-bold">{{ ctrans('texts.total') }}:</span>
<span class="text-sm uppercase font-bold">{{ ctrans('texts.price') }}:</span>
<div class="flex space-x-2">
<h1 class="text-2xl font-bold tracking-wide">{{ App\Utils\Number::formatMoney($billing_subscription->product->price, $billing_subscription->company) }}</h1>
@if($billing_subscription->per_seat_enabled)
<span class="text-sm">/unit</span>
@endif
</div>
<div class="flex mt-4 space-x-4 items-center">
<span class="text-sm">{{ ctrans('texts.qty') }}</span>
<button wire:click="updateQuantity('decrement')" class="bg-gray-100 border rounded p-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="feather feather-minus">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button>{{ $quantity }}</button>
<button wire:click="updateQuantity('increment')" class="bg-gray-100 border rounded p-1">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="feather feather-plus">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
</div>
@if(auth('contact')->user())
<a href="{{ route('client.invoices.index') }}" class="block mt-16 inline-flex items-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none"
@ -32,14 +58,14 @@
<div class="col-span-12 lg:col-span-6 bg-white lg:shadow-lg lg:h-screen">
<div class="grid grid-cols-12 flex flex-col p-10 lg:mt-48 lg:ml-16">
<div class="col-span-12 w-full lg:col-span-6">
<h2 class="text-2xl font-bold tracking-wide">{{ $heading_text }}</h2>
<h2 class="text-2xl font-bold tracking-wide">{{ $heading_text ?? ctrans('texts.login') }}</h2>
@if (session()->has('message'))
@component('portal.ninja2020.components.message')
{{ session('message') }}
@endcomponent
@endif
@if($this->steps['fetched_payment_methods'])
@if($steps['fetched_payment_methods'])
<div class="flex items-center mt-4 text-sm">
<form action="{{ route('client.payments.process', ['hash' => $hash, 'sidebar' => 'hidden']) }}"
method="post"
@ -67,6 +93,17 @@
</button>
@endforeach
</div>
@elseif($steps['show_start_trial'])
<form wire:submit.prevent="handleTrial" class="mt-8">
@csrf
<p class="mb-4">Some text about the trial goes here. Details about the days, etc.</p>
<button class="px-3 py-2 border rounded mr-4 hover:border-blue-600">
{{ ctrans('texts.trial_call_to_action') }}
</button>
</form>
@else
<form wire:submit.prevent="authenticate" class="mt-8">
@csrf
@ -110,17 +147,12 @@
</div>
</div>
<form wire:submit.prevent="applyCouponCode" class="mt-4">
@csrf
<div class="flex items-center">
<div class="flex items-center mt-4">
<label class="w-full mr-2">
<input type="text" wire:model.defer="coupon" class="input w-full m-0" />
<input type="text" wire:model.lazy="coupon" class="input w-full m-0"/>
<small class="block text-gray-900 mt-2">{{ ctrans('texts.billing_coupon_notice') }}</small>
</label>
<button class="button bg-primary m-0 text-white">{{ ctrans('texts.apply') }}</button>
</div>
</form>
</div>
</div>
</div>