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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<BerthRuleTrigger, RuleConfig> = {
|
||||
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' },
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
|
||||
@@ -63,6 +63,13 @@ const PUBLIC_PATHS: string[] = [
|
||||
'/setup',
|
||||
'/api/v1/bootstrap/',
|
||||
'/scan',
|
||||
// Tracked-link redirector. Outbound sales email embeds public
|
||||
// `<APP_URL>/q/<slug>` 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.
|
||||
|
||||
Reference in New Issue
Block a user