ci / lint-test (push) Successful in 43s
Splits on orders are made by hand, so a third participant or an uneven share is a deliberate decision — and "Make it just me" deleted every split row regardless. A one-tap button silently destroying an arrangement made with more care than the tap that undid it is the same failure shape as the rewrite that dropped `settled`. Now it refuses when a participant other than the two consumers is present, or when the share is not 50. Verified against the running stack: a three-way split and a 70/30 both survive a press; a plain 50/50 still toggles off and back on. Also: the nudge reads share state instead of assuming false. Today a freshly ingested order has no splits — the 140 that do were split by hand after the backfill, not by a rule — but the label drives a destructive button, so a wrong assumption there costs data rather than a cosmetic error. One query is cheaper.
174 lines
6.7 KiB
TypeScript
174 lines
6.7 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { queryRow } from "@/lib/db";
|
|
import {
|
|
parseOrderHTML,
|
|
parseOrderAmendment,
|
|
isAmendment,
|
|
validateOrderTotals,
|
|
processOrderIngestion,
|
|
applyOrderAmendment,
|
|
reconcilePendingOrders,
|
|
OrderParseError,
|
|
NotAReceiptError,
|
|
type MessageMeta,
|
|
} from "@/lib/order-ingestion";
|
|
import { merchantVerdict, SECOND_CONSUMER_ID } from "@/lib/order-reviews";
|
|
import { nudgeBlocks } from "@/lib/slack-blocks";
|
|
|
|
/**
|
|
* Machine ingest endpoint for order receipts.
|
|
*
|
|
* n8n polls the two mailboxes and POSTs each message here. The parsing lives in
|
|
* the app, not in an n8n Code node, because the n8n sandbox has no `require`
|
|
* and no filesystem — a parser there could not be unit-tested against the real
|
|
* fixture corpus, which is the whole reason this one is trustworthy.
|
|
*
|
|
* Auth is a shared secret, not the Traefik `x-forwarded-user` header: this is
|
|
* called machine-to-machine and there is no browser session to forward.
|
|
*/
|
|
function authorised(req: NextRequest): boolean {
|
|
const expected = process.env.ORDER_INGEST_TOKEN;
|
|
if (!expected) return false; // fail closed when unconfigured
|
|
const got = req.headers.get("x-ingest-token");
|
|
return !!got && got === expected;
|
|
}
|
|
|
|
/** Does a split with the second consumer already exist on this transaction? */
|
|
async function isShared(transactionId: number | null): Promise<boolean> {
|
|
if (!transactionId) return false;
|
|
const row = await queryRow<{ n: string }>(
|
|
`SELECT count(*) AS n FROM transaction_splits
|
|
WHERE transaction_id = $1 AND participant_id = $2`,
|
|
[transactionId, SECOND_CONSUMER_ID]
|
|
);
|
|
return Number(row?.n ?? 0) > 0;
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
if (!authorised(req)) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
|
|
let body: { html?: string; meta?: MessageMeta; dryRun?: boolean };
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
|
|
}
|
|
|
|
const { html, meta, dryRun } = body;
|
|
if (!html || !meta?.messageId || !meta?.subject || !meta?.receivedAt) {
|
|
return NextResponse.json(
|
|
{ error: "html and meta{messageId,subject,receivedAt} are required" },
|
|
{ status: 400 }
|
|
);
|
|
}
|
|
|
|
try {
|
|
// Amendments restate an existing order; they are not receipts.
|
|
if (isAmendment(html)) {
|
|
const amendment = parseOrderAmendment(html, meta);
|
|
if (dryRun) return NextResponse.json({ kind: "amendment", amendment });
|
|
const applied = await applyOrderAmendment(amendment);
|
|
return NextResponse.json({ kind: "amendment", amendment, applied });
|
|
}
|
|
|
|
const order = parseOrderHTML(html, meta);
|
|
|
|
const check = validateOrderTotals(order, html);
|
|
if (!check.ok) {
|
|
// Refuse rather than record a number we cannot stand behind.
|
|
return NextResponse.json(
|
|
{ kind: "rejected", reason: check.reason, order_reference: order.order_reference },
|
|
{ status: 422 }
|
|
);
|
|
}
|
|
|
|
if (dryRun) return NextResponse.json({ kind: "order", order });
|
|
|
|
const result = await processOrderIngestion(order, {
|
|
messageId: meta.messageId,
|
|
subject: meta.subject,
|
|
sender: meta.sender,
|
|
});
|
|
// What we said about this merchant before, so the Slack nudge can warn at
|
|
// the moment the order lands rather than waiting for someone to open the
|
|
// app. `result.transactionId` is excluded because a brand-new order has no
|
|
// verdict yet — anything found is genuinely a previous visit.
|
|
const verdict = result.skipped
|
|
? null
|
|
: await merchantVerdict(order.merchant_name, result.transactionId);
|
|
|
|
return NextResponse.json({
|
|
kind: "order",
|
|
order_reference: order.order_reference,
|
|
merchant: order.merchant_name,
|
|
total: order.totals.total_charged,
|
|
currency: order.currency,
|
|
is_family: order.is_family,
|
|
...result,
|
|
prior_verdict: verdict && {
|
|
warn: verdict.warn,
|
|
counts: verdict.counts,
|
|
last_note: verdict.history.find((h) => h.note)?.note ?? null,
|
|
},
|
|
// The nudge message is built here, not in n8n expressions: Block Kit in a
|
|
// template string is untestable, and this shape has to stay in step with
|
|
// what /api/slack/interactive renders after a button press. Null when
|
|
// there is no transaction to act on — a card-settled order is parked
|
|
// until its statement arrives, so there is nothing yet to split or rate.
|
|
slack_blocks:
|
|
result.transactionId && !result.skipped
|
|
? nudgeBlocks({
|
|
transactionId: result.transactionId,
|
|
merchant: order.merchant_name,
|
|
currency: order.currency,
|
|
total: Number(order.totals.total_charged),
|
|
isFamily: order.is_family,
|
|
// Read rather than assume. Today a freshly ingested order has no
|
|
// splits, so `false` would be right — the 140 ingested orders
|
|
// that do carry splits were split by hand after the backfill,
|
|
// not by a rule. But the card's label drives a destructive
|
|
// button: if a split rule is ever added, an assumed `false`
|
|
// would label a shared order "Not shared" and offer to remove
|
|
// the split. One query is cheaper than that failure.
|
|
shared: await isShared(result.transactionId),
|
|
warn: verdict?.warn ?? false,
|
|
warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
|
|
})
|
|
: null,
|
|
});
|
|
} catch (e) {
|
|
// Not a receipt: promotions, delivery updates, adjustment and refund
|
|
// notices. Expected traffic — 200 and silent, or the alert channel fills
|
|
// with noise and stops being read.
|
|
if (e instanceof NotAReceiptError) {
|
|
return NextResponse.json({ kind: "skipped", reason: e.message });
|
|
}
|
|
|
|
// IS a receipt, could not be parsed. This is the failure that matters and
|
|
// it must be loud: a provider template change breaks every order at once,
|
|
// and the only other symptom is spend quietly ceasing to appear. Returning
|
|
// 200 here — as this route originally did — made the most likely
|
|
// production failure completely invisible.
|
|
if (e instanceof OrderParseError) {
|
|
return NextResponse.json(
|
|
{ kind: "parse_failed", reason: e.message, messageId: e.messageId },
|
|
{ status: 422 }
|
|
);
|
|
}
|
|
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
return NextResponse.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/** Statement-import hook: resolve orders parked awaiting a card statement. */
|
|
export async function PATCH(req: NextRequest) {
|
|
if (!authorised(req)) {
|
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
}
|
|
const out = await reconcilePendingOrders();
|
|
return NextResponse.json(out);
|
|
}
|