diff --git a/app/Console/Commands/EncryptNinja.php b/app/Console/Commands/EncryptNinja.php new file mode 100644 index 000000000000..162b574d8564 --- /dev/null +++ b/app/Console/Commands/EncryptNinja.php @@ -0,0 +1,83 @@ +option('encrypt')) + return $this->encryptFiles(); + + if($this->option('decrypt')) { + return $this->decryptFiles(); + } + + } + + private function encryptFiles() + { + foreach ($this->files as $file) { + $contents = Storage::disk('base')->get($file); + $encrypted = encrypt($contents); + Storage::disk('base')->put($file.".enc", $encrypted); + Storage::disk('base')->delete($file); + } + } + + private function decryptFiles() + { + foreach ($this->files as $file) { + $encrypted_file = "{$file}.enc"; + $contents = Storage::disk('base')->get($encrypted_file); + $decrypted = decrypt($contents); + Storage::disk('base')->put($file, $decrypted); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/ClientPortal/PaymentMethodController.php b/app/Http/Controllers/ClientPortal/PaymentMethodController.php index b8b0441679d5..5d131daad74d 100644 --- a/app/Http/Controllers/ClientPortal/PaymentMethodController.php +++ b/app/Http/Controllers/ClientPortal/PaymentMethodController.php @@ -144,7 +144,10 @@ class PaymentMethodController extends Controller try { event(new MethodDeleted($payment_method, auth()->guard('contact')->user()->company, Ninja::eventVars(auth()->guard('contact')->user()->id))); + $payment_method->is_deleted = true; $payment_method->delete(); + $payment_method->save(); + } catch (Exception $e) { nlog($e->getMessage()); diff --git a/app/Http/Middleware/TokenAuth.php b/app/Http/Middleware/TokenAuth.php index 37776bb7c0c7..ab62859f2f9b 100644 --- a/app/Http/Middleware/TokenAuth.php +++ b/app/Http/Middleware/TokenAuth.php @@ -74,6 +74,7 @@ class TokenAuth */ app('queue')->createPayloadUsing(function () use ($company_token) { return ['db' => $company_token->company->db]; + // return ['db' => $company_token->company->db, 'is_premium' => $company_token->account->isPremium()]; }); //user who once existed, but has been soft deleted diff --git a/app/Import/Definitions/TaskMap.php b/app/Import/Definitions/TaskMap.php index 08af5d591b1a..c2218341c3f6 100644 --- a/app/Import/Definitions/TaskMap.php +++ b/app/Import/Definitions/TaskMap.php @@ -31,9 +31,9 @@ class TaskMap 12 => 'task.duration', 13 => 'task.status', 14 => 'task.custom_value1', - 15 => 'task.custom_value1', - 16 => 'task.custom_value1', - 17 => 'task.custom_value1', + 15 => 'task.custom_value2', + 16 => 'task.custom_value3', + 17 => 'task.custom_value4', 18 => 'task.notes', ]; } diff --git a/app/Import/Transformer/Csv/TaskTransformer.php b/app/Import/Transformer/Csv/TaskTransformer.php index cfe44f7676c4..d2c6d4538f33 100644 --- a/app/Import/Transformer/Csv/TaskTransformer.php +++ b/app/Import/Transformer/Csv/TaskTransformer.php @@ -115,11 +115,22 @@ class TaskTransformer extends BaseTransformer $this->stubbed_timestamp = $stub_start_date->timestamp; return $stub_start_date->timestamp; + } catch (\Exception $e) { + nlog("fall back failed too" . $e->getMessage()); + // return $this->stubbed_timestamp; + } + + + try { + + $stub_start_date = \Carbon\Carbon::createFromFormat($this->company->date_format(), $stub_start_date); + $this->stubbed_timestamp = $stub_start_date->timestamp; } catch (\Exception $e) { nlog($e->getMessage()); return $this->stubbed_timestamp; } + } private function resolveEndDate($item) @@ -142,9 +153,23 @@ class TaskTransformer extends BaseTransformer } catch (\Exception $e) { nlog($e->getMessage()); + // return $this->stubbed_timestamp; + } + + + + try { + + $stub_end_date = \Carbon\Carbon::createFromFormat($this->company->date_format(), $stub_end_date); + $this->stubbed_timestamp = $stub_end_date->timestamp; + } catch (\Exception $e) { + nlog("fall back failed too" . $e->getMessage()); return $this->stubbed_timestamp; } + + + } private function getTaskStatusId($item): ?int diff --git a/app/Jobs/Mail/NinjaMailer.php b/app/Jobs/Mail/NinjaMailer.php index 9dfd369de3ce..8f5de94390ae 100644 --- a/app/Jobs/Mail/NinjaMailer.php +++ b/app/Jobs/Mail/NinjaMailer.php @@ -34,6 +34,7 @@ class NinjaMailer extends Mailable */ public function build() { + $from_name = config('mail.from.name'); if (property_exists($this->mail_obj, 'from_name')) { diff --git a/app/Models/Account.php b/app/Models/Account.php index 7249f4308085..32b81723e4a2 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -294,6 +294,11 @@ class Account extends BaseModel return Ninja::isNinja() ? ($this->isPaidHostedClient() && !$this->isTrial()) : $this->hasFeature(self::FEATURE_WHITE_LABEL); } + public function isPremium(): bool + { + return Ninja::isHosted() && $this->isPaidHostedClient() && !$this->isTrial() && Carbon::createFromTimestamp($this->created_at)->diffInMonths() > 2; + } + public function isPaidHostedClient(): bool { if (!Ninja::isNinja()) { diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 960817cac99c..132b57cfe6e3 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -11,7 +11,9 @@ namespace App\Models; +use App\Services\Subscription\PaymentLinkService; use App\Services\Subscription\SubscriptionService; +use App\Services\Subscription\SubscriptionStatus; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; @@ -121,6 +123,8 @@ class Subscription extends BaseModel 'updated_at' => 'timestamp', 'created_at' => 'timestamp', 'deleted_at' => 'timestamp', + 'trial_enabled' => 'boolean', + 'allow_plan_changes' => 'boolean', ]; protected $with = [ @@ -132,6 +136,16 @@ class Subscription extends BaseModel return new SubscriptionService($this); } + public function link_service(): PaymentLinkService + { + return new PaymentLinkService($this); + } + + public function status(RecurringInvoice $recurring_invoice): SubscriptionStatus + { + return (new SubscriptionStatus($this, $recurring_invoice))->run(); + } + public function company(): \Illuminate\Database\Eloquent\Relations\BelongsTo { return $this->belongsTo(Company::class); diff --git a/app/Providers/ComposerServiceProvider.php b/app/Providers/ComposerServiceProvider.php index 7c8199142f9e..4f71bdf6a2a6 100644 --- a/app/Providers/ComposerServiceProvider.php +++ b/app/Providers/ComposerServiceProvider.php @@ -24,6 +24,18 @@ class ComposerServiceProvider extends ServiceProvider public function boot() { view()->composer('portal.*', PortalComposer::class); + + // view()->composer( + // ['email.admin.generic', 'email.client.generic'], + // function ($view) { + // $view->with( + // 'template', + // Ninja::isHosted() + // ); + // } + // ); + + } /** diff --git a/app/Providers/MultiDBProvider.php b/app/Providers/MultiDBProvider.php index 8ff9c569dca2..4f01911ea087 100644 --- a/app/Providers/MultiDBProvider.php +++ b/app/Providers/MultiDBProvider.php @@ -33,6 +33,7 @@ class MultiDBProvider extends ServiceProvider */ public function register() { + $this->app['events']->listen( JobProcessing::class, function ($event) { diff --git a/app/Repositories/TaskRepository.php b/app/Repositories/TaskRepository.php index f003d4f7a1c7..426b2e5ed81b 100644 --- a/app/Repositories/TaskRepository.php +++ b/app/Repositories/TaskRepository.php @@ -141,7 +141,7 @@ class TaskRepository extends BaseRepository { if(isset($time_log[0][0])) { - return \Carbon\Carbon::createFromTimestamp($time_log[0][0])->addSeconds($task->company->utc_offset()); + return \Carbon\Carbon::createFromTimestamp($time_log[0][0]); } return null; diff --git a/app/Services/Invoice/AutoBillInvoice.php b/app/Services/Invoice/AutoBillInvoice.php index 1ba08461991d..1c65aa6999d3 100644 --- a/app/Services/Invoice/AutoBillInvoice.php +++ b/app/Services/Invoice/AutoBillInvoice.php @@ -323,13 +323,22 @@ class AutoBillInvoice extends AbstractService public function getGateway($amount) { //get all client gateway tokens and set the is_default one to the first record - $gateway_tokens = $this->client - ->gateway_tokens() - ->whereHas('gateway', function ($query) { - $query->where('is_deleted', 0) - ->where('deleted_at', null); - })->orderBy('is_default', 'DESC') - ->get(); + $gateway_tokens = \App\Models\ClientGatewayToken::query() + ->where('client_id', $this->client->id) + ->where('is_deleted', 0) + ->whereHas('gateway', function ($query) { + $query->where('is_deleted', 0) + ->where('deleted_at', null); + })->orderBy('is_default', 'DESC') + ->get(); + + // $gateway_tokens = $this->client + // ->gateway_tokens() + // ->whereHas('gateway', function ($query) { + // $query->where('is_deleted', 0) + // ->where('deleted_at', null); + // })->orderBy('is_default', 'DESC') + // ->get(); $filtered_gateways = $gateway_tokens->filter(function ($gateway_token) use ($amount) { $company_gateway = $gateway_token->gateway; diff --git a/app/Services/Subscription/ChangePlanInvoice.php b/app/Services/Subscription/ChangePlanInvoice.php new file mode 100644 index 000000000000..b732072e892a --- /dev/null +++ b/app/Services/Subscription/ChangePlanInvoice.php @@ -0,0 +1,128 @@ +status = $this->recurring_invoice + ->subscription + ->status($this->recurring_invoice); + + //refund + $refund = $this->status->getProRataRefund(); + + //newcharges + $new_charge = $this->target->price; + + $invoice = $this->generateInvoice($refund); + + if($refund >= $new_charge){ + $invoice = $invoice->markPaid()->save(); + + //generate new recurring invoice at this point as we know the user has succeeded with their upgrade. + } + + if($refund > $new_charge) + return $this->generateCredit($refund - $new_charge); + + return $invoice; + } + + private function generateCredit(float $credit_balance): Credit + { + + $credit_repo = new CreditRepository(); + + $credit = CreditFactory::create($this->target->company_id, $this->target->user_id); + $credit->status_id = Credit::STATUS_SENT; + $credit->date = now()->addSeconds($this->recurring_invoice->client->timezone_offset())->format('Y-m-d'); + $credit->subscription_id = $this->target->id; + + $invoice_item = new InvoiceItem(); + $invoice_item->type_id = '1'; + $invoice_item->product_key = ctrans('texts.credit'); + $invoice_item->notes = ctrans('texts.credit') . " # {$this->recurring_invoice->subscription->name} #"; + $invoice_item->quantity = 1; + $invoice_item->cost = $credit_balance; + + $invoice_items = []; + $invoice_items[] = $invoice_item; + + $data = [ + 'client_id' => $this->recurring_invoice->client_id, + 'date' => now()->format('Y-m-d'), + ]; + + return $credit_repo->save($data, $credit)->service()->markSent()->fillDefaults()->save(); + + } + + //Careful with Invoice Numbers. + private function generateInvoice(float $refund): Invoice + { + + $subscription_repo = new SubscriptionRepository(); + $invoice_repo = new InvoiceRepository(); + + $invoice = InvoiceFactory::create($this->target->company_id, $this->target->user_id); + $invoice->date = now()->format('Y-m-d'); + $invoice->subscription_id = $this->target->id; + + $invoice_item = new InvoiceItem(); + $invoice_item->type_id = '1'; + $invoice_item->product_key = ctrans('texts.refund'); + $invoice_item->notes = ctrans('texts.refund'). " #{$this->status->refundable_invoice->number}"; + $invoice_item->quantity = 1; + $invoice_item->cost = $refund; + + $invoice_items = []; + $invoice_items[] = $subscription_repo->generateLineItems($this->target); + $invoice_items[] = $invoice_item; + $invoice->line_items = $invoice_items; + $invoice->is_proforma = true; + + $data = [ + 'client_id' => $this->recurring_invoice->client_id, + 'date' => now()->addSeconds($this->recurring_invoice->client->timezone_offset())->format('Y-m-d'), + ]; + + $invoice = $invoice_repo->save($data, $invoice) + ->service() + ->markSent() + ->fillDefaults() + ->save(); + + return $invoice; + + } +} \ No newline at end of file diff --git a/app/Services/Subscription/InvoiceToRecurring.php b/app/Services/Subscription/InvoiceToRecurring.php new file mode 100644 index 000000000000..8ba371a37bd7 --- /dev/null +++ b/app/Services/Subscription/InvoiceToRecurring.php @@ -0,0 +1,70 @@ +subscription->company->db); + + $client = Client::withTrashed()->find($this->client_id); + + $subscription_repo = new SubscriptionRepository(); + + $line_items = count($this->bundle) > 1 ? $subscription_repo->generateBundleLineItems($this->bundle, true, false) : $subscription_repo->generateLineItems($this->subscription, true, false); + + $recurring_invoice = RecurringInvoiceFactory::create($this->subscription->company_id, $this->subscription->user_id); + $recurring_invoice->client_id = $this->client_id; + $recurring_invoice->line_items = $line_items; + $recurring_invoice->subscription_id = $this->subscription->id; + $recurring_invoice->frequency_id = $this->subscription->frequency_id ?: RecurringInvoice::FREQUENCY_MONTHLY; + $recurring_invoice->date = now(); + $recurring_invoice->remaining_cycles = -1; + $recurring_invoice->auto_bill = $client->getSetting('auto_bill'); + $recurring_invoice->auto_bill_enabled = $this->setAutoBillFlag($recurring_invoice->auto_bill); + $recurring_invoice->due_date_days = 'terms'; + $recurring_invoice->next_send_date = now()->format('Y-m-d'); + $recurring_invoice->next_send_date_client = now()->format('Y-m-d'); + $recurring_invoice->next_send_date = $recurring_invoice->nextSendDate(); + $recurring_invoice->next_send_date_client = $recurring_invoice->nextSendDateClient(); + + return $recurring_invoice; + + } + + private function setAutoBillFlag($auto_bill): bool + { + if ($auto_bill == 'always' || $auto_bill == 'optout') { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/app/Services/Subscription/PaymentLinkService.php b/app/Services/Subscription/PaymentLinkService.php new file mode 100644 index 000000000000..da693a87f7c6 --- /dev/null +++ b/app/Services/Subscription/PaymentLinkService.php @@ -0,0 +1,470 @@ +data, 'billing_context')) { + throw new \Exception("Illegal entrypoint into method, payload must contain billing context"); + } + + if ($payment_hash->data->billing_context->context == 'change_plan') { + return $this->handlePlanChange($payment_hash); + } + + // if ($payment_hash->data->billing_context->context == 'whitelabel') { + // return $this->handleWhiteLabelPurchase($payment_hash); + // } + + if (strlen($this->subscription->recurring_product_ids) >= 1) { + + $bundle = isset($payment_hash->data->billing_context->bundle) ? $payment_hash->data->billing_context->bundle : []; + $recurring_invoice = (new InvoiceToRecurring($payment_hash->payment->client_id, $this->subscription, $bundle))->run(); + + $recurring_invoice_repo = new RecurringInvoiceRepository(); + + $recurring_invoice = $recurring_invoice_repo->save([], $recurring_invoice); + $recurring_invoice->auto_bill = $this->subscription->auto_bill; + + /* Start the recurring service */ + $recurring_invoice->service() + ->start() + ->save(); + + //update the invoice and attach to the recurring invoice!!!!! + $invoice = Invoice::withTrashed()->find($payment_hash->fee_invoice_id); + $invoice->recurring_id = $recurring_invoice->id; + $invoice->is_proforma = false; + $invoice->save(); + + //execute any webhooks + $context = [ + 'context' => 'recurring_purchase', + 'recurring_invoice' => $recurring_invoice->hashed_id, + 'invoice' => $this->encodePrimaryKey($payment_hash->fee_invoice_id), + 'client' => $recurring_invoice->client->hashed_id, + 'subscription' => $this->subscription->hashed_id, + 'contact' => auth()->guard('contact')->user() ? auth()->guard('contact')->user()->hashed_id : $recurring_invoice->client->contacts()->whereNotNull('email')->first()->hashed_id, + 'account_key' => $recurring_invoice->client->custom_value2, + ]; + + if (property_exists($payment_hash->data->billing_context, 'campaign')) { + $context['campaign'] = $payment_hash->data->billing_context->campaign; + } + + $response = $this->triggerWebhook($context); + + return $this->handleRedirect('/client/recurring_invoices/' . $recurring_invoice->hashed_id); + } else { + $invoice = Invoice::withTrashed()->find($payment_hash->fee_invoice_id); + + $context = [ + 'context' => 'single_purchase', + 'invoice' => $this->encodePrimaryKey($payment_hash->fee_invoice_id), + 'client' => $invoice->client->hashed_id, + 'subscription' => $this->subscription->hashed_id, + 'account_key' => $invoice->client->custom_value2, + ]; + + //execute any webhooks + $this->triggerWebhook($context); + + /* 06-04-2022 */ + /* We may not be in a state where the user is present */ + if (auth()->guard('contact')) { + return $this->handleRedirect('/client/invoices/' . $this->encodePrimaryKey($payment_hash->fee_invoice_id)); + } + } + + return null; + + } + + /** + * isEligible + * ["message" => "Success", "status_code" => 200]; + * @param ClientContact $contact + * @return array{"message": string, "status_code": int} + */ + public function isEligible(ClientContact $contact): array + { + + $context = [ + 'context' => 'is_eligible', + 'subscription' => $this->subscription->hashed_id, + 'contact' => $contact->hashed_id, + 'contact_email' => $contact->email, + 'client' => $contact->client->hashed_id, + 'account_key' => $contact->client->custom_value2, + ]; + + $response = $this->triggerWebhook($context); + + return $response; + + } + + /* Starts the process to create a trial + - we create a recurring invoice, which has its next_send_date as now() + trial_duration + - we then hit the client API end point to advise the trial payload + - we then return the user to either a predefined user endpoint, OR we return the user to the recurring invoice page. + + * startTrial + * + * @param array $data{contact_id: int, client_id: int, bundle: \Illuminate\Support\Collection, coupon?: string, } + * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + */ + public function startTrial(array $data): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + { + + // Redirects from here work just fine. Livewire will respect it. + $client_contact = ClientContact::find($this->decodePrimaryKey($data['contact_id'])); + + if(is_string($data['client_id'])) { + $data['client_id'] = $this->decodePrimaryKey($data['client_id']); + } + + if (!$this->subscription->trial_enabled) { + return new \Exception("Trials are disabled for this product"); + } + + //create recurring invoice with start date = trial_duration + 1 day + $recurring_invoice_repo = new RecurringInvoiceRepository(); + + $bundle = []; + + if (isset($data['bundle'])) { + + $bundle = $data['bundle']->map(function ($bundle) { + return (object) $bundle; + })->toArray(); + } + + $recurring_invoice = (new InvoiceToRecurring($client_contact->client_id, $this->subscription, $bundle))->run(); + + $recurring_invoice->next_send_date = now()->addSeconds($this->subscription->trial_duration); + $recurring_invoice->next_send_date_client = now()->addSeconds($this->subscription->trial_duration); + $recurring_invoice->backup = 'is_trial'; + + if (array_key_exists('coupon', $data) && ($data['coupon'] == $this->subscription->promo_code) && $this->subscription->promo_discount > 0) { + $recurring_invoice->discount = $this->subscription->promo_discount; + $recurring_invoice->is_amount_discount = $this->subscription->is_amount_discount; + } elseif (strlen($this->subscription->promo_code ?? '') == 0 && $this->subscription->promo_discount > 0) { + $recurring_invoice->discount = $this->subscription->promo_discount; + $recurring_invoice->is_amount_discount = $this->subscription->is_amount_discount; + } + + $recurring_invoice = $recurring_invoice_repo->save($data, $recurring_invoice); + + /* Start the recurring service */ + $recurring_invoice->service() + ->start() + ->save(); + + $context = [ + 'context' => 'trial', + 'recurring_invoice' => $recurring_invoice->hashed_id, + 'client' => $recurring_invoice->client->hashed_id, + 'subscription' => $this->subscription->hashed_id, + 'account_key' => $recurring_invoice->client->custom_value2, + ]; + + //execute any webhooks + $response = $this->triggerWebhook($context); + + return $this->handleRedirect('/client/recurring_invoices/' . $recurring_invoice->hashed_id); + + } + + /** + * calculateUpdatePriceV2 + * + * Need to change the naming of the method + * + * @param RecurringInvoice $recurring_invoice - The Current Recurring Invoice for the subscription. + * @param Subscription $target - The new target subscription to move to + * @return float - the upgrade price + */ + public function calculateUpgradePriceV2(RecurringInvoice $recurring_invoice, Subscription $target): ?float + { + return (new UpgradePrice($recurring_invoice, $target))->run()->upgrade_price; + } + + /** + * When changing plans, we need to generate a pro rata invoice + * + * @param array $data{recurring_invoice: RecurringInvoice, subscription: Subscription, target: Subscription, hash: string} + * @return Invoice | Credit + */ + public function createChangePlanInvoice($data): Invoice | Credit + { + $recurring_invoice = $data['recurring_invoice']; + $old_subscription = $data['subscription']; + $target_subscription = $data['target']; + $hash = $data['hash']; + + return (new ChangePlanInvoice($recurring_invoice, $target_subscription, $hash))->run(); + } + + + /** + * 'email' => $this->email ?? $this->contact->email, + * 'quantity' => $this->quantity, + * 'contact_id' => $this->contact->id, + * + * @param array $data + * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + */ + public function handleNoPaymentRequired(array $data): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + { + $context = (new ZeroCostProduct($this->subscription, $data))->run(); + + // Forward payload to webhook + if (array_key_exists('context', $context)) { + $response = $this->triggerWebhook($context); + } + + // Hit the redirect + return $this->handleRedirect($context['redirect_url']); + } + + /** + * @param Invoice $invoice + * @return true + * @throws BindingResolutionException + */ + public function planPaid(Invoice $invoice) + { + $recurring_invoice_hashed_id = $invoice->recurring_invoice()->exists() ? $invoice->recurring_invoice->hashed_id : null; + + $context = [ + 'context' => 'plan_paid', + 'subscription' => $this->subscription->hashed_id, + 'recurring_invoice' => $recurring_invoice_hashed_id, + 'client' => $invoice->client->hashed_id, + 'contact' => $invoice->client->primary_contact()->first() ? $invoice->client->primary_contact()->first()->hashed_id : $invoice->client->contacts->first()->hashed_id, + 'invoice' => $invoice->hashed_id, + 'account_key' => $invoice->client->custom_value2, + ]; + + $response = $this->triggerWebhook($context); + + nlog($response); + + return true; + } + + + /** + * Response from payment service on + * return from a plan change + * + * @param PaymentHash $payment_hash + */ + private function handlePlanChange(PaymentHash $payment_hash): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + { + nlog("handle plan change"); + + $old_recurring_invoice = RecurringInvoice::query()->find($this->decodePrimaryKey($payment_hash->data->billing_context->recurring_invoice)); + + if (!$old_recurring_invoice) { + return $this->handleRedirect('/client/recurring_invoices/'); + } + + $old_recurring_invoice->service()->stop()->save(); + + $recurring_invoice = (new InvoiceToRecurring($old_recurring_invoice->client_id, $this->subscription, []))->run(); + + $recurring_invoice->service() + ->start() + ->save(); + + //update the invoice and attach to the recurring invoice!!!!! + $invoice = Invoice::query()->find($payment_hash->fee_invoice_id); + $invoice->recurring_id = $recurring_invoice->id; + $invoice->is_proforma = false; + $invoice->save(); + + // 29-06-2023 handle webhooks for payment intent - user may not be present. + $context = [ + 'context' => 'change_plan', + 'recurring_invoice' => $recurring_invoice->hashed_id, + 'invoice' => $this->encodePrimaryKey($payment_hash->fee_invoice_id), + 'client' => $recurring_invoice->client->hashed_id, + 'subscription' => $this->subscription->hashed_id, + 'contact' => auth()->guard('contact')->user()?->hashed_id ?? $recurring_invoice->client->contacts()->first()->hashed_id, + 'account_key' => $recurring_invoice->client->custom_value2, + ]; + + $response = $this->triggerWebhook($context); + + nlog($response); + + return $this->handleRedirect('/client/recurring_invoices/'.$recurring_invoice->hashed_id); + } + + + + + + + + + + + + /** + * Handles redirecting the user + */ + private function handleRedirect($default_redirect): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + { + if (array_key_exists('return_url', $this->subscription->webhook_configuration) && strlen($this->subscription->webhook_configuration['return_url']) >= 1) { + return method_exists(redirect(), "send") ? redirect($this->subscription->webhook_configuration['return_url'])->send() : redirect($this->subscription->webhook_configuration['return_url']); + } + + return method_exists(redirect(), "send") ? redirect($default_redirect)->send() : redirect($default_redirect); + } + + /** + * Hit a 3rd party API if defined in the subscription + * + * @param array $context + * @return array + */ + public function triggerWebhook($context): array + { + if (empty($this->subscription->webhook_configuration['post_purchase_url']) || is_null($this->subscription->webhook_configuration['post_purchase_url']) || strlen($this->subscription->webhook_configuration['post_purchase_url']) < 1) { + return ["message" => "Success", "status_code" => 200]; + } + + $response = false; + + $body = array_merge($context, [ + 'db' => $this->subscription->company->db, + ]); + + $response = $this->sendLoad($this->subscription, $body); + + /* Append the response to the system logger body */ + if (is_array($response)) { + $body = $response; + } else { + $body = $response->getStatusCode(); + } + + $client = Client::query()->where('id', $this->decodePrimaryKey($body['client']))->withTrashed()->first(); + + SystemLogger::dispatch( + $body, + SystemLog::CATEGORY_WEBHOOK, + SystemLog::EVENT_WEBHOOK_RESPONSE, + SystemLog::TYPE_WEBHOOK_RESPONSE, + $client, + $client->company, + ); + + nlog("ready to fire back"); + + if (is_array($body)) { + return $response; + } else { + return ['message' => 'There was a problem encountered with the webhook', 'status_code' => 500]; + } + } + + public function sendLoad($subscription, $body) + { + $headers = [ + 'Content-Type' => 'application/json', + 'X-Requested-With' => 'XMLHttpRequest', + ]; + + if (!isset($subscription->webhook_configuration['post_purchase_url']) && !isset($subscription->webhook_configuration['post_purchase_rest_method'])) { + return []; + } + + if (count($subscription->webhook_configuration['post_purchase_headers']) >= 1) { + $headers = array_merge($headers, $subscription->webhook_configuration['post_purchase_headers']); + } + + $client = new \GuzzleHttp\Client( + [ + 'headers' => $headers, + ] + ); + + $post_purchase_rest_method = (string) $subscription->webhook_configuration['post_purchase_rest_method']; + $post_purchase_url = (string) $subscription->webhook_configuration['post_purchase_url']; + + try { + $response = $client->{$post_purchase_rest_method}($post_purchase_url, [ + RequestOptions::JSON => ['body' => $body], RequestOptions::ALLOW_REDIRECTS => false, + ]); + + return array_merge($body, json_decode($response->getBody(), true)); + } catch (ClientException $e) { + $message = $e->getMessage(); + + $error = json_decode($e->getResponse()->getBody()->getContents()); + + if (is_null($error)) { + nlog("empty response"); + nlog($e->getMessage()); + } + + if ($error && property_exists($error, 'message')) { + $message = $error->message; + } + + return array_merge($body, ['message' => $message, 'status_code' => 500]); + } catch (\Exception $e) { + return array_merge($body, ['message' => $e->getMessage(), 'status_code' => 500]); + } + } +} \ No newline at end of file diff --git a/app/Services/Subscription/ProRata.php b/app/Services/Subscription/ProRata.php deleted file mode 100644 index da69dd7e560c..000000000000 --- a/app/Services/Subscription/ProRata.php +++ /dev/null @@ -1,308 +0,0 @@ - | null $unpaid_invoices */ - private $unpaid_invoices = null; - - /** @var bool $refundable */ - private bool $refundable = false; - - /** @var int $pro_rata_duration */ - private int $pro_rata_duration = 0; - - /** @var int $subscription_interval_duration */ - private int $subscription_interval_duration = 0; - - /** @var int $pro_rata_ratio */ - private int $pro_rata_ratio = 1; - - public function __construct(public Subscription $subscription, protected RecurringInvoice $recurring_invoice) - { - } - - public function run() - { - $this->setCalculations(); - } - - private function setCalculations(): self - { - $this->isInTrialPeriod() - ->checkUnpaidInvoices() - ->checkRefundPeriod() - ->checkProRataDuration() - ->calculateSubscriptionIntervalDuration() - ->calculateProRataRatio(); - - return $this; - } - - /** - * Calculates the number of seconds - * of the current interval that has been used. - * - * @return self - */ - private function checkProRataDuration(): self - { - - $primary_invoice = $this->recurring_invoice - ->invoices() - ->where('is_deleted', 0) - ->where('is_proforma', 0) - ->orderBy('id', 'desc') - ->first(); - - $duration = Carbon::parse($primary_invoice->date)->startOfDay()->diffInSeconds(now()); - - $this->setProRataDuration(max(0, $duration)); - - return $this; - } - - private function calculateProRataRatio(): self - { - if($this->pro_rata_duration < $this->subscription_interval_duration) - $this->setProRataRatio($this->pro_rata_duration/$this->subscription_interval_duration); - - return $this; - } - - - private function calculateSubscriptionIntervalDuration(): self - { - if($this->getIsTrial()) - return $this->setSubscriptionIntervalDuration(0); - - $primary_invoice = $this->recurring_invoice - ->invoices() - ->where('is_deleted', 0) - ->where('is_proforma', 0) - ->orderBy('id', 'desc') - ->first(); - - if(!$primary_invoice) - return $this->setSubscriptionIntervalDuration(0); - - $start = Carbon::parse($primary_invoice->date); - $end = Carbon::parse($this->recurring_invoice->next_send_date_client); - - $this->setSubscriptionIntervalDuration($start->diffInSeconds($end)); - - return $this; - } - - /** - * Determines if this subscription - * is eligible for a refund. - * - * @return self - */ - private function checkRefundPeriod(): self - { - if(!$this->subscription->refund_period || $this->subscription->refund_period === 0) - return $this->setRefundable(false); - - $primary_invoice = $this->recurring_invoice - ->invoices() - ->where('is_deleted', 0) - ->where('is_proforma', 0) - ->orderBy('id', 'desc') - ->first(); - - if($primary_invoice && - $primary_invoice->status_id == Invoice::STATUS_PAID && - Carbon::parse($primary_invoice->date)->addSeconds($this->subscription->refund_period)->lte(now()->startOfDay()->addSeconds($primary_invoice->client->timezone_offset())) - ){ - return $this->setRefundable(true); - } - - return $this->setRefundable(false); - - } - - /** - * Gathers any unpaid invoices for this subscription. - * - * @return self - */ - private function checkUnpaidInvoices(): self - { - $this->unpaid_invoices = $this->recurring_invoice - ->invoices() - ->where('is_deleted', 0) - ->where('is_proforma', 0) - ->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) - ->where('balance', '>', 0) - ->get(); - - return $this; - } - - private function setProRataRatio(int $ratio): self - { - $this->pro_rata_ratio = $ratio; - - return $this; - } - /** - * setSubscriptionIntervalDuration - * - * @param int $seconds - * @return self - */ - private function setSubscriptionIntervalDuration(int $seconds): self - { - $this->subscription_interval_duration = $seconds; - - return $this; - } - - /** - * setProRataDuration - * - * @param int $seconds - * @return self - */ - private function setProRataDuration(int $seconds): self - { - $this->pro_rata_duration = $seconds; - - return $this; - } - - /** - * setRefundable - * - * @param bool $refundable - * @return self - */ - private function setRefundable(bool $refundable): self - { - $this->refundable = $refundable; - - return $this; - } - - /** - * Determines if this users is in their trial period - * - * @return self - */ - private function isInTrialPeriod(): self - { - - if(!$this->subscription->trial_enabled) - return $this->setIsTrial(false); - - $primary_invoice = $this->recurring_invoice - ->invoices() - ->where('is_deleted', 0) - ->where('is_proforma', 0) - ->orderBy('id', 'asc') - ->first(); - - if($primary_invoice && Carbon::parse($primary_invoice->date)->addSeconds($this->subscription->trial_duration)->lte(now()->startOfDay()->addSeconds($primary_invoice->client->timezone_offset()))) - return $this->setIsTrial(true); - - $this->setIsTrial(false); - - return $this; - } - - /** - * Sets the is_trial flag - * - * @param bool $is_trial - * @return self - */ - private function setIsTrial(bool $is_trial): self - { - $this->is_trial = $is_trial; - - return $this; - } - - - /** - * Getter for unpaid invoices - * - * @return \Illuminate\Database\Eloquent\Collection | null - */ - public function getUnpaidInvoices(): ?\Illuminate\Database\Eloquent\Collection - { - return $this->unpaid_invoices; - } - - /** - * Gets the is_trial flag - * - * @return bool - */ - public function getIsTrial(): bool - { - return $this->is_trial; - } - - /** - * Getter for refundable flag - * - * @return bool - */ - public function getRefundable(): bool - { - return $this->refundable; - } - - /** - * The number of seconds used in the current duration - * - * @return int - */ - public function getProRataDuration(): int - { - return $this->pro_rata_duration; - } - - /** - * The total number of seconds in this subscription interval - * - * @return int - */ - public function getSubscriptionIntervalDuration(): int - { - return $this->subscription_interval_duration; - } - - - /** - * Returns the pro rata ratio to be applied to any credit. - * - * @return int - */ - public function getProRataRatio(): int - { - return $this->pro_rata_ratio; - } -} \ No newline at end of file diff --git a/app/Services/Subscription/SubscriptionService.php b/app/Services/Subscription/SubscriptionService.php index d9631b766ce3..3d9d21c1b3a5 100644 --- a/app/Services/Subscription/SubscriptionService.php +++ b/app/Services/Subscription/SubscriptionService.php @@ -763,7 +763,7 @@ class SubscriptionService /** * When changing plans, we need to generate a pro rata invoice * - * @param array $data + * @param array $data{recurring_invoice: RecurringInvoice, subscription: Subscription, target: Subscription} * @return Invoice */ public function createChangePlanInvoice($data) @@ -1087,12 +1087,12 @@ class SubscriptionService $recurring_invoice->line_items = $subscription_repo->generateBundleLineItems($bundle, true, false); $recurring_invoice->subscription_id = $this->subscription->id; $recurring_invoice->frequency_id = $this->subscription->frequency_id ?: RecurringInvoice::FREQUENCY_MONTHLY; - $recurring_invoice->date = now(); + $recurring_invoice->date = now()->addSeconds($client->timezone_offset()); $recurring_invoice->remaining_cycles = -1; $recurring_invoice->auto_bill = $client->getSetting('auto_bill'); $recurring_invoice->auto_bill_enabled = $this->setAutoBillFlag($recurring_invoice->auto_bill); $recurring_invoice->due_date_days = 'terms'; - $recurring_invoice->next_send_date = now()->format('Y-m-d'); + $recurring_invoice->next_send_date = now()->addSeconds($client->timezone_offset())->format('Y-m-d'); $recurring_invoice->next_send_date_client = now()->format('Y-m-d'); $recurring_invoice->next_send_date = $recurring_invoice->nextSendDate(); $recurring_invoice->next_send_date_client = $recurring_invoice->nextSendDateClient(); @@ -1352,7 +1352,7 @@ class SubscriptionService * * @return int Number of days */ - private function getDaysInFrequency(): int + public function getDaysInFrequency(): int { switch ($this->subscription->frequency_id) { case RecurringInvoice::FREQUENCY_DAILY: diff --git a/app/Services/Subscription/SubscriptionStatus.php b/app/Services/Subscription/SubscriptionStatus.php new file mode 100644 index 000000000000..90633404a51f --- /dev/null +++ b/app/Services/Subscription/SubscriptionStatus.php @@ -0,0 +1,220 @@ +checkTrial() + ->checkRefundable() + ->checkInGoodStanding(); + + return $this; + } + + /** + * GetProRataRefund + * + * @return float + */ + public function getProRataRefund(): float + { + + $subscription_interval_end_date = Carbon::parse($this->recurring_invoice->next_send_date_client); + $subscription_interval_start_date = $subscription_interval_end_date->copy()->subDays($this->recurring_invoice->subscription->service()->getDaysInFrequency())->subDay(); + + $primary_invoice = Invoice::query() + ->where('company_id', $this->recurring_invoice->company_id) + ->where('client_id', $this->recurring_invoice->client_id) + ->where('recurring_id', $this->recurring_invoice->id) + ->whereIn('status_id', [Invoice::STATUS_PAID]) + ->whereBetween('date', [$subscription_interval_start_date, $subscription_interval_end_date]) + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'desc') + ->first(); + + $this->refundable_invoice = $primary_invoice; + + return $primary_invoice ? max(0, round(($primary_invoice->paid_to_date * $this->getProRataRatio()),2)) : 0; + + } + + /** + * GetProRataRatio + * + * The ratio of days used / days in interval + * @return float + */ + public function getProRataRatio():float + { + + $subscription_interval_end_date = Carbon::parse($this->recurring_invoice->next_send_date_client); + $subscription_interval_start_date = $subscription_interval_end_date->copy()->subDays($this->recurring_invoice->subscription->service()->getDaysInFrequency())->subDay(); + + $primary_invoice = Invoice::query() + ->where('company_id', $this->recurring_invoice->company_id) + ->where('client_id', $this->recurring_invoice->client_id) + ->where('recurring_id', $this->recurring_invoice->id) + ->whereIn('status_id', [Invoice::STATUS_PAID]) + ->whereBetween('date', [$subscription_interval_start_date, $subscription_interval_end_date]) + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'desc') + ->first(); + + if(!$primary_invoice) + return 0; + + $subscription_start_date = Carbon::parse($primary_invoice->date)->startOfDay(); + + $days_of_subscription_used = $subscription_start_date->copy()->diffInDays(now()); + + return 1 - ($days_of_subscription_used / $this->recurring_invoice->subscription->service()->getDaysInFrequency()); + + } + + /** + * CheckInGoodStanding + * + * Are there any outstanding invoices? + * + * @return self + */ + private function checkInGoodStanding(): self + { + + $this->is_in_good_standing = Invoice::query() + ->where('company_id', $this->recurring_invoice->company_id) + ->where('client_id', $this->recurring_invoice->client_id) + ->where('recurring_id', $this->recurring_invoice->id) + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) + ->where('balance', '>', 0) + ->doesntExist(); + + return $this; + + } + + /** + * CheckTrial + * + * Check if this subscription is in its trial window. + * + * Trials do not have an invoice yet - only a pending recurring invoice. + * + * @return self + */ + private function checkTrial(): self + { + + if(!$this->subscription->trial_enabled) + return $this->setIsTrial(false); + + $primary_invoice = Invoice::query() + ->where('company_id', $this->recurring_invoice->company_id) + ->where('client_id', $this->recurring_invoice->client_id) + ->where('recurring_id', $this->recurring_invoice->id) + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'asc') + ->doesntExist(); + + if($primary_invoice && Carbon::parse($this->recurring_invoice->next_send_date_client)->gte(now()->startOfDay()->addSeconds($this->recurring_invoice->client->timezone_offset()))) { + return $this->setIsTrial(true); + } + + $this->setIsTrial(false); + + return $this; + + } + + /** + * Determines if this subscription + * is eligible for a refund. + * + * @return self + */ + private function checkRefundable(): self + { + if(!$this->recurring_invoice->subscription->refund_period || $this->recurring_invoice->subscription->refund_period === 0) + return $this->setRefundable(false); + + $primary_invoice = $this->recurring_invoice + ->invoices() + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'desc') + ->first(); + + if($primary_invoice && + $primary_invoice->status_id == Invoice::STATUS_PAID && + Carbon::parse($primary_invoice->date)->addSeconds($this->recurring_invoice->subscription->refund_period)->lte(now()->startOfDay()->addSeconds($primary_invoice->client->timezone_offset())) + ){ + return $this->setRefundable(true); + } + + return $this->setRefundable(false); + + } + + /** + * setRefundable + * + * @param bool $refundable + * @return self + */ + private function setRefundable(bool $refundable): self + { + $this->is_refundable = $refundable; + + return $this; + } + + /** + * Sets the is_trial flag + * + * @param bool $is_trial + * @return self + */ + private function setIsTrial(bool $is_trial): self + { + $this->is_trial = $is_trial; + + return $this; + } + +} diff --git a/app/Services/Subscription/UpgradePrice.php b/app/Services/Subscription/UpgradePrice.php new file mode 100644 index 000000000000..c4cc6e3e81e2 --- /dev/null +++ b/app/Services/Subscription/UpgradePrice.php @@ -0,0 +1,99 @@ +status = $this->recurring_invoice + ->subscription + ->status($this->recurring_invoice); + + if($this->status->is_in_good_standing) + $this->calculateUpgrade(); + else + $this->upgrade_price = $this->subscription->price; + + return $this; + + } + + private function calculateUpgrade(): self + { + $ratio = $this->status->getProRataRatio(); + + $last_invoice = $this->recurring_invoice + ->invoices() + ->where('is_deleted', 0) + ->where('is_proforma', 0) + ->orderBy('id', 'desc') + ->first(); + + $this->refund = $this->getRefundableAmount($last_invoice, $ratio); + $this->outstanding_credit = $this->getCredits(); + + nlog("{$this->subscription->price} - {$this->refund} - {$this->outstanding_credit}"); + + $this->upgrade_price = $this->subscription->price - $this->refund - $this->outstanding_credit; + + return $this; + } + + private function getRefundableAmount(?Invoice $invoice, float $ratio): float + { + if (!$invoice || !$invoice->date || $invoice->status_id != Invoice::STATUS_PAID || $ratio == 0) + return 0; + + return max(0, round(($invoice->paid_to_date*$ratio),2)); + } + + private function getCredits(): float + { + $outstanding_credits = 0; + + $use_credit_setting = $this->recurring_invoice->client->getSetting('use_credits_payment'); + + if($use_credit_setting){ + + $outstanding_credits = Credit::query() + ->where('client_id', $this->recurring_invoice->client_id) + ->whereIn('status_id', [Credit::STATUS_SENT,Credit::STATUS_PARTIAL]) + ->where('is_deleted', 0) + ->where('balance', '>', 0) + ->sum('balance'); + + } + + return $outstanding_credits; + } + +} \ No newline at end of file diff --git a/app/Utils/Traits/Inviteable.php b/app/Utils/Traits/Inviteable.php index fef8239788a3..5ac92929683e 100644 --- a/app/Utils/Traits/Inviteable.php +++ b/app/Utils/Traits/Inviteable.php @@ -108,9 +108,9 @@ trait Inviteable switch ($this->company->portal_mode) { case 'subdomain': - if(Ninja::isHosted()) - return 'https://router.invoiceninja.com/route/'.encrypt($domain.'/client/'.$entity_type.'/'.$this->key); - else + // if(Ninja::isHosted()) + // return 'https://router.invoiceninja.com/route/'.encrypt($domain.'/client/'.$entity_type.'/'.$this->key); + // else return $domain.'/client/'.$entity_type.'/'.$this->key; break; case 'iframe': diff --git a/lang/en/texts.php b/lang/en/texts.php index f35c0fdf488a..253e5153e1ae 100644 --- a/lang/en/texts.php +++ b/lang/en/texts.php @@ -3868,7 +3868,7 @@ $lang = array( 'cancellation_pending' => 'Cancellation pending, we\'ll be in touch!', 'list_of_payments' => 'List of payments', 'payment_details' => 'Details of the payment', - 'list_of_payment_invoices' => 'List of invoices affected by the payment', + 'list_of_payment_invoices' => 'Associate invoices', 'list_of_payment_methods' => 'List of payment methods', 'payment_method_details' => 'Details of payment method', 'permanently_remove_payment_method' => 'Permanently remove this payment method.', diff --git a/resources/views/email/template/admin_premium.blade.php.enc b/resources/views/email/template/admin_premium.blade.php.enc new file mode 100644 index 000000000000..56fe343c6a4f --- /dev/null +++ b/resources/views/email/template/admin_premium.blade.php.enc @@ -0,0 +1 @@ +eyJpdiI6ImxQL09xZ29ScER1bEtLbktHenNleUE9PSIsInZhbHVlIjoiMXE0Rk9LdEp1N285SzdqYXlDRmJycXlHY0xoTVF4eHpoTUN4L1ZXM2lIMTFVVjNpOHFIelFiODNzRTBEVllkdTc5SHNCUE5UVUN3R1hiYkxwcUR5V1J2SzJkZHk1bTR0NXNESitTOWFEaWZRTWF3NzZaQ1ZNQU5oOHJwc2Y5aDUwMTdVcXpuMWlURW1mN241aSt0NG9CK29nMVN6T3Q1L1RRVVVMMm5WMVJINUZHSEFRYi92Z21lQjBuL1h6OUZmOTRoSHNhZnE4N29WQ29hSWI5ck9FemxQMGdXY2dUK3FmSGRIWlV2dkhzblJYQzZmdWFISG9OcUFzOFlCY1B0cTd0VDBFNGpsMHpRbkl4dTBwYTVMVENNRVVMaEpkRnpHcTRNdytDQk5JbzNqbmV0M3RIV3phbmdWbkNQMXVBZ3h1aVkyUW5mMy80a0t4R2RxdmV0dlpXRFY2M3pmeWJnNk44ckZjdTJ3VEtRekNIdnRqbWR2UWxNT3lZQ1J5TmFHeU85Rm43QWhBMC9JMUpjcm15TVBZVzdmSmlBU0QxRm5CczFtaUpqdzRHQ1B5YlkwclZtc0trWHI0NnUyZkNMUHQ2a1FBdGx3VE4rU0t6MWxhQjAxMEMrNThKQjZ4QlQ0WWQ0QStzMFNHNGVVRXlKSVZYSjRkQ2NCRy84OEVXRkNUL1ZTMmNWRFMwOW5Vd1J2a3d2QVhTcUxEdFpURXIrVDRyZWJLbTdSOHpseUxCSHBBRVVaSnFEV0oxRHJuZU1GeUh4dVVLYnkzWHBUaitDM0p1dzQ0SVY3cE1YL2dQR1VxWWRkVVFHTGQ0ZUFVanhHYTBkZjlvamhEaHBlUmh6THdIMGhjNmNJVEZTdHhsN29KSjhFclNHNDZKUlJPMjRDa3oyaTdwRXFkTTQ0MGhoZkJnR2kvOGprbUVaMk1sejhNbDY4WkxaU0RNeWhnNGY2RTFmN1A0T3R0V2h1QnlXRXV6OEgyRCtPczFZMmhNcTdFYU9IeTBGaHRXVXBOSnFubXVsNjk4dUo1MGQyVkw3eGxqMzN6dlN4SytmaXF6eHZMMjkwSHNKSnZ4N3lReDM4UWd3T05rQnVMbEdmOW9zbEt4ZEI1V3phaUlkUUxPRStLUnhBdDlIdWNhUzYwV3N4NDhBVnA0MXJxWk05K2xFU1pmNDlWaU5mazVPcE5ScERyeUxqbzFheGk4ci9iL1hObXJQRXpaZVYzV3RySEdIK2djOTdIRGQ4cE9oWG1jdU96ZkFjNWV5QStYZ0k1cDgwOHUxQlRuSkJEaFJSVUR0V25ZY1VyekZDdHZqVHIvU2hxbjR5MVRGc3owL2lqbE9tWm9ZNHZQdVdtcVVrR2xTTDIyYzlGRWE3TXRCUHZPZkdYYkhuUEhTUDFEV3M1V3hLYkkrdFovbFV0QXpRMnZBK296VU02bmNSUWNOYTZYR3JsdG5GK2VNTG5weThmTHVuVURLc2tLYVNpV3IvZ2ROT0JLOWxyVnRBY1o2QnV1WWVkYkFvQ01pdDByVVpqbkpBK096cFZ0M2xLRm9USS93ME5pTEtmNUJMY1ByTjA4SERLc2ZkdXBUSmhUMzdGM1d1NGx6RDVFK0dpK1plYitoYW1JbGwxNUhRR2p6WmNLWVk4c2ZvNkI3emhOTDUrdmhsTHJvZWpnWGF6VGd6cm9Ocld6bDFWS0xKV2ZuMEo2QnVoOEpMdUl2ZHg1TmtBY2ZuSCtQSGVRRXVRdWl6M1lpS2pHaDlEZVVyb203VDgxNkVsK3plZTZGcmdXU2x2WnRlak9JR3dxZmdGcWRnYm5YNnhuQ1NTc2lXWEsrZFNzWkdMM3lkZHJJTzJFeEUzcjZUVnlYL0lSSTA1UDFJNXJaNVhiRW1xOTg3d2ExRzBMd05ZSXZoZFhSM0tQTUJnTzYwRnh6akZNdkpuUlBmWStWbDl2WW96UEVOWWF5TDZlcXJoa0xVWk82VEtQWVB5TGFQWEd1TjBCL2JwcjNkUDdqZUlCUm83dnVIRGk3Ulc3ZFJBWnJRbDNJZ3hzWnMyQ3hIMXk1OENxaW5vMkdEaXFYTmRzZzhYa3RGdWoyR1FvMk5OSjhEQmdkRWEwV29FWTVCSmxQUjV0Z015MVQ3NG5VY0w1TGp2VEJkRTlRK1JodC9FQzhJdkd3TmdxK1Jac2lDOCtock81SDgrblB4TlgwWGpGYklUS0ZseXA4a3YzZFRIRjNsVVVpKzhGemd3UGNUS0pLMmZBZktrRnlOSXNWc1BWdU9IVXRUOW1LZTlGU2c5Ym81R2hkSnBIU29Kdmhsd3NzTVdva05LZ1ZOaDVpaHdleFJOMEI4YmtyT0FkK1pMTVhocitjYi84aENXVSszcHVXWFJTRlpDWUY5Q3NCU25aRkxQdkdLWUhINHB1TEVsZVNoVEQ0Q2Y0c2U2Q0N5dGlZaTNsdHgwdVgyODI2M2NKUXN5YzdtdDhYaVhNV0dUREtEQmtWeWpaOVJjUTBtR004WmxIY2l5Vy9KRGtLWXNveWwvbFlSZjJhaDd4eW8zU3BrNzBoZ0lKZEdXUGN4M0g2b3p5aWQvRCs3T1lTdkZESjdCcDBKMStJb2J0WDIzaUFCem92NC9sRUt2NXF5SEtrcFQ1TFBmUDJOZ2F3WllzaVB4RHpEa0RYMTlpaWVYNHlSMUdEMkkzcGRNL04vZnVaU2dmTGZrTkx2SnNkcjBnWld6YzFjZFZ4SjJ0MWhCRkpNV2ZpN29rNkdPSkpYbTFHN243US94REFGNTUrS3NVcE9sMGg0QWpLMVU3V3pnQXNPSjMyRHBPUVNieDZkcTlZV3FicVNPcmZ1ZW5BOWJ6T1F0b2xLM2FhbjVVbVJHcTAwbTlyNXNXS2xsdTZzbHdheVRIc0RBamZORnUxeWt3bFZyRm5LL2NPRWoycVhmY1VHT3JQUGkvcmhMM0FLcWMvbi9EZm84eDhvUlNMTjBFWVpKaFBpVW4zaHM5UmcvQXBlRXJFZTRLU3lvMlRjYi83aUdVRUg1a3hCSHJWN2M5TnF2RFdjeFlGaUJtNkZWK0lmTW9IVjJETEQ0a2ExeVpmRGlFRnRhYnJPc0dHc3pUQ1ZWbFJsTkd2QjNjNnhBMEZSRk83RytUYWxzUVQvdjJRTDlrTDVrQTJCSUI5c0hPRzNqcC9iQjdZRUZNb2lFTHhaeHRmZGpxakxUTUgxanpiVzVHLzUvZlFhbjVwSHBiclB6YUEwRFFzTmdXZHVWZVVnTStnRTVUditkY3orbS9EKzh6WlcvLzAyQ3A2Tkh1N0ptTDVGSXlDUVYvN05ub1VZZ0RONzFxeHRYZUUxVWRUT0JGRjI4UzZBckhublJEdTA2QXVOb0dlSDVxT0w3a0RGNUpHVlVpd2pvUTQ0WFdHRnl0L3FvLzNuTHR6M1Y5Mk83OWM4Tm5tYXB1OTRBWUEzM2orMWVLcWdKODhia2RCbTUwSG9XalhvdHpPMnFLZjhKa0UvZ3dLUHZYVExveGZaVkk1NTFXL3ZLdFg2Rlc5U1p1Sm1LQ0F4R2hwSEF6Qk5KU2x3cVB6Z1FBcWlESTR5UkJTQ3NtRzMyc3hPMnQzVXo0Wk55QldKK0pjcVlLaE9uOEU3V2ZDY0NWSWl4aENQbllXVTRkTG9uQjk1clZLclFwZ1lScmZnL29UdGVjWXBhUDlnWE9wWWZ2bHU1V2lVaU5TdFNsaWpRVFZiaHByOXJkdnp5dS9oVWh1QkdBL2w4SWZ6cTg0Nmw5QVEzWG9qdlVhNThUeSszdGJzWDlWd2NRWG90enF6RFZEQitDdlhtUmN3OEJYU3JZeUt0SUpyTHpNb1A2N2Q2WlNIZ2VsTlpzcU1XT0srNzJiVHNUOXRDYytkWE5QVUV3a1ZMdm5oSmNUV1Z5MW5JcnNOQnlhYW13TzFaenNCdFcxSkhGc0ZsRHc3YTE5SDdoUEFlUEFxNVE4ZENkVExaRWFZbVNlMDNEWnlDTVZkcjRMaWtZMXp2dm1tSjhrcWl3NlJ2MjJUN2dDRXpLZVVUSkYzSmJSL0tuZytvUm1CWWtZTzd5eGdhaG1pUHUvK3BzdnR6QTJkN3FQNmJyRmNwc0VjUktOVnBwWDFscXNTY3NBRGhNWituMDQ3TW9ObzNXbk1iNE9aeDNibDhiSlBxZlo2ejc2T1cyNGdMczBxNG1PUUp0Z285WUJaTjFtaDA0N3lTT3MxYnRYa1hXRzJzVFZ6d0FVV2c0M1Q4NUY1c0JEclZWWmtpdldZcHo1cW80dmovNnlnWUkyVWVndnBKL21hR3FXd0k4Vlp5OFNTWmxDWnV3WDJDaHRyMEdtMUpPK3lRUENKWDhzMHVZdU53WGFTRDVpVjZhaFV0bXBUYUNaa1JrQ29FSkV5aU9sMUsvU2hwSXEwdU50WElvNE9oMFVSMzdHenhuVmhsZFZoVnpRdmVOU0wwWGhzcjA3N1RFYVJPK0tqMi82c1I1ZnhqYUlRZXBNbXFINk1VSU9xOC94VjFDeTdwSUNabnl5cDIvb3dDVkQzMkJob29UQXNLK0hybkZobTVIbzdTS25sbmJ0ZExwYmdGdEhIbnAxa3d0YmgvWHorbUlGZTJXc2lxcWRUcCt3UE9XR3ZDVHJsbEs2S000eGNZcXMxNk1iZVRJb2hWVHk4bDhQWHZsOEsxZlI2QmRBUUpUZzlFTSt3QUNYSSt3TzVNVkF2UUozd0szckJDYVQ4aHpkWXRQL3lCN2dqQnhENzFoVnVxRUNNeGR6MlcrSlJNZ3lHOEpnblJPWmtucWMvU0RsdjdXZ3c5aTc1eFl4a3lvZkpZVDFwK3RHNFJwejBQNzcvWHR5YUR0V0xzNHFzTDBlOVdUUVQwVzFyeFUwd0JwRmxCdCtKS1g1dE11VDV1YmpScnh1ZzdocUg0K2o4Tm1ta0o5RnEzMjA0N3Jsb05yZVQ5TS9IM0d1WnpVVFgybVdxcllWOEducFhxMk9yWnJPQWh5SDdVRFcyTnN5cGZrVDgzbFNldis5MEhkc3NQL09lSFJPSEhBRFhGNjFOSk50cnpuN2hWc2tTK3FFU0p1NElwSjhoV2hDUTBTRlFpeWVTTzBrYjJQd0hmZE9mMStPV0J0NGljV2RYaEhQUEJVMEVFejBhNWN6RmxxbFVPMVBNQ3FIbTZ3TWhwUWpjbmM4WndMWmxMdzNoNktQUW5uYjJTRk1mVi9rVWpHSVRrbW5ua2JtTXhUSHdDd1JCeVNyMU1VQTgrR1RFSUhHbWdXUDdac1VhdDVhTjZFWEtWRlNaelZ5UGUzNUhraVZLdFFLN2prQTRHNFFTckJkNmxlcS9qSCtRUVZnRHk2YzVTZ0gyK0NQcG12aTlYanY4NVRnY1NrYThuZEc5cUw4T0d3QWZBbFgwVWMyQ0ZVb0hKRndvZFFmbVVrc3ZnTzk5alo1ZC80aVE1WVNMang2Q2dFcm5kVHl4NXg2RnJRNk8zVXJCaXhJS2pBdE16QUMvOVFBdFhsL253SU9vdEdXTzJJOHR6aWd5MDRoS1dUSU1aaE5WaTBzRlRWTUpoK3MySS9odzY0NE1YUmlwOGVGSkg0WmRJOGJENFJEZUc4WWpOdDNJWm8xcndHVURyYzdKeVlTVXVJM3dsdW1CNStyaTQ1aW1QM0FRTGFnaForNk5xeWNpM01HUkJHVTZWREdCcVQ2MklhQXhnTGE5TG1GbE5uRHFma013ZHRUbGU3K1BkcG5Da3pvQWkwazdDK2pFcU1MZ2ZYVEVXcnBqWVMvL2VUZEVGRDhEamtBTCtKTURaR0dQdVRlL1A1Z0w5bThRd0dDQUUzQVQvUm9BSWZITkxEckxlbjRDMDdINXZTVGU5eDl3SmY3Uk11eGxaOWhrV3FON3drbXV1TU9xbVRYRkRnUjFkaXo4UkVzNXU1Tzl1eUF3dCtlTWlWRjQwN2U5dnNPVmF1SnFQV1NkK0xUZStGRThBZHJaWVM1dlR6NTlHYjJ4NzVEU2xDd3g0OW9HbUtSYmZmVnJJWEtaNEdiNThhV09lUVhqNmNoRWFwdzRJeFlsdkI4Vjhpc2hscjZhY3JvMVViYmpSMU11b2FiY29RUk5lbmorUHdFY1p0M2tLQ29PTGh2UmdzQURtZEpEalFPeHNLQzQvNVFnelo3dWNSL2VuMkV0dUVqbVdwQXkxbnJjV0xMUHdIQ2NPNEQ1RDFWWDFnWVBxOGc4V29JZU43SG91dGtDS2dpdVViNisySlh4OW1KRDlPU0tsNS9IWWpCMDJHb2VlNWpCcHpXSDlLMHFJQ1FwTHo1OE9LOGppbnFQOW13aGZsY2F5MnhvZ3FzTmlMelhVUVZLUktZYXpkamw3dEhwUTJyZXpRbm90WER6V0FtUlFSdGo2K1VjMG5LVHk3VTQ2QnhFaUIxbGd0dk1RZkpKZEZQWEt0ZjlqYm5pZkZLc2t5dXAwU0R1MU1KUWl4bDJTS2V1OVlrQXAzbVpXOE01a0htV1JSR28yOFZQTWRwQjhucFQ3V2ZTaVBRNlhzRm9vbFJzYTZRbmtrKzhOaHY4Y3N4TTZha1R6RkFTY3BIWGM4L1M2dnBpdnNLWktVZHBJTGdDbXNpM1lPTHF1cEM0SGJiekRWVmxoanI1Q0lKakM5aXNzQWkxTnlkQllPU3ZIRU5JMElnTzVzaWQ0SnhjRkRBelNMdklzUXVkMllrRGtIdVErZEV4NTdYaGZSTE85NU55THhkR0NNTi9sRVp2VjJSdmxFSXJUNldQNVF2NkZBTVVDdzY1ZEhqdEFyTjhRQTZVakIySFFoWEZ4TTZ4bkovbUVHUVVXY3NqbzRvdEswS1o4YkZiWWtmMEtqSFAwYnRZRGowc0NwRWJxVzV3L2hEM3lmZkdkSThtSDJmNkdUTFh1U3hTNUlSNWVjU2RJdlFvNHNSelZsQjBQQzllMkpKU0M1YkNkVVJWSEpJb1VnYnpoc1UrREZDS3BuVnVPUTFHa2NtMnl3Nndndmk2K1ZDR2JlSmVpYllWWGVaTEw1UExnc053aFE1b0lvU3JyM0pFTlNnNFlESys3N2E1ZUxjWTZxRjlEWi9DU0x4ekU5RlVKRjJHbUxZNHFxN0xjaXVoVitWanRpQXVNVmZ2YmtMNU8xNldDYXpYQW1ibXBUd09FS2NuczgyZUFPRjFWWG9LdlZ0OTFTL2ZHSGJqZGFUNVBuYTE0SWx3d3JPUTNxVlZQSlFBY2M0OWRRR2czRkpEYXRaV1QwczhTRkNMRjd3OUwxVWJ3OTgySEtvaHFuSnlVOW5VRWtVUnlqeHBQemVPRjhJeVJReWdoYTlGNUUrb0t5QkY3ZHNtdDJyTUF5QkFBU2dIQUpZcUZCbmpRT2xadmN2VFAra2dTZ0crbGJWeWNISEFua3AwbnUvdWdoZFZNNWd2S01QQml1MDZuQWlUcSt0Vk51dlZuWHkwQUV4elR3NlpPUmp3a3QwRk1OM2hGQVpnRm1sSE44dWFTZkplRnA5aHpMK3c1NGlXeXZUbC9leG9nUFZzT0JMVTlpOVZZVmlhcGRhNWVvUzdrb3kwdlNRK2pLZXNDUk9iVWNmSmZMdXhPaFczVEJTdUtURFUzaEFCMTg2MUtpU011SWV0S2YvVFZJY25kQ3JnQ1NEYkNnMEdDVnpraHJsaWpjV3pOeW5udVBLSC9nN1NBNVp0QW9mMktPRTJhelBVREJLVnFuMTdkN3M5S1UzbDEyenlpMC9wa3Y1d0RJS0duQU5icktPR2ZHd2VmcXlwQ2grbWFLSmkrWTQwU0pvYko1R3VPOWlPZUxpdml2NlBBdWNuSStPaHo3R3BQK0NrZlZQcVQrOFV4K3JYUno0Q2YzUk1KeWVHWkdMb1ZtNGdROXV3QkVTM2RLSFdVODJvYVovTktjOWNaZi83YzEzejZsbnY4VzdsMUY5aDFOSjZBdHhkL0owSzYwMXMwWjlxbHc1OTJ4MXZRWDdaaGRHNUpqKzAreUE0VGlaeG5WdkxlMWNvL0wxNzB0bktvcDBSUTA3bFlsc0Vpd2pncEZTRUprZUFjRGpvY3BRd3greXBIRHN2ZFFjUWUxaEZCT2RYdFhrVHdkVjVCbGFDcWtON0YrOVQ3OFl1bUNYdDZhd2RkNEhXdEF1TG1VY1RuaTdUWXZISWQxQ2tvYjVmcnJyU3BOZVd1N3dNcUJGYlJPQmJFY0FkaGYwMUdXMkRqa3Bxdkg2RlZDWnhISk16VHF0Rk1SWmd4S29TMWplaS9IL3lwa05YeUkwSzUvNHlLVmVKclU3SGZCbHhoRTIrajNPcDA1NGhqWFA3cks4M0hGdHhLTmF4NUovUGk5MFBSdnArSVZCcU84MVNQYUxhdzl6NmREVGdrelo1dTJUY1k0QWtJNGt4QUk2QkRncmQ2QzFxQS9mdllvNVAzUWdFQS9RWDdlbTkyc0RaT3hEdW5UbWhMdFJCdEpEMXliY2tYdWZUSDB0V1hSK1RTazFNWnBsQUtrNXBXL2VxTFdzbXpKTEVTOFZ4ZVp5WjcrbEEvV2Z2MTNLZ1QwTmZIMGlxcFRqN0NNSmdLTTFhVERZZFBYU1l2VXBKS3lJL3lCRXRpVCtZMFZiTVVJT0gvd1ZMU0VWMGp1Q2s2YTB6NVhVeDl4bjlRUFVxRXdBUStVNnBpcDRvOWtQZVlBZmpWS1pKNzFjU0lJWDA3S3R1d1BaVlRYUzZiZkFiNmJBVW1jZzcvMU04Z1JudmorR2VQbXhvMXRlUzZuU1BWeXl4c0R1UXZwb05UUXphUUxVWGJMOVNpdXE5b3hRNVltd1F6NWpjaldJOVo5SEkwenZ1amJaN3o5eEdzVWt5dUpUSzVveFZzcmV2ZVBtNFdIRzRwK2dIYm11S1JrR1BJTUpYK0xPZndXWVNUREFpMFBIQ0tuc1B0QmdMME5jWnZqcUdxcjF5WUtNNFMwMms2bjRQcUZGZHFybzV1Q3FSQWo0bjVmMExxeGliNlZPcWJJUDEzVmNsekYyd0VIZi9HejVVcFFLUitHcXZSYzlHa1Q0REFRUHhrOWRIb21ZN2lKZFN5VDB6cDR6NEJkaWtkMDYrOFVyWGJaOVFSRDNZU1EvWGdacW5sdnl2YTBiUTlYS0Y1NDQweGszMkFUMm9NS2VuWFk4djJaVFdHbmxlQXZSTEkvUWt4VUNQMUZhaDY5bjAxMHBRd2tXM1ZaSzY4U1MxdTVEMEdvYUtrcUZoeXpqSmRTMXQ3aE4rcGtDc3BlYW5IOEd0Zmd4dTA3QzAvVVBJZUVxZkFpUng3Q3pQb2czR1ArQkV3SU15cGl5SHd2NzVhZVArQlBQM3VhQ1ZxbnVtcVpYOXByQkxhcUpzUjhkT2lxYjlScXN6Y2h3UXFVb1pqNkUzT2FjUHh5KzVvQ1JHeU9xQUtJNmVUell0dWRudWVxMDcvTU50NU4vZFlCWXhBTnhRQW4xbG1uRkdJNnFpVW5meDZ0VStzdW1XSGlDNkVoajE0NC9KL01KeU80YTZMd1NMWVo2czI2OHFkMmpoUkYyZGJrTSt0dkg4eHdtWmdhMG1xbFN1WGx4N01Wak1ZU2o1MUtObXBFVHhESkNuTHJFWGtQZ2x6N3Z1UEJIaDE1bXNHa2g0V1NyVU1nT1lIVnVvN2hxMWNUSHNXZkxHV0c2cDdVNEpHckVXQzZtd3dSTU9nUDRGaUVuSEZhSVNBSC9oK1pVS3JNN0RERkFCSkQxNU81ZHYwdWRQNm93NlVBWDZ3Rm5vUzdLZWlpSzBKWjRzeVNLcVp4SUJvYzBRQnRFY3dEbVFTN3ppLzV2dWFuRDZ4QmFOVEtmQk5wblRTaytzRHlENUdWc25EY2hZbmlmcmkwTE9xbE1aV0JDMjFZTEJYOWNzSGhrelcraUpnUGNZOWRwak42NWp6TStDbldCWXlpOTRVUHBOZjVTMklFMlJRZlRyam1rM0tQbUx2Rk51WjcrVkhUNldIa3hzU3JPNnVmdUlMdGZ4eDBvR040bmtiRkl1NUZNR1VOL2Vtalk2NHpWL3ZPb0srdURmM0VJSlQ5K1BvNmhDeTVpL1Y5UEE1cW5DaVFqOXdwZ3NoMG1VNmxkTU5peS8rTmw1Rk1CQzZpTFZNSFY2ekVKQ1l2S2lIRFRPZ0FsOVQxeHEyS2pkdTF0MDFvRnJ3K1lYYVdqWmtwOWs3a0NhcmJaRHNMbXo1OEoxK1RydlgwS20yUERzRkVLN0xpbk1sblowUmErQVR4Q3lJdHZyZW5zMTUzRm8yQyttUFVxMXpPOExpWXBteFJYaitFTjZmYzBzZDMyK3Q2YStweURWenBNQ2l6aitVREdBU2NuVVZRZk1VZzJ3dTN6L051cTc2Uko5cVhIRVY0azA2NWFFUjZSUW0rMWU5Tkk2WHJ3ajFKNGg0STkxU0JUSXJaZjJycExzKzU2a0dIczYxSTJtZUhnTG81Z2NBV2JTbDV3WXVDL3MvVjFiMDU1N0t3Skd1aTZKVlFLWHpqYUt4Q0NQeFczSCt6Y3krcEs3N1RsSGRXcHBGTVZHVVBZZmNrWXAwcFl6WnIwcFlMZFh5OG9oOHh1d28ydDY5UCs3cGpZblIzU296QzY5OEhITFRkeEZMNU1YeWRlbkZ6Z3ZRSkJwbmkyaTgxU3FHUHgweEZ4WG1CcXlTNGFNRGRsZ0Y0ZmdrczQrS2g1RDBLNEtUOXA3YjBmMmZVZ3YwZGM3Wm5rc0t3dE5FclNBbGRqOWxqZnpaZXRyTFNybmZOcjFhS0N6b2N2dGwrb1R6TGdoY2JBQUI3aEFvL2o4NllORGJIM3NUcHlPekQ0aU5jcEpIcFViemJJd2ZUR2JwY0g5QXk3SkVtNlVEOWdsbG5tSGdHR3NNY1BQT3NkOEJuMzVFRmpqc1ZzVmdjcFZKbW84Rm5ib2FxSi9ZdW1OcmppcGV2bTRiODBtL01PWnJvUEZZTkJsUDZEdEhmbUFoU3R2ZUkyMmZpY3l0SVRYVmprODlNOHdHRVVkdGxYK2xVVGF5UkJVT2xMeGgremVpSnBpTm8wUGhMUWtLd1ZVRkJUUjJ3VmNLeEdraDFMd1BZOFNGMk1ZQWNHQ2VoTnlJcVc5amxFTlpyUGwrbmhhdUtIcEN3cGJyMmxEY3N2Vml2VERCTDR6cnJUVlRDT3hxQlpjVjBSclhLK2NNZEdQOVAzRU9vb0diWjlOYnRWTDJHT0JDVG1PTnB5R2JQckNiOHJVSXRmQzRMenVzYXhMYUF5NUEyL09RREh6aFNOMHhxS3F0UUI2TVRXWXdtRGVjUjU2b1FYbmpMZnkyTFhuNG1CalNnampmb0E3NVlWNC9jLzlYZ0R1TlRSVEVZdS9ZaUJBSnJxUVdaSUgxZ1FTeFVJS3RoK1RmeUJQT0xpaHByd1lQaTZFek13bEtrU2tyTU9PUmxrZ1ViM3ZVa1JCcHFnS2VJNURMcDNsR1BSc0p4NWFQUzdSVkZOVThGZzFSMThXSGZjSlNQQllaWUdmVHlGN1p1OE1MZlRQYmFwaStMdk9ZSVk2QzhMdlVVdnlnbkVJYTVJdFVGMU52T3Z5Q3Z3RStHMU1MM3AwcUdYVFgxT2ZFZEhKQlE5VTZra3BBRnhBNVl4UFV5QnNLN1RJUm5iOFlORFFWZjN3cUZPNkxZbDlmYSt5THU3RHBSZGI0c2gzSDRQbG01am92dW5jUkFHcWNaZnV6UnVIam9uVmY1NXVvcXl4N3U4NzZxUXhUZm00TlZsT0ZVUHNnTEtLNm9RR2dCM2tGWUdaSDVNelJMUTZtRE9mUHYrQS9ycGpYTUtqWXd5dVk1TEhKTkU4SVV4YlB0TGIvVk1aNWROOThGdFlLYm43N1pIOVFuYW1WNVdGYVBlVFphOW9uNE5jMStIbkx3bGtxTXdUNVB3ZkdDSVZpUlI4T3VxRkRldHFHY3pLaDhqbEVMVTNXRUs5eHBzVDVRZmlNSFRDbDkvekRTQVU0Q3plUURHRkxkdTU4Zm44aVIrM014UW5oMjc0ZzdjTnVCNUV4RG92Zkw2VmYwUTZxSWpwWFlwSEMzVmd1clh2Y0F6c1ExalZWNU5CTzFPK1RLaUpXQWJ6S3NVM3haQTNja2ZrNG5uVlJZcWpGRWxzdEU2SjkvZHJJODMzdVVtOHNLZ096QUdUQ0E9PSIsIm1hYyI6ImE2MGExZDgwZGE4YTAwNGMzYWI3NjM5NGZlYTE1NzlkNGVlZDkwZDhiZTA4Mzc0YTk5Y2M5MzVkMTk0MjdmOWQiLCJ0YWciOiIifQ== \ No newline at end of file diff --git a/resources/views/email/template/client_premium.blade.php.enc b/resources/views/email/template/client_premium.blade.php.enc new file mode 100644 index 000000000000..4de854848a74 --- /dev/null +++ b/resources/views/email/template/client_premium.blade.php.enc @@ -0,0 +1 @@ +eyJpdiI6ImgvQXJrQ0Q2L1F3STdEYWNmOVJqSUE9PSIsInZhbHVlIjoiNkFSMkJKOUNCeTFUczRnQzNKbEV1bS93RnYvWHVnanEwREF1YVJHSXhrM2dBRS84ZkhjZWZRTVNCR2Q5L2lsd2pPVExEYzVvbXJ5bExDM2ZhYnorODVZQWdqK3hmMjZOekw4QW12SlFSdkowOWc2YjdkOU9HQ2ZYSmw3SXhXVVk1dnZtdU1VcndHN1pwUFNMaUZPS1BnaHRJK1I3NllwRWltNk85OFdyQWtpSkNGRDJxN0tTME1SSDM3QkdVa2xYcmpKVk1PMkR0TTZXZmZEVU12U054aFE2amdNMW41UWJ3UGJJKzFXQVlVR1JoSk1Sd3lnZGVYeGxySm9iWUVNby9vY09kK2E0cUd2UW1WeEROemV1UTR3TGVHQ1ZTUFhCbENpelcwdVh5WHRJLzcxT2trQVZpaFRtMGF6ZDJVL285M2NQaG55SjYzMUplaXdqQWlJU1JONnpFSXVTRzlPK0JJcmZSWldPS3lvVSsraUFNcXhPaXBFbHpxY1J5cVBSODd2Y3Bybzk4Z25PclhPN2p1bEhmaENzenRMSllSRzdXdXpoZmk1SXBvTXY4MmlVWmpIcHgwVThpVGZLb2RTNWxNQkZmNC9OV3FDVzF2RnZ1Z2lSVk9SazNQWFo2QjJkbjdSbEZRNkRjcytJbm9OSVY5dzkySkZPdnFhY2hZOEpNdWs5SEFFSHBERUgvNUlkSUE3UTlscC9OOWRNSXJEOTdxSDB0MXRjWkNYeUpHM3FPN3p1STMwcW9NT25SQ1FBWWw0YTdVbUg2YjA5eG1CRlI3RTdjUVBFdUJxN2MvWGc2Z0hQcitPNEFjQTBMc2xuTERRbnpLd09tc0J4VERMVnpLTmJWNGNWQVZ0NDkyNmtsUTFEcUg5Vkl3WmlMdTdYdlRYckUwN2NTTEJkekpIVzlPWG42dU5VUnhoSk1waW9ORkRuTkJnQTAxOURyQUxta2lxeVVZOFJRK0lzQlhFY3Yzcm9iQ1ZaODJwYW42YWRqaEQwdm43aEtQUEdKN2VEcUR6aGt4U1JLRnhOZ284Qm1WSTRNR1dqVXhZVHZsbTNiZFVWV2pDTm50LzBqb08zRjc3eHhkTnBkTi8wT0lKN3lFSXVlWHNFNTR5K2lLeDFwL1BHbW1BZWxOeWtzM0dKdG95cjBTSGxpY2d4LzU2WTZ3N3pSRkpleVNzZFk4WXdwOUszc3k4cVJSeStVN0lkakt6VEZCVGExK3JHZW10UWNTVEVkbCtrQk85SzdOSUlKZXRJN3JidVpvSGJ2SWJ6RVhaaVdIS1VKOHpoSUs0MzRDTmRsWElOZFpqYzFjWGF0Tm01elltNjkybVRBNUkxL1cyUEt3WHRNR0dVRlNPc3BCVml3bXhBbk4rMUJkNVJ6NER0V3h3M2NIcEhPVjdpeHJuMnpjRkphWTI5WE5pVzY5SlJ1azBuenFPbmVUamJNK0NNdjVhSDQzcHNyUndWdXlQQkNsRDhNR2RYSmlsQVVaSXAzMzhsVkRxeGNtaEY2cXlzakFSMGRiaFVRc3c4cmc2NEEvQkNzZGNJa2dFWEhlQkE1RUFWbnFhZFJmZHpFZjJMYkNmL3BQZEtzNUJId0NrZXRwZVdkUnBmNWRVa3B0anh0NEtDTlFZVVdzK3FlY0dDQWpoUzY1SDJjaU1QQW5LcG4weWpoWTlKU0UvUEJtazZDVmoyL0JTSkxCYzRHKzJJZUVGUWhaL0JsVFNraTlIQkc0L3pWSUFIcndDaEpOYVo3VGdOcXFUVHNiZmV4L0VUbXd3MnRnODYrdzNLRzdkS2JDL25lbGVRTXVML0RYZEdSeElpUDJ2cUQ1SzVvMDRZZ0pZWEZ1U0hqNlBDZ3A5bXBTTWNHT1hnMlRkQmNGOVJCYjNjOXJ5M2dBMUtiSmdxMXJrTG0rM29GN2Vma2xua1F3YU9EdjJ4RXlLdXkyYVU5ZzN5VFBqTTRyUi9kaHhlNEJBaU16ejlrcStGUUc4QkpHV3NETzhJOHJ0MU93a21qRVBuN0I3TnNWQzRYM0ljVVdCSHUxMCtZYS9CNHJTTWFmWUQ2cHRPSFozWHNQU21HVTFjeGF2WEgvOTJ5cXp6NTZoQldtcTJNc0I1SGw2aVNOcTYvRlpXRDZsUStmZGwwOU1UK29ERDlOMHlBL21LTUI1UVhSVUVpdGZtdG9LVjArbTZ2YVlEQ2plWkxaY3BSbFVZM2N1YStNbUc3ZHFFYVQ5U3luVjJNTUVZQmI1SnpWbFdzNWRibTA3cnR6cnpVeGNESHFrT0p2UHRUc3ZTVGpBREtTU1hOamRnQ2l1dUswczNEWUFST0VVc1hxN2I3L3FLY3MyWEhnYjhDZFB5MU9RMktvYkR3UVBkQ1kydHFMQUZET1NBU2FwQ1ppVjNMaDQxWFlvKzFKZGw0eHFONjBodkNuTnIwUHV2bjZWUFV3VnB2ZURwSlJTY0w5SlZoekM0SDdlWXdqNEFneFpxbGUranVPMVNBMnNiOTY2ODJLS2htV21JMnNHMlpDS09Ca0ZzWTdaQnorQXNpTm5sNEQ1S20wQm54YWh1eWJrdFpOVDk4MzZYem1ENXBvbGpFQm5UYWRKaFc1V2VGeDNqTVlwTDB6bjFGQnNBcmo2VDJDNW9aYnBRTElhZUowOU9iSnVDRmpvdllGeDVXU2tKSHFxSnI4Mk16UUV4V1ZibzUzL3R5Rk1YQXltRFVrSGVrTTliNEdtbVlUWThZd3hna3BzMVBmUThkSCsrYTdTb3J6czAreHNKcXI4VTlPVEdhdGNHeEJrZXZnV1FUcnJScUxmTGZOWU4rb3ZPZG1TTi8wSVY0cG53OE9OU3RKY2tIOFRXS0lFdDR0TG9WYnJkQmNkN1lzMGV5b0VNdmwzanBXTGFRMTBiLzhyU25YcHpXUnM1ZWhQaEdvMVZrZ1dsUk95WjdXSzJXbWdiK1UvMy9rb2Z5NEZEM3hwdEJORlFGRk1VR28vaC9OOHllV3hjb0VsdStQUXdsY1pOOTd1L0RRNnJqZDR0VnRDWE00Z3lVcDdJVnNqWGZNcWF6Q1BJTWUwNUhQbGRDc0pXUUptbUo5NzhxVHVZYW5lNG54R3FGMHlnNlpaOHhuUS9jWEd5enhGMTc4TEM1M0RCVUFZSjRpbWNKSVMvRXVsQmE0S3EwdTJKV2t4bEJqTGJ1QXE0b2c4K0Uwck1seVhHQ0srNDd5WWEyMm5KcUFoNHl5UktLV0hjVjRNcDlPaHBRTlpzRXQxa3RNeHVVSXZtK2V0REpNYUFJdHppMW5kUnA4RHhwRW5uWUVPdEl0NUt5c2YwM2thbGdXRE9tTFRBZTRBL0RXcjFTZlYzbkZzRmxXZE9pbDFGR1IrTG80TGZ4bXgrcGxMSGtyNDNBQ2xnQUpqN1RPczR6anN0VzRVeUl0SFFsbDR0L3RhSXgraFBOblNBbnNMS2d6THQ5SlpDMHJpeS9yUzNISUNwVnV2RnByNXZ0QStPL3ZEUG1mckxvZ3JpSXRvdVlic2UwY3Y3NXFJS3ZsaWxDT3JTUEdqODBTdnhZNVpPdHorWXFpR1Y0QllCaW1ibGtwSGZ6Qk1WSkthanlmNERPcktmVmFzMHF1YXhxbVdVQ3J3YTgzd3YrMHc3R2ZVSnBDVTJqUGlaOThaYzJ5dVZueVJwV0pNT3l4Sjg3aWxHMjFnZXVXaFBnaUdaQUcyUDFNWVVxVjVMbVduR3JqK0JXSnBsTk9BR1hRdngxMVdOTFpwM3VXNkdmTWhya2ZXT3VERi9rRUt4OWNhWmd5bHFiaDAyRXVFc1NLTkVaaFNheHRwLy9UU0pRbEo0eGhDSEkxRmQveURoaWtNVjkxcEpJZngzblBUWHFjUmJTdkwvdUl5SGoyb1JwQlhncUVPeHN6b2VoZmpPOEp2QXFpY0VkekpMaXRsc1JjSWZnaDV0NzNZNEdSdGlQM3J6VGdsSFo0WlA2dTUvTHp3ZHpKdkhmNXRoSUVLbVN5UlRHdXhIemloV0hoM2VobmorU0RnOWd3YkxqcEZ5L3Q3aER2UW5MZENwQ1VaeEJvdFRaaEIwNU9sNXJReHNPQ3dhMWwvNDh3UlV0WHQxM3lWR3RURVhTUGE2ZWh4UzNuSHRGRHVhS1U2NGFxQjEwS25QT0t6OUYrUjkxVmtXWFlyZWcyeFBtYkFGYnduRGt1SjFpWldPdVA2L3JJWW1yVXBHV3NUOXRYNnFJRFZhQUlkZVhQK09UVTdZZGJsQXNXNGt5KzU4UVkwT2QvWjVVR0NOMGZ3a1VnK1Z2d2xiUzZQc3dtT3pWYWZmNW9QTkYvaytCV3Q1dktJeWovYWR0Y3BxT0hsV29TT1NhUXdFT2hnMU15cjRqSzVFOW9YRzdHQkVjUjlNOEZSZ2hnN2M3UTZrb2NLTVVTbmd0VkczT1cwOUhidDdnQlVrL1ZLMTFRMUxlVC9qN0dyOWxJTXFxMS9aRVdxeW5UQ1FTOURXS2tOd0NZYkpWeDg2U0ZVS0ZDVHhzUVM2cFY3MUZPdDMzYjZvNEZQNk92elpmTHoxa0E3UElmWnNNcUFTZkphZHdnVXlmMFBSVkdsMzdEMVY4QlhLUHZnQlNOeGZiN1N0cmlvNDhnM2wrSnJOU3c1Q2xIdGhTNnhIN2lxZjMvNWZWcjFhcmN3TjdQcVcvMEtNNnhIQlJ1ZHJNT21xRkU0NFBjVnRhMFgvZ3RFTnRUYis4SWVFc2dmc1pIZmFQdU1WV25Ya0dJYVA3RHJ0Zzl3Y2NrSllDeTVDOUNlbng4eTF5OWJ6S2ExQmVOdXMzT00vUGVya1RBWTRWTnVMRUxrN01xbjFRMXEvNElaRGhhTGN5dE1KeExGaUp0dE85VG53MWZ2QzBoSlVYdUFtb0xvME1uaTVVZXU3bVF3OFBoTmlQQzlpUE96Y0I2bGw1MlNBMUNLWlJwd3RqSTBueGNpempOMCtabTJOSGR3NkxsRTEwbkdLWllBdXV0dHdDL3lGRVRIUWZkNmRiN2ZFeGdFQi9MemZLQzAvWE8rT3AwK1g5QlEwTU4rRzhjYnR0R2JUNEhxcS9CQWRhSTZhWGFPcU5GejY0cms0eVRBYkV2QkpBalVpa2lLZ0dKRC9oMktXRUtXMzlPTEFjamJVajFReXpneUlGd1BKU1dUSE83MHg4RXlVTHJ6S0VSUVo2YkRVS2w5WGlIcXZsZlJUVlBRMzErR3h0Q3dJTnl0VGh2SW1YUzFWV3NLVlVhT3lJRjhjKzlCTlhITG9pUU1pU2t3eXd0NW5pVFVUUDRiSHNWWGUvU2srbjJvZjRLcGdsRTFGOGI4Ukh4VjJ1UUhSY2RIUlRaeFo2UTdJbXBXcWpNQnJ3ekNJalA2KzVLMzljOUdBcHRsTWRiUnpxOFphSk9UK01JaVRjcmc1ZFZ5cjRzbUd1aUs1aTE4aS9wczFmdFY5M1dlK0tQdC9kbFlqaFV4WHh5dzRBaytPblBQMUk5TUdpRHo4Y3JhYzBPOEtsMy9XcFRYSHZwRmFCaVVuWmZWYzVFRGdwcmZHbjE4SnFPaEQ4ekZ0Z25IRmRwK2g2Q05RVTJ0WllWakV5dkloZzJJL0JINFlwNWtTN3MwOXE4b21Ob2F3SFBzcUUxS1JZSmg0T1JxREQya1NYaUcrY2VWU2hjcDZ2eWU5emNEUStKZWY4cEdEb0JibkJQbEVWVENocjVZTC96bFlrSjJYYVk3WmpnVnlEWlk0WkxuYWZHejE1OEU5aHRiQVRBcGhpbmNKZUdKUytsYUd4eU9qUmx2dk5kZ2Q4djN1d2dyUEczdktscUxHTWNzdlZFUlVXdXl0Qk80MjI0L3NFTk5WY2pEZHZLVVhVb3dRci9UNEFORk84RDRTc043dExaRmd2UWM1Vy9JTytmbFZtMjk2R0JYOWRsZVN2TzBYZVBCYTB5Q3QzMHdXK0wzTDI2TWpaU1Vma0NPTmpKY0I2Myt4UkRZVEFUN21EZjN6TGZUdW82Yk9UbmxvWXRxWXowTU5YcUdEMGV3dEVsZU5OQUI2bXh5N1R0TVpYMXMwdWw4bENkV0ZxNkNtcEZQb2ZoU0RqUitLbGhxY25veHVTUXdBTTRCK3RNazVTMDZSWkZNTWZOUS9ROWZ1NXZrL0pJcTdBcU8vNm5OK2pBWFVBUDZMYk9Cb0Fhby9ndXFhZkUwSXliVG4zblY1V0dua1QrbzRMa0k4VjBRWnFONE1ZMXRWc0J0ZjdpL0drcnhEb1pub2FEbVB4MHAzNm5GYVVKWkdQMWRZTVBKdUdYcFU1TVhHR1VOWGcvaHdsTXBrOUxwd2VkVW1LeVJLdEVybzBCTVhrKzNNY0w4Tm1IcjVYNzE3RmZUTm5VdUNuSkZ0aG9qNGdObWszTTRpTmZuRFQ0KzA0SzhwWjR2eUNkSzA1dW5aSWxKTy85cG5zOWN2eGw1M1MzU0g2eHJDZ2NmUEJPeFh6SHRMZE90cjlUSFppVVdYOExjVkloWitTMG1qWHYwb3B4OWVJbk13QTNyNDZBU256Q1BybXZsYzlqaFNQa0Nma0NJZ3A4QTZueFQ1UnhnZkFjeUtvTUdXWThDZEZVT25GRTdtU0YzLy93aWRtY29KaVBvd0oxSEtGWjdseGF0WUhsUVYvZGlhOUh1aWF6Z0hZQjgvTCtqekpGRjlpRHV3bXF2WGM1SFZyREFJK0JoVjdGQkgvM09ja2xCZHJQTitYSVpIQmhsZVA3dTlUMGp3eU9uMVhUNzdvbWFpQzRkdy9UZTljbzBSTHc3NHpSclUyVm9IMUFIWTYzeTFnb1RSR2NtcEkwdDQ3ZVFlZ0JXZ1lyR3dhVDZ4WTJLdnpIWDF4WHQvUzJpeER5UjFKOVpKdTdveFQ3bEtrRytRSGJCN2IxZXFUL1RUbWZtSjZzeGxDMnFKUlRlcVhTNVp1TlZNQUZwamhXemFERzN1SjBlMldrSUNXVlo3WHgvRlN1d25aSVFxTytrMTRkQmhrM0o5b0FrSjNGelIrVWo1WisxdENUdE1yeGptaWx5RXl3Vms5dmpwVzZoMnJHS2RwMGlib1hrblJiNEIvNVBYdjVqajJxb3dPWjdBYnJsQ0pyRm9CVVJVV2NZTVZhOTRiYllFZUhtTkZpS0RhUEJIYUZKSGFta1lnckh5ZklxNFJSeWxMaEo0b04yVTgwSTRrc29jODAxTTZWU2tNakRKdkwxemVHblIyb283OUR6V2toS2ovdWQyQjVOcWRlZ0xmb09sVFhOb3VZQnZUSC9nTFRrTVBrSm5rVEZMR1l4NytPN1hoeTRzd1hyRG9OTW5zV3RYZmEzMjBCYmZuWTVINXF2WDB4TzQwaW5LMlhLaVNtT3NWRFNwZnJuWGNUM3luZEZUcjFNUkEva25tZTI2cnREZ3lJMVdlTllObnFoZWlWckVGd2FCWVp4K21rMzg3Y1ZuN0UrOHhiNkJ2NUVoN3VWSXRDamlmRS9oc3RDR1dLQlg4SmdRaFJSV2JQNmc0VzBMTFo5bHJ5S0wrVDhYaVFHYUxNT3MrRDZHTG1UTGpIY3lhYjV1d245WkgrR0g1SWtqaWM0eEZWOTdqcndDMlQ2U1Q1enpCMjRhVVIyTTVXRVhDbXF6RUk1RG05QW9RY2lXQzkyYUlwTlltZW1pTEE0TE9HeTczUHVzL29nS09JVVlBZ1RpaW5NdUxkN3hVOTdYcUlWc2Q2ZHZBZTI2TVdJcndzUDFvZndPVTBnNEswc3hsaWxPbTgwY3pUYU5zak9TTHB6YTdLT0RKbEJNV3JiempZc1lNc2hiU0NJaklpQTJDNE92QlZUWC94WVQvWG01eStKV1J6Tzg1S3RpM2p0Y3ZHa2M4bFFqUUk4QWRNbjlURkJhamtEL2xtSGkzOEJPZmtST0lXVUY1QUFRdkhqL0JkeERMS2FIdVBCZFR0SmwvcjdDUG1rYnNUVkc0NHEzOTlUWStYcWtpcnRzMnhwRmR2Qm52a25weCs0UTNzRFd4OENsN3Y5eTlQeFdGa005NFpJTFZraHZPVU9zVjNZN0dpWE9xUDZOVkdXd0dnVmo2Mko5VEVzRFNXbFBXSllySmxadHNlbUlGSmVoUXBMUEgxU2VSVGhONzcvb1B3YjdDY21jZzNabmhjQm1hNGVhY2s2aGRjMHNjalZBUkFWUXI1NGk2MjIxak1zT2RDRHhzUmVna0IyR2Q5Y25wdG9nRXZESk4zNFkycWEzejJXZVpMQ2JSZTkxVnd3dmY0OUNGd2JUcDdBKzFTTTdHSFIxWFZpNDBHQlZjc3k1TDA0dko5QWxZMU1ONU9WMGc0WWVjamhxYStMYU96cjBWTnlJci96enBxdk12YUhMako5VlFmR1ltRG9USW85ZFJOaWhSTDErbFgyclIvdXBmeVVaMVNBWWJIaVNCdTFHbGJQT3NpZzRiak9QTVQrVU9JcFlZKy9PVEJsb044TXVnN1BJMG1KRXVkZlZ0QTIrcjNZSUI2SW1yWGViV3ZpdjRSTkQ1cEVLdGkyUG5EWWZJMnZWWGhtbGhGNExuYnk4ZWFLdzFtMTZxa3BTbS9CdkRDbHVwN3E2ZzdhR1h0eHFTTUFuYTROb2tXdWpOcU9SRW5lcmpvTm1LZVIzanN6bkQ4TmdtTmZxVGxQaUVyVkNLdU5Nb0RFZHlhN21zOXkzS3FGMXRpUW9zRmYvaFVRdFV0OSszNzZtSlNmSi9sWUw0WjJFZGZrUjQxQnladkt2N1c2TnFuRktoMERJQVJxQmRzU0hOdU9IblF4bWpmQjQ5bEJ3c01tcGpSRHMzd2NadmNFT0ZPZmNsVEUzMlVKelNIUTR0MTFCNjJIdjZOVjBnYzN3K3AxNHVjNENnNTBKWVpTV1I1TDAxc3NzNDRMWXFuL1BiVzI1VXBjcXByT0tyWkZzQUU5bXJzWmdOeFdlNlBCNUVmQnZVdE9ZbEFkc1BtZWNLZGNuSmVqZUZ4eE0zenBtTjlFd2ZMaFA5VUt6VXE1TE1oS0l4ZklDT2krWG9HdEhOM01oSXBYWkZsNzIwd3pVV05jRE5LalA4djVwYlR1bm5kQzRDMml0Z29DR3lOUXFYVkM0RjJUeFZiV01oTnhhYzltUG42WDRwUmo1aHZMQlpwQkhiR1ZCYzk1R1M0N3NRejVoOFpPaGxEeFJwZTJ0R3l6N2lSSHR6ZDhTZnNHVUd5RzVvY2pZc2JqY3lPTEhjMWdwNXV4QU9nMHNqQmtmd1dla3Z3cCsydjdBQm1rMU5JY1czVWlBcEsvRFpHYjEvd0JZV3A1ZEt1Rm14UjM4enlRVWZweHRxUzFRUVVGaFdjOWFpSjlNL1prenlYdzMwdnYrQU5xd2IrNmM4ZC93bmZzS1J5L2htVUg5bDZBQkxrc3JRcC9pV0RlbVRsWXRsN1VjM21iUE5FdENiR2JaSVEyOWhEazZOM001aTdPdVhIbm52aUdPS05rWHcrVXFwZUMyKzR3MUZEcFlOMkttSENjYXBjamgxOWJ2cVRZOUk5d2dqcVk0L21TUEFsZzd5d3hyK2wydUgvTUxSZmNDbDBnY3BKVUF1cjQyb2FYR2pxbmxNK2NObVpjbWJJSFZneDFkWjVTT1BUdFY1cTdjRklodmR6cENtK2xBOWVaWC9WY25SZC9ZMkNTT0dsakhtRUtYSzV1QitsWkZCc3AzVjVTa0RybEtpSjUwdWs5aVZIUHF5My8zanRId1lZalQxSmFhdVluT1NRd3pQU3d2b2JtVkhLYnVGa0hlZHRzam1MUEJjZUxDWDNCcENnNThmMisvRlJSVHVIVm9keklUdGtPWUtwK2VNRm9wTVVKeUdhcytYRVRSOUZBQzYxaVVyVFhUSVN4WjdxODErOUxQeTA4aVpnaDRNMi9CQXhRSGJaVi8xTTlkd1VydzhraWNzOEdpUHBKYWRObFdUemJRZ2RmZm9FdTQ0ZXF0QmZYL2tXOGdSSWVhYW9aQlplVmdKR2xMZFEzWklocCswbkRzM3MzUUlNOUVnbkdRTkxHUm1FTUhDNlFOVEhhVnZxNGJqM2FtTHpPUGtFSmpLeThHTXpCU1Zxb09oN1FDZ0pZQTlpWnhoMnBySis2Z3NPTlZzbnZaMnR2Z2hJdm9SRUZubFZ1VjRHcG50dG9SdFplbTROOVZyTC9lTkZqUkhsbVA0VFByZmMveG5RN2JHZ0daMjRwdEo4UlN5bGVmcDI5Q1FpemJVelkzeXFkN3liY2VpQ3QyZHlPQlhRdFMwRFdZL3hxNFZIY051Z2RvcFVHeWFIY1YwMjFZU2tDRSsxQmpydnV6Wnhwb0FRelJYWnVyNGRHdnlPN3RTNzdwUXk4MXo4bkp6UTlha1pSa05jWVE4UzR0UVZpdnNIdlZlYStpNTBGTDg5Z041NXROWVlHcHFKV3ZVVWRMc2pEVnlnRklGdjFvWXpBT3RQMTdmZXlZYWlRQjRncndraEhLR1E5QkxOaTh6eFdGOEV6aGFiVE1MTGV5YVcrUkpIcWFuZmtYL0krOU5TV2ZhTUMwY3JqOExmOUpCazdHaHZGNTFtTjFzSDEvejhGTWJPMXVRTXNGRE9DZkxFVjdqdVhNSkRQUHM1TWZCMnFWRHYvbm1uWThTQzV5MVIyOXBYR3V5aFkydytjQ2ZMM3BRSHpFNmZyYVJudDVDSXNYMkV2YWN6WHcwZXBzMVJ1QkV4b2NVbm00LzdIT0RtaGQ3VXo5NmRnSzlPZkVQcXZBeHp1YUpsK0ZHemY3NWN0NnkvQ05ja0FCNGcwa1dtL3VQL3NKNGpZN1czbER5UWRISE5TbnJienpIS1MvakgvRi9NZTZVUzBxc1I5T2p2MGljSms3M3JCSUlhNXBkdTNJbW91WnF4TW1xNTN5bEtCcHBJK1hmOTZzc3JiTWptNVpRbDQ1bnVwdkhYTHQ3VlhScUh2N1hMZW5heEVKcW51VHIvOGF0UnRReUpsbEp1TWU0YWk5Ulh6N21XZmlWc21Nb0tBK2J0Y3NDdHBiSG1ZakZIOEhMd0pRV0pESlhvL1hQaG5vWlRGQXdvYzNXUjMwTERkYjJlQVN3cElDM3VQNkZxSlJCRml6SWdGcWlQdGYzYit3NlVUbzFPZXFrZDEzaG1nYmZzWVJKaUdVZ2l1RXd3TjA0RVA4TnNUTTM0cDRVVGNDQXFiRTBWbUY5Z2xKTy9sejNmZEJpdWF3SGpFSTUreTc2K3pRdFVYRDlONFhEZ2Qvck5WNm84RDVFZTdsL0xQTFY1cXN3RkFWeEc5OWFocE5WdXZsVDNzRVZRbGhKaVIzbEliUGhFWklVL09lQmZ6Vy8vOCtCbEJiL2lhcG1VTkhQWlBHUFRnWjBFSGx1SzRRV3YwdVY4SUlKaktDeTZ0S25EUi9BUjlhZEVyOVc3aHJXSDlSVUlWR3pxT2l3RkgvSEx1VjVBb2N3V0tSWFJ6a1U5Rm91VXpkS1hROEp6ZE14dUF6SmZyd1hLbUluL2ZveFUwRHhsNlJmeUVZQ1hxS0dBa3VIaVFjOVBDRlEzWmIyYjBUbEp6T0tRUnF6WmxacVAzTmNjR1M0RitUekh5V3dMVGxta1dkekdScGlObnZKOHNCK0V5a2lEZVRIaXJQbWNIYVQ3eUEzMlhQaiszMGdyNDVIWVV6MEJkT2oxOWVpbDBqdFlJVWZIRVkxaTdCNnZ2R2tBM2xsMTc1bURtZHIwdXFUZFBTTjNITXpWVm5VaWtoUThuV0JZdnBDUHFvb3paUk9OQmNOenFpUitHVSsrdmc5czgwZUh1dDR5cjl6L1JYM1FtUUxMVXlaREdybG56eEI3NHBNSG15N29zTm5TVnkxM1d2c1c4dDAyMGNSQWtLZ2UvSTVGcU5aZFpiZ3hXRDNMY0Zza3lmYUdMT0ovc1pNVHhEMlVUb1d6Rk5yTlppc01TcmpJUWJDT3NHMW5xZEo2TndPOWtaVlhpN3VnUjZ2VjVXU0MrNWMyQWxEaW01ajBYZFpFL1VYbGV0a1liZGxJSDJrNVlpL1NTMituSktaNjUycVJCSlpsandnOHhiSFlUT2xBS2xNYWQ2Unk4L0prZkdKOWozNjZmNTNYRzFteFdTRGlsQ2hlR3V4cStrYWRjUk10UG5Xb0xzdnJVa2lHb2czb0tZWG5WME41c0xrSzVGWThEYXBvSUk5aWNEWXZsOGRmTUpVRWJPTEpnWk5NZFE0cWhIQWdQVWJOVlNjYTJWSGd2TDViVXNZRWc4NmpwdE5LRFlFRXdyWnNMSGxXVHZYRDNFejF4QjM1WUJYUEhKN2trbTNRS09hNUhEdXpYM2tJQy9GOGd3aVg3K3JFKzhqa1ZiUVQ0L0NLUVpxZ085SkNzc2xaKzgyRVNua25TSGpOZW9HVnlxMXpSbDNNd2V3NG56dTRla3VRNXlyQzdxbWtzVEl5SjBYNDZJOXBjME5RUEp4WTRoSE9BVkhCYmRYZmpsYWRmanA2c1Q3RDJEc2tYdlJ3PT0iLCJtYWMiOiI2OTA2NDkxNDRiNjljNDU1MjBlNTUzOTA5NTZlYTAzZWZjOGE2MDlmMTI5NDBhNGUzN2FiY2NlZWFjNGRkYWYxIiwidGFnIjoiIn0= \ No newline at end of file diff --git a/tests/Feature/PaymentLink/PaymentLinkTest.php b/tests/Feature/PaymentLink/PaymentLinkTest.php new file mode 100644 index 000000000000..9695b0d57e9f --- /dev/null +++ b/tests/Feature/PaymentLink/PaymentLinkTest.php @@ -0,0 +1,228 @@ +makeTestData(); + } + + public function testCalcUpgradePrice() + { + $subscription = Subscription::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'price' => 10, + ]); + + $target = Subscription::factory()->create([ + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'price' => 20, + ]); + + $recurring_invoice = RecurringInvoice::factory()->create([ + 'line_items' => $this->buildLineItems(), + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, + 'tax_rate1' => 0, + 'tax_name1' => '', + 'tax_rate2' => 0, + 'tax_name2' => '', + 'tax_rate3' => 0, + 'tax_name3' => '', + 'discount' => 0, + 'subscription_id' => $subscription->id, + 'date' => now()->subWeeks(2), + 'next_send_date_client' => now(), + ]); + + + $invoice = Invoice::factory()->create([ + 'line_items' => $this->buildLineItems(), + 'company_id' => $this->company->id, + 'user_id' => $this->user->id, + 'client_id' => $this->client->id, + 'tax_rate1' => 0, + 'tax_name1' => '', + 'tax_rate2' => 0, + 'tax_name2' => '', + 'tax_rate3' => 0, + 'tax_name3' => '', + 'discount' => 0, + 'subscription_id' => $subscription->id, + 'date' => now()->subWeeks(2), + 'recurring_id' => $recurring_invoice->id, + ]); + + $recurring_invoice = $recurring_invoice->calc()->getInvoice(); + $invoice = $invoice->calc()->getInvoice(); + $this->assertEquals(10, $invoice->amount); + $invoice->service()->markSent()->save(); + $this->assertEquals(10, $invoice->amount); + $this->assertEquals(10, $invoice->balance); + $invoice = $invoice->service()->markPaid()->save(); + $this->assertEquals(0, $invoice->balance); + $this->assertEquals(10, $invoice->paid_to_date); + + $status = $recurring_invoice + ->subscription + ->status($recurring_invoice); + + $this->assertFalse($status->is_trial); + $this->assertFalse($status->is_refundable); + $this->assertTrue($status->is_in_good_standing); + + $days = $recurring_invoice->subscription->service()->getDaysInFrequency(); + + $ratio = 1 - (14 / $days); + + $this->assertEquals($ratio, $status->getProRataRatio()); + + $price = $target->link_service()->calculateUpgradePriceV2($recurring_invoice, $target); + + $refund = round($invoice->paid_to_date*$ratio,2); + + $this->assertEquals(($target->price - $refund), $price); + + } + + // public function testProrataDiscountRatioPercentage() + // { + // $subscription = Subscription::factory()->create([ + // 'company_id' => $this->company->id, + // 'user_id' => $this->user->id, + // 'price' => 100, + // ]); + + // $item = InvoiceItemFactory::create(); + // $item->quantity = 1; + + // $item->cost = 100; + // $item->product_key = 'xyz'; + // $item->notes = 'test'; + // $item->custom_value1 = 'x'; + // $item->custom_value2 = 'x'; + // $item->custom_value3 = 'x'; + // $item->custom_value4 = 'x'; + + // $line_items[] = $item; + + // $invoice = Invoice::factory()->create([ + // 'line_items' => $line_items, + // 'company_id' => $this->company->id, + // 'user_id' => $this->user->id, + // 'client_id' => $this->client->id, + // 'tax_rate1' => 0, + // 'tax_name1' => '', + // 'tax_rate2' => 0, + // 'tax_name2' => '', + // 'tax_rate3' => 0, + // 'tax_name3' => '', + // 'discount' => 0, + // 'subscription_id' => $subscription->id, + // 'date' => '2021-01-01', + // 'discount' => 10, + // 'is_amount_discount' => false, + // 'status_id' => 1, + // ]); + + // $invoice = $invoice->calc()->getInvoice(); + // $this->assertEquals(90, $invoice->amount); + // $this->assertEquals(0, $invoice->balance); + + // $invoice->service()->markSent()->save(); + + // $this->assertEquals(90, $invoice->amount); + // $this->assertEquals(90, $invoice->balance); + + + // $ratio = $subscription->service()->calculateDiscountRatio($invoice); + + // $this->assertEquals(.1, $ratio); + // } + + // public function testProrataDiscountRatioAmount() + // { + // $subscription = Subscription::factory()->create([ + // 'company_id' => $this->company->id, + // 'user_id' => $this->user->id, + // 'price' => 100, + // ]); + + // $item = InvoiceItemFactory::create(); + // $item->quantity = 1; + + // $item->cost = 100; + // $item->product_key = 'xyz'; + // $item->notes = 'test'; + // $item->custom_value1 = 'x'; + // $item->custom_value2 = 'x'; + // $item->custom_value3 = 'x'; + // $item->custom_value4 = 'x'; + + // $line_items[] = $item; + + // $invoice = Invoice::factory()->create([ + // 'line_items' => $line_items, + // 'company_id' => $this->company->id, + // 'user_id' => $this->user->id, + // 'client_id' => $this->client->id, + // 'tax_rate1' => 0, + // 'tax_name1' => '', + // 'tax_rate2' => 0, + // 'tax_name2' => '', + // 'tax_rate3' => 0, + // 'tax_name3' => '', + // 'discount' => 0, + // 'subscription_id' => $subscription->id, + // 'date' => '2021-01-01', + // 'discount' => 20, + // 'is_amount_discount' => true, + // 'status_id' => 1, + // ]); + + // $invoice = $invoice->calc()->getInvoice(); + // $this->assertEquals(80, $invoice->amount); + // $this->assertEquals(0, $invoice->balance); + + // $invoice->service()->markSent()->save(); + + // $this->assertEquals(80, $invoice->amount); + // $this->assertEquals(80, $invoice->balance); + + + // $ratio = $subscription->service()->calculateDiscountRatio($invoice); + + // $this->assertEquals(.2, $ratio); + // } +}