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. --}} +
- {{ $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 +{{ __('plans.pricing') }}
+ {{ __('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') }}
{{ $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')- {{ 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 +{{ __('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)