mirror of
				https://github.com/invoiceninja/invoiceninja.git
				synced 2025-10-31 15:37:30 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			470 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
			
		
		
	
	
			470 lines
		
	
	
		
			17 KiB
		
	
	
	
		
			PHP
		
	
	
	
	
	
| <?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\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)
 | |
|     {
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * CompletePurchase
 | |
|      *
 | |
|      * Perform the initial purchase of a one time
 | |
|      * or recurring product
 | |
|      *
 | |
|      * @param  PaymentHash $payment_hash
 | |
|      * @return  \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse|null
 | |
|      */
 | |
|     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;
 | |
| 
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * 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
 | |
|      */
 | |
|     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]);
 | |
|         }
 | |
|     }
 | |
| }
 |