From c71b2c362b3db3e4850b92bc9053e508433dec78 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 12:41:32 +0200 Subject: [PATCH] Skip the invoice for a full gift, keep it out of revenue, hide its price MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IssueInvoice now rejects free-grant orders before it will consume an invoice number for them — a full gift produces no invoice at all, while a discounted grant still charges something and is invoiced exactly like an ordinary sale. Keyed on the linked subscription/add-on's own provenance, not on the amount being zero, so a genuinely fully-discounted Stripe checkout is unaffected. Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count — a gift at 0 must not drag the average down — and shows their number as its own KPI instead of folding it in, since the owner measures himself on it. The portal (Cloud, Billing) shows a granted package or module without a price at all: not "free", not struck through, just the service, so a later conversion to paid does not read as a negotiation. --- app/Livewire/Admin/Revenue.php | 26 ++++- app/Livewire/Billing.php | 13 ++- app/Livewire/Cloud.php | 17 ++- app/Services/Billing/AddonCatalogue.php | 4 + app/Services/Billing/IssueInvoice.php | 6 + lang/de/billing.php | 1 + lang/de/cloud.php | 3 + lang/en/billing.php | 1 + lang/en/cloud.php | 3 + resources/views/livewire/billing.blade.php | 53 +++++---- tests/Feature/Admin/RevenueGrantedTest.php | 61 ++++++++++ tests/Feature/Billing/GrantInvoiceTest.php | 104 ++++++++++++++++++ .../Feature/Portal/GrantedPlanPortalTest.php | 85 ++++++++++++++ 13 files changed, 345 insertions(+), 32 deletions(-) create mode 100644 tests/Feature/Admin/RevenueGrantedTest.php create mode 100644 tests/Feature/Billing/GrantInvoiceTest.php create mode 100644 tests/Feature/Portal/GrantedPlanPortalTest.php diff --git a/app/Livewire/Admin/Revenue.php b/app/Livewire/Admin/Revenue.php index 99e245b..fa3e85d 100644 --- a/app/Livewire/Admin/Revenue.php +++ b/app/Livewire/Admin/Revenue.php @@ -59,6 +59,15 @@ class Revenue extends Component 'value' => (string) array_sum(array_column($totals, 'contracts')), 'sub' => __('admin.rev.contracts_sub'), ], + [ + // Shown separately rather than folded into the figures + // above: a granted contract at 0 must not drag ARPU down + // or read as revenue, and the owner measures himself on + // this number too. + 'label' => __('admin.rev.granted'), + 'value' => (string) $this->grantedCount(), + 'sub' => __('admin.rev.granted_sub'), + ], ], 'planCharts' => $this->planCharts(), 'payments' => $this->recentPayments($locale), @@ -66,13 +75,18 @@ class Revenue extends Component } /** - * Recurring revenue per currency, off the contracts. + * Recurring revenue per currency, off the PAYING contracts. * * Never summed across currencies. Frozen contract prices, not the * catalogue, so a price rise does not retroactively inflate what * grandfathered customers are reported to pay. Add-ons included and yearly * terms divided — totalMonthlyCents() owns both of those rules. * + * Granted contracts are excluded here, not merely zeroed: a contract with + * `granted_at` set is a gift or a discount an operator handed out, not a + * sale, and counting it — even at 0 — would inflate the contracts count + * this average is divided by and understate ARPU for everyone else. + * * @return array, contracts:int}> */ private function recurringTotals(): array @@ -81,6 +95,7 @@ class Revenue extends Component Subscription::query() ->where('status', 'active') + ->whereNull('granted_at') ->with('addons') ->chunkById(200, function ($subscriptions) use (&$totals) { foreach ($subscriptions as $subscription) { @@ -117,7 +132,7 @@ class Revenue extends Component * * By what customers are ON, not by what is on sale: a withdrawn plan still * bills, and leaving it out would understate the total the chart sits next - * to. + * to. Granted contracts are excluded — see recurringTotals(). * * Per currency, because a doughnut adds its slices together. Two currencies * in one ring produces a total that is not an amount of anything, and it @@ -131,6 +146,7 @@ class Revenue extends Component Subscription::query() ->where('status', 'active') + ->whereNull('granted_at') ->with('addons') ->chunkById(200, function ($subscriptions) use (&$byCurrency) { foreach ($subscriptions as $subscription) { @@ -169,6 +185,12 @@ class Revenue extends Component return $charts; } + /** How many active contracts are gifts or discounts, not sales. */ + private function grantedCount(): int + { + return Subscription::query()->where('status', 'active')->whereNotNull('granted_at')->count(); + } + /** * The last payments actually recorded. * diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 80cdc9a..f2ba019 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -9,8 +9,11 @@ use App\Models\Subscription; use App\Services\Billing\AddonCatalogue; use App\Services\Billing\DowngradeCheck; use App\Services\Billing\PlanCatalogue; +use App\Services\Billing\TaxTreatment; +use App\Services\Traffic\TrafficMeter; use Illuminate\Support\Facades\DB; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Component; #[Layout('layouts.portal-app')] @@ -112,7 +115,7 @@ class Billing extends Component $this->dispatch('notify', message: __('billing.purchased')); } - #[\Livewire\Attributes\On('order-removed')] + #[On('order-removed')] public function orderRemoved(): void { $this->dispatch('notify', message: __('billing.cart.removed')); @@ -136,6 +139,10 @@ class Billing extends Component // Per month: the card says "/ month", and a yearly contract stores // the whole year. 'price_cents' => $subscription->monthlyPriceCents(), + // The portal shows a granted package without a price at all — not + // "free", not struck through — so a later conversion to paid does + // not read as a negotiation. + 'granted' => $subscription->isGranted(), 'currency' => $subscription->currency, 'quota_gb' => $subscription->quota_gb, 'traffic_gb' => $subscription->traffic_gb, @@ -187,8 +194,8 @@ class Billing extends Component 'trafficAddon' => (array) config('provisioning.traffic.addon'), // Resolved once: the cart and the plan cards must not state // different tax treatments on the same page. - 'tax' => \App\Services\Billing\TaxTreatment::for($customer), - 'trafficMeter' => $instance !== null ? \App\Services\Traffic\TrafficMeter::for($instance) : null, + 'tax' => TaxTreatment::for($customer), + 'trafficMeter' => $instance !== null ? TrafficMeter::for($instance) : null, // Booked modules at the price they were booked at; everything else // at today's. Reading them all off the catalogue would re-price // half a customer's bill behind their back. diff --git a/app/Livewire/Cloud.php b/app/Livewire/Cloud.php index d5021da..9c13579 100644 --- a/app/Livewire/Cloud.php +++ b/app/Livewire/Cloud.php @@ -47,12 +47,17 @@ class Cloud extends Component 'name' => $customer ? __('cloud.instance_name', ['name' => $customer->name]) : __('cloud.title'), 'domain' => $domain, 'status' => $shown->status ?? 'active', - 'plan' => __('cloud.plan_line', [ - 'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey), - // The line ends in "/mo", so a yearly contract has to be - // divided down before it goes in. - 'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100), - ]), + // A granted package shows without a price at all — not "free", + // not struck through — so a later conversion to paid does not + // read as a negotiation. + 'plan' => $contract?->isGranted() + ? __('cloud.plan_line_granted', ['plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey)]) + : __('cloud.plan_line', [ + 'plan' => 'CluPilot Cloud '.__('billing.plan.'.$planKey), + // The line ends in "/mo", so a yearly contract has to be + // divided down before it goes in. + 'price' => (int) round(($contract?->monthlyPriceCents() ?? 0) / 100), + ]), 'location' => __('cloud.datacenter'), 'storageUsed' => $used, 'storageQuota' => $quota, diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php index 0db0198..ec89ab9 100644 --- a/app/Services/Billing/AddonCatalogue.php +++ b/app/Services/Billing/AddonCatalogue.php @@ -76,6 +76,10 @@ final class AddonCatalogue }, 'monthly_cents' => (int) $own->sum(fn ($addon) => $addon->monthlyCents()), 'booked' => $own->isNotEmpty(), + // A granted module shows without a price too, the same rule as + // a granted plan — "ein Plugin schenken" should not read as + // "kostenlos" on the very page that sells it to everyone else. + 'granted' => $own->isNotEmpty() && $own->every(fn ($addon) => $addon->isGranted()), 'quantity' => (int) $own->sum('quantity'), 'bookings' => $own->map(fn ($addon) => [ 'price_cents' => $addon->price_cents, diff --git a/app/Services/Billing/IssueInvoice.php b/app/Services/Billing/IssueInvoice.php index 88da548..254ef43 100644 --- a/app/Services/Billing/IssueInvoice.php +++ b/app/Services/Billing/IssueInvoice.php @@ -43,6 +43,12 @@ final class IssueInvoice */ public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice { + // A full gift produces no invoice — no amount, no number drawn from + // the series. A zero invoice with a sequential number is a bookkeeping + // foreign body. A discounted grant is unaffected: it still charges + // something and is invoiced exactly like an ordinary sale below. + $orders = $orders->reject(fn (Order $order) => $order->isFreeGrant()); + if ($orders->isEmpty()) { throw new RuntimeException('Refusing to issue an invoice with no lines on it.'); } diff --git a/lang/de/billing.php b/lang/de/billing.php index 5dee999..b611251 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -15,6 +15,7 @@ return [ 'net_reverse_charge' => 'netto — Reverse Charge', 'net_per_month' => 'netto pro Monat', + 'granted_plan' => 'Bereitgestellt', 'net_once' => 'netto, einmalig', 'net_hint' => 'netto, zzgl. :percent % USt.', 'cart' => [ diff --git a/lang/de/cloud.php b/lang/de/cloud.php index 8461d82..e254d92 100644 --- a/lang/de/cloud.php +++ b/lang/de/cloud.php @@ -4,6 +4,9 @@ return [ 'title' => 'Meine Cloud', 'subtitle' => 'Details und Ressourcen Ihrer Instanz.', 'plan_line' => ':plan · :price €/Monat', + // A granted package: not "kostenlos", not a struck-through amount — just + // the service. A later conversion to paid should not read as a negotiation. + 'plan_line_granted' => ':plan', 'datacenter' => 'EU', 'now' => 'jetzt', 'instance_label' => 'Instanz', diff --git a/lang/en/billing.php b/lang/en/billing.php index 3ee9184..a2c6a65 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -15,6 +15,7 @@ return [ 'net_reverse_charge' => 'net — reverse charge', 'net_per_month' => 'net per month', + 'granted_plan' => 'Provided', 'net_once' => 'net, one-off', 'net_hint' => 'net, plus :percent % VAT', 'cart' => [ diff --git a/lang/en/cloud.php b/lang/en/cloud.php index 9286254..c81daa2 100644 --- a/lang/en/cloud.php +++ b/lang/en/cloud.php @@ -4,6 +4,9 @@ return [ 'title' => 'My cloud', 'subtitle' => 'Details and resources of your instance.', 'plan_line' => ':plan · €:price/mo', + // A granted package: not "free", not a struck-through amount — just the + // service. A later conversion to paid should not read as a negotiation. + 'plan_line_granted' => ':plan', 'datacenter' => 'EU', 'now' => 'now', 'instance_label' => 'Instance', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index ee385f7..588b8bc 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -15,14 +15,20 @@

{{ __('billing.plan.'.$currentKey) }}

-

{{ $eur($current['price_cents'] ?? 0) }}

-

{{ __('billing.net_per_month') }}

- @if ($totalMonthlyCents !== null && $totalMonthlyCents !== ($current['price_cents'] ?? 0)) - {{-- The plan alone is not the bill once modules are booked, - and every part of it is frozen at what was agreed. --}} -

- {{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }} -

+ @if ($current['granted'] ?? false) + {{-- Not "free", not struck through — just the service. A + later conversion to paid must not read as a negotiation. --}} +

{{ __('billing.granted_plan') }}

+ @else +

{{ $eur($current['price_cents'] ?? 0) }}

+

{{ __('billing.net_per_month') }}

+ @if ($totalMonthlyCents !== null && $totalMonthlyCents !== ($current['price_cents'] ?? 0)) + {{-- The plan alone is not the bill once modules are booked, + and every part of it is frozen at what was agreed. --}} +

+ {{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }} +

+ @endif @endif
@@ -245,19 +251,24 @@

{{ __('billing.addon.'.$key.'.name') }}

{{ __('billing.addon.'.$key.'.desc') }}

- {{-- What they actually pay for it once booked (packs and all), - and today's price while it is still a sale to be made. --}} -

- {{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }} / {{ __('billing.month_short') }} - @if ($addon['booked'] && $addon['quantity'] > 1) - · {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }} - @endif -

-

- {{ $tax->reverseCharge - ? __('billing.net_reverse_charge') - : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} -

+ @if ($addon['granted'] ?? false) + {{-- Not "free", not struck through — just the service. --}} +

{{ __('billing.granted_plan') }}

+ @else + {{-- What they actually pay for it once booked (packs and all), + and today's price while it is still a sale to be made. --}} +

+ {{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }} / {{ __('billing.month_short') }} + @if ($addon['booked'] && $addon['quantity'] > 1) + · {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }} + @endif +

+

+ {{ $tax->reverseCharge + ? __('billing.net_reverse_charge') + : __('billing.net_hint', ['percent' => $tax->percentLabel()]) }} +

+ @endif @if ($addon['booked']) {{-- Already theirs, at the price they booked it for — which is why this card does not show today's. --}} diff --git a/tests/Feature/Admin/RevenueGrantedTest.php b/tests/Feature/Admin/RevenueGrantedTest.php new file mode 100644 index 0000000..dfdd51a --- /dev/null +++ b/tests/Feature/Admin/RevenueGrantedTest.php @@ -0,0 +1,61 @@ +create(); + + Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'status' => 'active', 'term' => 'monthly', 'price_cents' => 17900, 'currency' => 'EUR', + ]); + + Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'status' => 'active', 'term' => 'monthly', 'price_cents' => 0, 'currency' => 'EUR', + 'granted_at' => now(), 'catalogue_price_cents' => 17900, + ]); + + $component = Livewire::actingAs(operator('Owner'), 'operator')->test(Revenue::class); + + // Only the paying contract's 179,00 shows in MRR — a granted contract + // adding its 0 would still inflate the contracts count underneath ARPU. + $component->assertViewHas('kpis', function (array $kpis) { + [$mrr, , $arpu, $contracts, $granted] = $kpis; + + return str_contains($mrr['value'], '179,00') + && str_contains($arpu['value'], '179,00') + && $contracts['value'] === '1' + && $granted['value'] === '1'; + }); +}); + +it('folds a discount into revenue at what the customer actually pays, but still counts it as granted', function () { + $customer = Customer::factory()->create(); + + Subscription::factory()->create([ + 'customer_id' => $customer->id, + 'status' => 'active', 'term' => 'monthly', 'price_cents' => 5000, 'currency' => 'EUR', + 'granted_at' => now(), 'catalogue_price_cents' => 17900, + ]); + + $component = Livewire::actingAs(operator('Owner'), 'operator')->test(Revenue::class); + + // A discount is still an operator's grant, so — like a full gift — it is + // excluded from what the owner counts as a sale. + $component->assertViewHas('kpis', function (array $kpis) { + [$mrr, , , $contracts, $granted] = $kpis; + + return str_contains($mrr['value'], '0,00') + && $contracts['value'] === '0' + && $granted['value'] === '1'; + }); +}); diff --git a/tests/Feature/Billing/GrantInvoiceTest.php b/tests/Feature/Billing/GrantInvoiceTest.php new file mode 100644 index 0000000..dc24790 --- /dev/null +++ b/tests/Feature/Billing/GrantInvoiceTest.php @@ -0,0 +1,104 @@ + 'CluPilot Cloud e.U.', + 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', + 'city' => 'Wien', + 'vat_id' => 'ATU00000000', + ]); +}); + +it('skips a fully granted subscription: no invoice, no number consumed', function () { + Queue::fake(); + + $customer = Customer::factory()->create(); + $owner = Operator::factory()->create(); + + app(GrantSubscription::class)( + customer: $customer, grantedBy: $owner, plan: 'team', + term: Subscription::TERM_MONTHLY, datacenter: 'fsn', priceCents: 0, + ); + + $order = Order::query()->where('customer_id', $customer->id)->sole(); + + expect(fn () => app(IssueInvoice::class)->forOrders($customer, collect([$order]))) + ->toThrow(RuntimeException::class); + + expect(Invoice::query()->count())->toBe(0) + ->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(1); +}); + +it('invoices a discounted grant normally, at what the customer actually pays', function () { + Queue::fake(); + + $customer = Customer::factory()->create(); + $owner = Operator::factory()->create(); + + app(GrantSubscription::class)( + customer: $customer, grantedBy: $owner, plan: 'team', + term: Subscription::TERM_MONTHLY, datacenter: 'fsn', priceCents: 5000, + ); + + $order = Order::query()->where('customer_id', $customer->id)->sole(); + $invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order])); + + expect($invoice->net_cents)->toBe(5000) + ->and($invoice->number)->toStartWith('RE-'); +}); + +it('skips a granted, free module the same way', function () { + $order = Order::factory()->create(['plan' => 'team', 'datacenter' => 'fsn', 'status' => 'paid']); + $subscription = app(OpenSubscription::class)($order); + $owner = Operator::factory()->create(); + $customer = $subscription->customer; + + app(GrantAddon::class)($subscription, $owner, 'priority_support', 0); + + $addonOrder = Order::query()->where('type', 'addon')->sole(); + + expect(fn () => app(IssueInvoice::class)->forOrders($customer, collect([$addonOrder]))) + ->toThrow(RuntimeException::class); + + expect(Invoice::query()->count())->toBe(0); +}); + +it('does not mistake an ordinary fully-discounted Stripe checkout for a grant', function () { + // The exact case OpenSubscription's own docblock warns about: a real + // Stripe checkout can also land at amount_cents = 0 (a 100% coupon). That + // subscription was never granted (granted_at stays null) and is still an + // owed invoice — isFreeGrant() must key off provenance, not off the + // amount alone. + $order = Order::factory()->create([ + 'plan' => 'team', 'datacenter' => 'fsn', 'amount_cents' => 0, + 'currency' => 'EUR', 'status' => 'paid', 'stripe_event_id' => 'evt_full_coupon', + ]); + $subscription = app(OpenSubscription::class)($order, Subscription::TERM_MONTHLY, ['price_cents' => 0]); + + expect($subscription->isGranted())->toBeFalse(); + + $invoice = app(IssueInvoice::class)->forOrders($order->customer, collect([$order])); + + expect($invoice->net_cents)->toBe(0) + ->and($invoice->number)->toStartWith('RE-'); +}); diff --git a/tests/Feature/Portal/GrantedPlanPortalTest.php b/tests/Feature/Portal/GrantedPlanPortalTest.php new file mode 100644 index 0000000..a694f19 --- /dev/null +++ b/tests/Feature/Portal/GrantedPlanPortalTest.php @@ -0,0 +1,85 @@ +create(); + $user = User::factory()->create(['email' => $customer->email]); + $customer->update(['user_id' => $user->id]); + + $instance = Instance::factory()->create([ + 'customer_id' => $customer->id, + 'host_id' => Host::factory()->active()->create()->id, + 'plan' => 'team', + 'status' => 'active', + ]); + $subscription = Subscription::factory()->plan('team')->create([ + 'customer_id' => $customer->id, + 'instance_id' => $instance->id, + 'price_cents' => 0, + 'granted_by' => Operator::factory()->create()->id, + 'granted_at' => now(), + 'catalogue_price_cents' => 17900, + ]); + + return [$customer, $user, $subscription]; +} + +it('shows the granted plan on the cloud dashboard without a price', function () { + [, $user] = customerWithGrantedPlan(); + + Livewire::actingAs($user) + ->test(Cloud::class) + ->assertDontSee('179') + ->assertDontSee('0 €') + ->assertSee(__('billing.plan.team')); +}); + +it('shows the granted plan on the billing page without a price', function () { + [, $user] = customerWithGrantedPlan(); + + $card = Livewire::actingAs($user)->test(Billing::class)->viewData('current'); + + expect($card['granted'])->toBeTrue(); + + Livewire::actingAs($user) + ->test(Billing::class) + ->assertSee(__('billing.granted_plan')) + ->assertDontSee('179,00'); +}); + +it('shows a granted module without a price, but a booked one still with its price', function () { + [$customer, $user, $subscription] = customerWithGrantedPlan(); + + SubscriptionAddon::query()->create([ + 'subscription_id' => $subscription->id, + 'addon_key' => 'priority_support', + 'price_cents' => 0, + 'currency' => 'EUR', + 'quantity' => 1, + 'booked_at' => now(), + 'granted_by' => Operator::factory()->create()->id, + 'granted_at' => now(), + 'catalogue_price_cents' => 2900, + ]); + + Livewire::actingAs($user) + ->test(Billing::class) + ->assertSee(__('billing.addon.priority_support.name')) + ->assertSee(__('billing.granted_plan')); +});