feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now SELLS by picking a plan over a date span; the price is LOOKED UP, never typed — removing the fat-finger risk on a money field — and day/week/month periods make the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span. - Schema/migration 0010: new `subscription_plans` (immutable, effective-dated, keyed by a stable planId; period day/week/month + per-period price + active flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a "Monthly" plan from the existing site default price (no data loss). - Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period), amount = periods × per-period price. Ceil = any started period is full (hotel practice). `resolvePlanVersion` picks the latest active version ≤ sale instant. - Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked sell path derives the amount from the plan; `POST /api/subscriptions/quote` returns a server-computed quote so the operator can't override it. The signed-payment sale fix is unchanged — only the amount SOURCE moved; payload now carries planId/planVersionId/periods. Updates never re-sell (price frozen). - Frontend: SubscriptionManager sell form swaps the price field for a plan picker + start/end dates + a live quote line. New SubscriptionPlansManager (Setup tab) for the admin catalog. i18n (sq+en) for both. Verified on a copy of the live DB: 0010 applies (existing subs intact), a 3-night hotel sale prices to 2,400 ALL, appends one signed payment with planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -46,6 +46,7 @@ export const en: Catalog = {
|
||||
tariff: "Tariff",
|
||||
tariffLab: "Tariff Lab",
|
||||
subscriptions: "Subscriptions",
|
||||
plans: "Plans",
|
||||
site: "Site",
|
||||
users: "Users",
|
||||
roles: "Roles",
|
||||
@@ -382,9 +383,15 @@ export const en: Catalog = {
|
||||
cred: "cred",
|
||||
plates: "{{count}} plate(s)",
|
||||
noPrice: "no price",
|
||||
perDay: "day",
|
||||
perWeek: "week",
|
||||
perMonth: "month",
|
||||
monthlyPrice: "Monthly price",
|
||||
pricePlaceholder: "e.g. 10000",
|
||||
plan: "Plan",
|
||||
planNone: "— comp / no charge —",
|
||||
planNoneAvail: "No plans defined — an admin must create one first.",
|
||||
quoting: "pricing…",
|
||||
quotePrompt: "pick an end date",
|
||||
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
||||
tender: "Paid by",
|
||||
tenderCash: "Cash",
|
||||
tenderCard: "Card",
|
||||
@@ -404,11 +411,7 @@ export const en: Catalog = {
|
||||
limitCarsInAtOnce: "limit cars in at once",
|
||||
validFrom: "Valid from",
|
||||
validTo: "Valid to",
|
||||
months: "Months",
|
||||
monthsHint: "months paid",
|
||||
coverageHint: "until {{end}}",
|
||||
totalDue: "total {{total}}",
|
||||
validToOverride: "Valid to (manual)",
|
||||
validToEnd: "Valid to (end)",
|
||||
isoDateOptional: "ISO date (optional)",
|
||||
boundPlates: "Bound plates",
|
||||
commaSeparatedOptional: "comma-separated (optional)",
|
||||
@@ -441,6 +444,29 @@ export const en: Catalog = {
|
||||
statusSuspended: "suspended",
|
||||
statusRevoked: "revoked",
|
||||
},
|
||||
plans: {
|
||||
title: "Subscription plans",
|
||||
intro: "Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.",
|
||||
add: "+ Add plan",
|
||||
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
|
||||
colName: "Name",
|
||||
colPrice: "Price",
|
||||
colEffective: "Effective",
|
||||
inForce: "in force",
|
||||
retired: "retired",
|
||||
newVersion: "New version",
|
||||
retire: "Retire",
|
||||
newTitle: "New plan",
|
||||
newVersionTitle: "Publish new version",
|
||||
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
|
||||
period: "Period",
|
||||
pricePer: "Price per period",
|
||||
namePlaceholder: "e.g. Hotel daily",
|
||||
needName: "A plan name is required.",
|
||||
needPrice: "Enter a price greater than zero.",
|
||||
saved: "Plan saved.",
|
||||
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
|
||||
},
|
||||
site: {
|
||||
occupancy: "Occupancy:",
|
||||
noCapacitySet: "(no capacity set)",
|
||||
|
||||
Reference in New Issue
Block a user