diff --git a/app/Filters/ClientFilters.php b/app/Filters/ClientFilters.php index cd19453991f1..65923049a754 100644 --- a/app/Filters/ClientFilters.php +++ b/app/Filters/ClientFilters.php @@ -193,9 +193,7 @@ class ClientFilters extends QueryFilters ->where('clients.company_id', '=', $company_id) ->where('client_contacts.is_primary', '=', true) ->where('client_contacts.deleted_at', '=', null) - //->whereRaw('(clients.name != "" or contacts.first_name != "" or contacts.last_name != "" or contacts.email != "")') // filter out buy now invoices ->select( - // DB::raw('COALESCE(clients.currency_id, companies.currency_id) currency_id'), DB::raw('COALESCE(clients.country_id, companies.country_id) country_id'), DB::raw("CONCAT(COALESCE(client_contacts.first_name, ''), ' ', COALESCE(client_contacts.last_name, '')) contact"), 'clients.id', diff --git a/app/Filters/DesignFilters.php b/app/Filters/DesignFilters.php index 37577562cb2a..5db04472ff30 100644 --- a/app/Filters/DesignFilters.php +++ b/app/Filters/DesignFilters.php @@ -105,7 +105,6 @@ class DesignFilters extends QueryFilters $query = DB::table('designs') ->join('companies', 'companies.id', '=', 'designs.company_id') ->where('designs.company_id', '=', $company_id) - //->whereRaw('(designs.name != "" or contacts.first_name != "" or contacts.last_name != "" or contacts.email != "")') // filter out buy now invoices ->select( 'designs.id', 'designs.name', diff --git a/app/Filters/ExpenseFilters.php b/app/Filters/ExpenseFilters.php index 697f4b64f148..8b17219efff4 100644 --- a/app/Filters/ExpenseFilters.php +++ b/app/Filters/ExpenseFilters.php @@ -205,9 +205,7 @@ class ExpenseFilters extends QueryFilters $query = DB::table('expenses') ->join('companies', 'companies.id', '=', 'expenses.company_id') ->where('expenses.company_id', '=', $company_id) - //->whereRaw('(expenses.name != "" or contacts.first_name != "" or contacts.last_name != "" or contacts.email != "")') // filter out buy now invoices ->select( - // DB::raw('COALESCE(expenses.currency_id, companies.currency_id) currency_id'), DB::raw('COALESCE(expenses.country_id, companies.country_id) country_id'), DB::raw("CONCAT(COALESCE(expense_contacts.first_name, ''), ' ', COALESCE(expense_contacts.last_name, '')) contact"), 'expenses.id', diff --git a/app/Filters/TokenFilters.php b/app/Filters/TokenFilters.php index 2f471a1304a7..a771aaab7f59 100644 --- a/app/Filters/TokenFilters.php +++ b/app/Filters/TokenFilters.php @@ -104,7 +104,6 @@ class TokenFilters extends QueryFilters $query = DB::table('company_tokens') ->join('companies', 'companies.id', '=', 'company_tokens.company_id') ->where('company_tokens.company_id', '=', $company_id) - //->whereRaw('(designs.name != "" or contacts.first_name != "" or contacts.last_name != "" or contacts.email != "")') // filter out buy now invoices ->select( 'company_tokens.id', 'company_tokens.name', diff --git a/app/Filters/VendorFilters.php b/app/Filters/VendorFilters.php index dd2c7e885824..b330d53a334d 100644 --- a/app/Filters/VendorFilters.php +++ b/app/Filters/VendorFilters.php @@ -118,7 +118,6 @@ class VendorFilters extends QueryFilters ->where('vendors.company_id', '=', $company_id) ->where('vendor_contacts.is_primary', '=', true) ->where('vendor_contacts.deleted_at', '=', null) - //->whereRaw('(vendors.name != "" or contacts.first_name != "" or contacts.last_name != "" or contacts.email != "")') // filter out buy now invoices ->select( // DB::raw('COALESCE(vendors.currency_id, companies.currency_id) currency_id'), DB::raw('COALESCE(vendors.country_id, companies.country_id) country_id'), diff --git a/app/Filters/WebhookFilters.php b/app/Filters/WebhookFilters.php index 5dec35538f32..b42f49909890 100644 --- a/app/Filters/WebhookFilters.php +++ b/app/Filters/WebhookFilters.php @@ -105,7 +105,6 @@ class WebhookFilters extends QueryFilters $query = DB::table('webhooks') ->join('companies', 'companies.id', '=', 'webhooks.company_id') ->where('webhooks.company_id', '=', $company_id) - //->whereRaw('(designs.name != "" or contacts.first_name != "" or contacts.last_name != "" or contacts.email != "")') // filter out buy now invoices ->select( 'webhooks.id', 'webhooks.target_url', diff --git a/app/Http/Controllers/ClientPortal/SubscriptionController.php b/app/Http/Controllers/ClientPortal/SubscriptionController.php index 21fa9116c23e..dd7634cfc264 100644 --- a/app/Http/Controllers/ClientPortal/SubscriptionController.php +++ b/app/Http/Controllers/ClientPortal/SubscriptionController.php @@ -13,6 +13,7 @@ namespace App\Http\Controllers\ClientPortal; use App\Http\Controllers\Controller; +use App\Http\Requests\ClientPortal\RecurringInvoices\ShowRecurringInvoiceRequest; use App\Models\RecurringInvoice; use App\Utils\Ninja; use Illuminate\Http\Request; @@ -38,4 +39,20 @@ class SubscriptionController extends Controller return render('subscriptions.index'); } + + /** + * Display the recurring invoice. + * + * @param ShowRecurringInvoiceRequest $request + * @param RecurringInvoice $recurring_invoice + * @return Factory|View + */ + public function show(ShowRecurringInvoiceRequest $request, RecurringInvoice $recurring_invoice) + { + return $this->render('subscriptions.show', [ + 'invoice' => $recurring_invoice->load('invoices','subscription'), + 'subscription' => $recurring_invoice->subscription + ]); + } + } diff --git a/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php b/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php index aa829ef457e0..34ad3ae061c9 100644 --- a/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php +++ b/app/Http/Controllers/ClientPortal/SubscriptionPlanSwitchController.php @@ -31,6 +31,7 @@ class SubscriptionPlanSwitchController extends Controller */ public function index(ShowPlanSwitchRequest $request, RecurringInvoice $recurring_invoice, Subscription $target) { + $amount = $recurring_invoice->subscription ->service() ->calculateUpgradePriceV2($recurring_invoice, $target); @@ -44,7 +45,6 @@ class SubscriptionPlanSwitchController extends Controller render('subscriptions.denied'); } - $amount = max(0,$amount); return render('subscriptions.switch', [ @@ -53,5 +53,6 @@ class SubscriptionPlanSwitchController extends Controller 'target' => $target, 'amount' => $amount, ]); + } } diff --git a/app/Http/Controllers/PaymentWebhookController.php b/app/Http/Controllers/PaymentWebhookController.php index 9f03b3697e09..7cd9152e949c 100644 --- a/app/Http/Controllers/PaymentWebhookController.php +++ b/app/Http/Controllers/PaymentWebhookController.php @@ -18,6 +18,10 @@ class PaymentWebhookController extends Controller { public function __invoke(PaymentWebhookRequest $request) { + //return early if we cannot resolve the company gateway + if(!$request->getCompanyGateway()) + return response()->json([], 200); + return $request ->getCompanyGateway() ->driver() diff --git a/app/Http/Livewire/SubscriptionsTable.php b/app/Http/Livewire/SubscriptionsTable.php new file mode 100644 index 000000000000..3f5071ed1028 --- /dev/null +++ b/app/Http/Livewire/SubscriptionsTable.php @@ -0,0 +1,51 @@ +company->db); + } + + public function render() + { + $query = RecurringInvoice::query() + ->where('client_id', auth()->guard('contact')->user()->client->id) + ->where('company_id', $this->company->id) + ->whereNotNull('subscription_id') + ->where('is_deleted', false) + ->where('status_id', RecurringInvoice::STATUS_ACTIVE) + ->orderBy($this->sort_field, $this->sort_asc ? 'asc' : 'desc') + ->withTrashed() + ->paginate($this->per_page); + + return render('components.livewire.subscriptions-table', [ + 'recurring_invoices' => $query, + ]); + } +} diff --git a/app/Http/Requests/Payments/PaymentWebhookRequest.php b/app/Http/Requests/Payments/PaymentWebhookRequest.php index b3c528c776d9..b55c587ab54a 100644 --- a/app/Http/Requests/Payments/PaymentWebhookRequest.php +++ b/app/Http/Requests/Payments/PaymentWebhookRequest.php @@ -47,7 +47,8 @@ class PaymentWebhookRequest extends Request { MultiDB::findAndSetDbByCompanyKey($this->company_key); - return CompanyGateway::withTrashed()->findOrFail($this->decodePrimaryKey($this->company_gateway_id)); + return CompanyGateway::withTrashed()->find($this->decodePrimaryKey($this->company_gateway_id)); + } /** diff --git a/app/Mail/RecurringInvoice/ClientContactRequestCancellationObject.php b/app/Mail/RecurringInvoice/ClientContactRequestCancellationObject.php index a0c5bdca62ad..5201fc07f5a6 100644 --- a/app/Mail/RecurringInvoice/ClientContactRequestCancellationObject.php +++ b/app/Mail/RecurringInvoice/ClientContactRequestCancellationObject.php @@ -11,35 +11,33 @@ namespace App\Mail\RecurringInvoice; +use App\Models\ClientContact; +use App\Models\RecurringInvoice; use App\Utils\Ninja; use Illuminate\Support\Facades\App; class ClientContactRequestCancellationObject { - public $recurring_invoice; - public $client_contact; - - private $company; - - public function __construct($recurring_invoice, $client_contact) - { - $this->recurring_invoice = $recurring_invoice; - $this->client_contact = $client_contact; - $this->company = $recurring_invoice->company; - } + public function __construct(public RecurringInvoice $recurring_invoice, public ClientContact $client_contact, private bool $gateway_refund_attempted){} public function build() { + $this->company = $this->recurring_invoice->company; + App::forgetInstance('translator'); App::setLocale($this->company->getLocale()); $t = app('translator'); $t->replace(Ninja::transformTranslations($this->company->settings)); + $content = ctrans('texts.recurring_cancellation_request_body', ['contact' => $this->client_contact->present()->name(), 'client' => $this->client_contact->client->present()->name(), 'invoice' => $this->recurring_invoice->number]); + + if($this->gateway_refund_attempted) + $content .= "\n\n" . ctrans('texts.status') . " : " . ctrans('texts.payment_status_6'); $data = [ 'title' => ctrans('texts.recurring_cancellation_request', ['contact' => $this->client_contact->present()->name()]), - 'content' => ctrans('texts.recurring_cancellation_request_body', ['contact' => $this->client_contact->present()->name(), 'client' => $this->client_contact->client->present()->name(), 'invoice' => $this->recurring_invoice->number]), + 'content' => $content, 'url' => config('ninja.web_url'), 'button' => ctrans('texts.account_login'), 'signature' => $this->company->settings->email_signature, diff --git a/app/Notifications/Ninja/RenewalFailureNotification.php b/app/Notifications/Ninja/RenewalFailureNotification.php new file mode 100644 index 000000000000..f9cf87f18afd --- /dev/null +++ b/app/Notifications/Ninja/RenewalFailureNotification.php @@ -0,0 +1,79 @@ +notification_message}"; + + return (new SlackMessage) + ->success() + ->from(ctrans('texts.notification_bot')) + ->image('https://app.invoiceninja.com/favicon.png') + ->content($content); + } +} diff --git a/app/PaymentDrivers/Stripe/ACH.php b/app/PaymentDrivers/Stripe/ACH.php index 50e5f32c4c50..a77838cca263 100644 --- a/app/PaymentDrivers/Stripe/ACH.php +++ b/app/PaymentDrivers/Stripe/ACH.php @@ -212,9 +212,9 @@ class ACH ->first(); if ($invoice) { - $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } else { - $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } @@ -250,9 +250,9 @@ class ACH ->first(); if ($invoice) { - $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } else { - $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } if (substr($cgt->token, 0, 2) === 'pm') { @@ -494,9 +494,9 @@ class ACH ->first(); if ($invoice) { - $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } else { - $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } if (substr($source->token, 0, 2) === 'pm') { diff --git a/app/PaymentDrivers/Stripe/Charge.php b/app/PaymentDrivers/Stripe/Charge.php index ee09ba95fb57..102e392f1f07 100644 --- a/app/PaymentDrivers/Stripe/Charge.php +++ b/app/PaymentDrivers/Stripe/Charge.php @@ -63,9 +63,9 @@ class Charge $invoice = Invoice::whereIn('id', $this->transformKeys(array_column($payment_hash->invoices(), 'invoice_id')))->withTrashed()->first(); if ($invoice) { - $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice->number, 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } else { - $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } $this->stripe->init(); diff --git a/app/PaymentDrivers/Stripe/CreditCard.php b/app/PaymentDrivers/Stripe/CreditCard.php index fb2f4e6b901d..670ea7d9f805 100644 --- a/app/PaymentDrivers/Stripe/CreditCard.php +++ b/app/PaymentDrivers/Stripe/CreditCard.php @@ -61,9 +61,8 @@ class CreditCard public function paymentView(array $data) { - // $description = $this->stripe->decodeUnicodeString(ctrans('texts.invoices') . ': ' . collect($data['invoices'])->pluck('invoice_number')) . " for client {$this->stripe->client->present()->name()}"; $invoice_numbers = collect($data['invoices'])->pluck('invoice_number')->implode(','); - $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice_numbers, 'amount' => Number::formatMoney($data['total']['amount_with_fee'], $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice_numbers, 'amount' => Number::formatMoney($data['total']['amount_with_fee'], $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); $payment_intent_data = [ 'amount' => $this->stripe->convertToStripeAmount($data['total']['amount_with_fee'], $this->stripe->client->currency()->precision, $this->stripe->client->currency()), diff --git a/app/PaymentDrivers/Stripe/Klarna.php b/app/PaymentDrivers/Stripe/Klarna.php index eb30191b4a44..77902124fdc0 100644 --- a/app/PaymentDrivers/Stripe/Klarna.php +++ b/app/PaymentDrivers/Stripe/Klarna.php @@ -53,9 +53,9 @@ class Klarna $invoice_numbers = collect($data['invoices'])->pluck('invoice_number'); if ($invoice_numbers->count() > 0) { - $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice_numbers->implode(', '), 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text', ['invoicenumber' => $invoice_numbers->implode(', '), 'amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } else { - $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()]); + $description = ctrans('texts.stripe_payment_text_without_invoice', ['amount' => Number::formatMoney($amount, $this->stripe->client), 'client' => $this->stripe->client->present()->name()], $this->stripe->client->company->locale()); } $intent = \Stripe\PaymentIntent::create([ diff --git a/app/Services/Subscription/SubscriptionService.php b/app/Services/Subscription/SubscriptionService.php index ba5fe331a5ad..3af24a43ea7a 100644 --- a/app/Services/Subscription/SubscriptionService.php +++ b/app/Services/Subscription/SubscriptionService.php @@ -393,6 +393,8 @@ class SubscriptionService if(!$invoice) return []; + $handle_discount = false; + /* depending on whether we are creating an invoice or a credit*/ $multiplier = $is_credit ? 1 : -1; @@ -408,17 +410,27 @@ class SubscriptionService $line_items = []; + //Handle when we are refunding a discounted invoice. Need to consider the + //total discount and also the line item discount. + if($invoice->discount > 0) + $handle_discount = true; + + foreach($invoice->line_items as $item) { if($item->product_key != ctrans('texts.refund') && ($item->type_id == "1" || $item->type_id == "2")) { - $item->cost = ($item->cost*$ratio*$multiplier); + $discount_ratio = 1; + + if($handle_discount) + $discount_ratio = $this->calculateDiscountRatio($invoice); + + $item->cost = ($item->cost*$ratio*$multiplier*$discount_ratio); $item->product_key = ctrans('texts.refund'); $item->notes = ctrans('texts.refund') . ": ". $item->notes; - $line_items[] = $item; } @@ -428,6 +440,23 @@ class SubscriptionService } + + /** + * We only charge for the used days + * + * @param Invoice $invoice + * @return float + */ + public function calculateDiscountRatio($invoice) : float + { + + if($invoice->is_amount_discount) + return $invoice->discount / ($invoice->amount + $invoice->discount); + else + return $invoice->discount / 100; + + } + /** * We only charge for the used days * @@ -679,8 +708,9 @@ class SubscriptionService } else if($last_invoice->balance > 0) { - $pro_rata_charge_amount = $this->calculateProRataCharge($last_invoice, $old_subscription); - nlog("pro rata charge = {$pro_rata_charge_amount}"); + $last_invoice = null; + // $pro_rata_charge_amount = $this->calculateProRataCharge($last_invoice, $old_subscription); + // nlog("pro rata charge = {$pro_rata_charge_amount}"); } else { @@ -1178,11 +1208,15 @@ class SubscriptionService { $invoice_start_date = false; $refund_end_date = false; + $gateway_refund_attempted = false; //only refund if they are in the refund window. $outstanding_invoice = Invoice::where('subscription_id', $this->subscription->id) ->where('client_id', $recurring_invoice->client_id) + ->where('status_id', Invoice::STATUS_PAID) ->where('is_deleted', 0) + ->where('is_proforma',0) + ->where('balance',0) ->orderBy('id', 'desc') ->first(); @@ -1198,7 +1232,7 @@ class SubscriptionService $recurring_invoice_repo->archive($recurring_invoice); /* Refund only if we are in the window - and there is nothing outstanding on the invoice */ - if($refund_end_date && $refund_end_date->greaterThan(now()) && (int)$outstanding_invoice->balance == 0) + if($refund_end_date && $refund_end_date->greaterThan(now())) { if($outstanding_invoice->payments()->exists()) @@ -1207,8 +1241,9 @@ class SubscriptionService $data = [ 'id' => $payment->id, - 'gateway_refund' => true, + 'gateway_refund' => $outstanding_invoice->amount >= 1 ? true : false, 'send_email' => true, + 'email_receipt', 'invoices' => [ ['invoice_id' => $outstanding_invoice->id, 'amount' => $outstanding_invoice->amount], ], @@ -1216,6 +1251,7 @@ class SubscriptionService ]; $payment->refund($data); + $gateway_refund_attempted = true; } } @@ -1231,7 +1267,7 @@ class SubscriptionService $this->triggerWebhook($context); $nmo = new NinjaMailerObject; - $nmo->mailable = (new NinjaMailer((new ClientContactRequestCancellationObject($recurring_invoice, auth()->guard('contact')->user()))->build())); + $nmo->mailable = (new NinjaMailer((new ClientContactRequestCancellationObject($recurring_invoice, auth()->guard('contact')->user(), $gateway_refund_attempted))->build())); $nmo->company = $recurring_invoice->company; $nmo->settings = $recurring_invoice->company->settings; diff --git a/resources/views/index/index.blade.php b/resources/views/index/index.blade.php index bdce834ae5fa..2d95233ce2f3 100644 --- a/resources/views/index/index.blade.php +++ b/resources/views/index/index.blade.php @@ -10,11 +10,21 @@ @if(\App\Utils\Ninja::isHosted()) + + + + + + @endif