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 @@ +
+
+
+ {{ $subscription->company->present()->name }} + + + +
+ @livewire($this->component, ['context' => $context, 'subscription' => $this->subscription], key($this->componentUniqueId())) +
+
+
+ +
+
+
+
+ @livewire('billing-portal.summary', ['subscription' => $subscription, 'context' => $context], key($this->summaryUniqueId())) +
+
+
+
+ +
+ @csrf + + + + + + + + + + +
+
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 @@ +
+
+
+ @csrf + + + + + + + + +
+
+
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') }} -

+
+
+ @unless($form_only) +
+

+ {{ ctrans('texts.required_payment_information') }} +

-

- {{ ctrans('texts.required_payment_information_more') }} -

-
+

+ {{ ctrans('texts.required_payment_information_more') }} +

+
+ @endunless
@foreach($fields as $field) diff --git a/routes/api.php b/routes/api.php index 8c8528a0801b..6749be50569b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ | is assigned the "api" middleware group. Enjoy building your API! | */ +use App\Http\Controllers\SubscriptionStepsController; use Illuminate\Support\Facades\Route; use App\Http\Controllers\BaseController; use App\Http\Controllers\BrevoController; @@ -409,7 +410,11 @@ Route::group(['middleware' => ['throttle:api', 'api_db', 'token_auth', 'locale'] Route::post('stripe/verify', [StripeController::class, 'verify'])->middleware('password_protected')->name('stripe.verify'); Route::post('stripe/disconnect/{company_gateway_id}', [StripeController::class, 'disconnect'])->middleware('password_protected')->name('stripe.disconnect'); + Route::get('subscriptions/steps', [SubscriptionStepsController::class, 'index']); + Route::post('subscriptions/steps/check', [SubscriptionStepsController::class, 'check']); + Route::resource('subscriptions', SubscriptionController::class); + Route::post('subscriptions/bulk', [SubscriptionController::class, 'bulk'])->name('subscriptions.bulk'); Route::get('statics', StaticController::class); // Route::post('apple_pay/upload_file','ApplyPayController::class, 'upload'); diff --git a/routes/client.php b/routes/client.php index feeefd1e9fb0..c2d3aff12e08 100644 --- a/routes/client.php +++ b/routes/client.php @@ -120,6 +120,7 @@ Route::get('payments/process/response', [App\Http\Controllers\ClientPortal\Payme Route::get('client/subscriptions/{subscription}/purchase', [App\Http\Controllers\ClientPortal\SubscriptionPurchaseController::class, 'index'])->name('client.subscription.purchase')->middleware('domain_db'); Route::get('client/subscriptions/{subscription}/purchase/v2', [App\Http\Controllers\ClientPortal\SubscriptionPurchaseController::class, 'upgrade'])->name('client.subscription.upgrade')->middleware('domain_db'); +Route::get('client/subscriptions/{subscription}/purchase/v3', [App\Http\Controllers\ClientPortal\SubscriptionPurchaseController::class, 'v3'])->name('client.subscription.v3')->middleware('domain_db'); Route::group(['middleware' => ['invite_db'], 'prefix' => 'client', 'as' => 'client.'], function () { /*Invitation catches*/ diff --git a/tailwind.config.cjs b/tailwind.config.cjs index 7144e9e4413f..00b25740a4c8 100644 --- a/tailwind.config.cjs +++ b/tailwind.config.cjs @@ -7,7 +7,8 @@ module.exports = { './resources/views/email/components/**/*.blade.php', './resources/views/themes/ninja2020/**/*.blade.php', './resources/views/auth/**/*.blade.php', - './resources/views/setup/**/*.blade.php' + './resources/views/setup/**/*.blade.php', + './resources/views/billing-portal/**/*.blade.php', ], theme: { extend: { diff --git a/tests/Unit/BillingPortal/DependencyTest.php b/tests/Unit/BillingPortal/DependencyTest.php new file mode 100644 index 000000000000..fb109a916e80 --- /dev/null +++ b/tests/Unit/BillingPortal/DependencyTest.php @@ -0,0 +1,111 @@ +assertCount(1, $results); + + $results = StepService::check([ + RegisterOrLogin::class, + Cart::class, + RFF::class, + ]); + + $this->assertCount(0, $results); + + $results = StepService::check([ + RegisterOrLogin::class, + RFF::class, + Cart::class, + ]); + + $this->assertCount(0, $results); + } + + public function testSorting() + { + $results = $this->sort([ + RFF::class, + Methods::class, + RegisterOrLogin::class, + Cart::class, + ]); + + $this->assertEquals(Purchase::defaultSteps(), $results); + + $results = $this->sort([ + RegisterOrLogin::class, + RFF::class, + Methods::class, + Cart::class, + ]); + + $this->assertEquals([ + Setup::class, + RegisterOrLogin::class, + RFF::class, + Methods::class, + Cart::class, + Submit::class, + ], $results); + + $results = $this->sort([ + RegisterOrLogin::class, + RFF::class, + Cart::class, + ]); + + $this->assertEquals([ + Setup::class, + RegisterOrLogin::class, + RFF::class, + Cart::class, + Submit::class, + ], $results); + } + + private function sort(array $dependencies): array + { + $errors = StepService::check($dependencies); + + if (count($errors)) { + return Purchase::defaultSteps(); + } + + return [Setup::class, ...$dependencies, Submit::class]; // Note: Re-index if you're doing any index-based checking/comparision. + } +}