@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

{{ t_raw('vendor.plans.your_plan_title') }}

@if ($subStatus !== '') {{ status_label('subscription', (string) $subStatus) }} @endif
@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')
{{ t_raw('vendor.plans.lapsed_title') }}

{!! t_raw('vendor.plans.lapsed_text', [':date' => fmt_day($term['ends_at'], true), ':days' => number_format(abs($daysLeft))]) !!}

@elseif ($term['state'] === 'ended')
{{ t_raw('vendor.plans.ended_title', [':status' => status_label('subscription', (string) $subStatus)]) }}

{{ t_raw('vendor.plans.ended_text') }}

@elseif ($term['state'] === 'expiring')
{{ t_raw('vendor.plans.expiring_title', [':days' => number_format($daysLeft)]) }}

{!! t_raw('vendor.plans.expiring_text', [':date' => fmt_day($term['ends_at'], true)]) !!}

@endif @if ($plan)
{{ (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
{{ t_raw('common.status') }}
{!! $subscription ? '' . status_label('subscription', (string) $subStatus) . '' : t('vendor.plans.no_active_sub') !!}
{{ t_raw('vendor.plans.renews_ends_label') }}
@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
{{ t_raw('vendor.plans.commission_label') }}
{{ (string) (float) $plan['commission_percent'] }}%{{ t_raw('vendor.plans.commission_hint') }}
{{ t_raw('vendor.plans.allowance_label') }}
@if ($allowance['unlimited']) {{ t_raw('vendor.plans.allowance_unlimited', [':used' => number_format($allowance['used'])]) }} @else {{ t_raw('vendor.plans.allowance_used', [':used' => number_format($allowance['used']), ':limit' => number_format($allowance['limit'])]) }} {!! $allowance['reached'] ? t('vendor.plans.allowance_full') : t('vendor.plans.allowance_remaining', [':n' => number_format($allowance['remaining'])]) !!} @endif
@if ($subscription && !empty($subscription['payment_method']))
{{ t_raw('vendor.plans.last_payment_label') }}
{{ status_label('payment_method', (string) $subscription['payment_method']) }}
@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))
@csrf
@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)
@endif @if ($plan && $isFree)

{{ t_raw('vendor.plans.free_plan_title') }}

{{ t_raw('vendor.plans.free_plan_text', [':site' => setting('site_name', 'QuickMunch')]) }}

@elseif ($plan)

{!! $gatewayWillCharge ? t('vendor.plans.auto_renew_title') : t('vendor.billing.pay_title_' . $payAction) !!}

@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

{!! $gatewayWillCharge ? t('vendor.plans.auto_renew_sub', [':price' => money($gross), ':currency' => $currency, ':date' => fmt_day($term['ends_at'])]) : t('vendor.billing.pay_sub_' . $payAction, [':plan' => (string) $plan['name'], ':price' => money($gross), ':currency' => $currency]) !!}

@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
{{ t_raw('vendor.plans.next_charge_label') }}
{{ money($gross) }} {{ $currency }}{{ t_raw('vendor.plans.days_left', [':days' => number_format($daysLeft)]) }}
{{ t_raw('vendor.plans.next_charge_date_label') }}
{!! fmt_day($term['ends_at']) !!}
@if ($subscription && !empty($subscription['payment_method']))
{{ t_raw('vendor.plans.charged_to_label') }}
{{ status_label('payment_method', (string) $subscription['payment_method']) }}
@endif
@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
@csrf
@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
@csrf @if (!empty($sellsAnnually)) @php /* Which term is being bought. Both labels quote the GROSS — each radio names a charge — and the consent button follows the selection. */ @endphp @php $radioMoGross = \App\Services\Invoice::grossFor(\App\Models\Subscription::priceFor($plan, 'monthly')); $radioYrGross = \App\Services\Invoice::grossFor(\App\Models\Subscription::priceFor($plan, 'yearly')); $radioCur = ($currentCycle ?? '') === 'yearly' ? 'yearly' : 'monthly'; @endphp
@endif @php /* The gross: this button IS the consent, and it named a figure the gateway was never going to take. "Renew for $49" then arrived at a checkout asking $58.80 — the same amount payShow() has always charged. */ @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

{{ t_raw('vendor.plans.change_plan_sub') }}

@php $gridAnnual = \App\Models\Subscription::annualEnabled() && array_filter($planOptions, static fn (array $q): bool => \App\Models\Subscription::sellsAnnually($q)) !== []; $gridCycle = ($currentCycle ?? '') === 'yearly' ? 'yearly' : 'monthly'; @endphp @if ($gridAnnual)
@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
{{ (string) $option['name'] }} {!! $isUpgrade ? t('vendor.plans.upgrade_pill') : t('vendor.plans.downgrade_pill') !!}

@if ($optAnnual && (float) ($annualPct ?? 0) > 0){{ money($optionPrice * 12) }}@endif {!! $optionFree ? t('common.free') : money($optShowYr ? $optYrPrice : $optionPrice) !!} @if (!$optionFree){{ $cycleLabel[$optShowYr ? 'yearly' : (string) $option['billing_cycle']] ?? '' }}@endif @if ($optionTaxNote !== '' || $optYrTaxNote !== ''){{ $optShowYr ? $optYrTaxNote : $optionTaxNote }}@endif

{{ t_raw('vendor.plans.change_plan_terms', [ ':commission' => (string) (float) $option['commission_percent'], ':items' => (int) $option['max_menu_items'] > 0 ? number_format((int) $option['max_menu_items']) : t_raw('admin.plans.unlimited'), ]) }}

@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
    @foreach ($optFeatures as $f)
  • {{ $f }}
  • @endforeach
@endif

@if ($term['lifetime'] || $term['ends_at'] === null || $daysLeft <= 0 || $creditValue <= 0 || $optCreditM <= 0) {{ t_raw('vendor.plans.change_plan_no_term_left') }} @else {!! $creditNoteFor($optShowYr ? $optCreditY : $optCreditM) !!} @endif

@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
@csrf @if ($optAnnual)@endif @php /* No "Pay with" select here either. Asking which gateway inside a card that is still describing the plan put the payment question before the decision it pays for, and it was the only control on this screen that had to be filled in before its button could be pressed. Checkout asks it, once, for every path that reaches it. */ @endphp @php /* The card has already worked out which direction this is — it draws the arrow and the Upgrade/Downgrade pill from it — and the button then threw that away and said "Pay $49 and switch": a price the card states in bold directly above, and a verb that names neither the plan nor the direction. Worse, the FREE branch beside it already did it properly ("Move to Starter"), so one component spoke two ways. One rule now: an upgrade says so, everything else moves, and both name where they are going. */ @endphp
@else

{{ t_raw('vendor.plans.change_plan_contact_admin') }}

@endif
@endforeach
@if ($gridAnnual) @endif
@endif @if ($plan)
@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

{{ t_raw('vendor.billing.history_title') }}

{{ t_raw('vendor.plans.history_moved') }}

{{ t_raw('vendor.billing.history_open') }}
@php /* /.qm-d2 */ @endphp @endsection