Merge pull request #9280 from beganovich/1314-subscriptions-v3

Subscriptions v3
This commit is contained in:
Benjamin Beganović 2024-04-03 17:38:42 +02:00 committed by GitHub
commit 857a03f216
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
64 changed files with 3077 additions and 176 deletions

1
.gitignore vendored
View File

@ -40,3 +40,4 @@ _ide_helper_models.php
_ide_helper.php
/composer.phar
.tx/
.phpunit.cache

View File

@ -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;
}

View File

@ -1,96 +0,0 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Helpers\Subscription;
use App\Helpers\Invoice\ProRata;
use App\Models\Invoice;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
/**
* SubscriptionCalculator.
*/
class SubscriptionCalculator
{
public Subscription $target_subscription;
public Invoice $invoice;
public function __construct(Subscription $target_subscription, Invoice $invoice)
{
$this->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();
}
}

View File

@ -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
{
@ -54,52 +51,15 @@ class ContactRegisterController extends Controller
{
$request->merge(['company' => $request->company()]);
$client = $this->getClient($request->all());
$client_contact = $this->getClientContact($request->all(), $client);
$service = new ClientRegisterService(
company: $request->company(),
);
$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;
}
}

View File

@ -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,

View File

@ -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.
*

View File

@ -0,0 +1,43 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Http\Controllers;
use App\Livewire\BillingPortal\Purchase;
use App\Rules\Subscriptions\Steps;
use Illuminate\Http\JsonResponse;
class SubscriptionStepsController extends BaseController
{
public function index(): JsonResponse
{
$dependencies = collect(Purchase::$dependencies)
->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);
}
}

View File

@ -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);

View File

@ -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);

View File

@ -0,0 +1,132 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Authentication;
use App\Factory\ClientContactFactory;
use App\Factory\ClientFactory;
use App\Models\Client;
use App\Models\ClientContact;
use App\Models\Company;
use App\Utils\Traits\GeneratesCounter;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
class ClientRegisterService
{
use GeneratesCounter;
public function __construct(
public Company $company,
public array $additional = [],
) {
}
public function rules(): array
{
$rules = [];
foreach ($this->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',
];
}
}

View File

@ -0,0 +1,174 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Authentication;
use App\Jobs\Mail\NinjaMailerJob;
use App\Jobs\Mail\NinjaMailerObject;
use App\Mail\Subscription\OtpCode;
use App\Models\ClientContact;
use App\Models\Subscription;
use Illuminate\Support\Facades\Cache;
use Livewire\Component;
class Login extends Component
{
public Subscription $subscription;
public array $context;
public ?string $email;
public ?string $password;
public ?int $otp;
public array $state = [
'otp' => 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');
}
}

View File

@ -0,0 +1,154 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Authentication;
use Illuminate\Support\Facades\Validator;
use Livewire\Component;
use App\Models\Subscription;
use App\Models\ClientContact;
class Register extends Component
{
public Subscription $subscription;
public array $context;
public ?string $email;
public ?string $password;
public ?int $otp;
public array $state = [
'initial_completed' => 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');
}
}

View File

@ -0,0 +1,261 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Authentication;
use Illuminate\Support\Facades\Validator;
use Livewire\Component;
use App\Models\Subscription;
use App\Models\ClientContact;
use App\Jobs\Mail\NinjaMailerJob;
use App\Mail\Subscription\OtpCode;
use App\Jobs\Mail\NinjaMailerObject;
use Illuminate\Support\Facades\Cache;
class RegisterOrLogin extends Component
{
public Subscription $subscription;
public array $context;
public ?string $email;
public ?string $password;
public ?int $otp;
public array $state = [
'otp' => 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,
]);
}
}

View File

@ -0,0 +1,44 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Cart;
use App\Libraries\MultiDB;
use App\Models\Subscription;
use Livewire\Component;
class Cart extends Component
{
public Subscription $subscription;
public array $context;
public function handleSubmit()
{
$this->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');
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Cart;
use App\Models\Subscription;
use Livewire\Component;
class OneTimeProducts extends Component
{
public Subscription $subscription;
public array $context;
public function quantity($id, $value): void
{
$this->dispatch('purchase.context', property: "bundle.one_time_products.{$id}.quantity", value: $value);
}
public function render()
{
return view('billing-portal.v3.cart.one-time-products');
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Cart;
use App\Models\Subscription;
use Livewire\Component;
class OptionalOneTimeProducts extends Component
{
public Subscription $subscription;
public array $context;
public function quantity($id, $value): void
{
$this->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');
}
}

View File

@ -0,0 +1,35 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Cart;
use App\Models\Subscription;
use Livewire\Component;
class OptionalRecurringProducts extends Component
{
public Subscription $subscription;
public array $context;
public function quantity($id, $value): void
{
$this->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');
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Cart;
use App\Models\Subscription;
use Livewire\Component;
class RecurringProducts extends Component
{
public array $context;
public Subscription $subscription;
public function quantity($id, $value): void
{
$this->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');
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal;
use Livewire\Component;
class Example extends Component
{
public array $context;
public function mount()
{
$this->dispatch('purchase.context', property: 'quantity', value: 1);
$this->dispatch('purchase.next');
}
public function render()
{
return <<<'HTML'
<div>This is step after auth. Currently logged in user is {{ $context['contact']['email'] }}.</div>
HTML;
}
}

View File

@ -0,0 +1,82 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal\Payments;
use Livewire\Component;
use App\Models\Subscription;
use Illuminate\Support\Facades\Cache;
class Methods extends Component
{
public Subscription $subscription;
public array $context;
public array $methods;
public function mount(): void
{
$total = collect($this->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');
}
}

View File

@ -0,0 +1,169 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal;
use App\Libraries\MultiDB;
use App\Livewire\BillingPortal\Authentication\Login;
use App\Livewire\BillingPortal\Authentication\Register;
use App\Livewire\BillingPortal\Authentication\RegisterOrLogin;
use App\Livewire\BillingPortal\Cart\Cart;
use App\Livewire\BillingPortal\Payments\Methods;
use App\Models\Subscription;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
use Illuminate\Support\Str;
class Purchase extends Component
{
public Subscription $subscription;
public string $db;
public array $request_data;
public string $hash;
public ?string $campaign;
//
public int $step = 0;
public string $id;
public static array $dependencies = [
Login::class => [
'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');
}
}

View File

@ -0,0 +1,83 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal;
use App\Models\CompanyGateway;
use Illuminate\Support\Facades\Cache;
use Livewire\Attributes\On;
use Livewire\Component;
class RFF extends Component
{
public array $context;
public string $contact_first_name;
public string $contact_last_name;
public string $contact_email;
#[On('passed-required-fields-check')]
public function continue(): void
{
$this->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,
]);
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal;
use Livewire\Component;
class Setup extends Component
{
public array $context;
public function mount()
{
$this->dispatch('purchase.context', property: 'quantity', value: 1);
$this->dispatch('purchase.next');
}
public function render()
{
return <<<'HTML'
<template></template>
HTML;
}
}

View File

@ -0,0 +1,76 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal;
use Livewire\Component;
use Livewire\Attributes\Lazy;
use App\Services\ClientPortal\InstantPayment;
class Submit extends Component
{
public array $context;
public function mount()
{
// $request = new \Illuminate\Http\Request([
// 'sidebar' => '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'
<svg class="animate-spin h-8 w-8 text-primary" xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor"
stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
HTML;
}
}

View File

@ -0,0 +1,188 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Livewire\BillingPortal;
use App\Models\RecurringInvoice;
use App\Models\Subscription;
use App\Utils\Number;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
class Summary extends Component
{
public Subscription $subscription;
public array $context;
public function mount()
{
$bundle = $this->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');
}
}

View File

@ -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()
@ -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]
@ -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

View File

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

View File

@ -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' => '<br>',
],
]);
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');

View File

@ -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);

View File

@ -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;
}

View File

@ -119,14 +119,59 @@ 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;

View File

@ -0,0 +1,26 @@
<?php
namespace App\Rules\Subscriptions;
use App\Services\Subscription\StepService;
use Closure;
use App\Livewire\BillingPortal\Purchase;
use Illuminate\Contracts\Validation\ValidationRule;
class Steps implements ValidationRule
{
/**
* Run the validation rule.
*
* @param \Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$steps = StepService::mapToClassNames($value);
$errors = StepService::check($steps);
if (count($errors) > 0) {
$fail($errors[0]);
}
}
}

View File

@ -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) {

View File

@ -0,0 +1,70 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Subscription;
use App\Livewire\BillingPortal\Purchase;
class StepService
{
public static function mapToClassNames(string $steps): array
{
$classes = collect(Purchase::$dependencies)->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];
}
}

View File

@ -0,0 +1,204 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace App\Services\Subscription;
use App\Models\Invoice;
use App\Models\Subscription;
use Illuminate\Support\Carbon;
use App\DataMapper\InvoiceItem;
use App\Factory\InvoiceFactory;
use App\Utils\Traits\MakesHash;
use App\Helpers\Invoice\ProRata;
use App\Repositories\InvoiceRepository;
/**
* SubscriptionCalculator.
*/
class SubscriptionCalculator
{
use MakesHash;
public function __construct(public Subscription $subscription){}
/**
* BuildPurchaseInvoice
*
* @param array $context
* @return Invoice
*/
public function buildPurchaseInvoice(array $context): Invoice
{
$invoice_repo = new InvoiceRepository();
$invoice = InvoiceFactory::create($this->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();
}
}

View File

@ -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,
];
}
}

2
composer.lock generated
View File

@ -19707,5 +19707,5 @@
"platform-dev": {
"php": "^8.1|^8.2"
},
"plugin-api-version": "2.3.0"
"plugin-api-version": "2.6.0"
}

View File

@ -0,0 +1,40 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
use App\Livewire\BillingPortal\Purchase;
use App\Services\Subscription\StepService;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -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',

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
public/build/assets/app-c6dc74fe.css vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,7 @@
]
},
"resources/js/app.js": {
"file": "assets/app-c80ec97e.js",
"file": "assets/app-042e859e.js",
"imports": [
"_index-08e160a7.js",
"__commonjsHelpers-725317a4.js"
@ -240,7 +240,7 @@
"src": "resources/js/setup/setup.js"
},
"resources/sass/app.scss": {
"file": "assets/app-91a05c24.css",
"file": "assets/app-c6dc74fe.css",
"isEntry": true,
"src": "resources/sass/app.scss"
}

View File

@ -14,7 +14,7 @@
Livewire.on('beforePaymentEventsCompleted', () => {
setTimeout(() => {
document.getElementById('payment-method-form').submit()
}, 2000);
}, 2500);
});
});

View File

@ -0,0 +1,91 @@
<div>
@if (session()->has('message'))
@component('portal.ninja2020.components.message')
{{ session('message') }}
@endcomponent
@endif
<div class="my-4">
<h1 class="text-3xl font-medium">{{ ctrans('texts.contact') }}</h1>
</div>
@if($state['initial_completed'] === false)
<form wire:submit="initial">
@csrf
<label for="email_address">
<span class="input-label">{{ ctrans('texts.email_address') }}</span>
<input wire:model="email" type="email" class="input w-full" />
@error('email')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</label>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
@endif
@if($state['login_form'])
<form wire:submit="handlePassword" class="space-y-3">
@csrf
<div>
<span class="input-label">{{ ctrans('texts.email_address') }}</span>
<input wire:model="email" type="email" class="input w-full" />
@error('email')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</div>
<div>
<span class="input-label">{{ ctrans('texts.password') }}</span>
<input wire:model="password" type="password" class="input w-full" />
@error('password')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</div>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
@endif
@if($state['otp_form'])
<form wire:submit="handleOtp" class="space-y-3">
@csrf
<div>
<span class="input-label">{{ ctrans('texts.code') }}</span>
<input wire:model="otp" type="text" class="input w-full" />
@error('otp')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</div>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
@endif
</div>

View File

@ -0,0 +1,130 @@
<form wire:submit="register(Object.fromEntries(new FormData($event.target)))" class="space-y-3">
@csrf
<div class="grid grid-cols-12 gap-4 mt-10">
@if($register_fields)
@foreach($register_fields as $field)
@if($field['visible'])
<div class="col-span-12 md:col-span-6">
<section class="flex items-center">
<label
for="{{ $field['key'] }}"
class="input-label">
@if(in_array($field['key'], ['custom_value1','custom_value2','custom_value3','custom_value4']))
{{ (new App\Utils\Helpers())->makeCustomField($subscription->company->custom_fields, str_replace("custom_value","client", $field['key']))}}
@elseif(array_key_exists('label', $field))
{{ ctrans("texts.{$field['label']}") }}
@else
{{ ctrans("texts.{$field['key']}") }}
@endif
</label>
@if($field['required'])
<section class="text-red-400 ml-1 text-sm">*</section>
@endif
</section>
@if($field['key'] === 'email')
<input
id="{{ $field['key'] }}"
class="input w-full"
type="email"
name="{{ $field['key'] }}"
value="{{ old($field['key'], $this->email ?? '') }}"
/>
@elseif($field['key'] === 'password')
<input
id="{{ $field['key'] }}"
class="input w-full"
type="password"
name="{{ $field['key'] }}"
/>
@elseif($field['key'] === 'currency_id')
<select
id="currency_id"
class="input w-full form-select bg-white"
name="currency_id">
@foreach(App\Utils\TranslationHelper::getCurrencies() as $currency)
<option
{{ $currency->id == $subscription->company->settings->currency_id ? 'selected' : null }} value="{{ $currency->id }}">
{{ $currency->name }}
</option>
@endforeach
</select>
@elseif($field['key'] === 'country_id')
<select
id="shipping_country"
class="input w-full form-select bg-white"
name="country_id">
<option value="none"></option>
@foreach(App\Utils\TranslationHelper::getCountries() as $country)
<option
{{ $country == isset(auth()->user()->client->shipping_country->id) ? 'selected' : null }} value="{{ $country->id }}">
{{ $country->iso_3166_2 }}
({{ $country->name }})
</option>
@endforeach
</select>
@elseif($field['key'] === 'shipping_country_id')
<select
id="shipping_country"
class="input w-full form-select bg-white"
name="shipping_country_id">
<option value="none"></option>
@foreach(App\Utils\TranslationHelper::getCountries() as $country)
<option
{{ $country == isset(auth()->user()->client->shipping_country->id) ? 'selected' : null }} value="{{ $country->id }}">
{{ $country->iso_3166_2 }}
({{ $country->name }})
</option>
@endforeach
</select>
@else
<input
id="{{ $field['key'] }}"
class="input w-full"
name="{{ $field['key'] }}"
value="{{ old($field['key']) }}"
/>
@endif
@error($field['key'])
<div class="validation validation-fail">
{{ $message }}
</div>
@enderror
</div>
@if($field['key'] === 'password')
<div class="col-span-12 md:col-span-6">
<section class="flex items-center">
<label
for="password_confirmation"
class="input-label">
{{ ctrans('texts.password_confirmation') }}
</label>
@if($field['required'])
<section class="text-red-400 ml-1 text-sm">*</section>
@endif
</section>
<input
id="password_confirmation"
type="password"
class="input w-full"
name="password_confirmation"
/>
</div>
@endif
@endif
@endforeach
@endif
</div>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>

View File

@ -0,0 +1,95 @@
<div>
@if (session()->has('message'))
@component('portal.ninja2020.components.message')
{{ session('message') }}
@endcomponent
@endif
<div class="my-4">
<h1 class="text-3xl font-medium">{{ ctrans('texts.contact') }}</h1>
</div>
@if($state['initial_completed'] === false)
<form wire:submit="initial">
@csrf
<label for="email_address">
<span class="input-label">{{ ctrans('texts.email_address') }}</span>
<input wire:model="email" type="email" class="input w-full" />
@error('email')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</label>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
@endif
@if($state['login_form'])
<form wire:submit="handlePassword" class="space-y-3">
@csrf
<div>
<span class="input-label">{{ ctrans('texts.email_address') }}</span>
<input wire:model="email" type="email" class="input w-full" />
@error('email')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</div>
<div>
<span class="input-label">{{ ctrans('texts.password') }}</span>
<input wire:model="password" type="password" class="input w-full" />
@error('password')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</div>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
@endif
@if($state['otp_form'])
<form wire:submit="handleOtp" class="space-y-3">
@csrf
<div>
<span class="input-label">{{ ctrans('texts.code') }}</span>
<input wire:model="otp" type="text" class="input w-full" />
@error('otp')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</div>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
@endif
@if($state['register_form'])
@include('billing-portal.v3.authentication.register-form')
@endif
</div>

View File

@ -0,0 +1,38 @@
<div>
@if (session()->has('message'))
@component('portal.ninja2020.components.message')
{{ session('message') }}
@endcomponent
@endif
<div class="my-4">
<h1 class="text-3xl font-medium">{{ ctrans('texts.contact') }}</h1>
</div>
@if($state['initial_completed'] === false)
<form wire:submit="initial">
@csrf
<label for="email_address">
<span class="input-label">{{ ctrans('texts.email_address') }}</span>
<input wire:model="email" type="email" class="input w-full" />
@error('email')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</label>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
@endif
@if($state['register_form'])
@include('billing-portal.v3.authentication.register-form')
@endif
</div>

View File

@ -0,0 +1,36 @@
<div>
<livewire:billing-portal.cart.recurring-products
:subscription="$subscription"
:context="$context"
/>
<livewire:billing-portal.cart.one-time-products
:subscription="$subscription"
:context="$context"
/>
@if($this->showOptionalProductsLabel())
<p class="text-xl mt-10 mb-4">{{ ctrans('texts.optional_products') }}</p>
@endif
<livewire:billing-portal.cart.optional-recurring-products
:subscription="$subscription"
:context="$context"
/>
<livewire:billing-portal.cart.optional-one-time-products
:subscription="$subscription"
:context="$context"
/>
<div class="mt-3">
<form wire:submit="handleSubmit">
<button
type="submit"
class="button button-block bg-primary text-white mt-4"
>
{{ ctrans('texts.next') }}
</button>
</form>
</div>
</div>

View File

@ -0,0 +1,56 @@
<div class="space-y-10">
@isset($context['bundle']['one_time_products'])
@foreach($context['bundle']['one_time_products'] as $key => $entry)
@php
$product = $entry['product'];
@endphp
<div>
<div class="flex items-start justify-between space-x-4">
<div class="flex flex-start">
@if(filter_var($product['product_image'], FILTER_VALIDATE_URL))
<div
class="h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 mr-2"
>
<img
src="{{ $product['product_image'] }}"
alt=""
class="h-full w-full object-cover object-center border rounded-md"
/>
</div>
@endif
<div class="flex flex-col">
<h2 class="text-lg font-medium">{{ $product['product_key'] }}</h2>
<p class="block text-sm">{{ \App\Utils\Number::formatMoney($product['price'], $subscription['company']) }}</p>
</div>
</div>
<div class="flex flex-col-reverse space-y-3">
<div class="flex">
@if($subscription->per_seat_enabled)
@if($subscription->use_inventory_management && $product['in_stock_quantity'] <= 0)
<p class="text-sm font-light text-red-500 text-right mr-2 mt-2">{{ ctrans('texts.out_of_stock') }}</p>
@else
<p class="text-sm font-light text-gray-700 text-right mr-2 mt-2">{{ ctrans('texts.qty') }}</p>
@endif
<select id="{{ $product['hashed_id'] }}" wire:change="quantity($event.target.id, $event.target.value)" class="rounded-md border-gray-300 shadow-sm sm:text-sm" {{ $subscription->use_inventory_management && $product['in_stock_quantity'] < 1 ? 'disabled' : '' }}>
<option {{ $entry['quantity'] == '0' ? 'selected' : '' }} value="0" selected="selected">0</option>
@for ($i = 1; $i <= ($subscription->use_inventory_management ? min($product['in_stock_quantity'], min(100,$product['max_quantity'])) : min(100,$product['max_quantity'])); $i++)
<option {{ $entry['quantity'] == $i ? 'selected' : '' }} value="{{ $i }}">{{ $i }}</option>
@endfor
</select>
@endif
</div>
</div>
</div>
<article class="prose my-3 text-sm">
{!! \App\Models\Product::markdownHelp($product['notes']) !!}
</article>
</div>
@endforeach
@endisset
</div>

View File

@ -0,0 +1,54 @@
<div class="space-y-10">
@isset($context['bundle']['optional_one_time_products'])
@foreach($context['bundle']['optional_one_time_products'] as $key => $entry)
@php
$product = $entry['product'];
@endphp
<div>
<div class="flex items-start justify-between space-x-4">
<div class="flex flex-start">
@if(filter_var($product['product_image'], FILTER_VALIDATE_URL))
<div
class="h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 mr-2"
>
<img
src="{{ $product['product_image'] }}"
alt=""
class="h-full w-full object-cover object-center border rounded-md"
/>
</div>
@endif
<div class="flex flex-col">
<h2 class="text-lg font-medium">{{ $product['product_key'] }}</h2>
<p class="block text-sm">{{ \App\Utils\Number::formatMoney($product['price'], $subscription['company']) }}</p>
</div>
</div>
<div class="flex flex-col-reverse space-y-3">
<div class="flex">
@if($subscription->use_inventory_management && $product['in_stock_quantity'] <= 0)
<p class="text-sm font-light text-red-500 text-right mr-2 mt-2">{{ ctrans('texts.out_of_stock') }}</p>
@else
<p class="text-sm font-light text-gray-700 text-right mr-2 mt-2">{{ ctrans('texts.qty') }}</p>
@endif
<select id="{{ $product['hashed_id'] }}" wire:change="quantity($event.target.id, $event.target.value)" class="rounded-md border-gray-300 shadow-sm sm:text-sm" {{ $subscription->use_inventory_management && $product['in_stock_quantity'] < 1 ? 'disabled' : '' }}>
<option {{ $entry['quantity'] == '0' ? 'selected' : '' }} value="0" selected="selected">0</option>
@for ($i = 1; $i <= ($subscription->use_inventory_management ? min($product['in_stock_quantity'], min(100,$product['max_quantity'])) : min(100,$product['max_quantity'])); $i++)
<option {{ $entry['quantity'] == $i ? 'selected' : '' }} value="{{ $i }}">{{ $i }}</option>
@endfor
</select>
</div>
</div>
</div>
<article class="prose my-3 text-sm">
{!! \App\Models\Product::markdownHelp($product['notes']) !!}
</article>
</div>
@endforeach
@endisset
</div>

View File

@ -0,0 +1,56 @@
<div class="space-y-10">
@isset($context['bundle']['optional_recurring_products'])
@foreach($context['bundle']['optional_recurring_products'] as $key => $entry)
@php
$product = $entry['product'];
@endphp
<div>
<div class="flex items-start justify-between space-x-4">
<div class="flex flex-start">
@if(filter_var($product['product_image'], FILTER_VALIDATE_URL))
<div
class="h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 mr-2"
>
<img
src="{{ $product['product_image'] }}"
alt=""
class="h-full w-full object-cover object-center border rounded-md"
/>
</div>
@endif
<div class="flex flex-col">
<h2 class="text-lg font-medium">{{ $product['product_key'] }}</h2>
<p class="block text-sm">{{ \App\Utils\Number::formatMoney($product['price'], $subscription['company']) }} / <span class="lowercase">{{ App\Models\RecurringInvoice::frequencyForKey($subscription->frequency_id) }}</span></p>
</div>
</div>
<div class="flex flex-col-reverse space-y-3">
<div class="flex">
@if($subscription->use_inventory_management && $product['in_stock_quantity'] <= 0)
<p class="text-sm font-light text-red-500 text-right mr-2 mt-2">{{ ctrans('texts.out_of_stock') }}</p>
@else
<p class="text-sm font-light text-gray-700 text-right mr-2 mt-2">{{ ctrans('texts.qty') }}</p>
@endif
<select id="{{ $product['hashed_id'] }}" wire:change="quantity($event.target.id, $event.target.value)" class="rounded-md border-gray-300 shadow-sm sm:text-sm" {{ $subscription->use_inventory_management && $product['in_stock_quantity'] < 1 ? 'disabled' : '' }}>
<option {{ $entry['quantity'] == '0' ? 'selected' : '' }} value="0" selected="selected">0</option>
@for ($i = 1; $i <= ($subscription->use_inventory_management ? min($product['in_stock_quantity'], min(100,$product['max_quantity'])) : min(100,$product['max_quantity'])); $i++)
<option {{ $entry['quantity'] == $i ? 'selected' : '' }} value="{{ $i }}">{{ $i }}</option>
@endfor
</select>
</div>
</div>
</div>
<article class="prose my-3 text-sm">
{!! \App\Models\Product::markdownHelp($product['notes']) !!}
</article>
</div>
@endforeach
@endisset
</div>

View File

@ -0,0 +1,68 @@
<div class="space-y-10">
@isset($context['bundle']['recurring_products'])
@foreach($context['bundle']['recurring_products'] as $key => $entry)
@php
$product = $entry['product'];
@endphp
<div>
<div class="flex items-start justify-between space-x-4">
<div class="flex flex-start">
@if(filter_var($product['product_image'], FILTER_VALIDATE_URL))
<div
class="h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border border-gray-200 mr-2"
>
<img
src="{{ $product['product_image'] }}"
alt=""
class="h-full w-full object-cover object-center border rounded-md"
/>
</div>
@endif
<div class="flex flex-col">
<h2 class="text-lg font-medium">{{ $product['product_key'] }}</h2>
<p class="block text-sm">{{ \App\Utils\Number::formatMoney($product['price'], $subscription['company']) }} / <span class="lowercase">{{ App\Models\RecurringInvoice::frequencyForKey($subscription->frequency_id) }}</span></p>
</div>
</div>
<div class="flex flex-col-reverse space-y-3">
<div class="flex">
@if($subscription->per_seat_enabled)
@if($subscription->use_inventory_management && $product['in_stock_quantity'] < 1)
<p class="text-sm font-light text-red-500 text-right mr-2 mt-2">{{ ctrans('texts.out_of_stock') }}</p>
@else
<p class="text-sm font-light text-gray-700 text-right mr-2 mt-2">{{ ctrans('texts.qty') }}</p>
@endif
<select
id="{{ $product['hashed_id'] }}"
class="rounded-md border-gray-300 shadow-sm sm:text-sm"
wire:change="quantity($event.target.id, $event.target.value)"
{{ $subscription->use_inventory_management && $product['in_stock_quantity'] < 1 ? 'disabled' : '' }}
>
<option {{ $entry['quantity'] == '1' ? 'selected' : '' }} value="1">1</option>
@if($subscription->max_seats_limit > 1)
@for ($i = 2; $i <= ($subscription->use_inventory_management ? min($subscription->max_seats_limit,$product['in_stock_quantity']) : $subscription->max_seats_limit); $i++)
<option {{ $entry['quantity'] == $i ? 'selected' : '' }} value="{{ $i }}">{{ $i }}</option>
@endfor
@else
@for ($i = 2; $i <= ($subscription->use_inventory_management ? min($product['in_stock_quantity'], min(100,$product['max_quantity'])) : min(100,$product['max_quantity'])); $i++)
<option {{ $entry['quantity'] == $i ? 'selected' : '' }} value="{{ $i }}">{{ $i }}</option>
@endfor
@endif
</select>
@endif
</div>
</div>
</div>
<article class="prose my-3 text-sm">
{!! \App\Models\Product::markdownHelp($product['notes']) !!}
</article>
</div>
@endforeach
@endisset
</div>

View File

@ -0,0 +1,46 @@
@extends('portal.ninja2020.layout.clean')
@section('meta_title', ctrans('texts.purchase'))
@section('body')
@if ($errors->any())
<div class="alert alert-danger" style="margin: 1rem">
@foreach ($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
@endif
@livewire('billing-portal.purchase', ['subscription' => $subscription, 'db' => $subscription->company->db, 'hash' => $hash, 'request_data' => $request_data, 'campaign' => request()->query('campaign') ?? null])
@stop
@push('footer')
<script>
document.addEventListener('livewire:init', () => {
Livewire.on('purchase.submit', (event) => {
document.getElementById('payment-method-form').submit();
});
const target = document.getElementById('container');
Livewire.on('purchase.next', (event) => {
document.getElementById('spinner').classList.remove('hidden');
document.getElementById('container').classList.add('hidden');
setTimeout(() => {
document.getElementById('spinner').classList.add('hidden');
document.getElementById('container').classList.remove('hidden');
}, 1500);
});
Livewire.on('update-shipping-data', (event) => {
for (field in event) {
let element = document.querySelector(`input[name=${field}]`);
if (element) {
element.value = event[field];
}
}
});
});
</script>
@endpush

View File

@ -0,0 +1,11 @@
<div>
<h1 class="text-2xl">{{ ctrans('texts.payment_methods') }}</h1>
<div class="flex flex-col space-y-3 my-3">
@foreach($methods as $method)
<button class="flex items-center justify-between mb-4 bg-white rounded px-6 py-4 shadow-sm border" wire:click="handleSelect('{{ $method['company_gateway_id'] }}', '{{ $method['gateway_type_id'] }}'); $wire.$refresh();">
{{ $method['label'] }}
</button>
@endforeach
</div>
</div>

View File

@ -0,0 +1,52 @@
<div class="grid grid-cols-12 bg-gray-50">
<div
@php
nlog($context);
@endphp
class="col-span-12 xl:col-span-6 bg-white flex flex-col items-center lg:h-screen"
>
<div class="w-full p-10 lg:mt-24 md:max-w-xl">
<img
class="h-8"
src="{{ $subscription->company->present()->logo }}"
alt="{{ $subscription->company->present()->name }}"
/>
<svg id="spinner" class="animate-spin h-8 w-8 text-primary mt-10 hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="my-10" id="container">
@livewire($this->component, ['context' => $context, 'subscription' => $this->subscription], key($this->componentUniqueId()))
</div>
</div>
</div>
<div class="col-span-12 xl:col-span-6">
<div class="sticky top-0">
<div class="w-full p-10 lg:mt-24 md:max-w-xl">
<div class="my-6 space-y-10 xl:ml-5">
@livewire('billing-portal.summary', ['subscription' => $subscription, 'context' => $context], key($this->summaryUniqueId()))
</div>
</div>
</div>
</div>
<form
action="{{ route('client.payments.process', ['hash' => $hash, 'sidebar' => 'hidden', 'source' => 'subscriptions']) }}"
method="post"
id="payment-method-form">
@csrf
<input type="hidden" name="action" value="payment">
<input type="hidden" name="invoices[]" />
<input type="hidden" name="payable_invoices[0][amount]" value="{{ $this->context['form']['payable_amount'] ?? '' }}" />
<input type="hidden" name="payable_invoices[0][invoice_id]" value="{{ $this->context['form']['invoice_hashed_id'] ?? '' }}" />
<input type="hidden" name="company_gateway_id" value="{{ $this->context['form']['company_gateway_id'] ?? '' }}" />
<input type="hidden" name="payment_method_id" value="{{ $this->context['form']['payment_method_id'] ?? '' }}" />
<input type="hidden" name="contact_first_name" value="{{ $this->context['contact']['first_name'] ?? '' }}" />
<input type="hidden" name="contact_last_name" value="{{ $this->context['contact']['last_name'] ?? '' }}" />
<input type="hidden" name="contact_email" value="{{ $this->context['contact']['email'] ?? '' }}" />
</form>
</div>

View File

@ -0,0 +1,46 @@
<div>
<div>
<form wire:submit="handleSubmit">
@csrf
<label for="contact_first_name">
<span class="input-label">{{ ctrans('texts.first_name') }}</span>
<input wire:model="contact_first_name" type="text" class="input w-full" />
@error('contact_first_name')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</label>
<label for="contact_last_name">
<span class="input-label">{{ ctrans('texts.last_name') }}</span>
<input wire:model="contact_last_name" type="text" class="input w-full" />
@error('contact_last_name')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</label>
<label for="contact_email">
<span class="input-label">{{ ctrans('texts.email_address') }}</span>
<input wire:model="contact_email" type="email" class="input w-full" />
@error('contact_email')
<p class="validation validation-fail block w-full" role="alert">
{{ $message }}
</p>
@enderror
</label>
<button
type="submit"
class="button button-block bg-primary text-white mt-4">
{{ ctrans('texts.next') }}
</button>
</form>
</div>
</div>

View File

@ -0,0 +1,15 @@
<div>
@if($errors->any())
<div class="alert alert-error">
<ul>
@foreach($errors->all() as $error)
<li class="text-sm">{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<div>
@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])
</div>
</div>

View File

@ -0,0 +1,35 @@
<div class="space-y-4">
<h1 class="text-2xl">{{ ctrans('texts.order') }}</h1>
@isset($this->context['bundle'])
<div class="space-y-2">
@foreach($this->items() as $item)
@if($item['quantity'] > 0)
<div class="flex justify-between text-sm">
<span>{{ $item['quantity'] }}x {{ $item['product_key'] }}</span>
<span>{{ $item['total'] }}</span>
</div>
@endif
@endforeach
</div>
<div class="space-y-2 mt-4 border-t pt-2">
<div class="flex justify-between text-sm">
<span class="uppercase">{{ ctrans('texts.one_time_purchases') }}</span>
<span>{{ $this->oneTimePurchasesTotal() }}</span>
</div>
<div class="flex justify-between text-sm">
<span class="uppercase">{{ ctrans('texts.recurring_purchases') }}</span>
<span>{{ $this->recurringPurchasesTotal() }}</span>
</div>
<div
class="flex justify-between text-sm uppercase border-t pt-2"
>
<span>{{ ctrans('texts.total') }}</span>
<span class="font-semibold">{{ $this->total() }}</span>
</div>
</div>
@endif
</div>

View File

@ -1,14 +1,16 @@
<div wire:ignore.self class="container mx-auto grid grid-cols-12 mb-4" data-ref="required-fields-container">
<div class="col-span-12 lg:col-span-6 lg:col-start-4 overflow-hidden bg-white shadow rounded-lg">
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">
{{ ctrans('texts.required_payment_information') }}
</h3>
<div wire:ignore.self class="@unless($form_only) container mx-auto grid grid-cols-12 @endunless mb-4" data-ref="required-fields-container">
<div class="col-span-12 lg:col-span-6 lg:col-start-4 overflow-hidden @unless($form_only) bg-white shadow rounded-lg @endunless">
@unless($form_only)
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 class="text-lg font-medium leading-6 text-gray-900">
{{ ctrans('texts.required_payment_information') }}
</h3>
<p class="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
{{ ctrans('texts.required_payment_information_more') }}
</p>
</div>
<p class="max-w-2xl mt-1 text-sm leading-5 text-gray-500">
{{ ctrans('texts.required_payment_information_more') }}
</p>
</div>
@endunless
<form id="required-client-info-form" x-on:submit.prevent="$wire.handleSubmit(Object.fromEntries(new FormData(document.getElementById('required-client-info-form'))))">
@foreach($fields as $field)

View File

@ -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');

View File

@ -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*/

View File

@ -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: {

View File

@ -0,0 +1,111 @@
<?php
/**
* Invoice Ninja (https://invoiceninja.com).
*
* @link https://github.com/invoiceninja/invoiceninja source repository
*
* @copyright Copyright (c) 2022. Invoice Ninja LLC (https://invoiceninja.com)
*
* @license https://www.elastic.co/licensing/elastic-license
*/
namespace Tests\Unit\BillingPortal;
use App\Livewire\BillingPortal\Authentication\RegisterOrLogin;
use App\Livewire\BillingPortal\Cart\Cart;
use App\Livewire\BillingPortal\Payments\Methods;
use App\Livewire\BillingPortal\Purchase;
use App\Livewire\BillingPortal\RFF;
use App\Livewire\BillingPortal\Setup;
use App\Livewire\BillingPortal\Submit;
use App\Services\Subscription\StepService;
use Tests\TestCase;
class DependencyTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
}
public function testDependencyOrder()
{
$results = StepService::check([
RFF::class,
RegisterOrLogin::class,
Cart::class,
]);
$this->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.
}
}