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
parent
55b1a1468f
commit
a87a4f512d
|
|
@ -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 ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue