From f1b81e158717c682c5065bbef60da9e2db48f6fc Mon Sep 17 00:00:00 2001 From: David Bomba Date: Thu, 8 Feb 2024 18:42:38 +1100 Subject: [PATCH] sub v2 --- .../Subscription/ChangePlanInvoice.php | 128 ++++++ .../Subscription/InvoiceToRecurring.php | 70 +++ .../Subscription/PaymentLinkService.php | 401 +++++++++++++++++- app/Services/Subscription/ProRata.php | 126 ------ .../Subscription/SubscriptionService.php | 6 +- .../Subscription/SubscriptionStatus.php | 70 ++- app/Services/Subscription/UpgradePrice.php | 28 +- tests/Feature/PaymentLink/PaymentLinkTest.php | 27 +- 8 files changed, 672 insertions(+), 184 deletions(-) create mode 100644 app/Services/Subscription/ChangePlanInvoice.php create mode 100644 app/Services/Subscription/InvoiceToRecurring.php delete mode 100644 app/Services/Subscription/ProRata.php 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 index baff45ea74dd..da693a87f7c6 100644 --- a/app/Services/Subscription/PaymentLinkService.php +++ b/app/Services/Subscription/PaymentLinkService.php @@ -11,14 +11,27 @@ namespace App\Services\Subscription; +use App\Models\Client; +use App\Models\Credit; +use App\Models\Invoice; +use App\Models\SystemLog; use App\Models\PaymentHash; use App\Models\Subscription; use App\Models\ClientContact; +use GuzzleHttp\RequestOptions; +use App\Jobs\Util\SystemLogger; +use App\Utils\Traits\MakesHash; use App\Models\RecurringInvoice; +use GuzzleHttp\Exception\ClientException; +use App\Services\Subscription\UpgradePrice; +use App\Services\Subscription\ZeroCostProduct; +use App\Repositories\RecurringInvoiceRepository; +use App\Services\Subscription\ChangePlanInvoice; class PaymentLinkService { - + use MakesHash; + public const WHITE_LABEL = 4316; public function __construct(public Subscription $subscription) @@ -32,10 +45,84 @@ class PaymentLinkService * or recurring product * * @param PaymentHash $payment_hash - * @return Illuminate\Routing\Redirector + * @return \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse|null */ - public function completePurchase(PaymentHash $payment_hash): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse + public function completePurchase(PaymentHash $payment_hash): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse|null { + + if (!property_exists($payment_hash->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; } @@ -47,7 +134,20 @@ class PaymentLinkService */ 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 @@ -58,11 +158,68 @@ class PaymentLinkService * startTrial * * @param array $data{contact_id: int, client_id: int, bundle: \Illuminate\Support\Collection, coupon?: string, } - * @return Illuminate\Routing\Redirector + * @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); + } /** @@ -76,6 +233,238 @@ class PaymentLinkService */ public function calculateUpgradePriceV2(RecurringInvoice $recurring_invoice, Subscription $target): ?float { - return (new UpgradePrice($recurring_invoice, $target))->run(); + 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 6a822c4d11ed..000000000000 --- a/app/Services/Subscription/ProRata.php +++ /dev/null @@ -1,126 +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; - } - - 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 - { - - $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; - } - - - 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; - } - - - -} \ No newline at end of file diff --git a/app/Services/Subscription/SubscriptionService.php b/app/Services/Subscription/SubscriptionService.php index 0daf9adac60f..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(); diff --git a/app/Services/Subscription/SubscriptionStatus.php b/app/Services/Subscription/SubscriptionStatus.php index 3d842675ab31..90633404a51f 100644 --- a/app/Services/Subscription/SubscriptionStatus.php +++ b/app/Services/Subscription/SubscriptionStatus.php @@ -29,7 +29,10 @@ class SubscriptionStatus extends AbstractService /** @var bool $is_in_good_standing */ public bool $is_in_good_standing = false; - + + /** @var Invoice $refundable_invoice */ + public Invoice $refundable_invoice; + public function run(): self { $this->checkTrial() @@ -38,15 +41,48 @@ class SubscriptionStatus extends AbstractService return $this; } - - public function getProRataRatio():float + + /** + * GetProRataRefund + * + * @return float + */ + public function getProRataRefund(): float { - //calculate how much used. $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() + $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) @@ -64,13 +100,15 @@ class SubscriptionStatus extends AbstractService $days_of_subscription_used = $subscription_start_date->copy()->diffInDays(now()); - return $days_of_subscription_used / $this->recurring_invoice->subscription->service()->getDaysInFrequency(); + return 1 - ($days_of_subscription_used / $this->recurring_invoice->subscription->service()->getDaysInFrequency()); } /** - * checkInGoodStanding - * + * CheckInGoodStanding + * + * Are there any outstanding invoices? + * * @return self */ private function checkInGoodStanding(): self @@ -91,8 +129,12 @@ class SubscriptionStatus extends AbstractService } /** - * checkTrial + * 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 @@ -101,14 +143,16 @@ class SubscriptionStatus extends AbstractService if(!$this->subscription->trial_enabled) return $this->setIsTrial(false); - $primary_invoice = $this->recurring_invoice - ->invoices() + $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') - ->first(); + ->doesntExist(); - if($primary_invoice && Carbon::parse($primary_invoice->date)->addSeconds($this->subscription->trial_duration)->lte(now()->startOfDay()->addSeconds($primary_invoice->client->timezone_offset()))) { + 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); } diff --git a/app/Services/Subscription/UpgradePrice.php b/app/Services/Subscription/UpgradePrice.php index cca93953e3f8..c4cc6e3e81e2 100644 --- a/app/Services/Subscription/UpgradePrice.php +++ b/app/Services/Subscription/UpgradePrice.php @@ -21,26 +21,33 @@ class UpgradePrice extends AbstractService { protected \App\Services\Subscription\SubscriptionStatus $status; + public float $upgrade_price = 0; + + public float $refund = 0; + + public float $outstanding_credit = 0; + public function __construct(protected RecurringInvoice $recurring_invoice, public Subscription $subscription) { } - public function run(): float + public function run(): self { $this->status = $this->recurring_invoice ->subscription ->status($this->recurring_invoice); - if($this->status->is_trial || !$this->status->is_in_good_standing) - return $this->subscription->price; - if($this->status->is_in_good_standing) - return $this->calculateUpgrade(); + $this->calculateUpgrade(); + else + $this->upgrade_price = $this->subscription->price; + + return $this; } - private function calculateUpgrade(): float + private function calculateUpgrade(): self { $ratio = $this->status->getProRataRatio(); @@ -51,13 +58,14 @@ class UpgradePrice extends AbstractService ->orderBy('id', 'desc') ->first(); - $refund = $this->getRefundableAmount($last_invoice, $ratio); - $outstanding_credit = $this->getCredits(); + $this->refund = $this->getRefundableAmount($last_invoice, $ratio); + $this->outstanding_credit = $this->getCredits(); - nlog("{$this->subscription->price} - {$refund} - {$outstanding_credit}"); + nlog("{$this->subscription->price} - {$this->refund} - {$this->outstanding_credit}"); - return $this->subscription->price - $refund - $outstanding_credit; + $this->upgrade_price = $this->subscription->price - $this->refund - $this->outstanding_credit; + return $this; } private function getRefundableAmount(?Invoice $invoice, float $ratio): float diff --git a/tests/Feature/PaymentLink/PaymentLinkTest.php b/tests/Feature/PaymentLink/PaymentLinkTest.php index 185f45099b10..9695b0d57e9f 100644 --- a/tests/Feature/PaymentLink/PaymentLinkTest.php +++ b/tests/Feature/PaymentLink/PaymentLinkTest.php @@ -104,7 +104,7 @@ class PaymentLinkTest extends TestCase $days = $recurring_invoice->subscription->service()->getDaysInFrequency(); - $ratio = (14 / $days); + $ratio = 1 - (14 / $days); $this->assertEquals($ratio, $status->getProRataRatio()); @@ -113,32 +113,7 @@ class PaymentLinkTest extends TestCase $refund = round($invoice->paid_to_date*$ratio,2); $this->assertEquals(($target->price - $refund), $price); - - - // $this->assertEquals($target->price-$refund, $upgrade_price); - // $sub_calculator = new SubscriptionCalculator($target->fresh(), $invoice->fresh()); - - // $this->assertFalse($sub_calculator->isPaidUp()); - - // $invoice = $invoice->service()->markPaid()->save(); - - // $this->assertTrue($sub_calculator->isPaidUp()); - - // $this->assertEquals(10, $invoice->amount); - // $this->assertEquals(0, $invoice->balance); - - // $pro_rata = new ProRata(); - - // $refund = $pro_rata->refund($invoice->amount, Carbon::parse('2021-01-01'), Carbon::parse('2021-01-06'), $subscription->frequency_id); - - // // $this->assertEquals(1.61, $refund); - - // $pro_rata = new ProRata(); - - // $upgrade = $pro_rata->charge($target->price, Carbon::parse('2021-01-01'), Carbon::parse('2021-01-06'), $subscription->frequency_id); - - // $this->assertEquals(3.23, $upgrade); } // public function testProrataDiscountRatioPercentage()