1
0
mirror of https://github.com/invoiceninja/invoiceninja.git synced 2024-11-10 05:02:36 +01:00

Refactor for subscriptions and changing between subscriptions

This commit is contained in:
David Bomba 2022-12-22 15:58:18 +11:00
parent 6d235bcf86
commit 28cbe52d9c
14 changed files with 229 additions and 18 deletions

View File

@ -307,7 +307,7 @@ class CreateSingleAccount extends Command
$webhook_config = [ $webhook_config = [
'post_purchase_url' => 'http://ninja.test:8000/api/admin/plan', 'post_purchase_url' => 'http://ninja.test:8000/api/admin/plan',
'post_purchase_rest_method' => 'POST', 'post_purchase_rest_method' => 'POST',
'post_purchase_headers' => [], 'post_purchase_headers' => [config('ninja.ninja_hosted_header') => config('ninja.ninja_hosted_secret')],
]; ];
$sub = SubscriptionFactory::create($company->id, $user->id); $sub = SubscriptionFactory::create($company->id, $user->id);

View File

@ -52,7 +52,7 @@ class InvoiceController extends Controller
* *
* @return Factory|View * @return Factory|View
*/ */
public function show(ShowInvoiceRequest $request, Invoice $invoice) public function show(ShowInvoiceRequest $request, Invoice $invoice, ?string $hash)
{ {
set_time_limit(0); set_time_limit(0);
@ -69,6 +69,7 @@ class InvoiceController extends Controller
'invoice' => $invoice, 'invoice' => $invoice,
'invitation' => $invitation ?: $invoice->invitations->first(), 'invitation' => $invitation ?: $invoice->invitations->first(),
'key' => $invitation ? $invitation->key : false, 'key' => $invitation ? $invitation->key : false,
'hash' => $hash,
]; ];
if ($request->query('mode') === 'fullscreen') { if ($request->query('mode') === 'fullscreen') {

View File

@ -148,8 +148,17 @@ class PaymentController extends Controller
$payment = $payment->service()->applyCredits($payment_hash)->save(); $payment = $payment->service()->applyCredits($payment_hash)->save();
$invoices = Invoice::whereIn('id', $this->transformKeys(array_column($payment_hash->invoices(), 'invoice_id')));
event('eloquent.created: App\Models\Payment', $payment); event('eloquent.created: App\Models\Payment', $payment);
if($invoices->sum('balance') > 0){
$invoice = $invoices->first();
return redirect()->route('client.invoice.show', ['invoice' => $invoice->hashed_id, 'hash' => $request->input('hash')]);
}
if (property_exists($payment_hash->data, 'billing_context')) { if (property_exists($payment_hash->data, 'billing_context')) {
$billing_subscription = \App\Models\Subscription::find($payment_hash->data->billing_context->subscription_id); $billing_subscription = \App\Models\Subscription::find($payment_hash->data->billing_context->subscription_id);

View File

@ -33,7 +33,9 @@ class SubscriptionPlanSwitchController extends Controller
{ {
$amount = $recurring_invoice->subscription $amount = $recurring_invoice->subscription
->service() ->service()
->calculateUpgradePrice($recurring_invoice, $target); ->calculateUpgradePriceV2($recurring_invoice, $target);
nlog("upgrade amoutn = {$amount}");
/** /**
* Null value here is a proxy for * Null value here is a proxy for
* denying the user a change plan option * denying the user a change plan option

View File

@ -330,6 +330,8 @@ class BillingPortalPurchase extends Component
else else
$this->steps['fetched_payment_methods'] = true; $this->steps['fetched_payment_methods'] = true;
nlog("payment methods price = {$this->price}");
$this->methods = $contact->client->service()->getPaymentMethods($this->price); $this->methods = $contact->client->service()->getPaymentMethods($this->price);
$this->heading_text = ctrans('texts.payment_methods'); $this->heading_text = ctrans('texts.payment_methods');

View File

@ -142,7 +142,7 @@ class SubscriptionPlanSwitch extends Component
{ {
$this->hide_button = true; $this->hide_button = true;
$response = $this->target->service()->createChangePlanCredit([ $response = $this->target->service()->createChangePlanCreditV2([
'recurring_invoice' => $this->recurring_invoice, 'recurring_invoice' => $this->recurring_invoice,
'subscription' => $this->subscription, 'subscription' => $this->subscription,
'target' => $this->target, 'target' => $this->target,

View File

@ -64,7 +64,7 @@ class AutoBillingFailureObject
/* Set customized translations _NOW_ */ /* Set customized translations _NOW_ */
$t->replace(Ninja::transformTranslations($this->company->settings)); $t->replace(Ninja::transformTranslations($this->company->settings));
$this->$invoices = Invoice::whereIn('id', $this->transformKeys(array_column($this->payment_hash->invoices(), 'invoice_id')))->get(); $this->invoices = Invoice::whereIn('id', $this->transformKeys(array_column($this->payment_hash->invoices(), 'invoice_id')))->get();
$mail_obj = new stdClass; $mail_obj = new stdClass;
$mail_obj->amount = $this->getAmount(); $mail_obj->amount = $this->getAmount();

View File

@ -48,7 +48,7 @@ class InstantPayment
public function run() public function run()
{ {
nlog($this->request->all());
$is_credit_payment = false; $is_credit_payment = false;
$tokens = []; $tokens = [];
@ -221,6 +221,9 @@ class InstantPayment
if ($this->request->query('hash')) { if ($this->request->query('hash')) {
$hash_data['billing_context'] = Cache::get($this->request->query('hash')); $hash_data['billing_context'] = Cache::get($this->request->query('hash'));
} }
elseif($this->request->hash){
$hash_data['billing_context'] = Cache::get($this->request->hash);
}
$payment_hash = new PaymentHash; $payment_hash = new PaymentHash;
$payment_hash->hash = Str::random(32); $payment_hash->hash = Str::random(32);

View File

@ -140,6 +140,39 @@ class PaymentService
return $this; return $this;
} }
public function applyCreditsToInvoice($invoice)
{
$amount = $invoice->amount;
$credits = $invoice->client
->service()
->getCredits();
foreach ($credits as $credit) {
//starting invoice balance
$invoice_balance = $invoice->balance;
//credit payment applied
$credit->service()->applyPayment($invoice, $amount, $this->payment);
//amount paid from invoice calculated
$remaining_balance = ($invoice_balance - $invoice->fresh()->balance);
//reduce the amount to be paid on the invoice from the NEXT credit
$amount -= $remaining_balance;
//break if the invoice is no longer PAYABLE OR there is no more amount to be applied
if (! $invoice->isPayable() || (int) $amount == 0) {
break;
}
}
return $this;
}
public function save() public function save()
{ {
$this->payment->saveQuietly(); $this->payment->saveQuietly();

View File

@ -15,6 +15,7 @@ use App\DataMapper\InvoiceItem;
use App\Factory\CreditFactory; use App\Factory\CreditFactory;
use App\Factory\InvoiceFactory; use App\Factory\InvoiceFactory;
use App\Factory\InvoiceToRecurringInvoiceFactory; use App\Factory\InvoiceToRecurringInvoiceFactory;
use App\Factory\PaymentFactory;
use App\Factory\RecurringInvoiceFactory; use App\Factory\RecurringInvoiceFactory;
use App\Jobs\Mail\NinjaMailer; use App\Jobs\Mail\NinjaMailer;
use App\Jobs\Mail\NinjaMailerJob; use App\Jobs\Mail\NinjaMailerJob;
@ -28,6 +29,7 @@ use App\Models\ClientContact;
use App\Models\Credit; use App\Models\Credit;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\PaymentHash; use App\Models\PaymentHash;
use App\Models\PaymentType;
use App\Models\Product; use App\Models\Product;
use App\Models\RecurringInvoice; use App\Models\RecurringInvoice;
use App\Models\Subscription; use App\Models\Subscription;
@ -89,11 +91,17 @@ class SubscriptionService
$recurring_invoice = $recurring_invoice_repo->save([], $recurring_invoice); $recurring_invoice = $recurring_invoice_repo->save([], $recurring_invoice);
$recurring_invoice->auto_bill = $this->subscription->auto_bill; $recurring_invoice->auto_bill = $this->subscription->auto_bill;
/* Start the recurring service */ /* Start the recurring service */
$recurring_invoice->service() $recurring_invoice->service()
->start() ->start()
->save(); ->save();
//update the invoice and attach to the recurring invoice!!!!!
$invoice = Invoice::find($payment_hash->fee_invoice_id);
$invoice->recurring_id = $recurring_invoice->id;
$invoice->save();
//execute any webhooks //execute any webhooks
$context = [ $context = [
'context' => 'recurring_purchase', 'context' => 'recurring_purchase',
@ -217,23 +225,69 @@ class SubscriptionService
* *
* @return float * @return float
*/ */
public function calculateUpgradePriceV2(RecurringInvoice $recurring_invoice, Subscription $target) :?float
{
$outstanding_credit = 0;
$use_credit_setting = $recurring_invoice->client->getSetting('use_credits_payment');
$last_invoice = Invoice::query()
->where('recurring_id', $recurring_invoice->id)
->where('is_deleted', 0)
->where('status_id', Invoice::STATUS_PAID)
->first();
$refund = $this->calculateProRataRefundForSubscription($last_invoice);
if($use_credit_setting != 'off')
{
$outstanding_credit = Credit::query()
->where('client_id', $recurring_invoice->client_id)
->whereIn('status_id', [Credit::STATUS_SENT,Credit::STATUS_PARTIAL])
->where('is_deleted', 0)
->where('balance', '>', 0)
->sum('balance');
}
nlog("{$target->price} - {$refund} - {$outstanding_credit}");
return $target->price - $refund - $outstanding_credit;
}
/**
* Returns an upgrade price when moving between plans
*
* However we only allow people to move between plans
* if their account is in good standing.
*
* @param RecurringInvoice $recurring_invoice
* @param Subscription $target
* @deprecated in favour of calculateUpgradePriceV2
* @return float
*/
public function calculateUpgradePrice(RecurringInvoice $recurring_invoice, Subscription $target) :?float public function calculateUpgradePrice(RecurringInvoice $recurring_invoice, Subscription $target) :?float
{ {
//calculate based on daily prices
//calculate based on daily prices
$current_amount = $recurring_invoice->amount; $current_amount = $recurring_invoice->amount;
$currency_frequency = $recurring_invoice->frequency_id; $currency_frequency = $recurring_invoice->frequency_id;
$outstanding = $recurring_invoice->invoices() $outstanding = Invoice::query()
->where('is_deleted', 0) ->where('recurring_id', $recurring_invoice->id)
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL]) ->where('is_deleted', 0)
->where('balance', '>', 0); ->where('is_proforma',0)
->whereIn('status_id', [Invoice::STATUS_SENT, Invoice::STATUS_PARTIAL])
->where('balance', '>', 0);
$outstanding_amounts = $outstanding->sum('balance'); $outstanding_amounts = $outstanding->sum('balance');
$outstanding_invoice = Invoice::where('subscription_id', $this->subscription->id) $outstanding_invoice = Invoice::where('client_id', $recurring_invoice->client_id)
->where('client_id', $recurring_invoice->client_id)
->where('is_deleted', 0) ->where('is_deleted', 0)
->where('is_proforma',0)
->where('subscription_id', $this->subscription->id)
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->first(); ->first();
@ -242,6 +296,7 @@ class SubscriptionService
$outstanding_invoice = Credit::where('subscription_id', $this->subscription->id) $outstanding_invoice = Credit::where('subscription_id', $this->subscription->id)
->where('client_id', $recurring_invoice->client_id) ->where('client_id', $recurring_invoice->client_id)
->where('is_proforma',0)
->where('is_deleted', 0) ->where('is_deleted', 0)
->orderBy('id', 'desc') ->orderBy('id', 'desc')
->first(); ->first();
@ -289,7 +344,6 @@ class SubscriptionService
$days_in_frequency = $this->getDaysInFrequency(); $days_in_frequency = $this->getDaysInFrequency();
//18-12-2022 - change $this->subscription->price => $invoice->amount if there was a discount on the invoice, we should not use the subscription price.
$pro_rata_refund = round((($days_in_frequency - $days_of_subscription_used)/$days_in_frequency) * $invoice->amount ,2); $pro_rata_refund = round((($days_in_frequency - $days_of_subscription_used)/$days_in_frequency) * $invoice->amount ,2);
return $pro_rata_refund; return $pro_rata_refund;
@ -398,10 +452,81 @@ class SubscriptionService
return $pro_rata_charge; return $pro_rata_charge;
} }
/**
* This entry point assumes the user does not have to make a
* payment for the service.
*
* In this case, we generate a credit note for the old service
* Generate a new invoice for the new service
* Apply credits to the invoice
*
* @param array $data
*/
public function createChangePlanCreditV2($data)
{
/* Init vars */
$recurring_invoice = $data['recurring_invoice'];
$old_subscription = $data['subscription'];
$target_subscription = $data['target'];
$pro_rata_charge_amount = 0;
$pro_rata_refund_amount = 0;
$is_credit = false;
/* Get last invoice */
$last_invoice = Invoice::where('subscription_id', $recurring_invoice->subscription_id)
->where('client_id', $recurring_invoice->client_id)
->where('is_proforma',0)
->where('is_deleted', 0)
->where('status_id', Invoice::STATUS_PAID)
->withTrashed()
->orderBy('id', 'desc')
->first();
// $pro_rata_refund_amount = $this->calculateProRataRefund($last_invoice, $old_subscription);
$credit = $this->createCredit($last_invoice, $target_subscription, false);
$new_recurring_invoice = $this->createNewRecurringInvoice($recurring_invoice);
$invoice = $this->changePlanInvoice($target_subscription, $recurring_invoice->client_id);
$invoice->recurring_id = $new_recurring_invoice->id;
$invoice->save();
$payment = PaymentFactory::create($invoice->company_id, $invoice->user_id, $invoice->client_id);
$payment->type_id = PaymentType::CREDIT;
$payment->client_id = $invoice->client_id;
$payment->is_manual = true;
$payment->save();
$payment->service()->applyCreditsToInvoice($invoice);
$context = [
'context' => 'change_plan',
'recurring_invoice' => $new_recurring_invoice->hashed_id,
'credit' => $credit ? $credit->hashed_id : null,
'client' => $new_recurring_invoice->client->hashed_id,
'subscription' => $target_subscription->hashed_id,
'contact' => auth()->guard('contact')->user()->hashed_id,
'account_key' => $new_recurring_invoice->client->custom_value2,
];
$response = $this->triggerWebhook($context);
if($credit){
return '/client/invoices/'.$invoice->hashed_id;
}
else{
return '/client/invoices';
}
}
/** /**
* When downgrading, we may need to create * When downgrading, we may need to create
* a credit * a credit
* *
* @deprecated in favour of createChangePlanCreditV2
* @param array $data * @param array $data
*/ */
public function createChangePlanCredit($data) public function createChangePlanCredit($data)
@ -658,9 +783,10 @@ class SubscriptionService
$credit->discount = $last_invoice->discount; $credit->discount = $last_invoice->discount;
$credit->is_amount_discount = $last_invoice->is_amount_discount; $credit->is_amount_discount = $last_invoice->is_amount_discount;
$line_items = $subscription_repo->generateLineItems($target, false, true); // $line_items = $subscription_repo->generateLineItems($target, false, true);
$credit->line_items = array_merge($line_items, $this->calculateProRataRefundItems($last_invoice, $last_invoice_is_credit)); // $credit->line_items = array_merge($line_items, $this->calculateProRataRefundItems($last_invoice, $last_invoice_is_credit));
$credit->line_items = $this->calculateProRataRefundItems($last_invoice, true);
$data = [ $data = [
'client_id' => $last_invoice->client_id, 'client_id' => $last_invoice->client_id,
@ -705,6 +831,39 @@ class SubscriptionService
} }
/**
* When changing plans we need to generate a pro rata
* invoice which takes into account any credits.
*
* @param Subscription $target
* @return Invoice
*/
private function changePlanInvoice($target, $client_id)
{
$subscription_repo = new SubscriptionRepository();
$invoice_repo = new InvoiceRepository();
$invoice = InvoiceFactory::create($this->subscription->company_id, $this->subscription->user_id);
$invoice->date = now()->format('Y-m-d');
$invoice->subscription_id = $target->id;
$invoice->line_items = $subscription_repo->generateLineItems($target);
$data = [
'client_id' => $client_id,
'quantity' => 1,
'date' => now()->format('Y-m-d'),
];
return $invoice_repo->save($data, $invoice)
->service()
->markSent()
->fillDefaults()
->save();
}
public function createInvoiceV2($bundle, $client_id, $valid_coupon = false) public function createInvoiceV2($bundle, $client_id, $valid_coupon = false)
{ {

View File

@ -191,6 +191,7 @@ return [
'ninja_default_company_id' => env('NINJA_COMPANY_ID', null), 'ninja_default_company_id' => env('NINJA_COMPANY_ID', null),
'ninja_default_company_gateway_id' => env('NINJA_COMPANY_GATEWAY_ID', null), 'ninja_default_company_gateway_id' => env('NINJA_COMPANY_GATEWAY_ID', null),
'ninja_hosted_secret' => env('NINJA_HOSTED_SECRET', null), 'ninja_hosted_secret' => env('NINJA_HOSTED_SECRET', null),
'ninja_hosted_header' =>env('NINJA_HEADER',''),
'internal_queue_enabled' => env('INTERNAL_QUEUE_ENABLED', true), 'internal_queue_enabled' => env('INTERNAL_QUEUE_ENABLED', true),
'ninja_apple_api_key' => env('APPLE_API_KEY', false), 'ninja_apple_api_key' => env('APPLE_API_KEY', false),
'ninja_apple_private_key' => env('APPLE_PRIVATE_KEY', false), 'ninja_apple_private_key' => env('APPLE_PRIVATE_KEY', false),

View File

@ -5,6 +5,7 @@
<form action="{{route('client.payments.credit_response')}}" method="post" id="credit-payment"> <form action="{{route('client.payments.credit_response')}}" method="post" id="credit-payment">
@csrf @csrf
<input type="hidden" name="payment_hash" value="{{$payment_hash}}"> <input type="hidden" name="payment_hash" value="{{$payment_hash}}">
<input type="hidden" name="hash" value="{{ request()->query('hash')}}">
</form> </form>
<div class="container mx-auto"> <div class="container mx-auto">

View File

@ -31,7 +31,7 @@
<input type="hidden" name="company_gateway_id" id="company_gateway_id"> <input type="hidden" name="company_gateway_id" id="company_gateway_id">
<input type="hidden" name="payment_method_id" id="payment_method_id"> <input type="hidden" name="payment_method_id" id="payment_method_id">
<input type="hidden" name="signature"> <input type="hidden" name="signature">
<input type="hidden" name="hash" value="{{ $hash }}">
<input type="hidden" name="payable_invoices[0][amount]" value="{{ $invoice->partial > 0 ? \App\Utils\Number::formatValue($invoice->partial, $invoice->client->currency()) : \App\Utils\Number::formatValue($invoice->balance, $invoice->client->currency()) }}"> <input type="hidden" name="payable_invoices[0][amount]" value="{{ $invoice->partial > 0 ? \App\Utils\Number::formatValue($invoice->partial, $invoice->client->currency()) : \App\Utils\Number::formatValue($invoice->balance, $invoice->client->currency()) }}">
<input type="hidden" name="payable_invoices[0][invoice_id]" value="{{ $invoice->hashed_id }}"> <input type="hidden" name="payable_invoices[0][invoice_id]" value="{{ $invoice->hashed_id }}">

View File

@ -54,7 +54,7 @@ Route::group(['middleware' => ['auth:contact', 'locale', 'domain_db','check_clie
Route::post('invoices/payment', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'bulk'])->name('invoices.bulk'); Route::post('invoices/payment', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'bulk'])->name('invoices.bulk');
Route::get('invoices/payment', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'catch_bulk'])->name('invoices.catch_bulk'); Route::get('invoices/payment', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'catch_bulk'])->name('invoices.catch_bulk');
Route::post('invoices/download', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'download'])->name('invoices.download'); Route::post('invoices/download', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'download'])->name('invoices.download');
Route::get('invoices/{invoice}', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'show'])->name('invoice.show'); Route::get('invoices/{invoice}/{hash?}', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'show'])->name('invoice.show');
Route::get('invoices/{invoice_invitation}', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'show'])->name('invoice.show_invitation'); Route::get('invoices/{invoice_invitation}', [App\Http\Controllers\ClientPortal\InvoiceController::class, 'show'])->name('invoice.show_invitation');
Route::get('recurring_invoices', [App\Http\Controllers\ClientPortal\RecurringInvoiceController::class, 'index'])->name('recurring_invoices.index')->middleware('portal_enabled'); Route::get('recurring_invoices', [App\Http\Controllers\ClientPortal\RecurringInvoiceController::class, 'index'])->name('recurring_invoices.index')->middleware('portal_enabled');