@extends('layouts.dashboard')
@section('content')
@php
/**
* Vendor subscription billing.
*
* The restaurant's whole billing record in one place: the plan it is on and how long the term
* has left, what the plan allows against what is being used, every payment it has made with a
* printable invoice for each, the pay/renew panel, and the path to a different plan.
*
* Plans are settled by gateway only: a subscription is a recurring charge, and cash cannot be
* charged again. Gateways are off by default, so an install with none enabled offers no way to pay
* here and loads nothing external.
*
* @var array|null $plan current plan row (or null)
* @var array|null $subscription latest subscription row (or null)
* @var array $history this page of the restaurant's subscription payments, newest first
* @var array $pager pagination metadata for $history
* @var array $term ['state','ends_at','days_left','lifetime'] — how the current term stands
* @var array $allowance menu-item allowance and usage for the current plan
* @var array $planOptions active plans other than the current one
* @var bool $canPayByGateway true when Stripe or PayPal is enabled and keyed
* @var float $price plan price — the NET, which is what the tier is advertised at
* @var float $gross that price plus the platform's subscription tax — what the gateway takes
* @var bool $isFree true when price <= 0
* @var string $currency ISO currency code
* @var array $methods ['stripe'=>bool,'paypal'=>bool]
*/
$cycleLabel = [
'monthly' => t_raw('vendor.billing.cycle_monthly_suffix'),
'yearly' => t_raw('vendor.billing.cycle_yearly_suffix'),
'lifetime' => t_raw('vendor.billing.cycle_lifetime_suffix'),
];
$statusPill = [
'active' => 'qm-pill-green',
'pending' => 'qm-pill-amber',
'expired' => 'qm-pill-grey',
'cancelled' => 'qm-pill-grey',
];
$subStatus = $subscription['status'] ?? '';
$canPay = !$isFree && ($methods['stripe'] || $methods['paypal']);
$daysLeft = (int) $term['days_left'];
/* What this panel offers is not one action, and the page already knows which it is. A term that is
still running gets RENEWED, one that has run out gets REACTIVATED, and one that never started
gets ACTIVATED — three different things to a restaurant owner, who is deciding whether they are
paying early, paying late, or paying for the first time. It read "Pay / Renew": a slash is a
label that declined to choose, and the sub-line said "Renew your plan" even to an owner who had
never paid at all. Same shape as $cycleLabel and $statusPill above — state in, wording out. */
$payAction = match ((string) $term['state']) {
'active', 'expiring' => 'renew',
'lapsed', 'ended' => 'reactivate',
default => 'activate', // 'none' (never subscribed) and 'pending' (never captured)
};
/* And whether there is anything to pay AT ALL. A live agreement bills itself: the gateway holds the
payment method and charges on the renewal date. Offering "Pay $49 by card" underneath "Renews
automatically" asked the owner to buy a term they had already bought — and taking it opened a
SECOND recurring agreement, so both charged, every cycle. The panel now states what will happen
instead of selling it again. PlansController::pay() refuses the same case server-side. */
$gatewayWillCharge = !empty($autoRenew)
&& in_array((string) $term['state'], ['active', 'expiring'], true)
&& !$term['lifetime'];
/* THE NET IS WHAT THE TIER COSTS; THE GROSS IS WHAT IS CHARGED. On an install that adds tax on top
of the plan price those are two different numbers, and this page named the net in every one of
them — the next-charge row, the button, the change-plan dialogs. The owner consented to "Renew
for $49" and landed on a checkout asking $58.80, because that is what payShow() and the
published gateway plan have always taken (Invoice::grossFor). So: a price that states what a
TIER COSTS stays the net, with this line beneath it saying what is added or already included;
every price that states a CHARGE is the gross.
The figures come from the same Invoice snapshot the issued invoice prints from — taxOn() of the
gross is literally what gets stamped on the row when the term activates — so what is promised
here and what the document says afterwards cannot drift apart. Empty string on an install that
charges no subscription tax, where the two figures are one number and there is nothing to
explain. Same shape as $cycleLabel and $payAction above: a value in, wording out. */
$taxNote = static function (float $net): string {
$gross = \App\Services\Invoice::grossFor($net);
$tax = \App\Services\Invoice::taxOn($gross);
if ($tax['mode'] === 'none' || $tax['amount'] <= 0) {
return '';
}
$label = \App\Services\Invoice::taxLineLabel($tax);
return $tax['mode'] === 'exclusive'
? t('vendor.plans.price_tax_added', [':label' => $label, ':total' => money($gross)])
: t('vendor.billing.price_tax_included', [':label' => $label, ':amount' => money($tax['amount'])]);
};
$planTaxNote = $isFree ? '' : $taxNote($price);
@endphp
@php /* .qm-d2: the dashboard density standard (app.css §35) */
@endphp
@php /* Renewal and expiry are stated up front, in days, so "when do I need to act?" never
requires reading a date out of a table and doing the arithmetic. */
@endphp
@if ($term['state'] === 'lapsed')
{{ (string) $plan['name'] }}
@php /* The LIST price — what this tier costs — so it stays the net it is
advertised at, with what tax does to it stated underneath rather than
folded into the headline figure. Nothing is charged from this card. */
@endphp
@if ($isFree){{ t_raw('common.free') }}@else{{ money($price) }} {{ $cycleLabel[(string) ($currentCycle ?? $plan['billing_cycle'])] ?? '' }}@endif
@if ($planTaxNote !== ''){{ $planTaxNote }}@endif
@if ($term['lifetime'])
{{ t_raw('vendor.billing.never_lifetime') }}
@elseif ($term['ends_at'] !== null)
{!! fmt_day($term['ends_at']) !!}
@if ($term['state'] === 'active' || $term['state'] === 'expiring')
{{ t_raw('vendor.plans.days_left', [':days' => number_format($daysLeft)]) }}
@endif
@php /* Whether that date is a renewal or an ending decides whether money
moves again, so it is stated rather than left to be inferred — and
the way to stop it belongs beside the fact, not on another screen. */
@endphp
@if (!empty($autoRenew))
{{ t_raw('vendor.plans.auto_renew_on') }}
@elseif ($subStatus === 'active')
{{ t_raw('vendor.plans.auto_renew_off') }}
@endif
@else
{!! dash() !!}
@endif
@php /* The way to stop the renewal stays on this card, but as the card's own
foot action — inside the stat band it inflated the Renews column and
broke the row's baseline. */
@endphp
@if (!$term['lifetime'] && $term['ends_at'] !== null && !empty($autoRenew))
@endif
@else
{{ t_raw('vendor.plans.no_plan_text') }}
@endif
@php /* The money row: what happens next on the left, where else you could go on the right. Both
were full-width bands stacked one above the other — the renewal panel is a heading and a
single button, so it drew a card the width of the screen around 150px of content and left
the rest empty, and the plan cards below tiled at 240px each and stopped, leaving the same
gap. Four and eight: the narrow panel is sized to what it holds, and the plan cards get a
column wide enough to fill. Opened for BOTH branches, so the free-plan notice and the plan
chooser sit on the same row as the paid panel does — otherwise a free vendor's chooser
column has no row to sit in. Your Plan above is untouched; it is shared with other screens. */
@endphp
@if ($plan)
@php /* Both of these name a CHARGE — "buy another term for :price", "renews for
:price" — so both take the gross. Left at the net, this sub-line contradicted
the button directly beneath it the moment an install added tax on top. */
@endphp
@if ($gatewayWillCharge)
@php /* Nothing to buy: the agreement is live and the gateway holds the payment method, so
this states the charge rather than selling the term again. Stopping it is the
button in Your Plan above, beside the renewal date it applies to — the same place
it has always been. */
@endphp
@php /* The figure the gateway will take on that date, not the tier's list price —
this row is a statement about money that is about to move. */
@endphp
@if (!empty($sellsAnnually))
@php /* THE OTHER CADENCE, offered beside the statement. It opens the ordinary
checkout for the same plan at the other term; the new agreement supersedes
this one at activation, exactly as a plan change does. */
@endphp
@endif
@elseif (!$canPay)
{{ t_raw('vendor.plans.no_method_title') }}
{{ t_raw('vendor.plans.no_method_text') }}
@else
@php /* ONE action. This was a card per gateway, each with its own Pay button, which made
the owner choose Stripe or PayPal before they had seen the plan, the amount or the
tax — and left two narrow boxes tiled across a wide panel with the rest of the row
empty. The gateway is picked at checkout now, beside the figure it applies to
(PlansController::payShow), so this states what is being bought and starts it.
Gateways only, still: a plan is a recurring charge and cash cannot be charged
again, so cash is not an option here — see PlansController::index(). */
@endphp
@if (!empty($sellsAnnually))
@endif
@endif
@endif
@if ($plan && $planOptions)
{{ t_raw('vendor.plans.change_plan_title') }}
@php /* What actually happens is stated plainly rather than implied: the unused value
of the current term carries over as credit (deferUntilTs), and the copy says so. */
@endphp
@endif
@php
/* THE CARRY-OVER. The remaining value of the running term — the very figure
deferUntilTs() converts at checkout — stated before the owner commits, in the
looked-at tier's own days. One value for every card; days per card and cadence. */
$creditValue = \App\Models\Subscription::remainingValue($subscription ?? null);
$creditNoteFor = static function (int $newDays) use ($creditValue, $daysLeft, $plan, $__env): string {
return t_raw('vendor.plans.change_plan_credit', [
':days' => number_format($daysLeft),
':plan' => (string) $plan['name'],
':value' => money($creditValue),
':newdays' => number_format($newDays),
':date' => fmt_day(date('Y-m-d', time() + $newDays * 86400)),
]);
};
@endphp
@foreach ($planOptions as $option)
@php
$optionPrice = (float) $option['price'];
$optionFree = $optionPrice <= 0;
/* The annual variant's figures, rendered server-side so the toggle is a display
swap. The card advertises NET (as its monthly face does); the confirm dialog
and the hidden cycle input carry the real consent. */
$optAnnual = $gridAnnual && \App\Models\Subscription::sellsAnnually($option);
$optYrPrice = $optAnnual ? \App\Models\Subscription::priceFor($option, 'yearly') : 0.0;
$optYrGross = $optAnnual ? \App\Services\Invoice::grossFor($optYrPrice) : 0.0;
$optYrTaxNote = $optAnnual ? $taxNote($optYrPrice) : '';
$optShowYr = $optAnnual && $gridCycle === 'yearly';
// The yearly rate is lower, so the same value buys more yearly days — both figures
// render and the toggle swaps them like every other figure on the card.
$optCreditM = \App\Models\Subscription::creditDaysOnPlan($creditValue, $option, 'monthly');
$optCreditY = $optAnnual ? \App\Models\Subscription::creditDaysOnPlan($creditValue, $option, 'yearly') : 0;
// Same two figures as the current plan above: the card advertises the net this tier costs,
// and the confirm dialog — which is where the owner agrees to the payment — names the gross
// that will actually be taken for it.
$optionGross = \App\Services\Invoice::grossFor($optionPrice);
$optionTaxNote = $optionFree ? '' : $taxNote($optionPrice);
$isUpgrade = $optionPrice > ($basePrice ?? $price);
// A paid plan needs a gateway to buy it; a free plan needs nothing, because there is
// no money to take. Offline payment stays administrator-only by design.
$selectable = $optionFree || $canPayByGateway;
@endphp
@php /* WHAT THE TIER ACTUALLY INCLUDES. The card offered a price, a commission rate and
an item ceiling — no mention of POS, QR ordering, the kiosk or the kitchen
display — so an owner was asked to choose between tiers on the two numbers that
differ least. The public Become a Partner page has always listed these, built
the same two ways: the tier's own `features` column, then the switchable
services this platform has turned on. Same rule here, so the pricing a vendor
saw before signing up and the pricing they see inside agree. */
@endphp
@php
$optFeatures = array_filter(array_map('trim', explode('|', (string) ($option['features'] ?? ''))));
foreach (\App\Services\Capability::SERVICES as $svcKey) {
if (!\App\Services\Capability::lacks($svcKey)) {
$optFeatures[] = \App\Services\Capability::label($svcKey);
}
}
// The AI assistant rides the same rule as the services: platform switch on,
// line on every card; switch off, line gone. Label shared with the sidebar.
if (setting('ai_vendor_enabled', '1') === '1') {
$optFeatures[] = t_raw('nav.ai_assistant');
}
@endphp
@if ($optFeatures)
@php /* A real , exactly as the public pricing page writes it — .qm-plan-features
styles that element, so a ::before with its own glyph escape rendered as a
square box here while the same list looked right on Become a Partner. */
@endphp
@if ($selectable)
@php /* The dialog quotes the GROSS. It is the last thing shown before the money moves,
and quoting the net there is what let an owner agree to $99 and be asked for
$118.80 by the checkout — the card above still states the $99 the tier costs,
with the tax line beneath it, so the two figures explain each other. */
@endphp
@else
@php /* .row — opened above for both the free and paid branches */
@endphp
@endif
@php /* The payment record used to sit here. It now lives beside the payment methods, under
My Account, because a plan term and a paid placement are both money paid to this platform
and belong in one book — see partials/billing-history.php. This page is about the PLAN. */
@endphp
@php /* THE HEAD EVERY OTHER SECTION USES. .qm-ops-sub is styled as the sub-line OF an
.qm-ops-head — dropped into .qm-card-head it inherited body type instead, so this
caption rendered at 16px in full ink against the 13px muted one on "Your Campaigns"
two cards above it. Same wrapper, same result, nothing new. */
@endphp