feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).
1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
amount = span price × quantity; maxConcurrent defaults to the quantity so all
N cars can be inside. Quantity rides in the payment payload.
2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
tariff (the subscriber is a transient for that time):
- early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
the vehicle_entry payload), collected at exit;
- late exit: window-close → departure, and exit is GATED
(sub.refused.unpaidWindow) until paid at the booth.
Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
reuses computeFee + the active tariff version
(apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
business gate — the fail-open rule still governs the offline path.
3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
max(0, quantity − itsCarsInside) per active subscription, so transients see
"full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.
Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -29,10 +29,40 @@ interface PlanForm {
|
||||
period: SubscriptionPeriod;
|
||||
priceMajor: string;
|
||||
currency: string;
|
||||
// Timeframes (tariff bridge). Off → 24/7. On → weekday window (enter-after / exit-before
|
||||
// as HH:MM) + weekend all-day toggle + grace minutes.
|
||||
restrictTimes: boolean;
|
||||
wdFrom: string; // weekday window opens (HH:MM) — when the subscriber may enter
|
||||
wdTo: string; // weekday window closes (HH:MM) — by when they should exit
|
||||
weekendAllDay: boolean;
|
||||
graceMin: string;
|
||||
}
|
||||
|
||||
function emptyForm(): PlanForm {
|
||||
return { planId: "", name: "", period: "month", priceMajor: "", currency: DEFAULT_CURRENCY };
|
||||
return {
|
||||
planId: "",
|
||||
name: "",
|
||||
period: "month",
|
||||
priceMajor: "",
|
||||
currency: DEFAULT_CURRENCY,
|
||||
restrictTimes: false,
|
||||
wdFrom: "20:00",
|
||||
wdTo: "08:00",
|
||||
weekendAllDay: true,
|
||||
graceMin: "0",
|
||||
};
|
||||
}
|
||||
|
||||
/** "HH:MM" → minutes-of-day, or null if blank/invalid. */
|
||||
function hhmmToMin(s: string): number | null {
|
||||
const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
|
||||
if (!m) return null;
|
||||
const min = Number(m[1]) * 60 + Number(m[2]);
|
||||
return min >= 0 && min <= 1439 ? min : null;
|
||||
}
|
||||
/** minutes-of-day → "HH:MM". */
|
||||
function minToHHMM(min: number): string {
|
||||
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SubscriptionPlansManager() {
|
||||
@@ -55,6 +85,20 @@ export function SubscriptionPlansManager() {
|
||||
const major = Number(form.priceMajor);
|
||||
if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") });
|
||||
if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") });
|
||||
// Build the timeframes blob from the form (null = 24/7). The weekday window is the
|
||||
// allowed interval [wdFrom, wdTo) (wraps midnight for a night plan); weekend is all-day
|
||||
// or inherits the weekday window. The server stamps the site tz.
|
||||
let timeframes = null as Parameters<typeof createSubscriptionPlan>[0]["timeframes"];
|
||||
if (form.restrictTimes) {
|
||||
const from = hhmmToMin(form.wdFrom);
|
||||
const to = hhmmToMin(form.wdTo);
|
||||
if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") });
|
||||
timeframes = {
|
||||
weekday: { fromMin: from, toMin: to },
|
||||
weekend: form.weekendAllDay ? { allDay: true } : { fromMin: from, toMin: to },
|
||||
graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)),
|
||||
};
|
||||
}
|
||||
try {
|
||||
await createSubscriptionPlan({
|
||||
planId: form.planId.trim() || undefined,
|
||||
@@ -62,6 +106,7 @@ export function SubscriptionPlansManager() {
|
||||
period: form.period,
|
||||
pricePerPeriodMinor: Math.round(major * 100),
|
||||
currency: form.currency.trim() || DEFAULT_CURRENCY,
|
||||
timeframes,
|
||||
});
|
||||
setForm(null);
|
||||
reload();
|
||||
@@ -80,12 +125,19 @@ export function SubscriptionPlansManager() {
|
||||
|
||||
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
|
||||
function newVersionOf(p: SubscriptionPlan) {
|
||||
const tf = p.timeframes ?? null;
|
||||
const wd = tf?.weekday;
|
||||
setForm({
|
||||
planId: p.planId,
|
||||
name: p.name,
|
||||
period: p.period,
|
||||
priceMajor: String(p.pricePerPeriodMinor / 100),
|
||||
currency: p.currency,
|
||||
restrictTimes: tf != null,
|
||||
wdFrom: wd?.fromMin != null ? minToHHMM(wd.fromMin) : "20:00",
|
||||
wdTo: wd?.toMin != null ? minToHHMM(wd.toMin) : "08:00",
|
||||
weekendAllDay: tf?.weekend?.allDay ?? true,
|
||||
graceMin: String(tf?.graceMin ?? 0),
|
||||
});
|
||||
setMsg(null);
|
||||
}
|
||||
@@ -179,6 +231,48 @@ export function SubscriptionPlansManager() {
|
||||
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
|
||||
window they're charged the transient tariff for the gap. Off = 24/7. */}
|
||||
<div className="mt-3 border-t border-term-border pt-3">
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={form.restrictTimes}
|
||||
onChange={(e) => setForm((f) => f && { ...f, restrictTimes: e.target.checked })}
|
||||
/>
|
||||
{t("plans.restrictTimes")}
|
||||
</label>
|
||||
{form.restrictTimes && (
|
||||
<div className="mt-2 grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("plans.weekdayWindow")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
|
||||
{t("plans.enterAfter")}
|
||||
<input type="time" className="input w-28" value={form.wdFrom} onChange={(e) => setForm((f) => f && { ...f, wdFrom: e.target.value })} />
|
||||
{t("plans.exitBefore")}
|
||||
<input type="time" className="input w-28" value={form.wdTo} onChange={(e) => setForm((f) => f && { ...f, wdTo: e.target.value })} />
|
||||
</span>
|
||||
<label className="label">{t("plans.weekend")}</label>
|
||||
<label className="flex items-center gap-2 text-[12px] text-term-text">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={form.weekendAllDay}
|
||||
onChange={(e) => setForm((f) => f && { ...f, weekendAllDay: e.target.checked })}
|
||||
/>
|
||||
{t("plans.weekendAllDay")}
|
||||
</label>
|
||||
<label className="label">{t("plans.grace")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
|
||||
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
|
||||
</div>
|
||||
|
||||
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||
|
||||
Reference in New Issue
Block a user