diff --git a/.gitignore b/.gitignore
index 2b9f631c7963..0b3c53481c27 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,4 +39,5 @@ public/storage/test.pdf
_ide_helper_models.php
_ide_helper.php
/composer.phar
-.tx/
\ No newline at end of file
+.tx/
+.phpunit.cache
\ No newline at end of file
diff --git a/app/Factory/SubscriptionFactory.php b/app/Factory/SubscriptionFactory.php
index 8395afb10d83..315716b10772 100644
--- a/app/Factory/SubscriptionFactory.php
+++ b/app/Factory/SubscriptionFactory.php
@@ -11,7 +11,9 @@
namespace App\Factory;
+use App\Livewire\BillingPortal\Purchase;
use App\Models\Subscription;
+use App\Services\Subscription\StepService;
class SubscriptionFactory
{
@@ -20,6 +22,9 @@ class SubscriptionFactory
$billing_subscription = new Subscription();
$billing_subscription->company_id = $company_id;
$billing_subscription->user_id = $user_id;
+ $billing_subscription->steps = collect(Purchase::defaultSteps())
+ ->map(fn($step) => StepService::mapClassNameToString($step))
+ ->implode(',');
return $billing_subscription;
}
diff --git a/app/Helpers/Subscription/SubscriptionCalculator.php b/app/Helpers/Subscription/SubscriptionCalculator.php
deleted file mode 100644
index 4413b0d593a7..000000000000
--- a/app/Helpers/Subscription/SubscriptionCalculator.php
+++ /dev/null
@@ -1,96 +0,0 @@
-target_subscription = $target_subscription;
- $this->invoice = $invoice;
- }
-
- /**
- * Tests if the user is currently up
- * to date with their payments for
- * a given recurring invoice
- *
- * @return bool
- */
- public function isPaidUp(): bool
- {
- $outstanding_invoices_exist = Invoice::query()->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
- ->where('subscription_id', $this->invoice->subscription_id)
- ->where('client_id', $this->invoice->client_id)
- ->where('balance', '>', 0)
- ->exists();
-
- return ! $outstanding_invoices_exist;
- }
-
- public function calcUpgradePlan()
- {
- //set the starting refund amount
- $refund_amount = 0;
-
- $refund_invoice = false;
-
- //are they paid up to date.
-
- //yes - calculate refund
- if ($this->isPaidUp()) {
- $refund_invoice = $this->getRefundInvoice();
- }
-
- if ($refund_invoice) {
- /** @var \App\Models\Subscription $subscription **/
- $subscription = Subscription::find($this->invoice->subscription_id);
- $pro_rata = new ProRata();
-
- $to_date = $subscription->service()->getNextDateForFrequency(Carbon::parse($refund_invoice->date), $subscription->frequency_id);
-
- $refund_amount = $pro_rata->refund($refund_invoice->amount, now(), $to_date, $subscription->frequency_id);
-
- $charge_amount = $pro_rata->charge($this->target_subscription->price, now(), $to_date, $this->target_subscription->frequency_id);
-
- return $charge_amount - $refund_amount;
- }
-
- //no - return full freight charge.
- return $this->target_subscription->price;
- }
-
- public function executeUpgradePlan()
- {
- }
-
- private function getRefundInvoice()
- {
- return Invoice::where('subscription_id', $this->invoice->subscription_id)
- ->where('client_id', $this->invoice->client_id)
- ->where('is_deleted', 0)
- ->orderBy('id', 'desc')
- ->first();
- }
-}
diff --git a/app/Http/Controllers/Auth/ContactRegisterController.php b/app/Http/Controllers/Auth/ContactRegisterController.php
index eb942c1780f3..b47dce8e54d4 100644
--- a/app/Http/Controllers/Auth/ContactRegisterController.php
+++ b/app/Http/Controllers/Auth/ContactRegisterController.php
@@ -11,17 +11,14 @@
namespace App\Http\Controllers\Auth;
-use App\Factory\ClientContactFactory;
-use App\Factory\ClientFactory;
use App\Http\Controllers\Controller;
use App\Http\Requests\ClientPortal\RegisterRequest;
-use App\Models\Client;
+use App\Livewire\BillingPortal\Authentication\ClientRegisterService;
use App\Models\Company;
use App\Utils\Ninja;
use App\Utils\Traits\GeneratesCounter;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Auth;
-use Illuminate\Support\Facades\Hash;
class ContactRegisterController extends Controller
{
@@ -53,53 +50,16 @@ class ContactRegisterController extends Controller
public function register(RegisterRequest $request)
{
$request->merge(['company' => $request->company()]);
+
+ $service = new ClientRegisterService(
+ company: $request->company(),
+ );
- $client = $this->getClient($request->all());
- $client_contact = $this->getClientContact($request->all(), $client);
+ $client = $service->createClient($request->all());
+ $client_contact = $service->createClientContact($request->all(), $client);
Auth::guard('contact')->loginUsingId($client_contact->id, true);
return redirect()->intended(route('client.dashboard'));
}
-
- private function getClient(array $data)
- {
- $client = ClientFactory::create($data['company']->id, $data['company']->owner()->id);
-
- $client->fill($data);
-
- $client->save();
-
- if (isset($data['currency_id'])) {
- $settings = $client->settings;
- $settings->currency_id = isset($data['currency_id']) ? $data['currency_id'] : $data['company']->settings->currency_id;
- $client->settings = $settings;
- }
-
- $client->number = $this->getNextClientNumber($client);
- $client->save();
-
- if (! array_key_exists('country_id', $data) && strlen($client->company->settings->country_id) > 1) {
- $client->update(['country_id' => $client->company->settings->country_id]);
- }
-
- return $client;
- }
-
- public function getClientContact(array $data, Client $client)
- {
- $client_contact = ClientContactFactory::create($data['company']->id, $data['company']->owner()->id);
- $client_contact->fill($data);
-
- $client_contact->client_id = $client->id;
- $client_contact->is_primary = true;
-
- if (array_key_exists('password', $data)) {
- $client_contact->password = Hash::make($data['password']);
- }
-
- $client_contact->save();
-
- return $client_contact;
- }
}
diff --git a/app/Http/Controllers/ClientPortal/PrePaymentController.php b/app/Http/Controllers/ClientPortal/PrePaymentController.php
index 643a05aba46d..d2659d566064 100644
--- a/app/Http/Controllers/ClientPortal/PrePaymentController.php
+++ b/app/Http/Controllers/ClientPortal/PrePaymentController.php
@@ -105,6 +105,13 @@ class PrePaymentController extends Controller
return $invoice;
});
+
+ $variables = false;
+
+ if(($invitation = $invoices->first()->invitations()->first() ?? false) && $invoice->client->getSetting('show_accept_invoice_terms')) {
+ $variables = (new HtmlEngine($invitation))->generateLabelsAndValues();
+ }
+
$data = [
'settings' => auth()->guard('contact')->user()->client->getMergedSettings(),
'invoices' => $invoices,
diff --git a/app/Http/Controllers/ClientPortal/SubscriptionPurchaseController.php b/app/Http/Controllers/ClientPortal/SubscriptionPurchaseController.php
index ffc5e517eeaf..f7cdc576b093 100644
--- a/app/Http/Controllers/ClientPortal/SubscriptionPurchaseController.php
+++ b/app/Http/Controllers/ClientPortal/SubscriptionPurchaseController.php
@@ -75,6 +75,17 @@ class SubscriptionPurchaseController extends Controller
]);
}
+ public function v3(Subscription $subscription, Request $request)
+ {
+ // Todo: Prerequirement checks for subscription.
+
+ return view('billing-portal.v3.index', [
+ 'subscription' => $subscription,
+ 'hash' => Str::uuid()->toString(),
+ 'request_data' => $request->all(),
+ ]);
+ }
+
/**
* Set locale for incoming request.
*
diff --git a/app/Http/Controllers/SubscriptionStepsController.php b/app/Http/Controllers/SubscriptionStepsController.php
new file mode 100644
index 000000000000..81243ffbbba4
--- /dev/null
+++ b/app/Http/Controllers/SubscriptionStepsController.php
@@ -0,0 +1,43 @@
+map(fn($dependency) => [
+ 'id' => $dependency['id'],
+ 'dependencies' => collect($dependency['dependencies'])
+ ->map(fn($dependency) => Purchase::$dependencies[$dependency]['id'])
+ ->toArray(),
+ ])
+ ->toArray();
+
+ return response()->json($dependencies);
+ }
+
+ public function check(): JsonResponse
+ {
+ request()->validate(([
+ 'steps' => ['required', new Steps()]
+ ]));
+
+ return response()->json([], 200);
+ }
+}
diff --git a/app/Http/Requests/Subscription/StoreSubscriptionRequest.php b/app/Http/Requests/Subscription/StoreSubscriptionRequest.php
index 2a358ac37a94..1831366c7414 100644
--- a/app/Http/Requests/Subscription/StoreSubscriptionRequest.php
+++ b/app/Http/Requests/Subscription/StoreSubscriptionRequest.php
@@ -14,6 +14,7 @@ namespace App\Http\Requests\Subscription;
use App\Http\Requests\Request;
use App\Models\Account;
use App\Models\Subscription;
+use App\Rules\Subscriptions\Steps;
use Illuminate\Validation\Rule;
class StoreSubscriptionRequest extends Request
@@ -63,7 +64,8 @@ class StoreSubscriptionRequest extends Request
'registration_required' => 'bail|sometimes|bool',
'optional_recurring_product_ids' => 'bail|sometimes|nullable|string',
'optional_product_ids' => 'bail|sometimes|nullable|string',
- 'use_inventory_management' => 'bail|sometimes|bool'
+ 'use_inventory_management' => 'bail|sometimes|bool',
+ 'steps' => ['required', new Steps()],
];
return $this->globalRules($rules);
diff --git a/app/Http/Requests/Subscription/UpdateSubscriptionRequest.php b/app/Http/Requests/Subscription/UpdateSubscriptionRequest.php
index 811d38000c67..c21af7024223 100644
--- a/app/Http/Requests/Subscription/UpdateSubscriptionRequest.php
+++ b/app/Http/Requests/Subscription/UpdateSubscriptionRequest.php
@@ -12,6 +12,7 @@
namespace App\Http\Requests\Subscription;
use App\Http\Requests\Request;
+use App\Rules\Subscriptions\Steps;
use App\Utils\Traits\ChecksEntityStatus;
use Illuminate\Validation\Rule;
@@ -65,6 +66,7 @@ class UpdateSubscriptionRequest extends Request
'optional_recurring_product_ids' => 'bail|sometimes|nullable|string',
'optional_product_ids' => 'bail|sometimes|nullable|string',
'use_inventory_management' => 'bail|sometimes|bool',
+ 'steps' => ['required', new Steps()],
];
return $this->globalRules($rules);
diff --git a/app/Livewire/BillingPortal/Authentication/ClientRegisterService.php b/app/Livewire/BillingPortal/Authentication/ClientRegisterService.php
new file mode 100644
index 000000000000..35cea03d6941
--- /dev/null
+++ b/app/Livewire/BillingPortal/Authentication/ClientRegisterService.php
@@ -0,0 +1,132 @@
+company->client_registration_fields as $field) {
+ if ($field['visible'] ?? true) {
+ $rules[$field['key']] = $field['required'] ? ['bail', 'required'] : ['sometimes'];
+ }
+ }
+
+ foreach ($rules as $field => $properties) {
+ if ($field === 'email') {
+ $rules[$field] = array_merge($rules[$field], ['email:rfc,dns', 'max:191', Rule::unique('client_contacts')->where('company_id', $this->company->id)]);
+ }
+
+ if ($field === 'current_password' || $field === 'password') {
+ $rules[$field] = array_merge($rules[$field], ['string', 'min:6', 'confirmed']);
+ }
+ }
+
+ if ($this->company->settings->client_portal_terms || $this->company->settings->client_portal_privacy_policy) {
+ $rules['terms'] = ['required'];
+ }
+
+ foreach ($this->additional as $field) {
+ if ($field['visible'] ?? true) {
+ $rules[$field['key']] = $field['required'] ? ['bail', 'required'] : ['sometimes'];
+ }
+ }
+
+ return $rules;
+ }
+
+ public function createClient(array $data): Client
+ {
+ $client = ClientFactory::create($this->company->id, $this->company->owner()->id);
+
+ $client->fill($data);
+
+ $client->save();
+
+ if (isset($data['currency_id'])) {
+ $settings = $client->settings;
+ $settings->currency_id = isset($data['currency_id']) ? $data['currency_id'] : $this->company->settings->currency_id;
+ $client->settings = $settings;
+ }
+
+ $client->number = $this->getNextClientNumber($client);
+ $client->save();
+
+ if (!array_key_exists('country_id', $data) && strlen($client->company->settings->country_id) > 1) {
+ $client->update(['country_id' => $client->company->settings->country_id]);
+ }
+
+ return $client;
+ }
+
+ public function createClientContact(array $data, Client $client): ClientContact
+ {
+ $client_contact = ClientContactFactory::create($this->company->id, $this->company->owner()->id);
+ $client_contact->fill($data);
+
+ $client_contact->client_id = $client->id;
+ $client_contact->is_primary = true;
+
+ if (array_key_exists('password', $data)) {
+ $client_contact->password = Hash::make($data['password']);
+ }
+
+ $client_contact->save();
+
+ return $client_contact;
+ }
+
+ public static function mappings(): array
+ {
+ return [
+ 'contact_first_name' => 'first_name',
+ 'contact_last_name' => 'last_name',
+ 'contact_email' => 'email',
+ 'client_phone' => 'phone',
+ 'client_city' => 'city',
+ 'client_address_line_1' => 'address1',
+ 'client_address_line_2' => 'address2',
+ 'client_state' => 'state',
+ 'client_country_id' => 'country_id',
+ 'client_postal_code' => 'postal_code',
+ 'client_shipping_postal_code' => 'shipping_postal_code',
+ 'client_shipping_address_line_1' => 'shipping_address1',
+ 'client_shipping_city' => 'shipping_city',
+ 'client_shipping_state' => 'shipping_state',
+ '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',
+ ];
+ }
+}
diff --git a/app/Livewire/BillingPortal/Authentication/Login.php b/app/Livewire/BillingPortal/Authentication/Login.php
new file mode 100644
index 000000000000..3c24baae9562
--- /dev/null
+++ b/app/Livewire/BillingPortal/Authentication/Login.php
@@ -0,0 +1,174 @@
+ false, // Use as preference. E-mail/password or OTP.
+ 'login_form' => false,
+ 'otp_form' => false,
+ 'initial_completed' => false,
+ ];
+
+ public function initial()
+ {
+ $this->validateOnly('email', ['email' => 'required|bail|email:rfc|email']);
+
+ $contact = ClientContact::where('email', $this->email)
+ ->where('company_id', $this->subscription->company_id)
+ ->first();
+
+ if ($contact === null) {
+ $this->addError('email', ctrans('texts.checkout_only_for_existing_customers'));
+
+ return;
+ }
+
+ $this->state['initial_completed'] = true;
+
+ if ($this->state['otp']) {
+ return $this->withOtp();
+ }
+
+ return $this->withPassword();
+ }
+
+ public function withPassword()
+ {
+ $contact = ClientContact::where('email', $this->email)
+ ->where('company_id', $this->subscription->company_id)
+ ->first();
+
+ if ($contact) {
+ return $this->state['login_form'] = true;
+ }
+
+ $this->state['login_form'] = false;
+
+ $contact = $this->createClientContact();
+
+ auth()->guard('contact')->loginUsingId($contact->id, true);
+
+ $this->dispatch('purchase.context', property: 'contact', value: $contact);
+ $this->dispatch('purchase.next');
+ }
+
+ public function withOtp()
+ {
+ $code = rand(100000, 999999);
+ $email_hash = "subscriptions:otp:{$this->email}";
+
+ Cache::put($email_hash, $code, 600);
+
+ $cc = new ClientContact();
+ $cc->email = $this->email;
+
+ $nmo = new NinjaMailerObject();
+ $nmo->mailable = new OtpCode($this->subscription->company, $this->context['contact'] ?? null, $code);
+ $nmo->company = $this->subscription->company;
+ $nmo->settings = $this->subscription->company->settings;
+ $nmo->to_user = $cc;
+
+ NinjaMailerJob::dispatch($nmo);
+
+ if (app()->environment('local')) {
+ session()->flash('message', "[dev]: Your OTP is: {$code}");
+ }
+
+ $this->state['otp_form'] = true;
+ }
+
+ public function handleOtp()
+ {
+ $this->validate([
+ 'otp' => 'required|numeric|digits:6 ',
+ 'email' => 'required|bail|email:rfc|exists:client_contacts,email',
+ ]);
+
+ $code = Cache::get("subscriptions:otp:{$this->email}");
+
+ if ($this->otp != $code) { //loose comparison prevents edge cases
+ $errors = $this->getErrorBag();
+ $errors->add('otp', ctrans('texts.invalid_code'));
+
+ return;
+ }
+
+ $contact = ClientContact::where('email', $this->email)
+ ->where('company_id', $this->subscription->company_id)
+ ->first();
+
+ if ($contact) {
+ auth()->guard('contact')->loginUsingId($contact->id, true);
+
+ $this->dispatch('purchase.context', property: 'contact', value: $contact);
+ $this->dispatch('purchase.next');
+
+ return;
+ }
+ }
+
+ public function handlePassword()
+ {
+ $this->validate([
+ 'email' => 'required|bail|email:rfc',
+ 'password' => 'required',
+ ]);
+
+ $attempt = auth()->guard('contact')->attempt([
+ 'email' => $this->email,
+ 'password' => $this->password,
+ 'company_id' => $this->subscription->company_id,
+ ]);
+
+ if ($attempt) {
+
+ $this->dispatch('purchase.context', property: 'contact', value: auth()->guard('contact')->user());
+ $this->dispatch('purchase.next');
+ }
+
+ session()->flash('message', 'These credentials do not match our records.');
+ }
+
+ public function mount()
+ {
+ if (auth()->guard('contact')->check()) {
+ $this->dispatch('purchase.context', property: 'contact', value: auth()->guard('contact')->user());
+ $this->dispatch('purchase.next');
+ }
+ }
+
+ public function render(): \Illuminate\View\View
+ {
+ return view('billing-portal.v3.authentication.login');
+ }
+}
\ No newline at end of file
diff --git a/app/Livewire/BillingPortal/Authentication/Register.php b/app/Livewire/BillingPortal/Authentication/Register.php
new file mode 100644
index 000000000000..43c5631b5f47
--- /dev/null
+++ b/app/Livewire/BillingPortal/Authentication/Register.php
@@ -0,0 +1,154 @@
+ false,
+ 'register_form' => false,
+ ];
+
+ public array $register_fields = [];
+
+ public array $additional_fields = [];
+
+ public function initial(): void
+ {
+ $this->validateOnly('email', ['email' => 'required|bail|email:rfc']);
+
+ $contact = ClientContact::where('email', $this->email)
+ ->where('company_id', $this->subscription->company_id)
+ ->first();
+
+ if ($contact) {
+ $this->addError('email', ctrans('texts.checkout_only_for_new_customers'));
+
+ return;
+ }
+
+ $this->state['initial_completed'] = true;
+
+ $this->registerForm();
+ }
+
+ public function register(array $data)
+ {
+ $service = new ClientRegisterService(
+ company: $this->subscription->company,
+ additional: $this->additional_fields,
+ );
+
+ $rules = $service->rules();
+
+ $data = Validator::make($data, $rules)->validate();
+
+ $client = $service->createClient($data);
+ $contact = $service->createClientContact($data, $client);
+
+ auth()->guard('contact')->loginUsingId($contact->id, true);
+
+ $this->dispatch('purchase.context', property: 'contact', value: $contact);
+ $this->dispatch('purchase.next');
+ }
+
+ public function registerForm()
+ {
+ $count = collect($this->subscription->company->client_registration_fields ?? [])
+ ->filter(fn($field) => $field['required'] === true || $field['visible'] === true)
+ ->count();
+
+ if ($count === 0) {
+ $service = new ClientRegisterService(
+ company: $this->subscription->company,
+ );
+
+ $client = $service->createClient([]);
+ $contact = $service->createClientContact(['email' => $this->email], $client);
+
+ auth()->guard('contact')->loginUsingId($contact->id, true);
+
+ $this->dispatch('purchase.context', property: 'contact', value: $contact);
+ $this->dispatch('purchase.next');
+
+ return;
+ }
+
+ $this->register_fields = [...collect($this->subscription->company->client_registration_fields ?? [])->toArray()];
+
+ $first_gateway = collect($this->subscription->company->company_gateways)
+ ->sortBy('sort_order')
+ ->first();
+
+ $mappings = ClientRegisterService::mappings();
+
+ collect($first_gateway->driver()->getClientRequiredFields() ?? [])
+ ->each(function ($field) use ($mappings) {
+ $mapping = $mappings[$field['name']] ?? null;
+
+ if ($mapping === null) {
+ return;
+ }
+
+ $i = collect($this->register_fields)->search(fn ($field) => $field['key'] == $mapping);
+
+ if ($i !== false) {
+ $this->register_fields[$i]['visible'] = true;
+ $this->register_fields[$i]['required'] = true;
+
+
+ $this->additional_fields[] = $this->register_fields[$i];
+ } else {
+ $field = [
+ 'key' => $mapping,
+ 'required' => true,
+ 'visible' => true,
+ ];
+
+ $this->register_fields[] = $field;
+ $this->additional_fields[] = $field;
+ }
+ })
+ ->toArray();
+
+ return $this->state['register_form'] = true;
+ }
+
+ public function mount()
+ {
+ if (auth()->guard('contact')->check()) {
+ $this->dispatch('purchase.context', property: 'contact', value: auth()->guard('contact')->user());
+ $this->dispatch('purchase.next');
+ }
+ }
+
+ public function render()
+ {
+ return view('billing-portal.v3.authentication.register');
+ }
+}
diff --git a/app/Livewire/BillingPortal/Authentication/RegisterOrLogin.php b/app/Livewire/BillingPortal/Authentication/RegisterOrLogin.php
new file mode 100644
index 000000000000..f3d84177507d
--- /dev/null
+++ b/app/Livewire/BillingPortal/Authentication/RegisterOrLogin.php
@@ -0,0 +1,261 @@
+ false, // Use as preference. E-mail/password or OTP.
+ 'login_form' => false,
+ 'otp_form' => false,
+ 'register_form' => false,
+ 'initial_completed' => false,
+ ];
+
+ public array $register_fields = [];
+
+ public array $additional_fields = [];
+
+ public function initial()
+ {
+ $this->validateOnly('email', ['email' => 'required|bail|email:rfc']);
+
+ $this->state['initial_completed'] = true;
+
+ if ($this->state['otp']) {
+ return $this->withOtp();
+ }
+
+ return $this->withPassword();
+ }
+
+ public function withPassword()
+ {
+ $contact = ClientContact::where('email', $this->email)
+ ->where('company_id', $this->subscription->company_id)
+ ->first();
+
+ if ($contact) {
+ return $this->state['login_form'] = true;
+ }
+
+ $this->state['login_form'] = false;
+ $this->registerForm();
+ }
+
+ public function handlePassword()
+ {
+ $this->validate([
+ 'email' => 'required|bail|email:rfc',
+ 'password' => 'required',
+ ]);
+
+ $attempt = auth()->guard('contact')->attempt([
+ 'email' => $this->email,
+ 'password' => $this->password,
+ 'company_id' => $this->subscription->company_id,
+ ]);
+
+ if ($attempt) {
+ $this->dispatch('purchase.next');
+ }
+
+ session()->flash('message', 'These credentials do not match our records.');
+ }
+
+ public function withOtp(): void
+ {
+ $contact = ClientContact::where('email', $this->email)
+ ->where('company_id', $this->subscription->company_id)
+ ->first();
+
+ if ($contact === null) {
+ $this->registerForm();
+
+ return;
+ }
+
+ $code = rand(100000, 999999);
+ $email_hash = "subscriptions:otp:{$this->email}";
+
+ Cache::put($email_hash, $code, 600);
+
+ $cc = new ClientContact();
+ $cc->email = $this->email;
+
+ $nmo = new NinjaMailerObject();
+ $nmo->mailable = new OtpCode($this->subscription->company, $this->context['contact'] ?? null, $code);
+ $nmo->company = $this->subscription->company;
+ $nmo->settings = $this->subscription->company->settings;
+ $nmo->to_user = $cc;
+
+ NinjaMailerJob::dispatch($nmo);
+
+ if (app()->environment('local')) {
+ session()->flash('message', "[dev]: Your OTP is: {$code}");
+ }
+
+ $this->state['otp_form'] = true;
+ }
+
+ public function handleOtp(): void
+ {
+ $this->validate([
+ 'otp' => 'required|numeric|digits:6',
+ ]);
+
+ $code = Cache::get("subscriptions:otp:{$this->email}");
+
+ if ($this->otp != $code) { //loose comparison prevents edge cases
+ $errors = $this->getErrorBag();
+ $errors->add('otp', ctrans('texts.invalid_code'));
+
+ return;
+ }
+
+ $contact = ClientContact::where('email', $this->email)
+ ->where('company_id', $this->subscription->company_id)
+ ->first();
+
+ if ($contact) {
+ auth()->guard('contact')->loginUsingId($contact->id, true);
+
+ $this->dispatch('purchase.context', property: 'contact', value: $contact);
+ $this->dispatch('purchase.next');
+
+ return;
+ }
+
+ $this->state['otp_form'] = false;
+ $this->registerForm();
+ }
+
+ public function register(array $data): void
+ {
+ $service = new ClientRegisterService(
+ company: $this->subscription->company,
+ additional: $this->additional_fields,
+ );
+
+ $rules = $service->rules();
+ $data = Validator::make($data, $rules)->validate();
+
+ $client = $service->createClient($data);
+ $contact = $service->createClientContact($data, $client);
+
+ auth()->guard('contact')->loginUsingId($contact->id, true);
+
+ $this->dispatch('purchase.context', property: 'contact', value: $contact);
+ $this->dispatch('purchase.next');
+ }
+
+ public function registerForm()
+ {
+ $count = collect($this->subscription->company->client_registration_fields ?? [])
+ ->filter(fn($field) => $field['required'] === true || $field['visible'] === true)
+ ->count();
+
+ if ($count === 0) {
+ $service = new ClientRegisterService(
+ company: $this->subscription->company,
+ );
+
+ $client = $service->createClient([]);
+ $contact = $service->createClientContact(['email' => $this->email], $client);
+
+ auth()->guard('contact')->loginUsingId($contact->id, true);
+
+ $this->dispatch('purchase.context', property: 'contact', value: $contact);
+ $this->dispatch('purchase.next');
+
+ return;
+ }
+
+ $this->register_fields = [...collect($this->subscription->company->client_registration_fields ?? [])->toArray()];
+
+ $first_gateway = collect($this->subscription->company->company_gateways)
+ ->sortBy('sort_order')
+ ->first();
+
+ $mappings = ClientRegisterService::mappings();
+
+ collect($first_gateway->driver()->getClientRequiredFields() ?? [])
+ ->each(function ($field) use ($mappings) {
+ $mapping = $mappings[$field['name']] ?? null;
+
+ if ($mapping === null) {
+ return;
+ }
+
+ $i = collect($this->register_fields)->search(fn ($field) => $field['key'] == $mapping);
+
+ if ($i !== false) {
+ $this->register_fields[$i]['visible'] = true;
+ $this->register_fields[$i]['required'] = true;
+
+
+ $this->additional_fields[] = $this->register_fields[$i];
+ } else {
+ $field = [
+ 'key' => $mapping,
+ 'required' => true,
+ 'visible' => true,
+ ];
+
+ $this->register_fields[] = $field;
+ $this->additional_fields[] = $field;
+ }
+ })
+ ->toArray();
+
+ return $this->state['register_form'] = true;
+ }
+
+ public function mount()
+ {
+ if (auth()->guard('contact')->check()) {
+ $this->dispatch('purchase.context', property: 'contact', value: auth()->guard('contact')->user());
+ $this->dispatch('purchase.next');
+
+ return;
+ }
+ }
+
+ public function render()
+ {
+ $countries = Cache::get('countries');
+
+ return view('billing-portal.v3.authentication.register-or-login', [
+ 'countries' => $countries,
+ ]);
+ }
+}
diff --git a/app/Livewire/BillingPortal/Cart/Cart.php b/app/Livewire/BillingPortal/Cart/Cart.php
new file mode 100644
index 000000000000..f8fae332df76
--- /dev/null
+++ b/app/Livewire/BillingPortal/Cart/Cart.php
@@ -0,0 +1,44 @@
+dispatch('purchase.next');
+ }
+
+ public function showOptionalProductsLabel()
+ {
+ $optional = [
+ ...$this->context['bundle']['optional_recurring_products'] ?? [],
+ ...$this->context['bundle']['optional_one_time_products'] ?? [],
+ ];
+
+ return count($optional) > 0;
+ }
+
+ public function render()
+ {
+ return view('billing-portal.v3.cart.cart');
+ }
+}
diff --git a/app/Livewire/BillingPortal/Cart/OneTimeProducts.php b/app/Livewire/BillingPortal/Cart/OneTimeProducts.php
new file mode 100644
index 000000000000..f6c538037dce
--- /dev/null
+++ b/app/Livewire/BillingPortal/Cart/OneTimeProducts.php
@@ -0,0 +1,33 @@
+dispatch('purchase.context', property: "bundle.one_time_products.{$id}.quantity", value: $value);
+ }
+
+ public function render()
+ {
+ return view('billing-portal.v3.cart.one-time-products');
+ }
+}
diff --git a/app/Livewire/BillingPortal/Cart/OptionalOneTimeProducts.php b/app/Livewire/BillingPortal/Cart/OptionalOneTimeProducts.php
new file mode 100644
index 000000000000..22350ee06b69
--- /dev/null
+++ b/app/Livewire/BillingPortal/Cart/OptionalOneTimeProducts.php
@@ -0,0 +1,33 @@
+dispatch('purchase.context', property: "bundle.optional_one_time_products.{$id}.quantity", value: $value);
+ }
+
+ public function render(): \Illuminate\View\View
+ {
+ return view('billing-portal.v3.cart.optional-one-time-products');
+ }
+}
diff --git a/app/Livewire/BillingPortal/Cart/OptionalRecurringProducts.php b/app/Livewire/BillingPortal/Cart/OptionalRecurringProducts.php
new file mode 100644
index 000000000000..9193ba59ae7c
--- /dev/null
+++ b/app/Livewire/BillingPortal/Cart/OptionalRecurringProducts.php
@@ -0,0 +1,35 @@
+dispatch('purchase.context', property: "bundle.optional_recurring_products.{$id}.quantity", value: $value);
+
+ }
+
+ public function render(): \Illuminate\View\View
+ {
+ return view('billing-portal.v3.cart.optional-recurring-products');
+ }
+}
diff --git a/app/Livewire/BillingPortal/Cart/RecurringProducts.php b/app/Livewire/BillingPortal/Cart/RecurringProducts.php
new file mode 100644
index 000000000000..d57bc9ede43e
--- /dev/null
+++ b/app/Livewire/BillingPortal/Cart/RecurringProducts.php
@@ -0,0 +1,33 @@
+dispatch('purchase.context', property: "bundle.recurring_products.{$id}.quantity", value: $value);
+ }
+
+ public function render(): \Illuminate\View\View
+ {
+ return view('billing-portal.v3.cart.recurring-products');
+ }
+}
diff --git a/app/Livewire/BillingPortal/Example.php b/app/Livewire/BillingPortal/Example.php
new file mode 100644
index 000000000000..00cdba32fe2e
--- /dev/null
+++ b/app/Livewire/BillingPortal/Example.php
@@ -0,0 +1,33 @@
+dispatch('purchase.context', property: 'quantity', value: 1);
+ $this->dispatch('purchase.next');
+ }
+
+ public function render()
+ {
+ return <<<'HTML'
+
This is step after auth. Currently logged in user is {{ $context['contact']['email'] }}.
+ HTML;
+ }
+}
diff --git a/app/Livewire/BillingPortal/Payments/Methods.php b/app/Livewire/BillingPortal/Payments/Methods.php
new file mode 100644
index 000000000000..d467b0d85aa5
--- /dev/null
+++ b/app/Livewire/BillingPortal/Payments/Methods.php
@@ -0,0 +1,82 @@
+context['products'])->sum('total_raw');
+
+ $methods = auth()->guard('contact')->user()->client->service()->getPaymentMethods(
+ $total,
+ );
+
+ $this->methods = $methods;
+ }
+
+ public function handleSelect(string $company_gateway_id, string $gateway_type_id)
+ {
+ /** @var \App\Models\ClientContact $contact */
+ $contact = auth()->guard('contact')->user();
+
+ $this->dispatch('purchase.context', property: 'client_id', value: $contact->client->hashed_id);
+
+ $this->context['client_id'] = $contact->client->hashed_id;
+
+ $invoice = $this->subscription
+ ->calc()
+ ->buildPurchaseInvoice($this->context)
+ ->service()
+ ->markSent()
+ ->fillDefaults()
+ ->adjustInventory()
+ ->save();
+
+ Cache::put($this->context['hash'], [
+ 'subscription_id' => $this->subscription->hashed_id,
+ 'email' => $contact->email,
+ 'client_id' => $contact->client->hashed_id,
+ 'invoice_id' => $invoice->hashed_id,
+ 'context' => 'purchase',
+ 'campaign' => $this->context['campaign'],
+ 'bundle' => $this->context['bundle'],
+ ], now()->addMinutes(60));
+
+ $payable_amount = $invoice->partial > 0
+ ? \App\Utils\Number::formatValue($invoice->partial, $invoice->client->currency())
+ : \App\Utils\Number::formatValue($invoice->balance, $invoice->client->currency());
+
+ $this->dispatch('purchase.context', property: 'form.company_gateway_id', value: $company_gateway_id);
+ $this->dispatch('purchase.context', property: 'form.payment_method_id', value: $gateway_type_id);
+ $this->dispatch('purchase.context', property: 'form.invoice_hashed_id', value: $invoice->hashed_id);
+ $this->dispatch('purchase.context', property: 'form.payable_amount', value: $payable_amount);
+
+ $this->dispatch('purchase.next');
+ }
+
+ public function render()
+ {
+ return view('billing-portal.v3.payments.methods');
+ }
+}
diff --git a/app/Livewire/BillingPortal/Purchase.php b/app/Livewire/BillingPortal/Purchase.php
new file mode 100644
index 000000000000..bcad791b4ff2
--- /dev/null
+++ b/app/Livewire/BillingPortal/Purchase.php
@@ -0,0 +1,169 @@
+ [
+ 'id' => 'auth.login',
+ 'dependencies' => [],
+ ],
+ RegisterOrLogin::class => [
+ 'id' => 'auth.login-or-register',
+ 'dependencies' => [],
+ ],
+ Register::class => [
+ 'id' => 'auth.register',
+ 'dependencies' => [],
+ ],
+ Cart::class => [
+ 'id' => 'cart',
+ 'dependencies' => [],
+ ],
+ ];
+
+ public array $steps = [];
+
+ public array $context = [];
+
+ #[On('purchase.context')]
+ public function handleContext(string $property, $value): self
+ {
+ $clone = $this->context;
+
+ data_set($this->context, $property, $value);
+
+ // The following may not be needed, as we can pass arround $context.
+ // cache()->set($this->hash, $this->context);
+
+ if ($clone !== $this->context) {
+ $this->id = Str::uuid();
+ }
+
+ return $this;
+ }
+
+ #[On('purchase.next')]
+ public function handleNext(): void
+ {
+ if (count($this->steps) >= 1 && $this->step < count($this->steps) - 1) {
+ $this->step++;
+ $this->id = Str::uuid();
+ }
+ }
+
+ #[On('purchase.forward')]
+ public function handleForward(string $component): void
+ {
+ $this->step = array_search($component, $this->steps);
+
+ $this->id = Str::uuid();
+ }
+
+ #[Computed()]
+ public function component(): string
+ {
+ return $this->steps[$this->step];
+ }
+
+ #[Computed()]
+ public function componentUniqueId(): string
+ {
+ return "purchase-{$this->id}";
+ }
+
+ #[Computed()]
+ public function summaryUniqueId(): string
+ {
+ return "summary-{$this->id}";
+ }
+
+ public static function defaultSteps()
+ {
+ return [
+ Cart::class,
+ RegisterOrLogin::class,
+ ];
+ }
+
+ public function mount()
+ {
+ $classes = collect(self::$dependencies)->mapWithKeys(fn($dependency, $class) => [$dependency['id'] => $class])->toArray();
+
+ if ($this->subscription->steps) {
+ $steps = collect(explode(',', $this->subscription->steps))
+ ->map(fn($step) => $classes[$step])
+ ->toArray();
+
+ $this->steps = [
+ Setup::class,
+ ...$steps,
+ Methods::class,
+ RFF::class,
+ Submit::class,
+ ];
+ } else {
+ $this->steps = [
+ Setup::class,
+ ...self::defaultSteps(),
+ Methods::class,
+ RFF::class,
+ Submit::class,
+ ];
+ }
+
+ $this->id = Str::uuid();
+
+ MultiDB::setDb($this->db);
+
+ $this
+ ->handleContext('hash', $this->hash)
+ ->handleContext('quantity', 1)
+ ->handleContext('request_data', $this->request_data)
+ ->handleContext('campaign', $this->campaign);
+ }
+
+ public function render()
+ {
+ return view('billing-portal.v3.purchase');
+ }
+}
diff --git a/app/Livewire/BillingPortal/RFF.php b/app/Livewire/BillingPortal/RFF.php
new file mode 100644
index 000000000000..38b36f868108
--- /dev/null
+++ b/app/Livewire/BillingPortal/RFF.php
@@ -0,0 +1,83 @@
+dispatch('purchase.context', property: 'contact', value: auth()->guard('contact')->user());
+
+ $this->dispatch('purchase.next');
+ }
+
+ public function handleSubmit()
+ {
+ $data = $this->validate([
+ 'contact_first_name' => 'required',
+ 'contact_last_name' => 'required',
+ 'contact_email' => 'required|email:rfc',
+ ]);
+
+ $contact = auth()->guard('contact');
+
+ $contact->user()->update([
+ 'first_name' => $data['contact_first_name'],
+ 'last_name' => $data['contact_last_name'],
+ 'email' => $data['contact_email'],
+ ]);
+
+ $this->dispatch('purchase.context', property: 'contact', value: auth()->guard('contact')->user());
+
+ $this->dispatch('purchase.next');
+ }
+
+ public function mount(): void
+ {
+ $this->contact_first_name = $this->context['contact']['first_name'] ?? '';
+ $this->contact_last_name = $this->context['contact']['last_name'] ?? '';
+ $this->contact_email = $this->context['contact']['email'] ?? '';
+ }
+
+ public function render()
+ {
+ $gateway = CompanyGateway::find($this->context['form']['company_gateway_id']);
+ $countries = Cache::get('countries');
+
+ if ($gateway === null) {
+ return view('billing-portal.v3.rff-basic');
+ }
+
+ return view('billing-portal.v3.rff', [
+ 'gateway' => $gateway->driver(
+ auth()->guard('contact')->user()->client
+ ),
+ 'countries' => $countries,
+ 'company' => $gateway->company,
+ ]);
+ }
+}
diff --git a/app/Livewire/BillingPortal/Setup.php b/app/Livewire/BillingPortal/Setup.php
new file mode 100644
index 000000000000..bee2f4c21419
--- /dev/null
+++ b/app/Livewire/BillingPortal/Setup.php
@@ -0,0 +1,33 @@
+dispatch('purchase.context', property: 'quantity', value: 1);
+ $this->dispatch('purchase.next');
+ }
+
+ public function render()
+ {
+ return <<<'HTML'
+
+ HTML;
+ }
+}
diff --git a/app/Livewire/BillingPortal/Submit.php b/app/Livewire/BillingPortal/Submit.php
new file mode 100644
index 000000000000..a1324f96a3c1
--- /dev/null
+++ b/app/Livewire/BillingPortal/Submit.php
@@ -0,0 +1,76 @@
+ 'hidden',
+ // 'hash' => $this->context['hash'],
+ // 'action' => 'payment',
+ // 'invoices' => [
+ // $this->context['form']['invoice_hashed_id'],
+ // ],
+ // 'payable_invoices' => [
+ // [
+ // 'amount' => $this->context['form']['payable_amount'],
+ // 'invoice_id' => $this->context['form']['invoice_hashed_id'],
+ // ],
+ // ],
+ // 'company_gateway_id' => $this->context['form']['company_gateway_id'],
+ // 'payment_method_id' => $this->context['form']['payment_method_id'],
+ // 'contact_first_name' => $this->context['contact']['first_name'],
+ // 'contact_last_name' => $this->context['contact']['last_name'],
+ // 'contact_email' => $this->context['contact']['email'],
+ // ]);
+
+ // return redirect((new InstantPayment($request))->run());
+ // dd($this->context);
+
+ nlog($this->context);
+
+ $this->dispatch(
+ 'purchase.submit',
+ invoice_hashed_id: $this->context['form']['invoice_hashed_id'],
+ payable_amount: $this->context['form']['payable_amount'],
+ company_gateway_id: $this->context['form']['company_gateway_id'],
+ payment_method_id: $this->context['form']['payment_method_id'],
+ contact_first_name: $this->context['contact']['first_name'],
+ contact_last_name: $this->context['contact']['last_name'],
+ contact_email: $this->context['contact']['email'],
+ );
+ }
+
+ public function render()
+ {
+
+ return <<<'HTML'
+
+ HTML;
+ }
+}
diff --git a/app/Livewire/BillingPortal/Summary.php b/app/Livewire/BillingPortal/Summary.php
new file mode 100644
index 000000000000..25e7c86a7684
--- /dev/null
+++ b/app/Livewire/BillingPortal/Summary.php
@@ -0,0 +1,188 @@
+context['bundle'] ?? [
+ 'recurring_products' => [],
+ 'optional_recurring_products' => [],
+ 'one_time_products' => [],
+ 'optional_one_time_products' => [],
+ ];
+
+ foreach ($this->subscription->service()->recurring_products() as $key => $product) {
+ $bundle['recurring_products'][$product->hashed_id] = [
+ 'product' => $product,
+ 'quantity' => $bundle['recurring_products'][$product->hashed_id]['quantity'] ?? 1,
+ 'notes' => $product->markdownNotes(),
+ ];
+ $bundle['recurring_products'][$product->hashed_id]['product']['is_recurring'] = true;
+ }
+
+ foreach ($this->subscription->service()->products() as $key => $product) {
+ $bundle['one_time_products'][$product->hashed_id] = [
+ 'product' => $product,
+ 'quantity' => $bundle['one_time_products'][$product->hashed_id]['quantity'] ?? 1,
+ 'notes' => $product->markdownNotes(),
+ ];
+ $bundle['one_time_products'][$product->hashed_id]['product']['is_recurring'] = false;
+ }
+
+ foreach ($this->subscription->service()->optional_recurring_products() as $key => $product) {
+ $bundle['optional_recurring_products'][$product->hashed_id] = [
+ 'product' => $product,
+ 'quantity' => $bundle['optional_recurring_products'][$product->hashed_id]['quantity'] ?? 0,
+ 'notes' => $product->markdownNotes(),
+ ];
+ $bundle['optional_recurring_products'][$product->hashed_id]['product']['is_recurring'] = true;
+ }
+
+ foreach ($this->subscription->service()->optional_products() as $key => $product) {
+ $bundle['optional_one_time_products'][$product->hashed_id] = [
+ 'product' => $product,
+ 'quantity' => $bundle['optional_one_time_products'][$product->hashed_id]['quantity'] ?? 0,
+ 'notes' => $product->markdownNotes(),
+ ];
+ $bundle['optional_one_time_products'][$product->hashed_id]['product']['is_recurring'] = false;
+ }
+
+ $this->dispatch('purchase.context', property: 'bundle', value: $bundle);
+ }
+
+ public function oneTimePurchasesTotal(bool $raw = false)
+ {
+ if (isset($this->context['bundle']['recurring_products']) === false) {
+ return 0;
+ }
+
+ $one_time = collect($this->context['bundle']['one_time_products'])->sum(function ($item) {
+ return $item['product']['price'] * $item['quantity'];
+ });
+
+ $one_time_optional = collect($this->context['bundle']['optional_one_time_products'])->sum(function ($item) {
+ return $item['product']['price'] * $item['quantity'];
+ });
+
+ if ($raw) {
+ return $one_time + $one_time_optional;
+ }
+
+ return Number::formatMoney($one_time + $one_time_optional, $this->subscription->company);
+
+ }
+
+ public function recurringPurchasesTotal(bool $raw = false)
+ {
+ if (isset($this->context['bundle']['recurring_products']) === false) {
+ return 0;
+ }
+
+ $recurring = collect($this->context['bundle']['recurring_products'])->sum(function ($item) {
+ return $item['product']['price'] * $item['quantity'];
+ });
+
+ $recurring_optional = collect($this->context['bundle']['optional_recurring_products'])->sum(function ($item) {
+ return $item['product']['price'] * $item['quantity'];
+ });
+
+ if ($raw) {
+ return $recurring + $recurring_optional;
+ }
+
+ return \sprintf(
+ '%s/%s',
+ Number::formatMoney($recurring + $recurring_optional, $this->subscription->company),
+ RecurringInvoice::frequencyForKey($this->subscription->frequency_id)
+ );
+ }
+
+ #[Computed()]
+ public function total()
+ {
+ return Number::formatMoney(
+ collect([
+ $this->oneTimePurchasesTotal(raw: true),
+ $this->recurringPurchasesTotal(raw: true),
+ ])->sum(),
+ $this->subscription->company
+ );
+ }
+
+ public function items()
+ {
+ if (isset($this->context['bundle']) === false) {
+ return [];
+ }
+
+ $products = [];
+
+ foreach ($this->context['bundle']['recurring_products'] as $key => $item) {
+ $products[] = [
+ 'product_key' => $item['product']['product_key'],
+ 'quantity' => $item['quantity'],
+ 'total_raw' => $item['product']['price'] * $item['quantity'],
+ 'total' => Number::formatMoney($item['product']['price'] * $item['quantity'], $this->subscription->company) . ' / ' . RecurringInvoice::frequencyForKey($this->subscription->frequency_id),
+ ];
+ }
+
+ foreach ($this->context['bundle']['optional_recurring_products'] as $key => $item) {
+ $products[] = [
+ 'product_key' => $item['product']['product_key'],
+ 'quantity' => $item['quantity'],
+ 'total_raw' => $item['product']['price'] * $item['quantity'],
+ 'total' => Number::formatMoney($item['product']['price'] * $item['quantity'], $this->subscription->company) . ' / ' . RecurringInvoice::frequencyForKey($this->subscription->frequency_id),
+ ];
+ }
+
+ foreach ($this->context['bundle']['one_time_products'] as $key => $item) {
+ $products[] = [
+ 'product_key' => $item['product']['product_key'],
+ 'quantity' => $item['quantity'],
+ 'total_raw' => $item['product']['price'] * $item['quantity'],
+ 'total' => Number::formatMoney($item['product']['price'] * $item['quantity'], $this->subscription->company),
+ ];
+ }
+
+ foreach ($this->context['bundle']['optional_one_time_products'] as $key => $item) {
+ $products[] = [
+ 'product_key' => $item['product']['product_key'],
+ 'quantity' => $item['quantity'],
+ 'total_raw' => $item['product']['price'] * $item['quantity'],
+ 'total' => Number::formatMoney($item['product']['price'] * $item['quantity'], $this->subscription->company),
+ ];
+ }
+
+ $this->dispatch('purchase.context', property: 'products', value: $products);
+
+ return $products;
+ }
+
+ public function render()
+ {
+ return view('billing-portal.v3.summary');
+ }
+}
diff --git a/app/Livewire/RequiredClientInfo.php b/app/Livewire/RequiredClientInfo.php
index 5daabf4b29a3..b174a87cf8fd 100644
--- a/app/Livewire/RequiredClientInfo.php
+++ b/app/Livewire/RequiredClientInfo.php
@@ -12,7 +12,6 @@
namespace App\Livewire;
-use App\Models\Client;
use App\Models\Invoice;
use Livewire\Component;
use App\Libraries\MultiDB;
@@ -186,6 +185,8 @@ class RequiredClientInfo extends Component
public $company_gateway_id;
+ public bool $form_only = false;
+
public $db;
public function mount()
@@ -222,7 +223,7 @@ class RequiredClientInfo extends Component
$this->show_form = true;
$hash = Cache::get(request()->input('hash'));
-
+
/** @var \App\Models\Invoice $invoice */
$invoice = Invoice::find($this->decodePrimaryKey($hash['invoice_id']));
@@ -232,6 +233,15 @@ class RequiredClientInfo extends Component
count($this->fields) > 0 || $this->show_terms
? $this->checkFields()
: $this->show_form = false;
+
+ if (request()->query('source') === 'subscriptions') {
+ $this->show_form = false;
+
+ $this->dispatch(
+ 'passed-required-fields-check',
+ client_postal_code: $this->contact->client->postal_code
+ );
+ }
}
#[Computed]
@@ -240,7 +250,7 @@ class RequiredClientInfo extends Component
MultiDB::setDb($this->db);
return ClientContact::withTrashed()->find($this->contact_id);
-
+
}
#[Computed]
@@ -259,7 +269,7 @@ class RequiredClientInfo extends Component
public function handleSubmit(array $data): bool
{
-
+
MultiDB::setDb($this->db);
$contact = ClientContact::withTrashed()->find($this->contact_id);
@@ -367,7 +377,6 @@ $_contact->push();
public function checkFields()
{
-
MultiDB::setDb($this->db);
$_contact = ClientContact::withTrashed()->find($this->contact_id);
@@ -375,7 +384,10 @@ $_contact->push();
$_field = $this->mappings[$field['name']];
if (Str::startsWith($field['name'], 'client_')) {
- if (empty($_contact->client->{$_field}) || is_null($_contact->client->{$_field}) || in_array($_field, $this->client_address_array)) {
+ if (empty($_contact->client->{$_field})
+ || is_null($_contact->client->{$_field})
+ // || in_array($_field, $this->client_address_array)
+ ) {
$this->show_form = true;
} else {
$this->fields[$index]['filled'] = true;
@@ -390,6 +402,17 @@ $_contact->push();
}
}
}
+
+ $left = collect($this->fields)
+ ->filter(fn ($field) => !array_key_exists('filled', $field))
+ ->count();
+
+ if ($left === 0) {
+ $this->dispatch(
+ 'passed-required-fields-check',
+ client_postal_code: $this->contact->client->postal_code
+ );
+ }
}
public function showCopyBillingCheckbox(): bool
diff --git a/app/Mail/Subscription/OtpCode.php b/app/Mail/Subscription/OtpCode.php
index 28fd20fe72e7..f6cafb778388 100644
--- a/app/Mail/Subscription/OtpCode.php
+++ b/app/Mail/Subscription/OtpCode.php
@@ -58,6 +58,8 @@ class OtpCode extends Mailable
'title' => ctrans('texts.otp_code_subject'),
'content' => ctrans('texts.otp_code_body', ['code' => $this->code]),
'whitelabel' => $this->company->account->isPaid(),
+ 'url' => 'xx',
+ 'button' => false,
'template' => $this->company->account->isPremium() ? 'email.template.admin_premium' : 'email.template.admin',
]);
}
diff --git a/app/Models/Product.php b/app/Models/Product.php
index e4736a0f7ca7..7c49afc9338f 100644
--- a/app/Models/Product.php
+++ b/app/Models/Product.php
@@ -148,6 +148,20 @@ class Product extends BaseModel
return $converter->convert($this->notes ?? '');
}
+ public static function markdownHelp(string $notes = '')
+ {
+
+ $converter = new CommonMarkConverter([
+ 'allow_unsafe_links' => false,
+ 'renderer' => [
+ 'soft_break' => '
',
+ ],
+ ]);
+
+ return $converter->convert($notes);
+
+ }
+
public function portalUrl($use_react_url): string
{
return $use_react_url ? config('ninja.react_url') . "/#/products/{$this->hashed_id}/edit" : config('ninja.app_url');
diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php
index 132b57cfe6e3..51a577c1c699 100644
--- a/app/Models/Subscription.php
+++ b/app/Models/Subscription.php
@@ -12,6 +12,7 @@
namespace App\Models;
use App\Services\Subscription\PaymentLinkService;
+use App\Services\Subscription\SubscriptionCalculator;
use App\Services\Subscription\SubscriptionService;
use App\Services\Subscription\SubscriptionStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -115,6 +116,7 @@ class Subscription extends BaseModel
'optional_product_ids',
'optional_recurring_product_ids',
'use_inventory_management',
+ 'steps',
];
protected $casts = [
@@ -146,6 +148,11 @@ class Subscription extends BaseModel
return (new SubscriptionStatus($this, $recurring_invoice))->run();
}
+ public function calc(): SubscriptionCalculator
+ {
+ return new SubscriptionCalculator($this);
+ }
+
public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Company::class);
diff --git a/app/PaymentDrivers/StripePaymentDriver.php b/app/PaymentDrivers/StripePaymentDriver.php
index 720155cab594..a7181a4b680c 100644
--- a/app/PaymentDrivers/StripePaymentDriver.php
+++ b/app/PaymentDrivers/StripePaymentDriver.php
@@ -387,7 +387,6 @@ class StripePaymentDriver extends BaseDriver
$fields[] = ['name' => 'client_custom_value4', 'label' => $this->helpers->makeCustomField($this->client->company->custom_fields, 'client4'), 'type' => 'text', 'validation' => 'required'];
}
-
return $fields;
}
diff --git a/app/Repositories/SubscriptionRepository.php b/app/Repositories/SubscriptionRepository.php
index f59992211c89..d8780746ccf3 100644
--- a/app/Repositories/SubscriptionRepository.php
+++ b/app/Repositories/SubscriptionRepository.php
@@ -118,15 +118,60 @@ class SubscriptionRepository extends BaseRepository
return $line_items;
}
+
+ /**
+ * ConvertV3Bundle
+ *
+ * Removing the nested keys of the items array
+ *
+ * @param array $bundle
+ * @return array
+ */
+ private function convertV3Bundle($bundle): array
+ {
+ if(is_object($bundle))
+ $bundle = json_decode(json_encode($bundle),1);
+
+ $items = [];
+
+ foreach($bundle['recurring_products'] as $key => $value) {
+
+ $line_item = new \stdClass;
+ $line_item->product_key = $value['product']['product_key'];
+ $line_item->qty = (float) $value['quantity'];
+ $line_item->unit_cost = (float) $value['product']['price'];
+ $line_item->description = $value['product']['notes'];
+ $line_item->is_recurring = $value['product']['is_recurring'] ?? false;
+ $items[] = $line_item;
+ }
+
+ foreach($bundle['recurring_products'] as $key => $value) {
+
+ $line_item = new \stdClass;
+ $line_item->product_key = $value['product']['product_key'];
+ $line_item->qty = (float) $value['quantity'];
+ $line_item->unit_cost = (float) $value['product']['price'];
+ $line_item->description = $value['product']['notes'];
+ $line_item->is_recurring = $value['product']['is_recurring'] ?? false;
+
+ }
+
+ return $items;
+
+ }
public function generateBundleLineItems($bundle, $is_recurring = false, $is_credit = false)
{
+
+ if(isset($bundle->recurring_products))
+ $bundle = $this->convertV3Bundle($bundle);
+
$multiplier = $is_credit ? -1 : 1;
$line_items = [];
$line_items = collect($bundle)->filter(function ($item) {
- return $item->is_recurring;
+ return $item->is_recurring ?? false;
})->map(function ($item) {
$line_item = new InvoiceItem();
$line_item->product_key = $item->product_key;
diff --git a/app/Rules/Subscriptions/Steps.php b/app/Rules/Subscriptions/Steps.php
new file mode 100644
index 000000000000..3273bc2c3e80
--- /dev/null
+++ b/app/Rules/Subscriptions/Steps.php
@@ -0,0 +1,26 @@
+ 0) {
+ $fail($errors[0]);
+ }
+ }
+}
diff --git a/app/Services/ClientPortal/InstantPayment.php b/app/Services/ClientPortal/InstantPayment.php
index 3ff5cd2c7758..f9e4a9476d06 100644
--- a/app/Services/ClientPortal/InstantPayment.php
+++ b/app/Services/ClientPortal/InstantPayment.php
@@ -44,6 +44,9 @@ class InstantPayment
public function run()
{
+ nlog($this->request->all());
+
+ /** @var \App\Models\ClientContact $cc */
$cc = auth()->guard('contact')->user();
@@ -69,6 +72,9 @@ class InstantPayment
* ['invoice_id' => xxx, 'amount' => 22.00]
*/
$payable_invoices = collect($this->request->payable_invoices);
+
+ nlog($payable_invoices);
+
$invoices = Invoice::query()->whereIn('id', $this->transformKeys($payable_invoices->pluck('invoice_id')->toArray()))->withTrashed()->get();
$invoices->each(function ($invoice) {
diff --git a/app/Services/Subscription/StepService.php b/app/Services/Subscription/StepService.php
new file mode 100644
index 000000000000..84ed67ecf965
--- /dev/null
+++ b/app/Services/Subscription/StepService.php
@@ -0,0 +1,70 @@
+mapWithKeys(fn($dependency, $class) => [$dependency['id'] => $class])->toArray();
+
+ return array_map(fn($step) => $classes[$step], explode(',', $steps));
+ }
+
+ public static function check(array $steps): array
+ {
+ $dependencies = Purchase::$dependencies;
+ $step_order = array_flip($steps);
+ $errors = [];
+
+ foreach ($steps as $step) {
+ $dependent = $dependencies[$step]['dependencies'] ?? [];
+
+ if (!empty($dependent) && !array_intersect($dependent, $steps)) {
+ $errors[] = ctrans('texts.step_dependency_fail', [
+ 'step' => ctrans('texts.' . self::mapClassNameToString($step)),
+ 'dependencies' => implode(', ', array_map(fn($dependency) => ctrans('texts.' . self::mapClassNameToString($dependency)), $dependent)),
+ ]);
+ }
+
+ foreach ($dependent as $dependency) {
+ if (in_array($dependency, $steps) && $step_order[$dependency] > $step_order[$step]) {
+ $errors[] = ctrans('texts.step_dependency_order_fail', [
+ 'step' => ctrans('texts.' . self::mapClassNameToString($step)),
+ 'dependency' => implode(', ', array_map(fn($dependency) => ctrans('texts.' . self::mapClassNameToString($dependency)), $dependent)),
+ ]);
+ }
+ }
+ }
+
+ $auth = collect($dependencies)
+ ->filter(fn ($dependency) => str_starts_with($dependency['id'], 'auth.'))
+ ->keys()
+ ->toArray();
+
+ if (count(array_intersect($auth, $steps)) === 0) {
+ $errors[] = ctrans('texts.step_authentication_fail');
+ }
+
+ return $errors;
+ }
+
+ public static function mapClassNameToString(string $class): string
+ {
+ $classes = collect(Purchase::$dependencies)->mapWithKeys(fn($dependency, $class) => [$class => $dependency['id']])->toArray();
+
+ return $classes[$class];
+ }
+}
\ No newline at end of file
diff --git a/app/Services/Subscription/SubscriptionCalculator.php b/app/Services/Subscription/SubscriptionCalculator.php
new file mode 100644
index 000000000000..ad730d1677d4
--- /dev/null
+++ b/app/Services/Subscription/SubscriptionCalculator.php
@@ -0,0 +1,204 @@
+subscription->company_id, $this->subscription->user_id);
+ $invoice->subscription_id = $this->subscription->id;
+ $invoice->client_id = $this->decodePrimaryKey($context['client_id']);
+ $invoice->is_proforma = true;
+ $invoice->number = "####" . ctrans('texts.subscription') . "_" . now()->format('Y-m-d') . "_" . rand(0, 100000);
+ $invoice->line_items = $this->buildItems($context);
+
+ if(isset($context['valid_coupon']) && $context['valid_coupon']) {
+ $invoice->discount = $this->subscription->promo_discount;
+ $invoice->is_amount_discount = $this->subscription->is_amount_discount;
+ }
+
+ return $invoice_repo->save([], $invoice);
+
+ }
+
+ /**
+ * Build Line Items
+ *
+ * @param array $context
+ *
+ * @return array
+ */
+ private function buildItems(array $context): array
+ {
+
+ $bundle = $context['bundle'];
+
+ $recurring = array_merge(isset($bundle['recurring_products']) ? $bundle['recurring_products'] : [], isset($bundle['optional_recurring_products']) ? $bundle['optional_recurring_products'] : []);
+ $one_time = array_merge(isset($bundle['one_time_products']) ? $bundle['one_time_products'] : [], isset($bundle['optional_one_time_products']) ? $bundle['optional_one_time_products'] : []);
+
+ $items = [];
+
+ foreach($recurring as $item) {
+
+ if($item['quantity'] < 1)
+ continue;
+
+ $line_item = new InvoiceItem();
+ $line_item->product_key = $item['product']['product_key'];
+ $line_item->quantity = (float) $item['quantity'];
+ $line_item->cost = (float) $item['product']['price'];
+ $line_item->notes = $item['product']['notes'];
+ $line_item->tax_id = $item['product']['tax_id'] ?? '1';
+ $items[] = $line_item;
+
+ }
+
+ foreach($one_time as $item) {
+
+ if($item['quantity'] < 1) {
+ continue;
+ }
+
+ $line_item = new InvoiceItem();
+ $line_item->product_key = $item['product']['product_key'];
+ $line_item->quantity = (float) $item['quantity'];
+ $line_item->cost = (float) $item['product']['price'];
+ $line_item->notes = $item['product']['notes'];
+ $line_item->tax_id = $item['product']['tax_id'] ?? '1';
+ $items[] = $line_item;
+
+ }
+
+ return $items;
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ /**
+ * Tests if the user is currently up
+ * to date with their payments for
+ * a given recurring invoice
+ *
+ * @return bool
+ */
+ public function isPaidUp(Invoice $invoice): bool
+ {
+ $outstanding_invoices_exist = Invoice::query()->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
+ ->where('subscription_id', $invoice->subscription_id)
+ ->where('client_id', $invoice->client_id)
+ ->where('balance', '>', 0)
+ ->exists();
+
+ return ! $outstanding_invoices_exist;
+ }
+
+ public function calcUpgradePlan(Invoice $invoice)
+ {
+ //set the starting refund amount
+ $refund_amount = 0;
+
+ $refund_invoice = false;
+
+ //are they paid up to date.
+
+ //yes - calculate refund
+ if ($this->isPaidUp($invoice)) {
+ $refund_invoice = $this->getRefundInvoice($invoice);
+ }
+
+ if ($refund_invoice) {
+ /** @var \App\Models\Subscription $subscription **/
+ $subscription = Subscription::find($invoice->subscription_id);
+ $pro_rata = new ProRata();
+
+ $to_date = $subscription->service()->getNextDateForFrequency(Carbon::parse($refund_invoice->date), $subscription->frequency_id);
+
+ $refund_amount = $pro_rata->refund($refund_invoice->amount, now(), $to_date, $subscription->frequency_id);
+
+ $charge_amount = $pro_rata->charge($this->subscription->price, now(), $to_date, $this->subscription->frequency_id);
+
+ return $charge_amount - $refund_amount;
+ }
+
+ //no - return full freight charge.
+ return $this->subscription->price;
+ }
+
+ public function executeUpgradePlan() {}
+
+ private function getRefundInvoice(Invoice $invoice)
+ {
+ return Invoice::where('subscription_id', $invoice->subscription_id)
+ ->where('client_id', $invoice->client_id)
+ ->where('is_deleted', 0)
+ ->orderBy('id', 'desc')
+ ->first();
+ }
+}
diff --git a/app/Transformers/SubscriptionTransformer.php b/app/Transformers/SubscriptionTransformer.php
index f06537d2421f..c2e50eb16e15 100644
--- a/app/Transformers/SubscriptionTransformer.php
+++ b/app/Transformers/SubscriptionTransformer.php
@@ -72,6 +72,7 @@ class SubscriptionTransformer extends EntityTransformer
'optional_recurring_product_ids' => (string)$subscription->optional_recurring_product_ids,
'optional_product_ids' => (string) $subscription->optional_product_ids,
'registration_required' => (bool) $subscription->registration_required,
+ 'steps' => $subscription->steps,
];
}
}
diff --git a/composer.lock b/composer.lock
index cdd6fdb3a5d5..ec06c31f9cbb 100644
--- a/composer.lock
+++ b/composer.lock
@@ -19707,5 +19707,5 @@
"platform-dev": {
"php": "^8.1|^8.2"
},
- "plugin-api-version": "2.3.0"
+ "plugin-api-version": "2.6.0"
}
diff --git a/database/migrations/2024_02_28_180250_add_steps_to_subscriptions.php b/database/migrations/2024_02_28_180250_add_steps_to_subscriptions.php
new file mode 100644
index 000000000000..04faa15f03f4
--- /dev/null
+++ b/database/migrations/2024_02_28_180250_add_steps_to_subscriptions.php
@@ -0,0 +1,40 @@
+string('steps')->nullable();
+ });
+
+ $steps = collect(Purchase::defaultSteps())
+ ->map(fn ($step) => StepService::mapClassNameToString($step))
+ ->implode(',');
+
+ \App\Models\Subscription::query()
+ ->withTrashed()
+ ->cursor()
+ ->each(function ($subscription) use ($steps){
+
+ $subscription->steps = $steps;
+ $subscription->save();
+
+ });
+ }
+};
diff --git a/lang/en/texts.php b/lang/en/texts.php
index 048c83d15258..8ba1bf2aa2aa 100644
--- a/lang/en/texts.php
+++ b/lang/en/texts.php
@@ -5242,11 +5242,27 @@ $lang = array(
'user_sales' => 'User Sales',
'iframe_url' => 'iFrame URL',
'user_unsubscribed' => 'User unsubscribed from emails :link',
+ 'out_of_stock' => 'Out of stock',
+ 'step_dependency_fail' => 'Component ":step" requires at least one of it\'s dependencies (":dependencies") in the list.',
+ 'step_dependency_order_fail' => 'Component ":step" depends on ":dependency". Make component(s) order is correct.',
+ 'step_authentication_fail' => 'You must include at least one of authentication methods.',
+ 'auth.login' => 'Login',
+ 'auth.login-or-register' => 'Login or Register',
+ 'auth.register' => 'Register',
+ 'cart' => 'Cart',
+ 'methods' => 'Methods',
+ 'rff' => 'Required fields form',
+ 'add_step' => 'Add step',
+ 'steps' => 'Steps',
+ 'steps_order_help' => 'The order of the steps is important. The first step should not depend on any other step. The second step should depend on the first step, and so on.',
+ 'other_steps' => 'Other steps',
'use_available_payments' => 'Use Available Payments',
'test_email_sent' => 'Successfully sent email',
'gateway_type' => 'Gateway Type',
'save_template_body' => 'Would you like to save this import mapping as a template for future use?',
'save_as_template' => 'Save Template Mapping',
+ 'checkout_only_for_existing_customers' => 'Checkout is enabled only for existing customers. Please login with existing account to checkout.',
+ 'checkout_only_for_new_customers' => 'Checkout is enabled only for new customers. Please register a new account to checkout.',
'auto_bill_standard_invoices_help' => 'Auto bill standard invoices on the due date',
'auto_bill_on_help' => 'Auto bill on send date OR due date (recurring invoices)',
'use_available_credits_help' => 'Apply any credit balances to payments prior to charging a payment method',
@@ -5268,6 +5284,7 @@ $lang = array(
'enable_rappen_rounding_help' => 'Rounds totals to nearest 5',
'duration_words' => 'Duration in words',
'upcoming_recurring_invoices' => 'Upcoming Recurring Invoices',
+ 'shipping_country_id' => 'Shipping Country',
'show_table_footer' => 'Show table footer',
'show_table_footer_help' => 'Displays the totals in the footer of the table',
'total_invoices' => 'Total Invoices',
diff --git a/public/build/assets/app-c80ec97e.js b/public/build/assets/app-042e859e.js
similarity index 82%
rename from public/build/assets/app-c80ec97e.js
rename to public/build/assets/app-042e859e.js
index 0a8f6cc39988..64e1f2bbeb1f 100644
--- a/public/build/assets/app-c80ec97e.js
+++ b/public/build/assets/app-042e859e.js
@@ -1,15 +1,15 @@
-import{A as pl}from"./index-08e160a7.js";import{c as Ht,g as hl}from"./_commonjsHelpers-725317a4.js";var gl={visa:{niceType:"Visa",type:"visa",patterns:[4],gaps:[4,8,12],lengths:[16,18,19],code:{name:"CVV",size:3}},mastercard:{niceType:"Mastercard",type:"mastercard",patterns:[[51,55],[2221,2229],[223,229],[23,26],[270,271],2720],gaps:[4,8,12],lengths:[16],code:{name:"CVC",size:3}},"american-express":{niceType:"American Express",type:"american-express",patterns:[34,37],gaps:[4,10],lengths:[15],code:{name:"CID",size:4}},"diners-club":{niceType:"Diners Club",type:"diners-club",patterns:[[300,305],36,38,39],gaps:[4,10],lengths:[14,16,19],code:{name:"CVV",size:3}},discover:{niceType:"Discover",type:"discover",patterns:[6011,[644,649],65],gaps:[4,8,12],lengths:[16,19],code:{name:"CID",size:3}},jcb:{niceType:"JCB",type:"jcb",patterns:[2131,1800,[3528,3589]],gaps:[4,8,12],lengths:[16,17,18,19],code:{name:"CVV",size:3}},unionpay:{niceType:"UnionPay",type:"unionpay",patterns:[620,[624,626],[62100,62182],[62184,62187],[62185,62197],[62200,62205],[622010,622999],622018,[622019,622999],[62207,62209],[622126,622925],[623,626],6270,6272,6276,[627700,627779],[627781,627799],[6282,6289],6291,6292,810,[8110,8131],[8132,8151],[8152,8163],[8164,8171]],gaps:[4,8,12],lengths:[14,15,16,17,18,19],code:{name:"CVN",size:3}},maestro:{niceType:"Maestro",type:"maestro",patterns:[493698,[5e5,504174],[504176,506698],[506779,508999],[56,59],63,67,6],gaps:[4,8,12],lengths:[12,13,14,15,16,17,18,19],code:{name:"CVC",size:3}},elo:{niceType:"Elo",type:"elo",patterns:[401178,401179,438935,457631,457632,431274,451416,457393,504175,[506699,506778],[509e3,509999],627780,636297,636368,[650031,650033],[650035,650051],[650405,650439],[650485,650538],[650541,650598],[650700,650718],[650720,650727],[650901,650978],[651652,651679],[655e3,655019],[655021,655058]],gaps:[4,8,12],lengths:[16],code:{name:"CVE",size:3}},mir:{niceType:"Mir",type:"mir",patterns:[[2200,2204]],gaps:[4,8,12],lengths:[16,17,18,19],code:{name:"CVP2",size:3}},hiper:{niceType:"Hiper",type:"hiper",patterns:[637095,63737423,63743358,637568,637599,637609,637612],gaps:[4,8,12],lengths:[16],code:{name:"CVC",size:3}},hipercard:{niceType:"Hipercard",type:"hipercard",patterns:[606282],gaps:[4,8,12],lengths:[16],code:{name:"CVC",size:3}}},ml=gl,ti={},bn={};Object.defineProperty(bn,"__esModule",{value:!0});bn.clone=void 0;function vl(e){return e?JSON.parse(JSON.stringify(e)):null}bn.clone=vl;var ri={};Object.defineProperty(ri,"__esModule",{value:!0});ri.matches=void 0;function yl(e,r,n){var a=String(r).length,s=e.substr(0,a),l=parseInt(s,10);return r=parseInt(String(r).substr(0,s.length),10),n=parseInt(String(n).substr(0,s.length),10),l>=r&&l<=n}function bl(e,r){return r=String(r),r.substring(0,e.length)===e.substring(0,r.length)}function _l(e,r){return Array.isArray(r)?yl(e,r[0],r[1]):bl(e,r)}ri.matches=_l;Object.defineProperty(ti,"__esModule",{value:!0});ti.addMatchingCardsToResults=void 0;var wl=bn,xl=ri;function Sl(e,r,n){var a,s;for(a=0;a=s&&(y.matchStrength=s),n.push(y);break}}}ti.addMatchingCardsToResults=Sl;var ni={};Object.defineProperty(ni,"__esModule",{value:!0});ni.isValidInputType=void 0;function El(e){return typeof e=="string"||e instanceof String}ni.isValidInputType=El;var ii={};Object.defineProperty(ii,"__esModule",{value:!0});ii.findBestMatch=void 0;function Ol(e){var r=e.filter(function(n){return n.matchStrength}).length;return r>0&&r===e.length}function Cl(e){return Ol(e)?e.reduce(function(r,n){return!r||Number(r.matchStrength)Ml?pn(!1,!1):Nl.test(e)?pn(!1,!0):pn(!0,!0)}ai.cardholderName=kl;var oi={};function Ll(e){for(var r=0,n=!1,a=e.length-1,s;a>=0;)s=parseInt(e.charAt(a),10),n&&(s*=2,s>9&&(s=s%10+1)),n=!n,r+=s,a--;return r%10===0}var jl=Ll;Object.defineProperty(oi,"__esModule",{value:!0});oi.cardNumber=void 0;var Il=jl,Ga=No;function gr(e,r,n){return{card:e,isPotentiallyValid:r,isValid:n}}function Dl(e,r){r===void 0&&(r={});var n,a,s;if(typeof e!="string"&&typeof e!="number")return gr(null,!1,!1);var l=String(e).replace(/-|\s/g,"");if(!/^\d*$/.test(l))return gr(null,!1,!1);var y=Ga(l);if(y.length===0)return gr(null,!1,!1);if(y.length!==1)return gr(null,!0,!1);var m=y[0];if(r.maxLength&&l.length>r.maxLength)return gr(m,!1,!1);m.type===Ga.types.UNIONPAY&&r.luhnValidateUnionPay!==!0?a=!0:a=Il(l),s=Math.max.apply(null,m.lengths),r.maxLength&&(s=Math.min(r.maxLength,s));for(var L=0;L4)return er(!1,!1);var m=parseInt(e,10),L=Number(String(s).substr(2,2)),q=!1;if(a===2){if(String(s).substr(0,2)===e)return er(!1,!0);n=L===m,q=m>=L&&m<=L+r}else a===4&&(n=s===m,q=m>=s&&m<=s+r);return er(q,q,n)}zr.expirationYear=Fl;var ui={};Object.defineProperty(ui,"__esModule",{value:!0});ui.isArray=void 0;ui.isArray=Array.isArray||function(e){return Object.prototype.toString.call(e)==="[object Array]"};Object.defineProperty(li,"__esModule",{value:!0});li.parseDate=void 0;var Bl=zr,Ul=ui;function Hl(e){var r=Number(e[0]),n;return r===0?2:r>1||r===1&&Number(e[1])>2?1:r===1?(n=e.substr(1),Bl.expirationYear(n).isPotentiallyValid?1:2):e.length===5?1:e.length>5?2:1}function ql(e){var r;if(/^\d{4}-\d{1,2}$/.test(e)?r=e.split("-").reverse():/\//.test(e)?r=e.split(/\s*\/\s*/g):/\s/.test(e)&&(r=e.split(/ +/g)),Ul.isArray(r))return{month:r[0]||"",year:r.slice(1).join()};var n=Hl(e),a=e.substr(0,n);return{month:a,year:e.substr(a.length)}}li.parseDate=ql;var wn={};Object.defineProperty(wn,"__esModule",{value:!0});wn.expirationMonth=void 0;function hn(e,r,n){return{isValid:e,isPotentiallyValid:r,isValidForThisYear:n||!1}}function Vl(e){var r=new Date().getMonth()+1;if(typeof e!="string")return hn(!1,!1);if(e.replace(/\s/g,"")===""||e==="0")return hn(!1,!0);if(!/^\d*$/.test(e))return hn(!1,!1);var n=parseInt(e,10);if(isNaN(Number(e)))return hn(!1,!1);var a=n>0&&n<13;return hn(a,a,a&&n>=r)}wn.expirationMonth=Vl;var Zi=Ht&&Ht.__assign||function(){return Zi=Object.assign||function(e){for(var r,n=1,a=arguments.length;nr?e[n]:r;return r}function Ir(e,r){return{isValid:e,isPotentiallyValid:r}}function Xl(e,r){return r===void 0&&(r=Mo),r=r instanceof Array?r:[r],typeof e!="string"||!/^\d*$/.test(e)?Ir(!1,!1):Gl(r,e.length)?Ir(!0,!0):e.lengthYl(r)?Ir(!1,!1):Ir(!0,!0)}ci.cvv=Xl;var fi={};Object.defineProperty(fi,"__esModule",{value:!0});fi.postalCode=void 0;var Ql=3;function Wi(e,r){return{isValid:e,isPotentiallyValid:r}}function Zl(e,r){r===void 0&&(r={});var n=r.minLength||Ql;return typeof e!="string"?Wi(!1,!1):e.lengthfunction(){return r||(0,e[Lo(e)[0]])((r={exports:{}}).exports,r),r.exports},yu=(e,r,n,a)=>{if(r&&typeof r=="object"||typeof r=="function")for(let s of Lo(r))!vu.call(e,s)&&s!==n&&ko(e,s,{get:()=>r[s],enumerable:!(a=gu(r,s))||a.enumerable});return e},rt=(e,r,n)=>(n=e!=null?hu(mu(e)):{},yu(r||!e||!e.__esModule?ko(n,"default",{value:e,enumerable:!0}):n,e)),wt=Yt({"../alpine/packages/alpinejs/dist/module.cjs.js"(e,r){var n=Object.create,a=Object.defineProperty,s=Object.getOwnPropertyDescriptor,l=Object.getOwnPropertyNames,y=Object.getPrototypeOf,m=Object.prototype.hasOwnProperty,L=(t,i)=>function(){return i||(0,t[l(t)[0]])((i={exports:{}}).exports,i),i.exports},q=(t,i)=>{for(var o in i)a(t,o,{get:i[o],enumerable:!0})},ce=(t,i,o,c)=>{if(i&&typeof i=="object"||typeof i=="function")for(let p of l(i))!m.call(t,p)&&p!==o&&a(t,p,{get:()=>i[p],enumerable:!(c=s(i,p))||c.enumerable});return t},se=(t,i,o)=>(o=t!=null?n(y(t)):{},ce(i||!t||!t.__esModule?a(o,"default",{value:t,enumerable:!0}):o,t)),K=t=>ce(a({},"__esModule",{value:!0}),t),Y=L({"node_modules/@vue/shared/dist/shared.cjs.js"(t){Object.defineProperty(t,"__esModule",{value:!0});function i(b,W){const ee=Object.create(null),le=b.split(",");for(let qe=0;qe!!ee[qe.toLowerCase()]:qe=>!!ee[qe]}var o={1:"TEXT",2:"CLASS",4:"STYLE",8:"PROPS",16:"FULL_PROPS",32:"HYDRATE_EVENTS",64:"STABLE_FRAGMENT",128:"KEYED_FRAGMENT",256:"UNKEYED_FRAGMENT",512:"NEED_PATCH",1024:"DYNAMIC_SLOTS",2048:"DEV_ROOT_FRAGMENT",[-1]:"HOISTED",[-2]:"BAIL"},c={1:"STABLE",2:"DYNAMIC",3:"FORWARDED"},p="Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt",d=i(p),g=2;function x(b,W=0,ee=b.length){let le=b.split(/(\r?\n)/);const qe=le.filter((yt,ct)=>ct%2===1);le=le.filter((yt,ct)=>ct%2===0);let tt=0;const vt=[];for(let yt=0;yt=W){for(let ct=yt-g;ct<=yt+g||ee>tt;ct++){if(ct<0||ct>=le.length)continue;const fn=ct+1;vt.push(`${fn}${" ".repeat(Math.max(3-String(fn).length,0))}| ${le[ct]}`);const Lr=le[ct].length,Gn=qe[ct]&&qe[ct].length||0;if(ct===yt){const jr=W-(tt-(Lr+Gn)),Vi=Math.max(1,ee>tt?Lr-jr:ee-W);vt.push(" | "+" ".repeat(jr)+"^".repeat(Vi))}else if(ct>yt){if(ee>tt){const jr=Math.max(Math.min(ee-tt,Lr),1);vt.push(" | "+"^".repeat(jr))}tt+=Lr+Gn}}break}return vt.join(`
-`)}var k="itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly",Z=i(k),Me=i(k+",async,autofocus,autoplay,controls,default,defer,disabled,hidden,loop,open,required,reversed,scoped,seamless,checked,muted,multiple,selected"),Qe=/[>/="'\u0009\u000a\u000c\u0020]/,$e={};function Ke(b){if($e.hasOwnProperty(b))return $e[b];const W=Qe.test(b);return W&&console.error(`unsafe attribute name: ${b}`),$e[b]=!W}var At={acceptCharset:"accept-charset",className:"class",htmlFor:"for",httpEquiv:"http-equiv"},Ft=i("animation-iteration-count,border-image-outset,border-image-slice,border-image-width,box-flex,box-flex-group,box-ordinal-group,column-count,columns,flex,flex-grow,flex-positive,flex-shrink,flex-negative,flex-order,grid-row,grid-row-end,grid-row-span,grid-row-start,grid-column,grid-column-end,grid-column-span,grid-column-start,font-weight,line-clamp,line-height,opacity,order,orphans,tab-size,widows,z-index,zoom,fill-opacity,flood-opacity,stop-opacity,stroke-dasharray,stroke-dashoffset,stroke-miterlimit,stroke-opacity,stroke-width"),Se=i("accept,accept-charset,accesskey,action,align,allow,alt,async,autocapitalize,autocomplete,autofocus,autoplay,background,bgcolor,border,buffered,capture,challenge,charset,checked,cite,class,code,codebase,color,cols,colspan,content,contenteditable,contextmenu,controls,coords,crossorigin,csp,data,datetime,decoding,default,defer,dir,dirname,disabled,download,draggable,dropzone,enctype,enterkeyhint,for,form,formaction,formenctype,formmethod,formnovalidate,formtarget,headers,height,hidden,high,href,hreflang,http-equiv,icon,id,importance,integrity,ismap,itemprop,keytype,kind,label,lang,language,loading,list,loop,low,manifest,max,maxlength,minlength,media,min,multiple,muted,name,novalidate,open,optimum,pattern,ping,placeholder,poster,preload,radiogroup,readonly,referrerpolicy,rel,required,reversed,rows,rowspan,sandbox,scope,scoped,selected,shape,size,sizes,slot,span,spellcheck,src,srcdoc,srclang,srcset,start,step,style,summary,tabindex,target,title,translate,type,usemap,value,width,wrap");function Ue(b){if(Nt(b)){const W={};for(let ee=0;ee{if(ee){const le=ee.split(He);le.length>1&&(W[le[0].trim()]=le[1].trim())}}),W}function Rt(b){let W="";if(!b)return W;for(const ee in b){const le=b[ee],qe=ee.startsWith("--")?ee:Kn(ee);(fr(le)||typeof le=="number"&&Ft(qe))&&(W+=`${qe}:${le};`)}return W}function Bt(b){let W="";if(fr(b))W=b;else if(Nt(b))for(let ee=0;ee]/;function Li(b){const W=""+b,ee=ki.exec(W);if(!ee)return W;let le="",qe,tt,vt=0;for(tt=ee.index;tt||--!>|Or(ee,W))}var In=b=>b==null?"":Ut(b)?JSON.stringify(b,Di,2):String(b),Di=(b,W)=>cr(W)?{[`Map(${W.size})`]:[...W.entries()].reduce((ee,[le,qe])=>(ee[`${le} =>`]=qe,ee),{})}:Mt(W)?{[`Set(${W.size})`]:[...W.values()]}:Ut(W)&&!Nt(W)&&!Hn(W)?String(W):W,$i=["bigInt","optionalChaining","nullishCoalescingOperator"],an=Object.freeze({}),on=Object.freeze([]),sn=()=>{},Cr=()=>!1,Ar=/^on[^a-z]/,Tr=b=>Ar.test(b),Pr=b=>b.startsWith("onUpdate:"),Dn=Object.assign,$n=(b,W)=>{const ee=b.indexOf(W);ee>-1&&b.splice(ee,1)},Fn=Object.prototype.hasOwnProperty,Bn=(b,W)=>Fn.call(b,W),Nt=Array.isArray,cr=b=>dr(b)==="[object Map]",Mt=b=>dr(b)==="[object Set]",ln=b=>b instanceof Date,un=b=>typeof b=="function",fr=b=>typeof b=="string",Fi=b=>typeof b=="symbol",Ut=b=>b!==null&&typeof b=="object",Rr=b=>Ut(b)&&un(b.then)&&un(b.catch),Un=Object.prototype.toString,dr=b=>Un.call(b),Bi=b=>dr(b).slice(8,-1),Hn=b=>dr(b)==="[object Object]",qn=b=>fr(b)&&b!=="NaN"&&b[0]!=="-"&&""+parseInt(b,10)===b,Vn=i(",key,ref,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),pr=b=>{const W=Object.create(null);return ee=>W[ee]||(W[ee]=b(ee))},zn=/-(\w)/g,Wn=pr(b=>b.replace(zn,(W,ee)=>ee?ee.toUpperCase():"")),Ui=/\B([A-Z])/g,Kn=pr(b=>b.replace(Ui,"-$1").toLowerCase()),hr=pr(b=>b.charAt(0).toUpperCase()+b.slice(1)),Hi=pr(b=>b?`on${hr(b)}`:""),cn=(b,W)=>b!==W&&(b===b||W===W),qi=(b,W)=>{for(let ee=0;ee{Object.defineProperty(b,W,{configurable:!0,enumerable:!1,value:ee})},Mr=b=>{const W=parseFloat(b);return isNaN(W)?b:W},kr,Jn=()=>kr||(kr=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});t.EMPTY_ARR=on,t.EMPTY_OBJ=an,t.NO=Cr,t.NOOP=sn,t.PatchFlagNames=o,t.babelParserDefaultPlugins=$i,t.camelize=Wn,t.capitalize=hr,t.def=Nr,t.escapeHtml=Li,t.escapeHtmlComment=ji,t.extend=Dn,t.generateCodeFrame=x,t.getGlobalThis=Jn,t.hasChanged=cn,t.hasOwn=Bn,t.hyphenate=Kn,t.invokeArrayFns=qi,t.isArray=Nt,t.isBooleanAttr=Me,t.isDate=ln,t.isFunction=un,t.isGloballyWhitelisted=d,t.isHTMLTag=Sr,t.isIntegerKey=qn,t.isKnownAttr=Se,t.isMap=cr,t.isModelListener=Pr,t.isNoUnitNumericStyleProp=Ft,t.isObject=Ut,t.isOn=Tr,t.isPlainObject=Hn,t.isPromise=Rr,t.isReservedProp=Vn,t.isSSRSafeAttrName=Ke,t.isSVGTag=Mi,t.isSet=Mt,t.isSpecialBooleanAttr=Z,t.isString=fr,t.isSymbol=Fi,t.isVoidTag=Er,t.looseEqual=Or,t.looseIndexOf=jn,t.makeMap=i,t.normalizeClass=Bt,t.normalizeStyle=Ue,t.objectToString=Un,t.parseStringStyle=mt,t.propsToAttrMap=At,t.remove=$n,t.slotFlagsText=c,t.stringifyStyle=Rt,t.toDisplayString=In,t.toHandlerKey=Hi,t.toNumber=Mr,t.toRawType=Bi,t.toTypeString=dr}}),E=L({"node_modules/@vue/shared/index.js"(t,i){i.exports=Y()}}),v=L({"node_modules/@vue/reactivity/dist/reactivity.cjs.js"(t){Object.defineProperty(t,"__esModule",{value:!0});var i=E(),o=new WeakMap,c=[],p,d=Symbol("iterate"),g=Symbol("Map key iterate");function x(u){return u&&u._isEffect===!0}function k(u,T=i.EMPTY_OBJ){x(u)&&(u=u.raw);const N=Qe(u,T);return T.lazy||N(),N}function Z(u){u.active&&($e(u),u.options.onStop&&u.options.onStop(),u.active=!1)}var Me=0;function Qe(u,T){const N=function(){if(!N.active)return u();if(!c.includes(N)){$e(N);try{return Se(),c.push(N),p=N,u()}finally{c.pop(),Ue(),p=c[c.length-1]}}};return N.id=Me++,N.allowRecurse=!!T.allowRecurse,N._isEffect=!0,N.active=!0,N.raw=u,N.deps=[],N.options=T,N}function $e(u){const{deps:T}=u;if(T.length){for(let N=0;N{ht&&ht.forEach(kt=>{(kt!==p||kt.allowRecurse)&&it.add(kt)})};if(T==="clear")je.forEach(bt);else if(N==="length"&&i.isArray(u))je.forEach((ht,kt)=>{(kt==="length"||kt>=ie)&&bt(ht)});else switch(N!==void 0&&bt(je.get(N)),T){case"add":i.isArray(u)?i.isIntegerKey(N)&&bt(je.get("length")):(bt(je.get(d)),i.isMap(u)&&bt(je.get(g)));break;case"delete":i.isArray(u)||(bt(je.get(d)),i.isMap(u)&&bt(je.get(g)));break;case"set":i.isMap(u)&&bt(je.get(d));break}const dn=ht=>{ht.options.onTrigger&&ht.options.onTrigger({effect:ht,target:u,key:N,type:T,newValue:ie,oldValue:J,oldTarget:me}),ht.options.scheduler?ht.options.scheduler(ht):ht()};it.forEach(dn)}var mt=i.makeMap("__proto__,__v_isRef,__isVue"),Rt=new Set(Object.getOwnPropertyNames(Symbol).map(u=>Symbol[u]).filter(i.isSymbol)),Bt=Er(),xr=Er(!1,!0),rn=Er(!0),nn=Er(!0,!0),Sr=Mi();function Mi(){const u={};return["includes","indexOf","lastIndexOf"].forEach(T=>{u[T]=function(...N){const ie=b(this);for(let me=0,je=this.length;me{u[T]=function(...N){Ft();const ie=b(this)[T].apply(this,N);return Ue(),ie}}),u}function Er(u=!1,T=!1){return function(ie,J,me){if(J==="__v_isReactive")return!u;if(J==="__v_isReadonly")return u;if(J==="__v_raw"&&me===(u?T?Wn:zn:T?pr:Vn).get(ie))return ie;const je=i.isArray(ie);if(!u&&je&&i.hasOwn(Sr,J))return Reflect.get(Sr,J,me);const it=Reflect.get(ie,J,me);return(i.isSymbol(J)?Rt.has(J):mt(J))||(u||Le(ie,"get",J),T)?it:le(it)?!je||!i.isIntegerKey(J)?it.value:it:i.isObject(it)?u?cn(it):hr(it):it}}var ki=Ln(),Li=Ln(!0);function Ln(u=!1){return function(N,ie,J,me){let je=N[ie];if(!u&&(J=b(J),je=b(je),!i.isArray(N)&&le(je)&&!le(J)))return je.value=J,!0;const it=i.isArray(N)&&i.isIntegerKey(ie)?Number(ie)i.isObject(u)?hr(u):u,on=u=>i.isObject(u)?cn(u):u,sn=u=>u,Cr=u=>Reflect.getPrototypeOf(u);function Ar(u,T,N=!1,ie=!1){u=u.__v_raw;const J=b(u),me=b(T);T!==me&&!N&&Le(J,"get",T),!N&&Le(J,"get",me);const{has:je}=Cr(J),it=ie?sn:N?on:an;if(je.call(J,T))return it(u.get(T));if(je.call(J,me))return it(u.get(me));u!==J&&u.get(T)}function Tr(u,T=!1){const N=this.__v_raw,ie=b(N),J=b(u);return u!==J&&!T&&Le(ie,"has",u),!T&&Le(ie,"has",J),u===J?N.has(u):N.has(u)||N.has(J)}function Pr(u,T=!1){return u=u.__v_raw,!T&&Le(b(u),"iterate",d),Reflect.get(u,"size",u)}function Dn(u){u=b(u);const T=b(this);return Cr(T).has.call(T,u)||(T.add(u),He(T,"add",u,u)),this}function $n(u,T){T=b(T);const N=b(this),{has:ie,get:J}=Cr(N);let me=ie.call(N,u);me?qn(N,ie,u):(u=b(u),me=ie.call(N,u));const je=J.call(N,u);return N.set(u,T),me?i.hasChanged(T,je)&&He(N,"set",u,T,je):He(N,"add",u,T),this}function Fn(u){const T=b(this),{has:N,get:ie}=Cr(T);let J=N.call(T,u);J?qn(T,N,u):(u=b(u),J=N.call(T,u));const me=ie?ie.call(T,u):void 0,je=T.delete(u);return J&&He(T,"delete",u,void 0,me),je}function Bn(){const u=b(this),T=u.size!==0,N=i.isMap(u)?new Map(u):new Set(u),ie=u.clear();return T&&He(u,"clear",void 0,void 0,N),ie}function Nt(u,T){return function(ie,J){const me=this,je=me.__v_raw,it=b(je),bt=T?sn:u?on:an;return!u&&Le(it,"iterate",d),je.forEach((dn,ht)=>ie.call(J,bt(dn),bt(ht),me))}}function cr(u,T,N){return function(...ie){const J=this.__v_raw,me=b(J),je=i.isMap(me),it=u==="entries"||u===Symbol.iterator&&je,bt=u==="keys"&&je,dn=J[u](...ie),ht=N?sn:T?on:an;return!T&&Le(me,"iterate",bt?g:d),{next(){const{value:kt,done:zi}=dn.next();return zi?{value:kt,done:zi}:{value:it?[ht(kt[0]),ht(kt[1])]:ht(kt),done:zi}},[Symbol.iterator](){return this}}}}function Mt(u){return function(...T){{const N=T[0]?`on key "${T[0]}" `:"";console.warn(`${i.capitalize(u)} operation ${N}failed: target is readonly.`,b(this))}return u==="delete"?!1:this}}function ln(){const u={get(me){return Ar(this,me)},get size(){return Pr(this)},has:Tr,add:Dn,set:$n,delete:Fn,clear:Bn,forEach:Nt(!1,!1)},T={get(me){return Ar(this,me,!1,!0)},get size(){return Pr(this)},has:Tr,add:Dn,set:$n,delete:Fn,clear:Bn,forEach:Nt(!1,!0)},N={get(me){return Ar(this,me,!0)},get size(){return Pr(this,!0)},has(me){return Tr.call(this,me,!0)},add:Mt("add"),set:Mt("set"),delete:Mt("delete"),clear:Mt("clear"),forEach:Nt(!0,!1)},ie={get(me){return Ar(this,me,!0,!0)},get size(){return Pr(this,!0)},has(me){return Tr.call(this,me,!0)},add:Mt("add"),set:Mt("set"),delete:Mt("delete"),clear:Mt("clear"),forEach:Nt(!0,!0)};return["keys","values","entries",Symbol.iterator].forEach(me=>{u[me]=cr(me,!1,!1),N[me]=cr(me,!0,!1),T[me]=cr(me,!1,!0),ie[me]=cr(me,!0,!0)}),[u,N,T,ie]}var[un,fr,Fi,Ut]=ln();function Rr(u,T){const N=T?u?Ut:Fi:u?fr:un;return(ie,J,me)=>J==="__v_isReactive"?!u:J==="__v_isReadonly"?u:J==="__v_raw"?ie:Reflect.get(i.hasOwn(N,J)&&J in ie?N:ie,J,me)}var Un={get:Rr(!1,!1)},dr={get:Rr(!1,!0)},Bi={get:Rr(!0,!1)},Hn={get:Rr(!0,!0)};function qn(u,T,N){const ie=b(N);if(ie!==N&&T.call(u,ie)){const J=i.toRawType(u);console.warn(`Reactive ${J} contains both the raw and reactive versions of the same object${J==="Map"?" as keys":""}, which can lead to inconsistencies. Avoid differentiating between the raw and reactive versions of an object and only use the reactive version if possible.`)}}var Vn=new WeakMap,pr=new WeakMap,zn=new WeakMap,Wn=new WeakMap;function Ui(u){switch(u){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function Kn(u){return u.__v_skip||!Object.isExtensible(u)?0:Ui(i.toRawType(u))}function hr(u){return u&&u.__v_isReadonly?u:Nr(u,!1,jn,Un,Vn)}function Hi(u){return Nr(u,!1,Di,dr,pr)}function cn(u){return Nr(u,!0,In,Bi,zn)}function qi(u){return Nr(u,!0,$i,Hn,Wn)}function Nr(u,T,N,ie,J){if(!i.isObject(u))return console.warn(`value cannot be made reactive: ${String(u)}`),u;if(u.__v_raw&&!(T&&u.__v_isReactive))return u;const me=J.get(u);if(me)return me;const je=Kn(u);if(je===0)return u;const it=new Proxy(u,je===2?ie:N);return J.set(u,it),it}function Mr(u){return kr(u)?Mr(u.__v_raw):!!(u&&u.__v_isReactive)}function kr(u){return!!(u&&u.__v_isReadonly)}function Jn(u){return Mr(u)||kr(u)}function b(u){return u&&b(u.__v_raw)||u}function W(u){return i.def(u,"__v_skip",!0),u}var ee=u=>i.isObject(u)?hr(u):u;function le(u){return!!(u&&u.__v_isRef===!0)}function qe(u){return yt(u)}function tt(u){return yt(u,!0)}var vt=class{constructor(u,T=!1){this._shallow=T,this.__v_isRef=!0,this._rawValue=T?u:b(u),this._value=T?u:ee(u)}get value(){return Le(b(this),"get","value"),this._value}set value(u){u=this._shallow?u:b(u),i.hasChanged(u,this._rawValue)&&(this._rawValue=u,this._value=this._shallow?u:ee(u),He(b(this),"set","value",u))}};function yt(u,T=!1){return le(u)?u:new vt(u,T)}function ct(u){He(b(u),"set","value",u.value)}function fn(u){return le(u)?u.value:u}var Lr={get:(u,T,N)=>fn(Reflect.get(u,T,N)),set:(u,T,N,ie)=>{const J=u[T];return le(J)&&!le(N)?(J.value=N,!0):Reflect.set(u,T,N,ie)}};function Gn(u){return Mr(u)?u:new Proxy(u,Lr)}var jr=class{constructor(u){this.__v_isRef=!0;const{get:T,set:N}=u(()=>Le(this,"get","value"),()=>He(this,"set","value"));this._get=T,this._set=N}get value(){return this._get()}set value(u){this._set(u)}};function Vi(u){return new jr(u)}function ul(u){Jn(u)||console.warn("toRefs() expects a reactive object but received a plain one.");const T=i.isArray(u)?new Array(u.length):{};for(const N in u)T[N]=Ja(u,N);return T}var cl=class{constructor(u,T){this._object=u,this._key=T,this.__v_isRef=!0}get value(){return this._object[this._key]}set value(u){this._object[this._key]=u}};function Ja(u,T){return le(u[T])?u[T]:new cl(u,T)}var fl=class{constructor(u,T,N){this._setter=T,this._dirty=!0,this.__v_isRef=!0,this.effect=k(u,{lazy:!0,scheduler:()=>{this._dirty||(this._dirty=!0,He(b(this),"set","value"))}}),this.__v_isReadonly=N}get value(){const u=b(this);return u._dirty&&(u._value=this.effect(),u._dirty=!1),Le(u,"get","value"),u._value}set value(u){this._setter(u)}};function dl(u){let T,N;return i.isFunction(u)?(T=u,N=()=>{console.warn("Write operation failed: computed value is readonly")}):(T=u.get,N=u.set),new fl(T,N,i.isFunction(u)||!u.set)}t.ITERATE_KEY=d,t.computed=dl,t.customRef=Vi,t.effect=k,t.enableTracking=Se,t.isProxy=Jn,t.isReactive=Mr,t.isReadonly=kr,t.isRef=le,t.markRaw=W,t.pauseTracking=Ft,t.proxyRefs=Gn,t.reactive=hr,t.readonly=cn,t.ref=qe,t.resetTracking=Ue,t.shallowReactive=Hi,t.shallowReadonly=qi,t.shallowRef=tt,t.stop=Z,t.toRaw=b,t.toRef=Ja,t.toRefs=ul,t.track=Le,t.trigger=He,t.triggerRef=ct,t.unref=fn}}),_=L({"node_modules/@vue/reactivity/index.js"(t,i){i.exports=v()}}),O={};q(O,{Alpine:()=>Ka,default:()=>ll}),r.exports=K(O);var R=!1,I=!1,U=[],Re=-1;function D(t){C(t)}function C(t){U.includes(t)||U.push(t),te()}function M(t){let i=U.indexOf(t);i!==-1&&i>Re&&U.splice(i,1)}function te(){!I&&!R&&(R=!0,queueMicrotask(be))}function be(){R=!1,I=!0;for(let t=0;tt.effect(i,{scheduler:o=>{Ge?D(o):o()}}),Je=t.raw}function dt(t){Q=t}function xt(t){let i=()=>{};return[c=>{let p=Q(c);return t._x_effects||(t._x_effects=new Set,t._x_runEffects=()=>{t._x_effects.forEach(d=>d())}),t._x_effects.add(p),i=()=>{p!==void 0&&(t._x_effects.delete(p),Pe(p))},p},()=>{i()}]}function Et(t,i){let o=!0,c,p=Q(()=>{let d=t();JSON.stringify(d),o?c=d:queueMicrotask(()=>{i(d,c),c=d}),o=!1});return()=>Pe(p)}function we(t,i,o={}){t.dispatchEvent(new CustomEvent(i,{detail:o,bubbles:!0,composed:!0,cancelable:!0}))}function ue(t,i){if(typeof ShadowRoot=="function"&&t instanceof ShadowRoot){Array.from(t.children).forEach(p=>ue(p,i));return}let o=!1;if(i(t,()=>o=!0),o)return;let c=t.firstElementChild;for(;c;)ue(c,i),c=c.nextElementSibling}function fe(t,...i){console.warn(`Alpine Warning: ${t}`,...i)}var Ee=!1;function ve(){Ee&&fe("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),Ee=!0,document.body||fe("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `
+@endpush
diff --git a/resources/views/billing-portal/v3/payments/methods.blade.php b/resources/views/billing-portal/v3/payments/methods.blade.php
new file mode 100644
index 000000000000..b2eab9f9f9ab
--- /dev/null
+++ b/resources/views/billing-portal/v3/payments/methods.blade.php
@@ -0,0 +1,11 @@
+
+
{{ ctrans('texts.payment_methods') }}
+
+
+ @foreach($methods as $method)
+
+ @endforeach
+
+
diff --git a/resources/views/billing-portal/v3/purchase.blade.php b/resources/views/billing-portal/v3/purchase.blade.php
new file mode 100644
index 000000000000..d1c41e441e0e
--- /dev/null
+++ b/resources/views/billing-portal/v3/purchase.blade.php
@@ -0,0 +1,52 @@
+
+
+
+
->logo }})
+
+
+
+
+ @livewire($this->component, ['context' => $context, 'subscription' => $this->subscription], key($this->componentUniqueId()))
+
+
+
+
+
+
+
+
+ @livewire('billing-portal.summary', ['subscription' => $subscription, 'context' => $context], key($this->summaryUniqueId()))
+
+
+
+
+
+
+
diff --git a/resources/views/billing-portal/v3/rff-basic.blade.php b/resources/views/billing-portal/v3/rff-basic.blade.php
new file mode 100644
index 000000000000..9b1448b6d215
--- /dev/null
+++ b/resources/views/billing-portal/v3/rff-basic.blade.php
@@ -0,0 +1,46 @@
+
diff --git a/resources/views/billing-portal/v3/rff.blade.php b/resources/views/billing-portal/v3/rff.blade.php
new file mode 100644
index 000000000000..0017d27967ac
--- /dev/null
+++ b/resources/views/billing-portal/v3/rff.blade.php
@@ -0,0 +1,15 @@
+
+ @if($errors->any())
+
+
+ @foreach($errors->all() as $error)
+ - {{ $error }}
+ @endforeach
+
+
+ @endif
+
+
+ @livewire('required-client-info', ['db' => $company->db, 'fields' => method_exists($gateway, 'getClientRequiredFields') ? $gateway->getClientRequiredFields() : [], 'contact_id' => auth()->guard('contact')->user()->id, 'countries' => $countries, 'company_id' => $company->id, 'company_gateway_id' => $gateway->company_gateway ? $gateway->company_gateway->id : $gateway->id, 'form_only' => true])
+
+
diff --git a/resources/views/billing-portal/v3/summary.blade.php b/resources/views/billing-portal/v3/summary.blade.php
new file mode 100644
index 000000000000..413571bc47a9
--- /dev/null
+++ b/resources/views/billing-portal/v3/summary.blade.php
@@ -0,0 +1,35 @@
+
+
{{ ctrans('texts.order') }}
+
+ @isset($this->context['bundle'])
+
+ @foreach($this->items() as $item)
+ @if($item['quantity'] > 0)
+
+ {{ $item['quantity'] }}x {{ $item['product_key'] }}
+ {{ $item['total'] }}
+
+ @endif
+ @endforeach
+
+
+
+
+ {{ ctrans('texts.one_time_purchases') }}
+ {{ $this->oneTimePurchasesTotal() }}
+
+
+
+ {{ ctrans('texts.recurring_purchases') }}
+ {{ $this->recurringPurchasesTotal() }}
+
+
+
+ {{ ctrans('texts.total') }}
+ {{ $this->total() }}
+
+
+ @endif
+
diff --git a/resources/views/portal/ninja2020/components/livewire/required-client-info.blade.php b/resources/views/portal/ninja2020/components/livewire/required-client-info.blade.php
index bda902de7424..fd5b6b8bbf25 100644
--- a/resources/views/portal/ninja2020/components/livewire/required-client-info.blade.php
+++ b/resources/views/portal/ninja2020/components/livewire/required-client-info.blade.php
@@ -1,14 +1,16 @@
-
-
-
-
- {{ ctrans('texts.required_payment_information') }}
-
+