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 { 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); }