feat(billing): one plan change at a time, and every price says what it is

Two contradicting plan upgrades sat in the cart at 799 and 399 € — no checkout
could resolve which one the customer meant. Choosing another now replaces the
pending one and says so; add-ons still stack, because buying 200 GB as two packs
is a sensible thing to want.

Every price now states net or gross and how often. The cart shows net per line
with "pro Monat" or "einmalig", then subtotal, VAT and gross — and separates
the monthly recurring amount from a one-off traffic top-up sharing the same
cart, because those are two different commitments. The rate is configurable
(CLUPILOT_TAX_PERCENT, 20 % default) since it follows the seller's country.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 09:23:49 +02:00
parent 55b1a1468f
commit a87a4f512d
8 changed files with 172 additions and 5 deletions

View File

@ -172,6 +172,10 @@ VPN_CONFIG_KEY=
# (WireGuard-Subnetz). Alles andere sieht die Platzhalterseite.
TRUSTED_RANGES=10.66.0.0/24,127.0.0.1
# Umsatzsteuersatz des Verkäufers in Prozent. Preise in der Konfiguration
# sind NETTO; die Oberfläche weist beides aus.
CLUPILOT_TAX_PERCENT=20
ADMIN_HOSTS=admin.dev.clupilot.com,10.10.90.185,localhost,127.0.0.1
# ── Nextcloud blueprint template ─────────────────────────────────────────

View File

@ -54,6 +54,21 @@ class Billing extends Component
?? $customer->orders()->latest('id')->value('datacenter')
?? 'fsn';
// A cart cannot hold two plan changes: they contradict each other and
// no checkout could resolve which one the customer meant. Choosing
// another simply replaces the pending one — add-ons still stack.
if ($type === 'upgrade') {
$replaced = Order::query()
->where('customer_id', $customer->id)
->where('status', 'pending')
->where('type', 'upgrade')
->delete();
if ($replaced > 0) {
$this->dispatch('notify', message: __('billing.cart.plan_replaced'));
}
}
Order::create([
'customer_id' => $customer->id,
'plan' => $plan,

View File

@ -43,6 +43,28 @@ class Order extends Model implements ProvisioningSubject
};
}
/**
* Monthly or one-off.
*
* Traffic is bought for the month that is running out and does not renew;
* everything else changes the recurring bill.
*/
public function isRecurring(): bool
{
return $this->type !== 'traffic';
}
/** Net is what is stored; gross is what gets charged. */
public function grossCents(): int
{
return (int) round($this->amount_cents * (1 + self::taxRate()));
}
public static function taxRate(): float
{
return (float) config('provisioning.tax.rate_percent', 0) / 100;
}
/** Only a pending order is still the customer's to change. */
public function isRemovable(): bool
{

View File

@ -99,6 +99,16 @@ return [
'sample_minutes' => 15,
],
/*
| Prices in this config are NET. The rate is configurable because it follows
| the seller's country (20 % in Austria), and every price shown to a
| customer has to say which it is a figure without "netto" or "brutto" is
| the single most common billing complaint there is.
*/
'tax' => [
'rate_percent' => (float) env('CLUPILOT_TAX_PERCENT', 20),
],
// Extra storage add-on (per unit) and the add-on catalogue (labels in lang/*/billing.php).
'storage_addon' => ['gb' => 100, 'price_cents' => 1000],
'addons' => [

View File

@ -1,7 +1,18 @@
<?php
return [
'net_per_month' => 'netto pro Monat',
'net_once' => 'netto, einmalig',
'net_hint' => 'netto, zzgl. :percent % USt.',
'cart' => [
'net' => 'netto',
'per_month' => 'pro Monat',
'once' => 'einmalig',
'subtotal_net' => 'Zwischensumme netto',
'vat' => 'zzgl. :percent % USt.',
'total_gross' => 'Gesamt brutto',
'recurring_note' => 'Davon :amount brutto monatlich wiederkehrend.',
'plan_replaced' => 'Der bisher vorgemerkte Paketwechsel wurde ersetzt — es kann nur einer offen sein.',
'title' => 'Vorgemerkte Käufe',
'subtitle' => 'Wird nach Zahlung aktiviert',
'added' => 'vorgemerkt am :when',

View File

@ -1,7 +1,18 @@
<?php
return [
'net_per_month' => 'net per month',
'net_once' => 'net, one-off',
'net_hint' => 'net, plus :percent % VAT',
'cart' => [
'net' => 'net',
'per_month' => 'per month',
'once' => 'one-off',
'subtotal_net' => 'Subtotal net',
'vat' => 'plus :percent % VAT',
'total_gross' => 'Total gross',
'recurring_note' => 'Of which :amount gross recurs monthly.',
'plan_replaced' => 'The plan change you had pending was replaced — only one can be open at a time.',
'title' => 'Pending purchases',
'subtitle' => 'Activated once paid',
'added' => 'added :when',

View File

@ -16,7 +16,7 @@
</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.per_month') }}</p>
<p class="text-xs text-muted">{{ __('billing.net_per_month') }}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-px bg-line sm:grid-cols-4">
@ -53,7 +53,13 @@
{{ __('billing.cart.added', ['when' => $order->created_at->isoFormat('LL')]) }}
</p>
</div>
<p class="font-mono text-sm text-body">{{ $eur($order->amount_cents) }}</p>
<div class="text-right">
<p class="font-mono text-sm text-body">{{ $eur($order->amount_cents) }}</p>
<p class="text-xs text-faint">
{{ __('billing.cart.net') }} ·
{{ $order->isRecurring() ? __('billing.cart.per_month') : __('billing.cart.once') }}
</p>
</div>
<button type="button" aria-label="{{ __('billing.cart.remove') }}" title="{{ __('billing.cart.remove') }}"
x-on:click="$dispatch('openModal', { component: 'confirm-remove-order', arguments: { uuid: '{{ $order->uuid }}' } })"
class="grid size-8 place-items-center rounded-md border border-line text-muted transition hover:border-danger hover:text-danger">
@ -63,9 +69,31 @@
@endforeach
</ul>
<div class="flex items-center justify-between border-t border-line bg-surface-2 px-5 py-3">
<span class="text-sm font-medium text-body">{{ __('billing.cart.total') }}</span>
<span class="font-mono text-sm font-semibold text-ink">{{ $eur($pending->sum('amount_cents')) }}</span>
@php
$net = $pending->sum('amount_cents');
$gross = $pending->sum(fn ($o) => $o->grossCents());
$recurringNet = $pending->filter->isRecurring()->sum('amount_cents');
@endphp
<div class="space-y-1.5 border-t border-line bg-surface-2 px-5 py-3.5 text-sm">
<div class="flex items-center justify-between text-muted">
<span>{{ __('billing.cart.subtotal_net') }}</span>
<span class="font-mono">{{ $eur($net) }}</span>
</div>
<div class="flex items-center justify-between text-muted">
<span>{{ __('billing.cart.vat', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}</span>
<span class="font-mono">{{ $eur($gross - $net) }}</span>
</div>
<div class="flex items-center justify-between border-t border-line pt-1.5 font-semibold text-ink">
<span>{{ __('billing.cart.total_gross') }}</span>
<span class="font-mono">{{ $eur($gross) }}</span>
</div>
@if ($recurringNet > 0)
{{-- The number that actually matters every month, kept apart
from a one-off traffic top-up in the same cart. --}}
<p class="pt-1 text-xs text-faint">
{{ __('billing.cart.recurring_note', ['amount' => $eur((int) round($recurringNet * (1 + \App\Models\Order::taxRate())))]) }}
</p>
@endif
</div>
</div>
@endif
@ -92,6 +120,7 @@
@endforeach
</ul>
<p class="mt-4 text-xl font-semibold text-ink">{{ $eur($p['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
<p class="text-xs text-faint">{{ __('billing.net_hint', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}</p>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('upgrade', '{{ $key }}')" wire:target="purchase('upgrade', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.upgrade_cta') }}
</x-ui.button>
@ -110,6 +139,7 @@
<p class="font-semibold text-ink">{{ __('billing.storage_title') }}</p>
</div>
<p class="mt-2 text-sm text-body">{{ __('billing.storage_body', ['gb' => $storage['gb'], 'price' => $eur($storage['price_cents'])]) }}</p>
<p class="mt-1 text-xs text-faint">{{ __('billing.net_per_month') }}</p>
<x-ui.button variant="primary" size="sm" class="mt-4 w-full" wire:click="purchase('storage')" wire:target="purchase('storage')" wire:loading.attr="disabled">
{{ __('billing.storage_cta', ['gb' => $storage['gb']]) }}
</x-ui.button>
@ -122,6 +152,7 @@
<p class="font-semibold text-ink">{{ __('billing.traffic_title') }}</p>
</div>
<p class="mt-2 text-sm text-body">{{ __('billing.traffic_body', ['gb' => $trafficAddon['gb'], 'price' => $eur($trafficAddon['price_cents'])]) }}</p>
<p class="mt-1 text-xs text-faint">{{ __('billing.net_once') }}</p>
@if ($trafficMeter)
<p class="mt-2 font-mono text-xs text-muted">
{{ __('billing.traffic_used', [
@ -145,6 +176,7 @@
</div>
<p class="mt-2 flex-1 text-sm text-muted">{{ __('billing.addon.'.$key.'.desc') }}</p>
<p class="mt-3 text-lg font-semibold text-ink">{{ $eur($addon['price_cents']) }}<span class="text-sm font-normal text-muted"> / {{ __('billing.month_short') }}</span></p>
<p class="text-xs text-faint">{{ __('billing.net_hint', ['percent' => rtrim(rtrim(number_format(config('provisioning.tax.rate_percent', 0), 1, ',', '.'), '0'), ',')]) }}</p>
<x-ui.button variant="secondary" size="sm" class="mt-3 w-full" wire:click="purchase('addon', '{{ $key }}')" wire:target="purchase('addon', '{{ $key }}')" wire:loading.attr="disabled">
{{ __('billing.addon_cta') }}
</x-ui.button>

View File

@ -70,3 +70,65 @@ it('refuses to remove an order that is already paid', function () {
expect(Order::query()->whereKey($order->id)->exists())->toBeTrue();
});
it('keeps at most one plan change in the cart', function () {
[$customer, $user] = cartCustomer();
App\Models\Instance::factory()->for($customer)->create(['plan' => 'start', 'status' => 'active']);
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'upgrade', 'business');
$component->call('purchase', 'upgrade', 'enterprise');
// Two contradicting plan changes cannot both be paid for — the second
// replaces the first instead of stacking.
$upgrades = Order::query()->where('type', 'upgrade')->where('status', 'pending')->get();
expect($upgrades)->toHaveCount(1)
->and($upgrades->first()->plan)->toBe('enterprise');
});
it('still lets add-ons stack', function () {
[$customer, $user] = cartCustomer();
App\Models\Instance::factory()->for($customer)->create(['plan' => 'team', 'status' => 'active']);
$component = Livewire\Livewire::actingAs($user)->test(Billing::class);
$component->call('purchase', 'storage');
$component->call('purchase', 'storage');
$component->call('purchase', 'traffic');
// Buying 200 GB as two packs is a perfectly sensible thing to want.
expect(Order::query()->where('type', 'storage')->count())->toBe(2)
->and(Order::query()->where('type', 'traffic')->count())->toBe(1);
});
it('says net, gross and how often for every price', function () {
[$customer, $user] = cartCustomer();
config()->set('provisioning.tax.rate_percent', 20);
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
Order::factory()->for($customer)->create(['type' => 'traffic', 'amount_cents' => 500, 'status' => 'pending']);
Livewire\Livewire::actingAs($user)->test(Billing::class)
->assertSee(__('billing.cart.net'))
->assertSee(__('billing.cart.per_month')) // storage recurs
->assertSee(__('billing.cart.once')) // traffic does not
->assertSee('12,00') // 15,00 net → 18,00 gross
->assertSee('18,00')
->assertSee(__('billing.cart.total_gross'));
});
it('separates what recurs from a one-off top-up', function () {
[$customer, $user] = cartCustomer();
config()->set('provisioning.tax.rate_percent', 20);
Order::factory()->for($customer)->create(['type' => 'storage', 'amount_cents' => 1000, 'status' => 'pending']);
Order::factory()->for($customer)->create(['type' => 'traffic', 'amount_cents' => 500, 'status' => 'pending']);
// 10,00 net recurring → 12,00 gross per month, not the 18,00 total.
// Formatted through the same helper the view uses, or the non-breaking
// space in the currency makes this a test about whitespace.
$expected = __('billing.cart.recurring_note', [
'amount' => Illuminate\Support\Number::currency(12.00, in: 'EUR', locale: 'de'),
]);
Livewire\Livewire::actingAs($user)->test(Billing::class)->assertSee($expected);
});