diff --git a/src/app/api/orders/ingest/route.ts b/src/app/api/orders/ingest/route.ts index dde5cd7..eaecbc3 100644 --- a/src/app/api/orders/ingest/route.ts +++ b/src/app/api/orders/ingest/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { queryRow } from "@/lib/db"; import { parseOrderHTML, parseOrderAmendment, @@ -11,7 +12,7 @@ import { NotAReceiptError, type MessageMeta, } from "@/lib/order-ingestion"; -import { merchantVerdict } from "@/lib/order-reviews"; +import { merchantVerdict, SECOND_CONSUMER_ID } from "@/lib/order-reviews"; import { nudgeBlocks } from "@/lib/slack-blocks"; /** @@ -32,6 +33,17 @@ function authorised(req: NextRequest): boolean { 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 }); @@ -113,7 +125,14 @@ export async function POST(req: NextRequest) { currency: order.currency, total: Number(order.totals.total_charged), isFamily: order.is_family, - shared: false, + // 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, }) diff --git a/src/app/api/slack/interactive/route.ts b/src/app/api/slack/interactive/route.ts index 8d78bc8..ad58f18 100644 --- a/src/app/api/slack/interactive/route.ts +++ b/src/app/api/slack/interactive/route.ts @@ -4,6 +4,7 @@ import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verif import { nudgeBlocks, detailsModal } from "@/lib/slack-blocks"; import { RATINGS, + OWNER_PARTICIPANT_ID, SECOND_CONSUMER_ID, merchantVerdict, type Rating, @@ -200,10 +201,23 @@ async function handleModalSubmit(payload: { * 2026-01-09 — so there is no settled historical obligation to lose. */ async function toggleShare(transactionId: number) { - const existing = await queryRaw<{ participant_id: number }>( - `SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`, + const existing = await queryRaw<{ participant_id: number; share_percent: string }>( + `SELECT participant_id, share_percent FROM transaction_splits WHERE transaction_id = $1`, [transactionId] ); + + // Refuse to touch an arrangement this button cannot express. Splits are made + // by hand here, so a third participant or an uneven share is deliberate — and + // a one-tap button that silently flattened it would destroy a decision made + // with more care than the tap that undid it. + const foreign = existing.filter( + (e) => e.participant_id !== SECOND_CONSUMER_ID && e.participant_id !== OWNER_PARTICIPANT_ID + ); + const uneven = existing.some( + (e) => e.participant_id === SECOND_CONSUMER_ID && Number(e.share_percent) !== 50 + ); + if (foreign.length || uneven) return; + if (existing.some((e) => e.participant_id === SECOND_CONSUMER_ID)) { await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [ transactionId,