diff --git a/app/Http/Controllers/LandingController.php b/app/Http/Controllers/LandingController.php index 5e0af28..6bcd6d2 100644 --- a/app/Http/Controllers/LandingController.php +++ b/app/Http/Controllers/LandingController.php @@ -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'], diff --git a/app/Livewire/Admin/PlanVersions.php b/app/Livewire/Admin/PlanVersions.php index f86d0f9..0b9611b 100644 --- a/app/Livewire/Admin/PlanVersions.php +++ b/app/Livewire/Admin/PlanVersions.php @@ -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), ]); } } diff --git a/app/Livewire/Billing.php b/app/Livewire/Billing.php index 041058f..4bbb06a 100644 --- a/app/Livewire/Billing.php +++ b/app/Livewire/Billing.php @@ -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. diff --git a/app/Livewire/Order.php b/app/Livewire/Order.php index 7f038ac..295c9cb 100644 --- a/app/Livewire/Order.php +++ b/app/Livewire/Order.php @@ -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'], diff --git a/app/Models/PlanVersion.php b/app/Models/PlanVersion.php index d554844..92b3fca 100644 --- a/app/Models/PlanVersion.php +++ b/app/Models/PlanVersion.php @@ -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, diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php index 4392ace..2d2080e 100644 --- a/app/Services/Billing/PlanCatalogue.php +++ b/app/Services/Billing/PlanCatalogue.php @@ -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 diff --git a/database/migrations/2026_07_31_230000_say_how_many_months_a_year_is_free.php b/database/migrations/2026_07_31_230000_say_how_many_months_a_year_is_free.php new file mode 100644 index 0000000..d20b05d --- /dev/null +++ b/database/migrations/2026_07_31_230000_say_how_many_months_a_year_is_free.php @@ -0,0 +1,81 @@ +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'); + }); + } +}; diff --git a/lang/de/billing.php b/lang/de/billing.php index c6d84b7..617211a 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -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', diff --git a/lang/de/order.php b/lang/de/order.php index aaf0fd3..10723ed 100644 --- a/lang/de/order.php +++ b/lang/de/order.php @@ -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', diff --git a/lang/de/plans.php b/lang/de/plans.php index 91d2848..d594780 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -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', diff --git a/lang/en/billing.php b/lang/en/billing.php index e536e33..0bcda3e 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -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', diff --git a/lang/en/order.php b/lang/en/order.php index d75f36f..46be820 100644 --- a/lang/en/order.php +++ b/lang/en/order.php @@ -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', diff --git a/lang/en/plans.php b/lang/en/plans.php index 36fce03..6fc36bf 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -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', diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index 89cf2ff..f49024c 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -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. --}} +
+
+
+ @foreach (['monthly' => 'Monatlich', 'yearly' => 'Jährlich'] as $option => $label) + + @endforeach +
+ @if ($bestFreeMonths > 0) + + {{ $bestFreeMonths === 1 ? '1 Monat gratis' : $bestFreeMonths.' Monate gratis' }} bei Jahreszahlung + + @endif +
+ {{-- 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. --}} -
+
@foreach ($plans as $i => $plan)
{{ $plan['audience'] }}

@endif -

- {{ $plan['price'] }} - /Monat -

- {{-- 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. --}} -

- inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }} -

+ {{-- 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. --}} +
+

+ {{ $plan['price'] }} + /Monat +

+ {{-- 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. --}} +

+ inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_net'] }} +

+
+ +
+

+ {{ $plan['price_yearly_monthly'] }} + /Monat +

+

+ {{ $plan['price_yearly'] }} jährlich · inkl. {{ $vat['label'] }} % MwSt. · netto {{ $plan['price_yearly_net'] }} +

+ @if ($plan['free_months'] > 0) +

+ {{ $plan['free_months'] === 1 ? '1 Monat gratis' : $plan['free_months'].' Monate gratis' }} · statt {{ $plan['price_twelve_months'] }} +

+ @endif +
{{-- 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 @@
@endforeach
+
{{-- /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 diff --git a/resources/views/livewire/admin/plan-versions.blade.php b/resources/views/livewire/admin/plan-versions.blade.php index 5676b30..59067a8 100644 --- a/resources/views/livewire/admin/plan-versions.blade.php +++ b/resources/views/livewire/admin/plan-versions.blade.php @@ -201,10 +201,30 @@

{{ __('plans.pricing') }}

- - + + {{-- 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. --}} +
+ {{-- What that comes to, before it is written. Live, from the same + arithmetic the draft uses. --}} + @if ($yearlyPreview !== null) +

+ {{ __('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 +

+ @endif +

{{ __('plans.features') }}

@foreach ($featureKeys as $key) diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index bcd6dee..8eec53e 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -41,6 +41,19 @@ @else

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

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

+ {{-- 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) +

+ {{ __('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 +

+ @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. --}} diff --git a/resources/views/livewire/order.blade.php b/resources/views/livewire/order.blade.php index b7a1fc0..665ecc7 100644 --- a/resources/views/livewire/order.blade.php +++ b/resources/views/livewire/order.blade.php @@ -32,7 +32,11 @@ @else @error('plan'){{ $message }}@enderror -
+ {{-- `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. --}} +
{{-- ── 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 @@ {{ $message }} @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 +
+
+ @foreach (['monthly', 'yearly'] as $option) + + @endforeach +
+ @if ($bestFreeMonths > 0) + + {{ trans_choice('order.free_months_hint', $bestFreeMonths, ['count' => $bestFreeMonths]) }} + + @endif +
+ {{-- ── 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. --}} -

- {{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }} - {{ __('order.per_month') }} -

-

- {{ __('order.incl_vat', ['rate' => $vat]) }} · - {{ __('order.net') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }} -

+ 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 + +
+

+ {{ $eur($plan['gross']) }} + {{ __('order.per_month') }} +

+

+ {{ __('order.incl_vat', ['rate' => $vat]) }} · + {{ __('order.net') }} {{ $eur($plan['net']) }} +

+
+ +
+

+ {{ $eur($plan['yearly_per_month']) }} + {{ __('order.per_month') }} +

+

+ {{ __('order.yearly_total', ['total' => $eur($plan['yearly_gross'])]) }} · + {{ __('order.incl_vat', ['rate' => $vat]) }} · + {{ __('order.net') }} {{ $eur($plan['yearly_net']) }} +

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

+ {{ trans_choice('order.free_months_hint', $plan['free_months'], ['count' => $plan['free_months']]) }} + · {{ __('order.instead_of', ['amount' => $eur($plan['twelve_months_gross'])]) }} +

+ @endif +
@if ($setup > 0)

{{ __('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)

-
{{ $term }}
+
{{ $label }}
{{ $value }}
@endforeach @@ -135,6 +206,11 @@
@csrf + {{-- 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. --}} + {{-- No order without the terms — so the button is not diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 1fa26ff..5c9c18e 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -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); }); diff --git a/tests/Feature/Billing/YearlyTermTest.php b/tests/Feature/Billing/YearlyTermTest.php new file mode 100644 index 0000000..25bce8d --- /dev/null +++ b/tests/Feature/Billing/YearlyTermTest.php @@ -0,0 +1,153 @@ +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'); +}); diff --git a/tests/Feature/BladeCommentsTest.php b/tests/Feature/BladeCommentsTest.php new file mode 100644 index 0000000..311fc33 --- /dev/null +++ b/tests/Feature/BladeCommentsTest.php @@ -0,0 +1,47 @@ +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([]); +});