Skip the invoice for a full gift, keep it out of revenue, hide its price

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.
feat/granted-plans
nexxo 2026-07-29 12:41:32 +02:00
parent 602602864a
commit c71b2c362b
13 changed files with 345 additions and 32 deletions

View File

@ -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<string, array{cents:int, customers:array<int,bool>, 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.
*

View File

@ -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.

View File

@ -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,

View File

@ -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,

View File

@ -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.');
}

View File

@ -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' => [

View File

@ -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',

View File

@ -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' => [

View File

@ -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',

View File

@ -15,14 +15,20 @@
<p class="mt-0.5 text-lg font-semibold text-ink">{{ __('billing.plan.'.$currentKey) }}</p>
</div>
<div class="text-right">
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
@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. --}}
<p class="mt-1 text-sm font-medium text-body">
{{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }}
</p>
@if ($current['granted'] ?? false)
{{-- Not "free", not struck through just the service. A
later conversion to paid must not read as a negotiation. --}}
<p class="text-xs text-muted">{{ __('billing.granted_plan') }}</p>
@else
<p class="text-2xl font-semibold text-ink">{{ $eur($current['price_cents'] ?? 0) }}</p>
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
@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. --}}
<p class="mt-1 text-sm font-medium text-body">
{{ __('billing.total_with_addons', ['total' => $eur($totalMonthlyCents)]) }}
</p>
@endif
@endif
</div>
</div>
@ -245,19 +251,24 @@
<p class="font-semibold text-ink">{{ __('billing.addon.'.$key.'.name') }}</p>
</div>
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
{{-- What they actually pay for it once booked (packs and all),
and today's price while it is still a sale to be made. --}}
<p class="mt-3 text-lg font-semibold text-ink">
{{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span>
@if ($addon['booked'] && $addon['quantity'] > 1)
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
@endif
</p>
<p class="text-xs text-muted">
{{ $tax->reverseCharge
? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
</p>
@if ($addon['granted'] ?? false)
{{-- Not "free", not struck through just the service. --}}
<p class="mt-3 text-sm font-medium text-muted">{{ __('billing.granted_plan') }}</p>
@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. --}}
<p class="mt-3 text-lg font-semibold text-ink">
{{ $eur($addon['booked'] ? $addon['monthly_cents'] : (int) $addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span>
@if ($addon['booked'] && $addon['quantity'] > 1)
<span class="text-sm font-normal text-muted">· {{ __('billing.addon_packs', ['count' => $addon['quantity']]) }}</span>
@endif
</p>
<p class="text-xs text-muted">
{{ $tax->reverseCharge
? __('billing.net_reverse_charge')
: __('billing.net_hint', ['percent' => $tax->percentLabel()]) }}
</p>
@endif
@if ($addon['booked'])
{{-- Already theirs, at the price they booked it for which
is why this card does not show today's. --}}

View File

@ -0,0 +1,61 @@
<?php
use App\Livewire\Admin\Revenue;
use App\Models\Customer;
use App\Models\Subscription;
use Livewire\Livewire;
/**
* A granted contract at 0 must not drag ARPU down or count as revenue-bearing
* and the owner measures himself on the number of granted contracts too, so
* it is shown separately rather than folded into MRR/ARR/ARPU/contracts.
*/
it('excludes a granted contract from MRR, ARR and the contracts count', function () {
$customer = Customer::factory()->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';
});
});

View File

@ -0,0 +1,104 @@
<?php
use App\Actions\GrantAddon;
use App\Actions\GrantSubscription;
use App\Actions\OpenSubscription;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\Operator;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\IssueInvoice;
use App\Support\CompanyProfile;
use Illuminate\Support\Facades\Queue;
/**
* The other half of the owner's second decision: a full gift produces no
* invoice at all, but a discounted grant is invoiced normally, at what the
* customer actually pays. This is the one place IssueInvoice had to change
* a single reject() of free-grant orders and this file is what proves it.
*/
beforeEach(function () {
CompanyProfile::put([
'name' => '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-');
});

View File

@ -0,0 +1,85 @@
<?php
use App\Livewire\Billing;
use App\Livewire\Cloud;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Operator;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use Livewire\Livewire;
/**
* The owner's third decision: the portal shows a granted package without a
* price not "free", not struck through so a later conversion to paid
* does not read as a negotiation.
*/
function customerWithGrantedPlan(): array
{
$customer = Customer::factory()->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'));
});