Compare commits
2 Commits
ba6ed43219
...
de3ecacc13
| Author | SHA1 | Date |
|---|---|---|
|
|
de3ecacc13 | |
|
|
b32c6fc33f |
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +561,11 @@
|
|||
<p class="mt-1 text-sm text-muted">{{ $plan['audience'] }}</p>
|
||||
@endif
|
||||
|
||||
{{-- 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>
|
||||
|
|
@ -548,6 +578,22 @@
|
|||
<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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. --}}
|
||||
|
|
|
|||
|
|
@ -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. --}}
|
||||
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">{{ Number::currency($plan['gross'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}</span>
|
||||
<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') }} {{ Number::currency($plan['net'] / 100, in: $plan['currency'], locale: app()->getLocale()) }}
|
||||
{{ __('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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
Loading…
Reference in New Issue