server: pay station + fee calc — full transient loop now passes
computeFee() in @parking/shared: pure integer fee over a TariffStructure (stepped blocks, rolling-24h cap). Two edges fixed under test: grace uses RAW duration (not rounded-up minutes), and the block ladder resets each 24h day. PayStation + routes (GET /api/pay/quote, POST /api/pay): look up the open session, resolve the active tariff version (latest effectiveFrom <= entry), computeFee, append a signed payment event (amount/currency/tender/ tariffVersionId/graceExitMin). overrideMinor handles lost-ticket/dispute. PCI stays out of the app: tender only records cash/card. Verified end to end: entry -> quote (300 for 90min) -> pay -> exit opens and closes the session, verifyChain ok.
This commit is contained in:
@@ -113,6 +113,60 @@ export interface TariffBlock {
|
||||
readonly priceMinorPerIncrement: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the parking fee (integer minor units) for a stay, from a TariffStructure.
|
||||
* PURE + deterministic + offline — the pay station calls it with asOf = now; the
|
||||
* result is fixed into a signed `payment` event, so it must be reproducible.
|
||||
*
|
||||
* Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if
|
||||
* within entry grace; else walk the stay one rolling-24h segment at a time, charging
|
||||
* each increment at its block's rate (blocks consumed in order by cumulative minutes),
|
||||
* capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller
|
||||
* validates the tariff exists first).
|
||||
*/
|
||||
export function computeFee(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructure,
|
||||
): number {
|
||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
||||
const inc = Math.max(1, tariff.incrementMin);
|
||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||
|
||||
const DAY = 24 * 60;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||
segFee += rateAt(tariff.blocks, within);
|
||||
}
|
||||
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
||||
total += segFee;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Price of the increment that starts at `cumulativeMin` — the block whose range
|
||||
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
let prev = 0;
|
||||
for (const b of blocks) {
|
||||
if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement;
|
||||
prev = b.uptoMin;
|
||||
void prev;
|
||||
}
|
||||
// No open-ended block and past the last bound: charge the last block's rate.
|
||||
return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0;
|
||||
}
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"admin",
|
||||
"operator",
|
||||
|
||||
Reference in New Issue
Block a user