mirror of
				https://github.com/invoiceninja/invoiceninja.git
				synced 2025-10-31 01:57:30 -04:00 
			
		
		
		
	sub v2
This commit is contained in:
		
							parent
							
								
									5e7a184118
								
							
						
					
					
						commit
						f1b81e1587
					
				
							
								
								
									
										128
									
								
								app/Services/Subscription/ChangePlanInvoice.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										128
									
								
								app/Services/Subscription/ChangePlanInvoice.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,128 @@ | ||||
| <?php | ||||
| /** | ||||
|  * Invoice Ninja (https://invoiceninja.com). | ||||
|  * | ||||
|  * @link https://github.com/invoiceninja/invoiceninja source repository | ||||
|  * | ||||
|  * @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com) | ||||
|  * | ||||
|  * @license https://www.elastic.co/licensing/elastic-license | ||||
|  */ | ||||
| 
 | ||||
| namespace App\Services\Subscription; | ||||
| 
 | ||||
| use App\Models\Credit; | ||||
| use App\Models\Invoice; | ||||
| use App\Models\Subscription; | ||||
| use App\Factory\CreditFactory; | ||||
| use App\DataMapper\InvoiceItem; | ||||
| use App\Factory\InvoiceFactory; | ||||
| use App\Models\RecurringInvoice; | ||||
| use App\Services\AbstractService; | ||||
| use App\Repositories\CreditRepository; | ||||
| use App\Repositories\InvoiceRepository; | ||||
| use App\Repositories\SubscriptionRepository; | ||||
| 
 | ||||
| class ChangePlanInvoice extends AbstractService | ||||
| {     | ||||
|     protected \App\Services\Subscription\SubscriptionStatus $status; | ||||
| 
 | ||||
|     public function __construct(protected RecurringInvoice $recurring_invoice, public Subscription $target, public string $hash) | ||||
|     { | ||||
|     } | ||||
| 
 | ||||
|     public function run(): Invoice | Credit | ||||
|     { | ||||
| 
 | ||||
|         $this->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; | ||||
| 
 | ||||
|     } | ||||
| } | ||||
							
								
								
									
										70
									
								
								app/Services/Subscription/InvoiceToRecurring.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								app/Services/Subscription/InvoiceToRecurring.php
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,70 @@ | ||||
| <?php | ||||
| /** | ||||
|  * Invoice Ninja (https://invoiceninja.com). | ||||
|  * | ||||
|  * @link https://github.com/invoiceninja/invoiceninja source repository | ||||
|  * | ||||
|  * @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com) | ||||
|  * | ||||
|  * @license https://www.elastic.co/licensing/elastic-license | ||||
|  */ | ||||
| 
 | ||||
| namespace App\Services\Subscription; | ||||
| 
 | ||||
| use App\Models\Client; | ||||
| use App\Libraries\MultiDB; | ||||
| use App\Models\Subscription; | ||||
| use App\Models\RecurringInvoice; | ||||
| use App\Services\AbstractService; | ||||
| use App\Factory\RecurringInvoiceFactory; | ||||
| use App\Repositories\SubscriptionRepository; | ||||
| 
 | ||||
| class InvoiceToRecurring extends AbstractService | ||||
| {     | ||||
| 
 | ||||
|     protected \App\Services\Subscription\SubscriptionStatus $status; | ||||
| 
 | ||||
|     public function __construct(protected int $client_id, public Subscription $subscription, public array $bundle = []) | ||||
|     { | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     public function run(): RecurringInvoice | ||||
|     { | ||||
| 
 | ||||
|         MultiDB::setDb($this->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; | ||||
|     } | ||||
| } | ||||
| @ -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]); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @ -1,126 +0,0 @@ | ||||
| <?php | ||||
| /** | ||||
|  * Invoice Ninja (https://invoiceninja.com). | ||||
|  * | ||||
|  * @link https://github.com/invoiceninja/invoiceninja source repository | ||||
|  * | ||||
|  * @copyright Copyright (c) 2023. Invoice Ninja LLC (https://invoiceninja.com) | ||||
|  * | ||||
|  * @license https://www.elastic.co/licensing/elastic-license | ||||
|  */ | ||||
| 
 | ||||
| namespace App\Services\Subscription; | ||||
| 
 | ||||
| use App\Models\Invoice; | ||||
| use App\Models\Subscription; | ||||
| use Illuminate\Support\Carbon; | ||||
| use App\Models\RecurringInvoice; | ||||
| use App\Services\AbstractService; | ||||
| 
 | ||||
| class ProRata extends AbstractService | ||||
| {     | ||||
|     /** @var bool $is_trial */ | ||||
|     private bool $is_trial = false; | ||||
|          | ||||
|     /** @var \Illuminate\Database\Eloquent\Collection<Invoice> | 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; | ||||
|     } | ||||
|      | ||||
|          | ||||
| 
 | ||||
| } | ||||
| @ -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(); | ||||
|  | ||||
| @ -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); | ||||
|         } | ||||
| 
 | ||||
|  | ||||
| @ -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 | ||||
|  | ||||
| @ -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()
 | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user