feat(orders): amendments, family imports, and the ingest API
Closes the three gaps left after the parser rebuild.
Refund amendments. ue-05 is a real refund: 'Previous total $49.94 / Refund
-$4.21 / New Total $45.73'. Uber reuses the order UUID across the receipt and
the amendment, so the two can be matched. The transaction is reduced in place
rather than offset with a second row — the order is one event whose cost
changed, and a compensating row would misreport both the meal count and the
merchant's spend. When the original was never ingested, nothing is invented.
[Family] orders now import instead of parking. Their payment line names the
payer, not an instrument ('Payments Siddharth LKR 3,783.20'), so no split is
recoverable and there is no card leg to reconcile against — they would have sat
pending forever, which fails the actual requirement to import and tag them.
Treated as credits, flagged as an assumption. Safe because the family tag
removes them from every budget regardless of instrument, and the LKR amount is
preserved with amount_aud left NULL rather than asserting an FX rate.
Ingest API. n8n now POSTs each message to /api/orders/ingest instead of parsing
in a Code node — the n8n sandbox has no require or fs, so a parser there cannot
be tested against the fixture corpus, which is the one thing that makes this
parser trustworthy. Auth is a shared secret, since machine callers have no
Traefik session header. Rejections return 422 and record nothing.
60 unit + 45 integration green on three consecutive runs; 63/65 corpus holds.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
parseOrderHTML,
|
||||
parseOrderAmendment,
|
||||
isAmendment,
|
||||
validateOrderTotals,
|
||||
processOrderIngestion,
|
||||
applyOrderAmendment,
|
||||
reconcilePendingOrders,
|
||||
OrderParseError,
|
||||
type MessageMeta,
|
||||
} from "@/lib/order-ingestion";
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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 });
|
||||
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,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof OrderParseError) {
|
||||
// Not a receipt (promotion, adjustment notice, delivery update). Expected
|
||||
// traffic — 200 with skipped, so n8n does not treat it as a failure.
|
||||
return NextResponse.json({ kind: "skipped", reason: e.message });
|
||||
}
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user