1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-12 14:12:44 +01:00

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 committed by GitHub
parent f25469a288
commit 2a1947ea6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 686 additions and 325 deletions

View File

@ -59,7 +59,6 @@ class Register extends Component
public function register(array $data)
{
$service = new ClientRegisterService(
company: $this->subscription->company,
additional: $this->additional_fields,

View File

@ -10,32 +10,26 @@
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire;
namespace App\Livewire\Flow2;
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\Models\Invoice;
use App\Utils\Number;
use App\Utils\Traits\MakesDates;
use App\Livewire\Flow2\Signature;
use App\Utils\Traits\MakesHash;
use App\Utils\Traits\WithSecureContext;
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;
use Livewire\Attributes\On;
use Livewire\Component;
class InvoicePay extends Component
{
use MakesDates;
use MakesHash;
use WithSecureContext;
private $mappings = [
private $mappings = [
'client_name' => 'name',
'client_website' => 'website',
'client_phone' => 'phone',
@ -91,7 +85,7 @@ class InvoicePay extends Component
public $settings;
public $terms_accepted = false;
public $signature_accepted = false;
public $payment_method_accepted = false;
@ -100,13 +94,10 @@ class InvoicePay extends Component
public $required_fields = false;
public array $context = [];
#[On('update.context')]
public function handleContext(string $property, $value): self
{
data_set($this->context, $property, $value);
$this->setContext(property: $property, value: $value);
return $this;
}
@ -116,7 +107,7 @@ class InvoicePay extends Component
{
nlog("Terms accepted");
// $this->invite = \App\Models\InvoiceInvitation::withTrashed()->find($this->invitation_id)->withoutRelations();
$this->terms_accepted =true;
$this->terms_accepted = true;
}
#[On('signature-captured')]
@ -128,35 +119,33 @@ class InvoicePay extends Component
$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;
$this->setContext('signature', $base64); // $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->setContext('payable_invoices.0.amount', Number::parseFloat($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->setContext('company_gateway_id', $company_gateway_id);
$this->setContext('gateway_type_id', $gateway_type_id);
$this->setContext('amount', $amount);
$this->setContext('pre_payment', false);
$this->setContext('is_recurring', false);
$this->setContext('invitation_id', $this->invitation_id);
$this->payment_method_accepted = true;
$company_gateway = CompanyGateway::find($company_gateway_id);
$this->checkRequiredFields($company_gateway);
}
#[On('required-fields')]
@ -167,33 +156,35 @@ class InvoicePay extends Component
private function checkRequiredFields(CompanyGateway $company_gateway)
{
$fields = $company_gateway->driver()->getClientRequiredFields();
$this->context['fields'] = $fields;
if($company_gateway->always_show_required_fields){
$fields = $company_gateway->driver()->getClientRequiredFields();
$this->setContext('fields', $fields); // $this->context['fields'] = $fields;
if ($company_gateway->always_show_required_fields) {
return $this->required_fields = true;
}
$contact = $this->context['contact'];
$contact = $this->getContext()['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})
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;
}
}
}
}
@ -202,53 +193,58 @@ class InvoicePay extends Component
#[Computed()]
public function component(): string
{
if(!$this->terms_accepted)
if (!$this->terms_accepted) {
return Terms::class;
}
if(!$this->signature_accepted)
if (!$this->signature_accepted) {
return Signature::class;
}
if($this->under_over_payment)
if ($this->under_over_payment) {
return UnderOverPayment::class;
}
if(!$this->payment_method_accepted)
if (!$this->payment_method_accepted) {
return PaymentMethod::class;
}
if($this->required_fields)
if ($this->required_fields) {
return RequiredFields::class;
}
return ProcessPayment::class;
}
#[Computed()]
public function componentUniqueId(): string
{
return "purchase-".md5(microtime());
return "purchase-" . md5(microtime());
}
public function mount()
{
$this->resetContext();
MultiDB::setDb($this->db);
// @phpstan-ignore-next-line
$invite = \App\Models\InvoiceInvitation::with('contact.client','company')->withTrashed()->find($this->invitation_id);
$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;
$this->setContext('contact', $invite->contact); // $this->context['contact'] = $invite->contact;
$this->setContext('settings', $settings); // $this->context['settings'] = $settings;
$this->setContext('db', $this->db); // $this->context['db'] = $this->db;
$invoices = Invoice::find($this->transformKeys($this->invoices));
$invoices = $invoices->filter(function ($i){
$invoices = $invoices->filter(function ($i) {
$i = $i->service()
->markSent()
->removeUnpaidGatewayFees()
->save();
return $i->isPayable();
});
//under-over / payment
@ -259,12 +255,12 @@ class InvoicePay extends Component
$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->setContext('variables', $this->variables); // $this->context['variables'] = $this->variables;
$this->setContext('invoices', $invoices); // $this->context['invoices'] = $invoices;
$this->setContext('settings', $settings); // $this->context['settings'] = $settings;
$this->setContext('invitation', $invite); // $this->context['invitation'] = $invite;
$this->context['payable_invoices'] = $invoices->map(function ($i){
$payable_invoices = $invoices->map(function ($i) {
return [
'invoice_id' => $i->hashed_id,
'amount' => $i->partial > 0 ? $i->partial : $i->balance,
@ -273,13 +269,12 @@ class InvoicePay extends Component
'date' => $i->translateDate($i->date, $i->client->date_format(), $i->client->locale())
];
})->toArray();
$this->setContext('payable_invoices', $payable_invoices);
}
public function render()
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return render('components.livewire.invoice-pay', [
'context' => $this->context
]);
return render('flow2.invoice-pay');
}
}
}

View File

@ -0,0 +1,45 @@
<?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\Flow2;
use App\Utils\Traits\WithSecureContext;
use Livewire\Attributes\On;
use Livewire\Component;
class InvoiceSummary extends Component
{
use WithSecureContext;
public $invoice;
public function mount()
{
//@TODO for a single invoice - show all details, for multi-invoices, only show the summaries
$this->invoice = $this->getContext()['invitation']->invoice; // $this->context['invitation']->invoice;
}
#[On(self::CONTEXT_UPDATE)]
public function onContextUpdate(): void
{
// refactor logic for updating the price for eg if it changes with under/over pay
$this->invoice = $this->getContext()['invitation']->invoice;
}
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return render('flow2.invoice-summary', [
'invoice' => $this->invoice
]);
}
}

View File

@ -12,14 +12,15 @@
namespace App\Livewire\Flow2;
use App\Utils\Traits\WithSecureContext;
use Livewire\Component;
use App\Libraries\MultiDB;
class PaymentMethod extends Component
{
public $invoice;
use WithSecureContext;
public $context;
public $invoice;
public $variables;
@ -36,7 +37,7 @@ class PaymentMethod extends Component
<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>
</svg>
</div>
HTML;
}
@ -44,12 +45,12 @@ class PaymentMethod extends Component
public function mount()
{
$this->variables = $this->context['variables'];
$this->amount = array_sum(array_column($this->context['payable_invoices'], 'amount'));
$this->variables = $this->getContext()['variables'];
$this->amount = array_sum(array_column($this->getContext()['payable_invoices'], 'amount'));
MultiDB::setDb($this->context['db']);
MultiDB::setDb($this->getContext()['db']);
$this->methods = $this->context['invitation']->contact->client->service()->getPaymentMethods($this->amount);
$this->methods = $this->getContext()['invitation']->contact->client->service()->getPaymentMethods($this->amount);
if(count($this->methods) == 1) {
$this->dispatch('singlePaymentMethodFound', company_gateway_id: $this->methods[0]['company_gateway_id'], gateway_type_id: $this->methods[0]['gateway_type_id'], amount: $this->amount);
@ -60,9 +61,8 @@ class PaymentMethod extends Component
}
}
public function render()
{
//If there is only one payment method, skip display and push straight to the form!!
return render('components.livewire.payment_method-flow2', ['methods' => $this->methods]);
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return render('flow2.payment-method', ['methods' => $this->methods]);
}
}

View File

@ -13,6 +13,7 @@
namespace App\Livewire\Flow2;
use App\Exceptions\PaymentFailed;
use App\Utils\Traits\WithSecureContext;
use Livewire\Component;
use App\Libraries\MultiDB;
use App\Models\CompanyGateway;
@ -22,8 +23,7 @@ use App\Services\ClientPortal\LivewireInstantPayment;
class ProcessPayment extends Component
{
public $context;
use WithSecureContext;
private string $component_view = '';
@ -33,17 +33,17 @@ class ProcessPayment extends Component
public function mount()
{
MultiDB::setDb($this->context['db']);
$invitation = InvoiceInvitation::find($this->context['invitation_id']);
MultiDB::setDb($this->getContext()['db']);
$invitation = InvoiceInvitation::find($this->getContext()['invitation_id']);
$data = [
'company_gateway_id' => $this->context['company_gateway_id'],
'payment_method_id' => $this->context['gateway_type_id'],
'payable_invoices' => $this->context['payable_invoices'],
'signature' => isset($this->context['signature']) ? $this->context['signature'] : false,
'signature_ip' => isset($this->context['signature_ip']) ? $this->context['signature_ip'] : false,
'company_gateway_id' => $this->getContext()['company_gateway_id'],
'payment_method_id' => $this->getContext()['gateway_type_id'],
'payable_invoices' => $this->getContext()['payable_invoices'],
'signature' => isset($this->getContext()['signature']) ? $this->getContext()['signature'] : false,
'signature_ip' => isset($this->getContext()['signature_ip']) ? $this->getContext()['signature_ip'] : false,
'pre_payment' => false,
'frequency_id' => false,
'remaining_cycles' => false,
@ -53,7 +53,7 @@ class ProcessPayment extends Component
$responder_data = (new LivewireInstantPayment($data))->run();
$company_gateway = CompanyGateway::find($this->context['company_gateway_id']);
$company_gateway = CompanyGateway::find($this->getContext()['company_gateway_id']);
$this->component_view = '';
@ -111,17 +111,14 @@ class ProcessPayment extends Component
}
public function boot()
public function render(): \Illuminate\Contracts\View\Factory|string|\Illuminate\View\View
{
if ($this->isLoading) {
return <<<'HTML'
<template></template>
HTML;
}
nlog($this->isLoading);
}
public function render()
{
if(!$this->isLoading)
return render('gateways.stripe.credit_card.livewire_pay', $this->payment_data_payload);
return render('gateways.stripe.credit_card.livewire_pay', $this->payment_data_payload);
}
}

View File

@ -12,19 +12,115 @@
namespace App\Livewire\Flow2;
use App\Libraries\MultiDB;
use App\Models\CompanyGateway;
use App\Services\Client\RFFService;
use App\Utils\Traits\WithSecureContext;
use Livewire\Component;
class RequiredFields extends Component
{
public $context;
use WithSecureContext;
public function mount()
public ?CompanyGateway $company_gateway;
public ?string $client_name;
public ?string $contact_first_name;
public ?string $contact_last_name;
public ?string $contact_email;
public ?string $client_phone;
public ?string $client_address_line_1;
public ?string $client_city;
public ?string $client_state;
public ?string $client_country_id;
public ?string $client_postal_code;
public ?string $client_shipping_address_line_1;
public ?string $client_shipping_city;
public ?string $client_shipping_state;
public ?string $client_shipping_postal_code;
public ?string $client_shipping_country_id;
public ?string $client_custom_value1;
public ?string $client_custom_value2;
public ?string $client_custom_value3;
public ?string $client_custom_value4;
/** @var array<int, string> */
public array $fields = [];
public bool $is_loading = true;
public function mount(): void
{
MultiDB::setDB(
$this->getContext()['db'],
);
$this->fields = $this->getContext()['fields'];
$this->company_gateway = CompanyGateway::withTrashed()
->with('company')
->find($this->getContext()['company_gateway_id']);
$contact = auth()->user();
$this->client_name = $contact->client->name;
$this->contact_first_name = $contact->first_name;
$this->contact_last_name = $contact->last_name;
$this->contact_email = $contact->email;
$this->client_phone = $contact->client->phone;
$this->client_address_line_1 = $contact->client->address1;
$this->client_city = $contact->client->city;
$this->client_state = $contact->client->state;
$this->client_country_id = $contact->client->country_id;
$this->client_postal_code = $contact->client->postal_code;
$this->client_shipping_address_line_1 = $contact->client->shipping_address1;
$this->client_shipping_city = $contact->client->shipping_city;
$this->client_shipping_state = $contact->client->shipping_state;
$this->client_shipping_postal_code = $contact->client->shipping_postal_code;
$this->client_shipping_country_id = $contact->client->shipping_country_id;
$this->client_custom_value1 = $contact->client->custom_value1;
$this->client_custom_value2 = $contact->client->custom_value2;
$this->client_custom_value3 = $contact->client->custom_value3;
$this->client_custom_value4 = $contact->client->custom_value4;
$rff = new RFFService(
fields: $this->getContext()['fields'],
database: $this->getContext()['db'],
company_gateway_id: $this->company_gateway->id,
);
/** @var \App\Models\ClientContact $contact */
$rff->check($contact);
if ($rff->unfilled_fields === 0) {
$this->dispatch('required-fields');
}
if ($rff->unfilled_fields > 0) {
$this->is_loading = false;
}
}
public function render()
public function handleSubmit(array $data)
{
return render('components.livewire.required-fields', ['contact' => $this->context['contact'], 'fields' => $this->context['fields']]);
$rff = new RFFService(
fields: $this->fields,
database: $this->getContext()['db'],
company_gateway_id: $this->company_gateway->id,
);
$contact = auth()->user();
/** @var \App\Models\ClientContact $contact */
$rff->handleSubmit($data, $contact, function () {
$this->dispatch('required-fields');
});
}
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return render('flow2.required-fields', [
'contact' => $this->getContext()['contact'],
]);
}
}

View File

@ -12,20 +12,21 @@
namespace App\Livewire\Flow2;
use App\Utils\Traits\WithSecureContext;
use Livewire\Component;
class Terms extends Component
{
public $invoice;
use WithSecureContext;
public $context;
public $invoice;
public $variables;
public function mount()
{
$this->invoice = $this->context['invoice'];
$this->variables = $this->context['variables'];
$this->invoice = $this->getContext()['invoice'];
$this->variables = $this->getContext()['variables'];
}
public function render()

View File

@ -13,12 +13,13 @@
namespace App\Livewire\Flow2;
use App\Utils\Number;
use App\Utils\Traits\WithSecureContext;
use Livewire\Component;
class UnderOverPayment extends Component
{
public $context;
use WithSecureContext;
public $payableAmount;
@ -32,17 +33,17 @@ class UnderOverPayment extends Component
public function mount()
{
$this->invoice_amount = array_sum(array_column($this->context['payable_invoices'], 'amount'));
$this->currency = $this->context['invitation']->contact->client->currency();
$this->payableInvoices = $this->context['payable_invoices'];
$this->invoice_amount = array_sum(array_column($this->getContext()['payable_invoices'], 'amount'));
$this->currency = $this->getContext()['invitation']->contact->client->currency();
$this->payableInvoices = $this->getContext()['payable_invoices'];
}
public function checkValue(array $payableInvoices)
{
$this->errors = '';
$settings = $this->context['settings'];
$settings = $this->getContext()['settings'];
$input_amount = 0;
foreach($payableInvoices as $key=>$invoice){
@ -66,15 +67,13 @@ class UnderOverPayment extends Component
}
if(!$this->errors){
$this->context['payable_invoices'] = $payableInvoices;
$this->getContext()['payable_invoices'] = $payableInvoices;
$this->dispatch('payable-amount', payable_amount: $input_amount );
}
}
public function render()
public function render(): \Illuminate\Contracts\View\Factory|\Illuminate\View\View
{
return render('components.livewire.under-over-payments',[
'settings' => $this->context['settings'],
]);
return render('flow2.under-over-payments');
}
}

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

View File

@ -0,0 +1,188 @@
<?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\Services\Client;
use App\Libraries\MultiDB;
use App\Models\ClientContact;
use App\Models\CompanyGateway;
use Illuminate\Support\Str;
use Validator;
class RFFService
{
public array $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 int $unfilled_fields = 0;
public function __construct(
public array $fields,
public string $database,
public string $company_gateway_id,
) {
}
public function check(ClientContact $contact): void
{
$_contact = $contact;
foreach ($this->fields as $index => $field) {
$_field = $this->mappings[$field['name']];
if (Str::startsWith($field['name'], 'client_')) {
if (
empty($_contact->client->{$_field})
|| is_null($_contact->client->{$_field})
) {
// $this->show_form = true;
$this->unfilled_fields++;
} else {
$this->fields[$index]['filled'] = true;
}
}
if (Str::startsWith($field['name'], 'contact_')) {
if (empty($_contact->{$_field}) || is_null($_contact->{$_field}) || str_contains($_contact->{$_field}, '@example.com')) {
$this->unfilled_fields++;
} else {
$this->fields[$index]['filled'] = true;
}
}
}
}
public function handleSubmit(array $data, ClientContact $contact, callable $callback): bool
{
MultiDB::setDb($this->database);
$rules = [];
collect($this->fields)->map(function ($field) use (&$rules) {
if (!array_key_exists('filled', $field)) {
$rules[$field['name']] = array_key_exists('validation_rules', $field)
? $field['validation_rules']
: 'required';
}
});
$validator = Validator::make($data, $rules);
if ($validator->fails()) {
session()->flash('validation_errors', $validator->getMessageBag()->getMessages());
return false;
}
if ($this->update($data, $contact)) {
$callback();
return true;
}
return false;
}
public function update(array $data, ClientContact $_contact): bool
{
$client = [];
$contact = [];
MultiDB::setDb($this->database);
foreach ($data as $field => $value) {
if (Str::startsWith($field, 'client_')) {
$client[$this->mappings[$field]] = $value;
}
if (Str::startsWith($field, 'contact_')) {
$contact[$this->mappings[$field]] = $value;
}
}
$_contact->first_name = $data['contact_first_name'] ?? '';
$_contact->last_name = $data['contact_last_name'] ?? '';
$_contact->client->name = $data['client_name'] ?? '';
$_contact->email = $data['contact_email'] ?? '';
$_contact->client->phone = $data['client_phone'] ?? '';
$_contact->client->address1 = $data['client_address_line_1'] ?? '';
$_contact->client->city = $data['client_city'] ?? '';
$_contact->client->state = $data['client_state'] ?? '';
$_contact->client->country_id = $data['client_country_id'] ?? '';
$_contact->client->postal_code = $data['client_postal_code'] ?? '';
$_contact->client->shipping_address1 = $data['client_shipping_address_line_1'] ?? '';
$_contact->client->shipping_city = $data['client_shipping_city'] ?? '';
$_contact->client->shipping_state = $data['client_shipping_state'] ?? '';
$_contact->client->shipping_postal_code = $data['client_shipping_postal_code'] ?? '';
$_contact->client->shipping_country_id = $data['client_shipping_country_id'] ?? '';
$_contact->client->custom_value1 = $data['client_custom_value1'] ?? '';
$_contact->client->custom_value2 = $data['client_custom_value2'] ?? '';
$_contact->client->custom_value3 = $data['client_custom_value3'] ?? '';
$_contact->client->custom_value4 = $data['client_custom_value4'] ?? '';
$_contact->push();
$_contact
->fill($contact)
->push();
$_contact->client
->fill($client)
->push();
if ($_contact) {
/** @var \App\Models\CompanyGateway $cg */
$cg = CompanyGateway::find(
$this->company_gateway_id,
);
if ($cg && $cg->update_details) {
$payment_gateway = $cg->driver($_contact->client)->init();
if (method_exists($payment_gateway, "updateCustomer")) {
$payment_gateway->updateCustomer();
}
}
return true;
}
return false;
}
}

View File

@ -0,0 +1,47 @@
<?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\Utils\Traits;
use Illuminate\Support\Str;
trait WithSecureContext
{
public const CONTEXT_UPDATE = 'secureContext.updated';
/**
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function getContext(): mixed
{
return session()->get('secureContext.invoice-pay');
}
public function setContext(string $property, $value): array
{
$clone = session()->pull('secureContext.invoice-pay', default: []);
data_set($clone, $property, $value);
session()->put('secureContext.invoice-pay', $clone);
$this->dispatch(self::CONTEXT_UPDATE);
return $clone;
}
public function resetContext(): void
{
session()->forget('secureContext.invoice-pay');
}
}

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

@ -240,7 +240,7 @@
"src": "resources/js/setup/setup.js"
},
"resources/sass/app.scss": {
"file": "assets/app-8544e4cc.css",
"file": "assets/app-608daae2.css",
"isEntry": true,
"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

@ -0,0 +1,9 @@
<div class="grid grid-cols-1 md:grid-cols-2">
<div class="p-2">
@livewire('flow2.invoice-summary')
</div>
<div class="p-2">
@livewire($this->component, [], key($this->componentUniqueId()))
</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="space-y-1.5 p-6 flex flex-row items-start bg-muted/50">
<div class="grid gap-0.5">
@ -14,7 +14,7 @@
</h3>
<p class="text-sm text-muted-foreground">{{ ctrans('texts.date') }}: {{ $invoice->translateDate($invoice->date, $invoice->client->date_format(), $invoice->client->locale()) }}</p>
</div>
</div>
<div class="p-6 text-sm">
<div class="grid gap-3">
@ -64,4 +64,4 @@
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,63 @@
<div class="flex flex-col p-4 rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden px-4 py-5 bg-white sm:gap-4 sm:px-6"
x-data="{ isLoading: @entangle('isLoading') }">
<svg x-show="isLoading" wire:loading class="animate-spin h-5 w-5 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" 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>
<p class="font-semibold tracking-tight group flex items-center gap-2 text-lg">{{ ctrans('texts.payment_methods') }}
</p>
<div class="my-3 flex flex-col space-y-3">
@foreach($methods as $index => $method)
<button wire:loading.remove
class="flex px-4 py-3 border rounded-lg lg:-mb-1 hover:shadow-sm transition duration-300"
@click="$wire.dispatch('payment-method-selected', { company_gateway_id: {{ $method['company_gateway_id'] }}, gateway_type_id: {{ $method['gateway_type_id'] }}, amount: {{ $amount }} })">
<span>{{ $method['label'] }}</span>
</button>
@endforeach
</div>
@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

@ -0,0 +1,61 @@
<div x-data="{ fields: @entangle('fields'), contact: @entangle('contact') }"
class="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden px-4 py-5 bg-white sm:gap-4 sm:px-6">
<p class="font-semibold tracking-tight group flex items-center gap-2 text-lg mb-3">
{{ ctrans('texts.required_fields') }}
</p>
@if($is_loading)
<svg class="animate-spin h-5 w-5 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" 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>
@else
<form id="required-client-info-form"
x-on:submit.prevent="$wire.handleSubmit(Object.fromEntries(new FormData(document.getElementById('required-client-info-form'))))"
class="-ml-4 lg:-ml-5">
@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 items-center w-full justify-end space-x-3">
<svg wire:loading class="animate-spin h-5 w-5 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" 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>
<button wire:loading.attr="disabled" class="button button-primary bg-primary">
<span>{{ ctrans('texts.next') }}</span>
</button>
</div>
</form>
@endif
</div>

View File

@ -0,0 +1,45 @@
<div x-data="{ payableInvoices: @entangle('payableInvoices'), errors: @entangle('errors') }"
class="rounded-lg border bg-card text-card-foreground shadow-sm overflow-hidden px-4 py-5 bg-white sm:gap-4 sm:px-6">
<p class="font-semibold tracking-tight group flex items-center gap-2 text-lg mb-3">
{{ ctrans('texts.payment_amount') }}
</p>
<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 items-center w-full justify-end space-x-3">
<svg wire:loading class="animate-spin h-5 w-5 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" 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>
<button wire:loading.attr="disabled" wire:click="checkValue(payableInvoices)"
class="button button-primary bg-primary">
<span>{{ ctrans('texts.next') }}</span>
</button>
</div>
</div>

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)
<meta name="stripe-account-id" content="{{ $stripe_account_id }}">
<meta name="stripe-publishable-key" content="{{ config('ninja.ninja_stripe_publishable_key') }}">

View File

@ -17,7 +17,7 @@
@endif
@if($invoice->isPayable())
@livewire('invoice-pay', ['invoices' => $invoices, 'invitation_id' => $invitation->id, 'db' => $invoice->company->db, 'variables' => $variables])
@livewire('flow2.invoice-pay', ['invoices' => $invoices, 'invitation_id' => $invitation->id, 'db' => $invoice->company->db, 'variables' => $variables])
@endif
@include('portal.ninja2020.components.entity-documents', ['entity' => $invoice])