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:
2026-06-02 11:54:36 +02:00
parent 30f6723fef
commit 7aa639f195
4 changed files with 37 additions and 9 deletions

View File

@@ -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',
);