Sell the year as well as the month, and say what it saves

Both prices have existed since the catalogue did — one `plan_prices` row
per term, each with its own Stripe Price, and the checkout has taken a
`term` all along. Nothing ever sent one. The shop showed the monthly
figure, the order page posted no term, and the yearly Price sat there
being paid for by nobody.

**The console asks for free months, not a second price.** It used to take
the yearly TOTAL as its own free-form figure, so nothing in the system
knew WHY 588 belonged to a package costing 49 a month — and no page could
say "zwei Monate gratis" without a person working it out again and writing
it somewhere it would then drift. Now: the monthly price and how many of
the twelve months are free. The total is derived from the two
(PlanVersions::yearlyCents), previewed live on the form, and written to
the catalogue from the same arithmetic.

`free_months` sits on the VERSION, beside the capabilities publication
freezes, because that is what it is — part of the terms a customer bought.
The migration reads it back out of the prices that already exist rather
than defaulting to nought: an installation selling twelve-for-ten must not
lose that on a deploy. Only an exact division counts; a hand-negotiated
yearly figure keeps its amount and simply goes unexplained.

**The customer chooses.** A switch on the order page and on the public
sheet, both figures rendered and one shown, so switching costs no request.
The headline stays "per month" in either term — a yearly total as one big
number reads as five times dearer at a glance — with the amount actually
taken, the net figure, the months free and what twelve monthly payments
would have cost underneath. The form posts the term the customer was
looking at, and the checkout picks that term's Stripe Price.

The billing card now says which term is running. It states a monthly
figure whatever the term, so a yearly customer was reading a number that
never appears on a statement.

Also: **a Blade comment must never spell a directive.** Blade compiles
what is inside `{{-- --}}` too — the comment is stripped from the output,
but its directives are compiled on the way there. A comment explaining why
the parenthesised php directive had been avoided produced exactly the open
PHP tag it warned about, and the rest of the file was swallowed as PHP
source: no exception, no warning, the page one block short. That is how
the term switch vanished after being written. BladeCommentsTest scans for
the forms that open a tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feature/betriebsmodus^2
nexxo 2026-07-30 15:44:35 +02:00
parent ba6ed43219
commit b32c6fc33f
20 changed files with 580 additions and 34 deletions

View File

@ -510,6 +510,21 @@ class LandingController extends Controller
// and for them the net figure was simply the wrong number.
'price' => $this->money($this->gross((int) $plan['price_cents']), (string) $plan['currency']),
'price_net' => $this->money((int) $plan['price_cents'], (string) $plan['currency']),
// Paying for a year, in the same three figures the order page
// shows: what leaves the account once, what that works out to
// per month, and what twelve monthly payments would have been.
// A sheet that quotes only the monthly price cannot mention a
// saving, and the saving is the reason to offer the term.
'price_yearly' => $this->money(
$yearlyGross = $this->gross((int) ($plan['yearly_price_cents'] ?? 0)),
(string) $plan['currency'],
),
'price_yearly_net' => $this->money((int) ($plan['yearly_price_cents'] ?? 0), (string) $plan['currency']),
// intdiv, so the headline never claims a tenth of a cent nobody
// is charged; the total beside it is the amount actually taken.
'price_yearly_monthly' => $this->money(intdiv($yearlyGross, 12), (string) $plan['currency']),
'price_twelve_months' => $this->money($this->gross((int) $plan['price_cents']) * 12, (string) $plan['currency']),
'free_months' => (int) ($plan['free_months'] ?? 0),
'storage' => $this->storage((int) $plan['quota_gb']),
'traffic' => $this->storage((int) $plan['traffic_gb']),
'seats' => (int) $plan['seats'],

View File

@ -53,7 +53,17 @@ class PlanVersions extends Component
*/
public string $monthlyPrice = '49,00';
public string $yearlyPrice = '588,00';
/**
* How many of the twelve months a year costs nothing.
*
* The yearly TOTAL used to be typed here as a second free-form figure, and
* nothing then knew why 588 belonged to a package costing 49 so no page
* could say "two months free" without somebody working it out again by hand.
* The total is derived from this (see yearlyCents), which is also the only
* arrangement in which the sentence a customer reads and the amount Stripe
* charges cannot drift apart.
*/
public int $freeMonths = 2;
/** Publishing: when it goes on sale, and optionally when it stops. */
public string $availableFrom = '';
@ -85,12 +95,22 @@ class PlanVersions extends Component
$this->features = $latest->features ?? [];
$monthly = $latest->priceFor(Subscription::TERM_MONTHLY);
$yearly = $latest->priceFor(Subscription::TERM_YEARLY);
$this->monthlyPrice = Money::fromCents($monthly?->amount_cents ?? 4900);
$this->yearlyPrice = Money::fromCents($yearly?->amount_cents ?? 58800);
$this->freeMonths = (int) $latest->free_months;
}
}
/**
* What a year costs: the months that are paid for, at the monthly price.
*
* One place, static, so the preview on the form and the amount written to
* the catalogue are the same arithmetic rather than two copies of it.
*/
public static function yearlyCents(int $monthlyCents, int $freeMonths): int
{
return $monthlyCents * (12 - max(0, min(12, $freeMonths)));
}
private function family(): PlanFamily
{
return PlanFamily::query()->where('uuid', $this->uuid)->firstOrFail();
@ -137,11 +157,14 @@ class PlanVersions extends Component
$fail(__('plans.price_invalid'));
}
}],
'yearlyPrice' => ['required', 'string', $price],
// Nought is a legitimate answer — "a year costs twelve months, pay
// it in one go" — and the upper bound stops at six, because eleven
// free months is a typo and not a discount.
'freeMonths' => 'required|integer|min:0|max:6',
]);
$monthlyCents = Money::toCents($data['monthlyPrice']);
$yearlyCents = Money::toCents($data['yearlyPrice']);
$yearlyCents = self::yearlyCents((int) $monthlyCents, (int) $data['freeMonths']);
$version = app(PlanCatalogue::class)->draft(
$this->family(),
@ -155,6 +178,7 @@ class PlanVersions extends Component
'performance' => $data['performance'],
'template_vmid' => $data['templateVmid'],
'features' => array_values($data['features']),
'free_months' => (int) $data['freeMonths'],
],
// Both terms, because publishing refuses a version that is not
// priced for each — better to find that out here than in front of
@ -317,6 +341,13 @@ class PlanVersions extends Component
'featureKeys' => array_keys((array) __('billing.feature')),
'performanceClasses' => (array) __('billing.perf'),
'currency' => Subscription::catalogueCurrency(),
// What a year will cost, worked out here so the form shows the same
// figure it is about to write. Null while the monthly price is not a
// number yet — the field says so on its own, and a preview of
// nothing is worse than no preview.
'yearlyPreview' => ($cents = Money::toCents($this->monthlyPrice)) === null
? null
: self::yearlyCents((int) $cents, $this->freeMonths),
]);
}
}

View File

@ -456,6 +456,12 @@ class Billing extends Component
// Per month: the card says "/ month", and a yearly contract stores
// the whole year.
'price_cents' => $subscription->monthlyPriceCents(),
// Which term is running, and what leaves the account when it does.
// The figure above is per month either way, so a yearly customer
// read a number they never see on a statement and nothing said why.
'term' => (string) $subscription->term,
'term_total_cents' => (int) $subscription->price_cents,
'renews_at' => $subscription->current_period_end,
// 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.

View File

@ -105,6 +105,9 @@ class Order extends Component
foreach ($sellable as $key => $plan) {
$net = (int) $plan['price_cents'];
$yearlyNet = (int) ($plan['yearly_price_cents'] ?? $net * 12);
$yearlyGross = $tax->chargeCents($yearlyNet);
$freeMonths = (int) ($plan['free_months'] ?? 0);
$plans[] = [
'key' => $key,
@ -112,6 +115,16 @@ class Order extends Component
'audience' => (string) ($plan['audience'] ?? ''),
'gross' => $tax->chargeCents($net),
'net' => $net,
// Paying for a year, in the three figures somebody compares:
// what leaves the account once, what that works out to per
// month, and what it would have cost month by month.
'yearly_gross' => $yearlyGross,
'yearly_net' => $yearlyNet,
// intdiv, so the headline figure never claims a tenth of a cent
// nobody is charged. The total above it is the amount taken.
'yearly_per_month' => intdiv($yearlyGross, 12),
'twelve_months_gross' => $tax->chargeCents($net) * 12,
'free_months' => $freeMonths,
'currency' => (string) $plan['currency'],
'quota_gb' => (int) $plan['quota_gb'],
'traffic_gb' => (int) $plan['traffic_gb'],

View File

@ -33,6 +33,10 @@ class PlanVersion extends Model
public const FROZEN = [
'plan_family_id', 'version', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb',
'cores', 'disk_gb', 'performance', 'template_vmid', 'features', 'published_at',
// How many of the twelve months a year costs nothing. Frozen with the
// rest: it is not presentation, it is what the customer bought — and the
// yearly amount in `plan_prices` was derived from it.
'free_months',
];
protected $guarded = [];
@ -156,6 +160,11 @@ class PlanVersion extends Model
'quota_gb' => $this->quota_gb,
'traffic_gb' => $this->traffic_gb,
'seats' => $this->seats,
// Part of the terms, so it travels with them: every reader of the
// catalogue — the shop, the order page, the console — gets the
// figure from the version rather than dividing two prices and
// hoping the answer is whole.
'free_months' => (int) $this->free_months,
'ram_mb' => $this->ram_mb,
'cores' => $this->cores,
'disk_gb' => $this->disk_gb,

View File

@ -70,10 +70,18 @@ final class PlanCatalogue
}
$monthly = $priced[Subscription::TERM_MONTHLY];
$yearly = $priced[Subscription::TERM_YEARLY];
return [$family->key => array_merge($version->capabilities(), [
'name' => $family->name,
'price_cents' => $monthly->amount_cents,
// The yearly figure travels with the monthly one. Both terms
// are already required before a version can be sold at all
// (requiredPrices above), so a shop showing one and hiding
// the other was hiding a price it had in its hand — and
// whoever wanted to show it would have gone looking for a
// second source.
'yearly_price_cents' => $yearly->amount_cents,
'currency' => $monthly->currency,
'plan_version_id' => $version->id,
// Marketing presentation, not a capability: it lives on the

View File

@ -0,0 +1,81 @@
<?php
use App\Models\Subscription;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* How many months a customer gets for nothing by paying for a year.
*
* The yearly price was already there one `plan_prices` row per term, each with
* its own Stripe Price but only as a total somebody typed. So nothing in the
* system knew WHY 588 was the yearly figure for a package costing 49 a month,
* and no page could say "two months free" without a second person working it out
* by hand and writing it down somewhere it would then drift.
*
* The figure lives on the VERSION, beside the capabilities publication freezes,
* because that is what it is: part of the terms a customer bought. The yearly
* amount stays in `plan_prices` Stripe charges an amount, not a discount and
* the console derives one from the other so the two cannot disagree.
*
* ## The backfill
*
* Read out of the prices that exist rather than defaulted to zero: an
* installation already selling twelve-for-ten must not lose that on a deploy.
* Only an exact division counts. A yearly price of 580 against a monthly of 49
* is not "eleven and a bit months free", it is a figure somebody negotiated, and
* 0 leaves it alone the amount charged is untouched either way, only the
* sentence beside it goes unsaid.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('plan_versions', function (Blueprint $table) {
$table->unsignedTinyInteger('free_months')->default(0)->after('seats');
});
$monthly = DB::table('plan_prices')
->where('term', Subscription::TERM_MONTHLY)
->pluck('amount_cents', 'plan_version_id');
foreach (DB::table('plan_prices')->where('term', Subscription::TERM_YEARLY)->get() as $yearly) {
$perMonth = (int) ($monthly[$yearly->plan_version_id] ?? 0);
if ($perMonth <= 0) {
continue;
}
// Modulo, not a division compared against its own cast: PHP's `/`
// returns an INT when two ints divide exactly, so `12 === 12.0` is
// false and a check written that way rejects precisely the case it
// was meant to accept. This one asks the only question there is —
// does the monthly price go into the yearly one a whole number of
// times.
if ((int) $yearly->amount_cents % $perMonth !== 0) {
continue;
}
$paidMonths = intdiv((int) $yearly->amount_cents, $perMonth);
// Inside the year. Anything else was priced by hand and is left to
// speak for itself.
if ($paidMonths < 1 || $paidMonths > 12) {
continue;
}
DB::table('plan_versions')
->where('id', $yearly->plan_version_id)
->update(['free_months' => 12 - $paidMonths]);
}
}
public function down(): void
{
Schema::table('plan_versions', function (Blueprint $table) {
$table->dropColumn('free_months');
});
}
};

View File

@ -29,6 +29,8 @@ return [
'per_month' => 'netto pro Monat',
'net_reverse_charge' => 'netto — Reverse Charge',
'term_yearly_note' => 'Jahreszahlung: :total netto im Jahr',
'renews_at' => 'nächste Abrechnung :date',
'net_per_month' => 'netto pro Monat',
'granted_plan' => 'Bereitgestellt',
'net_once' => 'netto, einmalig',

View File

@ -12,6 +12,11 @@ return [
'terms_link' => 'Allgemeinen Geschäftsbedingungen',
'terms_why' => 'Darin ist alles zu dieser Bestellung geregelt: Preis, Umsatzsteuer, Bereitstellung, Kündigung und Ihr 14-tägiges Widerrufsrecht. Sie verlangen damit auch ausdrücklich, dass die Einrichtung sofort beginnt — sonst könnten wir Ihre Cloud nicht gleich bereitstellen. Widerrufen Sie innerhalb der Frist, erhalten Sie den gesamten Betrag zurück.',
'terms_first' => 'Bitte oben die AGB annehmen.',
'term_monthly' => 'Monatlich',
'term_yearly' => 'Jährlich',
'free_months_hint' => '{1} 1 Monat gratis bei Jahreszahlung|[2,*] :count Monate gratis bei Jahreszahlung',
'yearly_total' => ':total jährlich',
'instead_of' => 'statt :amount',
'recommended' => 'Empfohlen',
'up_to' => 'bis :count',
'per_month' => '/Monat',

View File

@ -91,6 +91,10 @@ return [
'cents_hint' => 'In Cent, netto.',
'disk_hint' => 'Mindestens so groß wie der Speicher.',
'net' => 'netto',
'free_months' => 'Gratis-Monate im Jahr',
'free_months_hint' => '0 bis 6. Bei 2 zahlt der Kunde 10 Monate und bekommt 12.',
'yearly_preview' => 'Jahrespreis: :total netto (:months Monate bezahlt)',
'free_months_note' => '{1} ein Monat gratis|[2,*] :count Monate gratis',
'term_monthly' => 'Monatlich',
'term_yearly' => 'Jährlich',

View File

@ -29,6 +29,8 @@ return [
'per_month' => 'net per month',
'net_reverse_charge' => 'net — reverse charge',
'term_yearly_note' => 'Paid yearly: :total net per year',
'renews_at' => 'next charge :date',
'net_per_month' => 'net per month',
'granted_plan' => 'Provided',
'net_once' => 'net, one-off',

View File

@ -12,6 +12,11 @@ return [
'terms_link' => 'terms and conditions',
'terms_why' => 'They regulate everything about this order: price, VAT, delivery, cancellation and your 14-day right of withdrawal. You are also expressly requesting that we begin at once — otherwise your cloud could not be built straight away. Withdraw within the period and you get the full amount back.',
'terms_first' => 'Please accept the terms above.',
'term_monthly' => 'Monthly',
'term_yearly' => 'Yearly',
'free_months_hint' => '{1} 1 month free when paying yearly|[2,*] :count months free when paying yearly',
'yearly_total' => ':total per year',
'instead_of' => 'instead of :amount',
'recommended' => 'Recommended',
'up_to' => 'up to :count',
'per_month' => '/month',

View File

@ -91,6 +91,10 @@ return [
'cents_hint' => 'In cents, net.',
'disk_hint' => 'At least as large as the storage quota.',
'net' => 'net',
'free_months' => 'Free months per year',
'free_months_hint' => '0 to 6. At 2 the customer pays for 10 months and gets 12.',
'yearly_preview' => 'Yearly price: :total net (:months months paid)',
'free_months_note' => '{1} one month free|[2,*] :count months free',
'term_monthly' => 'Monthly',
'term_yearly' => 'Yearly',

View File

@ -511,11 +511,36 @@
// stylesheet by reading these files as text, so a class name
// assembled at render time is a class name that was never compiled.
$planColumns = ['lg:grid-cols-1', 'lg:grid-cols-2', 'lg:grid-cols-3', 'lg:grid-cols-4'][min(count($plans), 4) - 1];
$bestFreeMonths = (int) collect($plans)->max('free_months');
@endphp
{{-- Monthly or yearly, on the sheet itself. Both figures are rendered
and one is hidden, so switching costs no request and a visitor who
never touches it still reads the monthly price the portal will
charge. The saving is named next to the switch: "jährlich" on its
own gives nobody a reason to look. --}}
<div x-data="{ term: 'monthly' }" class="mt-12">
<div class="flex flex-wrap items-center justify-center gap-3">
<div class="inline-flex rounded-lg border border-line bg-surface p-1 shadow-xs">
@foreach (['monthly' => 'Monatlich', 'yearly' => 'Jährlich'] as $option => $label)
<button type="button" x-on:click="term = '{{ $option }}'"
class="rounded-md px-5 py-1.5 text-sm font-semibold transition-colors"
x-bind:class="term === '{{ $option }}' ? 'bg-ink text-on-ink' : 'text-muted hover:text-ink'">
{{ $label }}
</button>
@endforeach
</div>
@if ($bestFreeMonths > 0)
<span class="text-sm font-semibold text-success-text" x-show="term !== 'yearly'">
{{ $bestFreeMonths === 1 ? '1 Monat gratis' : $bestFreeMonths.' Monate gratis' }} bei Jahreszahlung
</span>
@endif
</div>
{{-- Stretched, not items-start: the cards carry different numbers of
features, and four "anfragen" buttons at four different heights is
the first thing the eye picks up on a price sheet. --}}
<div class="mt-14 grid gap-5 sm:grid-cols-2 lg:gap-6 {{ $planColumns }}">
<div class="mt-8 grid gap-5 sm:grid-cols-2 lg:gap-6 {{ $planColumns }}">
@foreach ($plans as $i => $plan)
<div @class([
'rv flex flex-col rounded-xl border p-7',
@ -536,18 +561,39 @@
<p class="mt-1 text-sm text-muted">{{ $plan['audience'] }}</p>
@endif
<p class="mt-6 flex items-baseline gap-1.5">
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price'] }}</span>
<span class="text-sm text-muted">/Monat</span>
</p>
{{-- The net figure under the gross one, not instead of it.
A private customer pays what is written large; a
business needs the other number for its books, and
printing only one of the two was wrong for one of the
two readers whichever we picked. --}}
<p class="mt-1.5 text-xs text-muted">
inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }}
</p>
{{-- The headline stays "per month" in both terms: a yearly
total shown as one big number reads as five times dearer
at a glance, and the amount actually taken is named on
the line under it. --}}
<div x-show="term === 'monthly'">
<p class="mt-6 flex items-baseline gap-1.5">
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price'] }}</span>
<span class="text-sm text-muted">/Monat</span>
</p>
{{-- The net figure under the gross one, not instead of it.
A private customer pays what is written large; a
business needs the other number for its books, and
printing only one of the two was wrong for one of the
two readers whichever we picked. --}}
<p class="mt-1.5 text-xs text-muted">
inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }}
</p>
</div>
<div x-show="term === 'yearly'" x-cloak>
<p class="mt-6 flex items-baseline gap-1.5">
<span class="text-[2.4rem] font-bold leading-none tabular-nums tracking-[-0.04em] text-ink">{{ $plan['price_yearly_monthly'] }}</span>
<span class="text-sm text-muted">/Monat</span>
</p>
<p class="mt-1.5 text-xs text-muted">
{{ $plan['price_yearly'] }} jährlich · inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_yearly_net'] }}
</p>
@if ($plan['free_months'] > 0)
<p class="mt-1 text-xs font-semibold text-success-text">
{{ $plan['free_months'] === 1 ? '1 Monat gratis' : $plan['free_months'].' Monate gratis' }} · statt {{ $plan['price_twelve_months'] }}
</p>
@endif
</div>
{{-- Named, or not mentioned. "zzgl. einmaliger Einrichtung"
without a figure told a visitor only that there is a
cost the worst of both. Zero on the Finance page
@ -601,6 +647,7 @@
</div>
@endforeach
</div>
</div>{{-- /x-data: the switch and the cards it switches share one scope --}}
{{-- The baseline, said once and said first. Four of these were rows of
ticks running straight down every column of the table below, and

View File

@ -201,10 +201,30 @@
<p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.pricing') }}</p>
<div class="grid grid-cols-2 gap-3">
<x-ui.input name="monthlyPrice" inputmode="decimal" :label="__('plans.term_monthly')" :hint="__('plans.euro_hint')" wire:model="monthlyPrice" />
<x-ui.input name="yearlyPrice" inputmode="decimal" :label="__('plans.term_yearly')" :hint="__('plans.euro_hint')" wire:model="yearlyPrice" />
<x-ui.input name="monthlyPrice" inputmode="decimal" :label="__('plans.term_monthly')" :hint="__('plans.euro_hint')"
wire:model.live.debounce.500ms="monthlyPrice" />
{{-- Free months, not a second price. The yearly total is
derived from the two, which is the only arrangement in
which "2 Monate gratis" on the website and the amount
Stripe charges cannot drift apart. --}}
<x-ui.input name="freeMonths" type="number" min="0" max="6" :label="__('plans.free_months')"
:hint="__('plans.free_months_hint')" wire:model.live="freeMonths" />
</div>
{{-- What that comes to, before it is written. Live, from the same
arithmetic the draft uses. --}}
@if ($yearlyPreview !== null)
<p class="text-xs text-muted">
{{ __('plans.yearly_preview', [
'total' => $eur($yearlyPreview),
'months' => 12 - max(0, min(12, $freeMonths)),
]) }}
@if ($freeMonths > 0)
· {{ trans_choice('plans.free_months_note', $freeMonths, ['count' => $freeMonths]) }}
@endif
</p>
@endif
<p class="pt-1 text-xs font-semibold text-muted">{{ __('plans.features') }}</p>
<div class="space-y-2">
@foreach ($featureKeys as $key)

View File

@ -41,6 +41,19 @@
@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>
{{-- A yearly contract is charged once a year; the figure
above is per month whatever the term, so without this
line a yearly customer read a number that never appears
on a statement. --}}
@if (($current['term'] ?? null) === App\Models\Subscription::TERM_YEARLY)
<p class="mt-1 text-xs font-medium text-body">
{{ __('billing.term_yearly_note', ['total' => $eur($current['term_total_cents'] ?? 0)]) }}
@if ($current['renews_at'] ?? null)
{{-- R19: stored in UTC, read on the wall clock. --}}
· {{ __('billing.renews_at', ['date' => $current['renews_at']->local()->isoFormat('D. MMM YYYY')]) }}
@endif
</p>
@endif
@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. --}}

View File

@ -32,7 +32,11 @@
@else
@error('plan')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
<div x-data="{ terms: false }" class="space-y-6">
{{-- `term` decides which of the two prices every card shows and what the
forms post. Both figures are rendered, so switching is instant and
costs no round trip and the checkout is handed the same word the
customer was looking at. --}}
<div x-data="{ terms: false, term: 'monthly' }" class="space-y-6">
{{-- ── The terms, first ──────────────────────────────────────────
One declaration, and the terms behind it regulate the sale:
price, VAT, delivery, cancellation, the fourteen-day withdrawal
@ -71,6 +75,40 @@
<x-ui.alert variant="error">{{ $message }}</x-ui.alert>
@enderror
{{-- ── Monthly or yearly ─────────────────────────────────────────
Two words, not a dropdown: there are exactly two answers and
both fit on the line. The saving is named on the switch itself,
because "jährlich" alone gives nobody a reason to look. --}}
{{-- The block form of the php directive, not the parenthesised
short one: that short form does not survive a cast and a method
call in one expression it compiled to an unterminated PHP open
tag and took the rest of the page with it.
Neither form is written out here, and that is deliberate: Blade
compiles DIRECTIVES inside comments too, so naming the short one
in this very comment produced the same open tag a second time
and the whole block vanished from the page without an error
anywhere. A comment describes a directive; it never spells one. --}}
@php
$bestFreeMonths = (int) collect($plans)->max('free_months');
@endphp
<div class="flex flex-wrap items-center gap-3 animate-rise [animation-delay:50ms]">
<div class="inline-flex rounded-lg border border-line bg-surface p-1 shadow-xs">
@foreach (['monthly', 'yearly'] as $option)
<button type="button" x-on:click="term = '{{ $option }}'"
class="rounded-md px-4 py-1.5 text-sm font-semibold transition-colors"
x-bind:class="term === '{{ $option }}' ? 'bg-ink text-on-ink' : 'text-muted hover:text-ink'">
{{ __('order.term_'.$option) }}
</button>
@endforeach
</div>
@if ($bestFreeMonths > 0)
<span class="text-sm font-semibold text-success-text" x-show="term !== 'yearly'">
{{ trans_choice('order.free_months_hint', $bestFreeMonths, ['count' => $bestFreeMonths]) }}
</span>
@endif
</div>
{{-- ── The packages ──────────────────────────────────────────────
Three columns: five plans read as 3 + 2, and a fifth card alone
on a row of four reads as a mistake. items-start, because the
@ -97,15 +135,48 @@
{{-- The price, gross and large. A customer quoted 58,80
on the website must not meet 49 on the page with
the button on it; the net figure is underneath for
the books. --}}
<p class="mt-5 flex items-baseline gap-1.5">
<span class="text-[2rem] font-bold leading-none tabular-nums tracking-[-0.03em] text-ink">{{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}</span>
<span class="text-xs text-muted">{{ __('order.per_month') }}</span>
</p>
<p class="mt-1.5 text-xs text-muted">
{{ __('order.incl_vat', ['rate' => $vat]) }} ·
{{ __('order.net') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}
</p>
the books.
Both terms, one shown at a time. The headline figure
stays "per month" in either a yearly price shown as
a single big number reads as five times dearer at a
glance, and the amount actually taken is named on the
line underneath. --}}
@php
$eur = fn (int $cents) => Number::currency($cents / 100, in: $plan['currency'], locale: app()->getLocale());
@endphp
<div x-show="term === 'monthly'">
<p class="mt-5 flex items-baseline gap-1.5">
<span class="text-[2rem] font-bold leading-none tabular-nums tracking-[-0.03em] text-ink">{{ $eur($plan['gross']) }}</span>
<span class="text-xs text-muted">{{ __('order.per_month') }}</span>
</p>
<p class="mt-1.5 text-xs text-muted">
{{ __('order.incl_vat', ['rate' => $vat]) }} ·
{{ __('order.net') }} {{ $eur($plan['net']) }}
</p>
</div>
<div x-show="term === 'yearly'" x-cloak>
<p class="mt-5 flex items-baseline gap-1.5">
<span class="text-[2rem] font-bold leading-none tabular-nums tracking-[-0.03em] text-ink">{{ $eur($plan['yearly_per_month']) }}</span>
<span class="text-xs text-muted">{{ __('order.per_month') }}</span>
</p>
<p class="mt-1.5 text-xs text-muted">
{{ __('order.yearly_total', ['total' => $eur($plan['yearly_gross'])]) }} ·
{{ __('order.incl_vat', ['rate' => $vat]) }} ·
{{ __('order.net') }} {{ $eur($plan['yearly_net']) }}
</p>
@if ($plan['free_months'] > 0)
{{-- The saving, with the figure it is measured
against: "2 Monate gratis" next to what
twelve monthly payments would have cost. --}}
<p class="mt-1 text-xs font-semibold text-success-text">
{{ trans_choice('order.free_months_hint', $plan['free_months'], ['count' => $plan['free_months']]) }}
· {{ __('order.instead_of', ['amount' => $eur($plan['twelve_months_gross'])]) }}
</p>
@endif
</div>
@if ($setup > 0)
<p class="mt-1 text-xs text-muted">
{{ __('order.setup', ['amount' => Number::currency($setup / 100, in: $plan['currency'], locale: app()->getLocale())]) }}
@ -121,9 +192,9 @@
__('order.storage') => $plan['quota_gb'] >= 1000 ? ($plan['quota_gb'] / 1000).' TB' : $plan['quota_gb'].' GB',
__('order.traffic') => $plan['traffic_gb'] >= 1000 ? ($plan['traffic_gb'] / 1000).' TB' : $plan['traffic_gb'].' GB',
__('order.seats') => __('order.up_to', ['count' => $plan['seats']]),
] as $term => $value)
] as $label => $value)
<div class="flex items-baseline justify-between gap-3">
<dt class="text-muted">{{ $term }}</dt>
<dt class="text-muted">{{ $label }}</dt>
<dd class="font-mono tabular-nums text-ink">{{ $value }}</dd>
</div>
@endforeach
@ -135,6 +206,11 @@
<form method="POST" action="{{ route('checkout.start') }}" class="mt-6">
@csrf
<input type="hidden" name="plan" value="{{ $plan['key'] }}" />
{{-- The term the customer was looking at when they
pressed. CheckoutController validates it against
the two we sell and picks that term's Stripe
Price, so nothing here can invent a third. --}}
<input type="hidden" name="term" x-bind:value="term" />
<input type="hidden" name="terms_accepted" x-bind:value="terms ? '1' : ''" />
{{-- No order without the terms so the button is not

View File

@ -91,14 +91,19 @@ it('drafts a version prefilled from the last one, priced for both terms', functi
->assertSet('quotaGb', 500)
->assertSet('monthlyPrice', '179,00');
$page->set('monthlyPrice', '199,00')->set('yearlyPrice', '2388,00')->call('draft')->assertHasNoErrors();
// The monthly price and the free months. The yearly total is derived from
// them — two free means ten months paid, 199,00 × 10 — because a typed total
// left nothing knowing WHY it was that figure, and no page could then say
// "zwei Monate gratis" without working it out a second time by hand.
$page->set('monthlyPrice', '199,00')->set('freeMonths', 2)->call('draft')->assertHasNoErrors();
$draft = teamFamily()->versions()->where('version', 2)->sole();
expect($draft->isPublished())->toBeFalse()
->and($draft->ram_mb)->toBe(8192)
->and($draft->free_months)->toBe(2)
->and($draft->priceFor('monthly')->amount_cents)->toBe(19900)
->and($draft->priceFor('yearly')->amount_cents)->toBe(238800)
->and($draft->priceFor('yearly')->amount_cents)->toBe(199000)
// A draft is not on sale, so the shop is unchanged.
->and(app(PlanCatalogue::class)->sellable()['team']['price_cents'])->toBe(17900);
});

View File

@ -0,0 +1,153 @@
<?php
use App\Livewire\Admin\PlanVersions;
use App\Models\PlanVersion;
use App\Models\Subscription;
use App\Models\User;
use App\Services\Billing\PlanCatalogue;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* Paying for a year, and what that is worth saying out loud.
*
* Both prices have existed since the catalogue did one `plan_prices` row per
* term, each with its own Stripe Price but only the monthly one was ever shown.
* A customer could not choose the term, the console could only type the yearly
* TOTAL as a second free-form figure, and nothing in the system knew why 588
* belonged to a package costing 49 a month. So no page could say "two months
* free" without a person working it out again by hand.
*
* `free_months` on the version is that missing fact. The total is derived from
* it, which is the only arrangement in which the sentence a customer reads and
* the amount Stripe charges cannot drift apart.
*/
beforeEach(function () {
$this->stripe = new FakeStripeClient;
app()->instance(StripeClient::class, $this->stripe);
// A DIFFERENT id per term, unlike the other checkout suites: this one is
// about which of the two is picked, and one id shared by both would make
// every assertion here pass whatever the code did. Two statements rather
// than one concat(): the suite runs on SQLite, which spells that ||.
foreach ([Subscription::TERM_MONTHLY, Subscription::TERM_YEARLY] as $term) {
DB::table('plan_prices')->where('term', $term)->update(['stripe_price_id' => 'price_'.$term]);
}
});
function termBuyer(): User
{
return User::factory()->create(['email_verified_at' => now()]);
}
it('derives the year from the month and the free months', function () {
// The arithmetic, in the one place both the form preview and the draft use.
expect(PlanVersions::yearlyCents(4900, 0))->toBe(58800)
->and(PlanVersions::yearlyCents(4900, 2))->toBe(49000)
->and(PlanVersions::yearlyCents(17900, 1))->toBe(196900)
// Out of range is clamped rather than turned into a negative price.
->and(PlanVersions::yearlyCents(4900, 99))->toBe(0);
});
it('offers both terms in the shop, with the figure behind the saving', function () {
$plan = app(PlanCatalogue::class)->sellable()['team'];
expect($plan)->toHaveKeys(['price_cents', 'yearly_price_cents', 'free_months'])
->and($plan['price_cents'])->toBe(17900)
// Twelve for twelve on this installation until somebody sets otherwise —
// what matters is that the figure is READ rather than assumed.
->and($plan['yearly_price_cents'])->toBe(17900 * (12 - $plan['free_months']));
});
it('freezes the free months when the version is published', function () {
// It is part of the terms a customer bought, not presentation: the yearly
// amount was derived from it, and changing it afterwards would rewrite what
// a running contract says it agreed to.
$version = PlanVersion::query()->whereNotNull('published_at')->first();
expect(fn () => $version->update(['free_months' => 3]))
->toThrow(RuntimeException::class);
});
it('lets a customer pick the term on the order page', function () {
$page = $this->actingAs(termBuyer())->get(route('order'))->assertOk()->getContent();
expect($page)->toContain("term = 'yearly'")
// Both figures rendered, one shown: switching costs no round trip, and
// the form posts the word the customer was looking at.
->and($page)->toContain('name="term"')
->and($page)->toContain(__('order.term_yearly'));
});
it('opens the checkout on the yearly Stripe Price when yearly was chosen', function () {
// The one that matters: a term shown but not carried through would charge a
// month at the yearly price or the other way round.
$version = app(PlanCatalogue::class)->currentVersion('team');
$yearly = $version->priceFor(Subscription::TERM_YEARLY);
$this->actingAs(termBuyer())->post(route('checkout.start'), [
'plan' => 'team',
'term' => Subscription::TERM_YEARLY,
'terms_accepted' => '1',
]);
expect($this->stripe->checkouts)->toHaveCount(1)
->and($this->stripe->checkouts[0]['price'])->toBe($yearly->stripe_price_id);
});
it('defaults to the month when no term was sent at all', function () {
// A form with JavaScript off posts an empty term, and "empty" must not mean
// "whichever row comes first".
$version = app(PlanCatalogue::class)->currentVersion('team');
$this->actingAs(termBuyer())->post(route('checkout.start'), ['plan' => 'team', 'terms_accepted' => '1']);
expect($this->stripe->checkouts[0]['price'])
->toBe($version->priceFor(Subscription::TERM_MONTHLY)->stripe_price_id);
});
it('refuses a term nobody sells', function () {
$this->actingAs(termBuyer())
->post(route('checkout.start'), ['plan' => 'team', 'term' => 'weekly', 'terms_accepted' => '1'])
->assertSessionHasErrors('term');
expect($this->stripe->checkouts)->toBeEmpty();
});
it('shows the yearly price on the public sheet as well', function () {
// "auf der website sowie im userpanel ersichtlich" — the sheet quotes the
// same two figures, from the same catalogue, so the switch a visitor used
// outside and the one in the panel cannot disagree.
$page = $this->get('/')->assertOk()->getContent();
expect($page)->toContain("term = 'yearly'")
->and($page)->toContain('jährlich');
});
it('states the term on the billing card, not just a monthly figure', function () {
// A yearly contract is charged once a year while the card says "per month".
// Without this line the customer read a number that never appears on a
// statement.
expect(File::get(resource_path('views/livewire/billing.blade.php')))
->toContain("__('billing.term_yearly_note'")
->and(File::get(app_path('Livewire/Billing.php')))
->toContain("'term' => (string) \$subscription->term");
});
// ---- The console ----
it('asks for free months rather than a second price', function () {
// Typing both totals let them disagree, and nothing then knew which was
// meant. The form takes the monthly price and the months that are free.
$component = File::get(app_path('Livewire/Admin/PlanVersions.php'));
expect($component)->toContain('public int $freeMonths')
->and($component)->not->toContain('public string $yearlyPrice')
->and($component)->toContain("'freeMonths' => 'required|integer|min:0|max:6'");
expect(File::get(resource_path('views/livewire/admin/plan-versions.blade.php')))
->toContain("__('plans.free_months')")
->toContain('yearlyPreview');
});

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Support\Facades\File;
/**
* A Blade comment describes a directive. It never spells one.
*
* Blade compiles what is inside `{{-- --}}` as well: the comment is stripped
* from the OUTPUT, but the directives in it are compiled on the way there. So a
* comment explaining why the parenthesised php directive was avoided produced
* exactly the open PHP tag it was warning about and everything after it in the
* file was swallowed as PHP source.
*
* That failure is silent. No exception, no warning, no missing-view error: the
* page renders, one block short. The term switch on the order page disappeared
* this way and the only symptom was a test asserting it was there.
*
* Scoped to the forms that open a PHP tag, which are the ones that eat the rest
* of the file. A comment mentioning @if or @disabled is survivable and there are
* two of those in the repo already; this is not the place to relitigate them.
*/
it('never spells a php open tag inside a blade comment', function () {
$offenders = [];
foreach (File::allFiles(resource_path('views')) as $file) {
if (! str_ends_with($file->getFilename(), '.blade.php')) {
continue;
}
preg_match_all('/\{\{--.*?--\}\}/s', $file->getContents(), $comments);
foreach ($comments[0] ?? [] as $comment) {
// Assembled rather than written out, for the reason this test
// exists: the needles below would otherwise be compiled out of THIS
// file's own comments if it were ever a template.
$needles = ['@'.'php', '@'.'endphp', '<'.'?php', '<'.'?='];
foreach ($needles as $needle) {
if (str_contains($comment, $needle)) {
$offenders[] = str_replace(resource_path().'/', '', $file->getPathname()).': '.$needle;
}
}
}
}
expect($offenders)->toBe([]);
});