From 7aa639f19529fa5cb91c14226f401586f7c1d145 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 2 Jun 2026 11:54:36 +0200 Subject: [PATCH] fix(audit): criticals C1 (currency-scoped deposit gate), C2 (outcome-aware berth rule), C4 (/q/ allowlist) C1: getDepositTotalForInterest now filters to the interest's depositExpectedCurrency for the auto-advance gate, so a wrong-currency payment can no longer satisfy the deposit expectation (and mark the berth Sold). C2: setInterestOutcome fires interest_completed only for 'won'; lost/cancelled fire a new 'deal_lost' rule that frees the berth instead of flipping it to 'sold'. C4: add '/q/' to proxy PUBLIC_PATHS so tracked links in outbound mail reach external recipients. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/services/berth-rules-engine.ts | 5 +++++ src/lib/services/interests.service.ts | 10 +++++----- src/lib/services/payments.service.ts | 24 ++++++++++++++++++++---- src/proxy.ts | 7 +++++++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/lib/services/berth-rules-engine.ts b/src/lib/services/berth-rules-engine.ts index 4c915081..cabe88a3 100644 --- a/src/lib/services/berth-rules-engine.ts +++ b/src/lib/services/berth-rules-engine.ts @@ -18,6 +18,7 @@ export type BerthRuleTrigger = | 'contract_signed' | 'interest_archived' | 'interest_completed' + | 'deal_lost' | 'berth_unlinked'; export type BerthRuleMode = 'auto' | 'suggest' | 'off'; @@ -42,6 +43,10 @@ const DEFAULT_RULES: Record = { contract_signed: { mode: 'auto', targetStatus: 'sold' }, interest_archived: { mode: 'suggest', targetStatus: 'available' }, interest_completed: { mode: 'auto', targetStatus: 'sold' }, + // Fired when a deal is closed lost/cancelled (audit C2). Frees the berth + // rather than the previous behaviour where ANY outcome reused + // `interest_completed` and flipped the berth to 'sold'. + deal_lost: { mode: 'auto', targetStatus: 'available' }, berth_unlinked: { mode: 'off', targetStatus: 'available' }, }; diff --git a/src/lib/services/interests.service.ts b/src/lib/services/interests.service.ts index 9d32e572..237a3fb8 100644 --- a/src/lib/services/interests.service.ts +++ b/src/lib/services/interests.service.ts @@ -1400,11 +1400,11 @@ export async function setInterestOutcome( stageAtOutcome, }); - // G-C4: fire interest_completed berth-rule for any non-null outcome - // (won / lost / cancelled all qualify). Default rule mode is 'auto' → - // berth status flips to 'sold' for won, but admins can scope per outcome - // via system_settings.berth_rules. - void evaluateRule('interest_completed', id, portId, meta); + // Berth-rule on deal close (audit C2). Only a WON deal should drive the + // berth to 'sold' (via interest_completed). Lost/cancelled deals fire + // `deal_lost`, which frees the berth — previously every outcome reused + // interest_completed and silently flipped the berth to 'sold'. + void evaluateRule(data.outcome === 'won' ? 'interest_completed' : 'deal_lost', id, portId, meta); // Phase 2 nested-subfolders - rename the interest's document folder // to surface the outcome inline (e.g. "Deal A1-A3 (Won)"). Dynamic diff --git a/src/lib/services/payments.service.ts b/src/lib/services/payments.service.ts index 967595f9..dbc4f17a 100644 --- a/src/lib/services/payments.service.ts +++ b/src/lib/services/payments.service.ts @@ -40,6 +40,7 @@ export async function listPaymentsForInterest(interestId: string, portId: string export async function getDepositTotalForInterest( interestId: string, portId: string, + targetCurrency?: string, ): Promise<{ total: string; currency: string }> { const rows = await db .select({ @@ -53,6 +54,12 @@ export async function getDepositTotalForInterest( eq(payments.interestId, interestId), eq(payments.portId, portId), sql`${payments.paymentType} IN ('deposit', 'refund')`, + // Currency-consistency (audit C1): when a target currency is supplied + // (the interest's depositExpectedCurrency) only matching-currency rows + // count, so the auto-advance gate can never satisfy a EUR expectation + // with a USD payment. Without a target, behaviour is unchanged for the + // display-only callers. + targetCurrency ? eq(payments.currency, targetCurrency) : undefined, ), ); @@ -60,7 +67,7 @@ export async function getDepositTotalForInterest( // need cent-precise math for the auto-advance gate, but we DO normalize the // sign of refunds so a refund stored as "+200" still subtracts. let net = 0; - let currency = 'EUR'; + let currency = targetCurrency ?? 'EUR'; for (const row of rows) { const n = Number(row.amount); if (!Number.isFinite(n)) continue; @@ -76,7 +83,13 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me // Resolve interest + sanity-check it belongs to this port. const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, data.interestId), eq(interests.portId, portId)), - columns: { id: true, clientId: true, depositExpectedAmount: true, pipelineStage: true }, + columns: { + id: true, + clientId: true, + depositExpectedAmount: true, + depositExpectedCurrency: true, + pipelineStage: true, + }, }); if (!interest) throw new NotFoundError('Interest'); @@ -127,7 +140,10 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me // promote the stage to 'deposit_paid'. Dynamic import keeps the // payments ↔ interests cycle one-way at module-load time. if (data.paymentType === 'deposit' || data.paymentType === 'refund') { - const { total } = await getDepositTotalForInterest(data.interestId, portId); + // Gate on the deposit total expressed in the interest's expected currency + // only — a wrong-currency payment must not satisfy the expectation (C1). + const expectedCurrency = interest.depositExpectedCurrency ?? 'EUR'; + const { total } = await getDepositTotalForInterest(data.interestId, portId, expectedCurrency); const expected = interest.depositExpectedAmount ? Number(interest.depositExpectedAmount) : null; if (expected !== null && Number.isFinite(expected) && Number(total) >= expected) { const { advanceStageIfBehindGated } = await import('@/lib/services/interests.service'); @@ -136,7 +152,7 @@ export async function createPayment(portId: string, data: CreatePaymentInput, me portId, 'deposit_paid', meta, - `Deposit total (${total} ${data.currency}) reached expected amount`, + `Deposit total (${total} ${expectedCurrency}) reached expected amount`, 'deposit_received', ); diff --git a/src/proxy.ts b/src/proxy.ts index f115c8ac..c2e0ed68 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -63,6 +63,13 @@ const PUBLIC_PATHS: string[] = [ '/setup', '/api/v1/bootstrap/', '/scan', + // Tracked-link redirector. Outbound sales email embeds public + // `/q/` links whose only audience is unauthenticated + // external recipients. The route self-protects (validates the slug + // regex before any DB hit and only 302s to an admin-stored target), + // so it belongs on the anonymous allowlist. Without this, every + // tracked link bounced recipients to /login (audit C4). + '/q/', // §7.1: public sales-playbook docs (deal pulse, etc) so the "Full // guide" link inside the in-app popover is reachable without a // session - and shareable to external collaborators.