New payment flow (#64)

* remove context from invoice-pay

* withsecurecontext trait

* update usages

* wip

* wip

* wip

* wip

* wip
This commit is contained in:
Benjamin Beganović 2024-07-05 07:13:38 +02:00
parent e70de19c9d
commit 8d4ab0cd69
17 changed files with 30 additions and 497 deletions

View File

@ -143,7 +143,7 @@ class InvoicePay extends Component
$this->payment_method_accepted = true; $this->payment_method_accepted = true;
$company_gateway = CompanyGateway::query()->find($company_gateway_id); $company_gateway = CompanyGateway::find($company_gateway_id);
$this->checkRequiredFields($company_gateway); $this->checkRequiredFields($company_gateway);
} }
@ -165,7 +165,6 @@ class InvoicePay extends Component
return $this->required_fields = true; return $this->required_fields = true;
} }
/** @var \App\Models\ClientContact $contact */
$contact = $this->getContext()['contact']; $contact = $this->getContext()['contact'];
foreach ($fields as $index => $field) { foreach ($fields as $index => $field) {
@ -174,7 +173,7 @@ class InvoicePay extends Component
if (\Illuminate\Support\Str::startsWith($field['name'], 'client_')) { if (\Illuminate\Support\Str::startsWith($field['name'], 'client_')) {
if ( if (
empty($contact->client->{$_field}) empty($contact->client->{$_field})
|| is_null($contact->client->{$_field}) //@phpstan-ignore-line || is_null($contact->client->{$_field})
) { ) {
return $this->required_fields = true; return $this->required_fields = true;
@ -183,7 +182,7 @@ class InvoicePay extends Component
} }
if (\Illuminate\Support\Str::startsWith($field['name'], 'contact_')) { if (\Illuminate\Support\Str::startsWith($field['name'], 'contact_')) {
if (empty($contact->{$_field}) || is_null($contact->{$_field}) || str_contains($contact->{$_field}, '@example.com')) { //@phpstan-ignore-line if (empty($contact->{$_field}) || is_null($contact->{$_field}) || str_contains($contact->{$_field}, '@example.com')) {
return $this->required_fields = true; return $this->required_fields = true;
} }
} }
@ -241,9 +240,8 @@ class InvoicePay extends Component
nlog($this->invoices); nlog($this->invoices);
if(is_array($this->invoices)) { if(is_array($this->invoices))
$this->invoices = Invoice::find($this->transformKeys($this->invoices)); $this->invoices = Invoice::find($this->transformKeys($this->invoices));
}
$invoices = $this->invoices->filter(function ($i) { $invoices = $this->invoices->filter(function ($i) {
$i = $i->service() $i = $i->service()

View File

@ -1,285 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire;
use App\Utils\Number;
use App\Models\Invoice;
use Livewire\Component;
use App\Utils\HtmlEngine;
use App\Libraries\MultiDB;
use Livewire\Attributes\On;
use App\Livewire\Flow2\Terms;
use App\Models\CompanyGateway;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\MakesDates;
use App\Livewire\Flow2\Signature;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Reactive;
use App\Livewire\Flow2\PaymentMethod;
use App\Livewire\Flow2\ProcessPayment;
use App\Livewire\Flow2\RequiredFields;
use App\Livewire\Flow2\UnderOverPayment;
class InvoicePay extends Component
{
use MakesDates;
use MakesHash;
private $mappings = [
'client_name' => 'name',
'client_website' => 'website',
'client_phone' => 'phone',
'client_address_line_1' => 'address1',
'client_address_line_2' => 'address2',
'client_city' => 'city',
'client_state' => 'state',
'client_postal_code' => 'postal_code',
'client_country_id' => 'country_id',
'client_shipping_address_line_1' => 'shipping_address1',
'client_shipping_address_line_2' => 'shipping_address2',
'client_shipping_city' => 'shipping_city',
'client_shipping_state' => 'shipping_state',
'client_shipping_postal_code' => 'shipping_postal_code',
'client_shipping_country_id' => 'shipping_country_id',
'client_custom_value1' => 'custom_value1',
'client_custom_value2' => 'custom_value2',
'client_custom_value3' => 'custom_value3',
'client_custom_value4' => 'custom_value4',
'contact_first_name' => 'first_name',
'contact_last_name' => 'last_name',
'contact_email' => 'email',
// 'contact_phone' => 'phone',
];
public $client_address_array = [
'address1',
'address2',
'city',
'state',
'postal_code',
'country_id',
'shipping_address1',
'shipping_address2',
'shipping_city',
'shipping_state',
'shipping_postal_code',
'shipping_country_id',
];
public $invitation_id;
public $invoices;
public $variables;
public $db;
public $settings;
public $terms_accepted = false;
public $signature_accepted = false;
public $payment_method_accepted = false;
public $under_over_payment = false;
public $required_fields = false;
public array $context = [];
#[On('update.context')]
public function handleContext(string $property, $value): self
{
data_set($this->context, $property, $value);
return $this;
}
#[On('terms-accepted')]
public function termsAccepted()
{
nlog("Terms accepted");
// $this->invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id)->withoutRelations();
$this->terms_accepted =true;
}
#[On('signature-captured')]
public function signatureCaptured($base64)
{
nlog("signature captured");
$this->signature_accepted = true;
$invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id)->withoutRelations();
$invite->signature_base64 = $base64;
$invite->signature_date = now()->addSeconds($invite->contact->client->timezone_offset());
$this->context['signature'] = $base64;
$invite->save();
}
#[On('payable-amount')]
public function payableAmount($payable_amount)
{
$this->context['payable_invoices'][0]['amount'] = Number::parseFloat($payable_amount);
$this->under_over_payment = false;
}
#[On('payment-method-selected')]
public function paymentMethodSelected($company_gateway_id, $gateway_type_id, $amount)
{
//@TODO only handles single invoice scenario
$this->context['company_gateway_id'] = $company_gateway_id;
$this->context['gateway_type_id'] = $gateway_type_id;
$this->context['amount'] = $amount;
$this->context['pre_payment'] = false;
$this->context['is_recurring'] = false;
$this->context['invitation_id'] = $this->invitation_id;
$this->payment_method_accepted = true;
$company_gateway = CompanyGateway::find($company_gateway_id);
$this->checkRequiredFields($company_gateway);
}
#[On('required-fields')]
public function requiredFieldsFilled()
{
$this->required_fields = false;
}
private function checkRequiredFields(CompanyGateway $company_gateway)
{
$fields = $company_gateway->driver()->getClientRequiredFields();
$this->context['fields'] = $fields;
if($company_gateway->always_show_required_fields){
return $this->required_fields = true;
}
$contact = $this->context['contact'];
foreach ($fields as $index => $field) {
$_field = $this->mappings[$field['name']];
if (\Illuminate\Support\Str::startsWith($field['name'], 'client_')) {
if (empty($contact->client->{$_field})
|| is_null($contact->client->{$_field})
) {
return $this->required_fields = true;
}
}
if (\Illuminate\Support\Str::startsWith($field['name'], 'contact_')) {
if (empty($contact->{$_field}) || is_null($contact->{$_field}) || str_contains($contact->{$_field}, '@example.com')) {
return $this->required_fields = true;
}
}
}
}
#[Computed()]
public function component(): string
{
if(!$this->terms_accepted)
return Terms::class;
if(!$this->signature_accepted)
return Signature::class;
if($this->under_over_payment)
return UnderOverPayment::class;
if(!$this->payment_method_accepted)
return PaymentMethod::class;
if($this->required_fields)
return RequiredFields::class;
return ProcessPayment::class;
}
#[Computed()]
public function componentUniqueId(): string
{
return "purchase-".md5(microtime());
}
public function mount()
{
MultiDB::setDb($this->db);
// @phpstan-ignore-next-line
$invite = \App\Models\InvoiceInvitation::with('contact.client','company')->withTrashed()->find($this->invitation_id);
$client = $invite->contact->client;
$settings = $client->getMergedSettings();
$this->context['contact'] = $invite->contact;
$this->context['settings'] = $settings;
$this->context['db'] = $this->db;
$invoices = Invoice::find($this->transformKeys($this->invoices));
$invoices = $invoices->filter(function ($i){
$i = $i->service()
->markSent()
->removeUnpaidGatewayFees()
->save();
return $i->isPayable();
});
//under-over / payment
//required fields
$this->terms_accepted = !$settings->show_accept_invoice_terms;
$this->signature_accepted = !$settings->require_invoice_signature;
$this->under_over_payment = $settings->client_portal_allow_over_payment || $settings->client_portal_allow_under_payment;
$this->required_fields = false;
$this->context['variables'] = $this->variables;
$this->context['invoices'] = $invoices;
$this->context['settings'] = $settings;
$this->context['invitation'] = $invite;
$this->context['payable_invoices'] = $invoices->map(function ($i){
return [
'invoice_id' => $i->hashed_id,
'amount' => $i->partial > 0 ? $i->partial : $i->balance,
'formatted_amount' => Number::formatValue($i->partial > 0 ? $i->partial : $i->balance, $i->client->currency()),
'number' => $i->number,
'date' => $i->translateDate($i->date, $i->client->date_format(), $i->client->locale())
];
})->toArray();
}
public function render()
{
return render('components.livewire.invoice-pay', [
'context' => $this->context
]);
}
}

View File

@ -1,35 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2024. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire;
use Livewire\Component;
class InvoiceSummary extends Component
{
public $context;
public $invoice;
public function mount()
{
//@TODO for a single invoice - show all details, for multi-invoices, only show the summaries
$this->invoice = $this->context['invitation']->invoice;
}
public function render()
{
return render('components.livewire.invoice-summary',[
'invoice' => $this->invoice
]);
}
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,9 @@
/**
* 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://www.elastic.co/licensing/elastic-license
*/class l{constructor(e,t,n,o){this.key=e,this.secret=t,this.onlyAuthorization=n,this.stripeConnect=o}setupStripe(){return this.stripeConnect?this.stripe=Stripe(this.key,{stripeAccount:this.stripeConnect}):this.stripe=Stripe(this.key),this.elements=this.stripe.elements(),this}createElement(){var e;return this.cardElement=this.elements.create("card",{hidePostalCode:((e=document.querySelector("meta[name=stripe-require-postal-code]"))==null?void 0:e.content)==="0",value:{postalCode:document.querySelector("meta[name=client-postal-code]").content},hideIcon:!1}),this}mountCardElement(){return this.cardElement.mount("#card-element"),this}completePaymentUsingToken(){let e=document.querySelector("input[name=token]").value,t=document.getElementById("pay-now");this.payNowButton=t,this.payNowButton.disabled=!0,this.payNowButton.querySelector("svg").classList.remove("hidden"),this.payNowButton.querySelector("span").classList.add("hidden"),this.stripe.handleCardPayment(this.secret,{payment_method:e}).then(n=>n.error?this.handleFailure(n.error.message):this.handleSuccess(n))}completePaymentWithoutToken(){let e=document.getElementById("pay-now");this.payNowButton=e,this.payNowButton.disabled=!0,this.payNowButton.querySelector("svg").classList.remove("hidden"),this.payNowButton.querySelector("span").classList.add("hidden");let t=document.getElementById("cardholder-name");this.stripe.handleCardPayment(this.secret,this.cardElement,{payment_method_data:{billing_details:{name:t.value}}}).then(n=>n.error?this.handleFailure(n.error.message):this.handleSuccess(n))}handleSuccess(e){document.querySelector('input[name="gateway_response"]').value=JSON.stringify(e.paymentIntent);let t=document.querySelector('input[name="token-billing-checkbox"]:checked');t&&(document.querySelector('input[name="store_card"]').value=t.value),document.getElementById("server-response").submit()}handleFailure(e){let t=document.getElementById("errors");t.textContent="",t.textContent=e,t.hidden=!1,this.payNowButton.disabled=!1,this.payNowButton.querySelector("svg").classList.add("hidden"),this.payNowButton.querySelector("span").classList.remove("hidden")}handleAuthorization(){let e=document.getElementById("cardholder-name"),t=document.getElementById("authorize-card");this.payNowButton=t,this.payNowButton.disabled=!0,this.payNowButton.querySelector("svg").classList.remove("hidden"),this.payNowButton.querySelector("span").classList.add("hidden"),this.stripe.handleCardSetup(this.secret,this.cardElement,{payment_method_data:{billing_details:{name:e.value}}}).then(n=>n.error?this.handleFailure(n.error.message):this.handleSuccessfulAuthorization(n))}handleSuccessfulAuthorization(e){document.getElementById("gateway_response").value=JSON.stringify(e.setupIntent),document.getElementById("server_response").submit()}handle(){this.setupStripe(),this.onlyAuthorization?(this.createElement().mountCardElement(),document.getElementById("authorize-card").addEventListener("click",()=>this.handleAuthorization())):(Array.from(document.getElementsByClassName("toggle-payment-with-token")).forEach(e=>e.addEventListener("click",t=>{document.getElementById("stripe--payment-container").classList.add("hidden"),document.getElementById("save-card--container").style.display="none",document.querySelector("input[name=token]").value=t.target.dataset.token})),document.getElementById("toggle-payment-with-credit-card").addEventListener("click",e=>{document.getElementById("stripe--payment-container").classList.remove("hidden"),document.getElementById("save-card--container").style.display="grid",document.querySelector("input[name=token]").value=""}),this.createElement().mountCardElement(),document.getElementById("pay-now").addEventListener("click",()=>{try{return document.querySelector("input[name=token]").value?this.completePaymentUsingToken():this.completePaymentWithoutToken()}catch(e){console.log(e.message)}}))}}Livewire.hook("component.init",()=>{var a,i,s,d;console.log("running now");const r=((a=document.querySelector('meta[name="stripe-publishable-key"]'))==null?void 0:a.content)??"",e=((i=document.querySelector('meta[name="stripe-secret"]'))==null?void 0:i.content)??"",t=((s=document.querySelector('meta[name="only-authorization"]'))==null?void 0:s.content)??"",n=((d=document.querySelector('meta[name="stripe-account-id"]'))==null?void 0:d.content)??"";let o=new l(r,e,t,n);o.handle(),document.addEventListener("livewire:init",()=>{Livewire.on("passed-required-fields-check",()=>o.handle())})});

View File

@ -243,6 +243,7 @@
<<<<<<< HEAD <<<<<<< HEAD
<<<<<<< HEAD <<<<<<< HEAD
<<<<<<< HEAD <<<<<<< HEAD
<<<<<<< HEAD
<<<<<<< HEAD <<<<<<< HEAD
"file": "assets/app-fee1da41.css", "file": "assets/app-fee1da41.css",
======= =======
@ -257,6 +258,9 @@
======= =======
"file": "assets/app-8544e4cc.css", "file": "assets/app-8544e4cc.css",
>>>>>>> c7cc0e084f (updates for html invoice layout) >>>>>>> c7cc0e084f (updates for html invoice layout)
=======
"file": "assets/app-608daae2.css",
>>>>>>> 2a1947ea6e (New payment flow (#64))
"isEntry": true, "isEntry": true,
"src": "resources/sass/app.scss" "src": "resources/sass/app.scss"
} }

View File

@ -1,10 +0,0 @@
<div class="grid grid-cols-1 md:grid-cols-2">
<div class="p-2">
@livewire('invoice-summary',['context' => $context])
</div>
<div class="p-2">
@livewire($this->component,['context' => $context], key($this->componentUniqueId()))
</div>
</div>

View File

@ -1,66 +0,0 @@
<div class="flex flex-col space-y-4 p-4" x-data="{ isLoading: @entangle('isLoading') }">
<div x-show="isLoading" class="flex items-center justify-center min-h-screen">
<svg class="animate-spin h-10 w-10 text-gray-500" 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" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</div>
@foreach($methods as $index => $method)
<button
class="button button-primary bg-primary payment-method flex items-center justify-center relative py-4"
@click="$wire.dispatch('payment-method-selected', { company_gateway_id: {{ $method['company_gateway_id'] }}, gateway_type_id: {{ $method['gateway_type_id'] }}, amount: {{ $amount }} })">
<svg class="animate-spin h-5 w-5 text-white hidden" 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" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ $method['label'] }}</span>
</button>
@endforeach
@script
<script>
Livewire.on('loadingCompleted', () => {
isLoading = false;
});
Livewire.on('singlePaymentMethodFound', (event) => {
$wire.dispatch('payment-method-selected', {company_gateway_id: event.company_gateway_id, gateway_type_id: event.gateway_type_id, amount: event.amount })
});
const buttons = document.querySelectorAll('.payment-method');
buttons.forEach(button => {
button.addEventListener('click', (event) => {
// Hide all buttons except the clicked one
buttons.forEach(btn => {
if (btn !== event.currentTarget) {
btn.style.display = 'none';
} else {
// Disable the clicked button
btn.disabled = true;
// Show the spinner by removing the 'hidden' class
const spinner = btn.querySelector('svg');
if (spinner) {
spinner.classList.remove('hidden');
}
const span = btn.querySelector('span');
if (span) {
span.style.display = 'none';
}
}
});
});
});
</script>
@endscript
</div>

View File

@ -1,37 +0,0 @@
<div x-data="{ fields: @entangle('fields'), contact: @entangle('contact') }" class="px-4 py-5 bg-white sm:gap-4 sm:px-6">
@foreach($fields as $field)
@component('portal.ninja2020.components.general.card-element', ['title' => $field['label']])
@if($field['name'] == 'client_country_id' || $field['name'] == 'client_shipping_country_id')
<select id="client_country" class="input w-full form-select bg-white" name="{{ $field['name'] }}" wire:model="{{ $field['name'] }}">
<option value="none"></option>
@foreach($countries as $country)
<option value="{{ $country->id }}">
{{ $country->iso_3166_2 }} ({{ $country->name }})
</option>
@endforeach
</select>
@else
<input class="input w-full" type="{{ $field['type'] ?? 'text' }}" name="{{ $field['name'] }}" wire:model="{{ $field['name'] }}">
@endif
@if(session()->has('validation_errors') && array_key_exists($field['name'], session('validation_errors')))
<p class="mt-2 text-gray-900 border-red-300 px-2 py-1 bg-gray-100">{{ session('validation_errors')[$field['name']][0] }}</p>
@endif
@endcomponent
@endforeach
<div class="bg-white px-4 py-5 flex w-full justify-end">
<button
class="button button-primary bg-primary payment-method flex items-center justify-center relative py-4"
@click="$wire.dispatch('required-fields')">
<svg class="animate-spin h-5 w-5 text-white hidden" 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" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ ctrans('texts.next') }}</span>
</button>
</div>
</div>

View File

@ -1,45 +0,0 @@
<div x-data="{ payableInvoices: @entangle('payableInvoices'), errors: @entangle('errors') }" class="px-4 py-5 bg-white sm:gap-4 sm:px-6">
<dt class="text-sm font-medium leading-5 text-gray-500 mb-3">
{{ ctrans('texts.payment_amount') }}
</dt>
<dd class="text-sm leading-5 text-gray-900 sm:mt-0 sm:col-span-2 flex flex-col">
<template x-for="(invoice, index) in payableInvoices" :key="index">
<div class="flex items-center mb-2">
<label>
<span x-text="'{{ ctrans('texts.invoice') }} ' + invoice.number" class="mt-2"></span>
<span class="pr-2">{{ $currency->code }} ({{ $currency->symbol }})</span>
<input
type="text"
class="input mt-0 mr-4 relative"
name="payable_invoices[]"
x-model="payableInvoices[index].formatted_amount"
/>
</label>
</div>
</template>
<template x-if="errors.length > 0">
<div x-text="errors" class="alert alert-failure mb-4"></div>
</template>
@if($settings->client_portal_allow_under_payment)
<span class="mt-1 text-sm text-gray-800">{{ ctrans('texts.minimum_payment') }}: {{ $settings->client_portal_under_payment_minimum }}</span>
@endif
</dd>
<div class="bg-white px-4 py-5 flex w-full justify-end">
<button
class="button button-primary bg-primary payment-method flex items-center justify-center relative py-4"
wire:click="checkValue(payableInvoices)">
<svg class="animate-spin h-5 w-5 text-white hidden" 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" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span>{{ ctrans('texts.next') }}</span>
</button>
</div>
</div>

View File

@ -1,4 +1,4 @@
<div style="w-full"> <div class="w-full">
<div class="rounded-lg border bg-card bg-white text-card-foreground shadow-sm overflow-hidden" x-chunk="An order details card with order details, shipping information, customer information and payment information."> <div class="rounded-lg border bg-card bg-white text-card-foreground shadow-sm overflow-hidden" x-chunk="An order details card with order details, shipping information, customer information and payment information.">
<div class="space-y-1.5 p-6 flex flex-row items-start bg-muted/50"> <div class="space-y-1.5 p-6 flex flex-row items-start bg-muted/50">
<div class="grid gap-0.5"> <div class="grid gap-0.5">

View File

@ -1,4 +1,4 @@
<div class="bg-white"> <div class="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden py-5 bg-white sm:gap-4">
@if($stripe_account_id) @if($stripe_account_id)
<meta name="stripe-account-id" content="{{ $stripe_account_id }}"> <meta name="stripe-account-id" content="{{ $stripe_account_id }}">
<meta name="stripe-publishable-key" content="{{ config('ninja.ninja_stripe_publishable_key') }}"> <meta name="stripe-publishable-key" content="{{ config('ninja.ninja_stripe_publishable_key') }}">