fix(slack): don't let one tap flatten a hand-made split
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.
This commit is contained in:
2026-07-28 16:40:53 +10:00
parent 14d6b40578
commit 50c5b7c430
2 changed files with 37 additions and 4 deletions
+21 -2
View File
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { queryRow } from "@/lib/db";
import { import {
parseOrderHTML, parseOrderHTML,
parseOrderAmendment, parseOrderAmendment,
@@ -11,7 +12,7 @@ import {
NotAReceiptError, NotAReceiptError,
type MessageMeta, type MessageMeta,
} from "@/lib/order-ingestion"; } 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"; import { nudgeBlocks } from "@/lib/slack-blocks";
/** /**
@@ -32,6 +33,17 @@ function authorised(req: NextRequest): boolean {
return !!got && got === expected; 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) { export async function POST(req: NextRequest) {
if (!authorised(req)) { if (!authorised(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -113,7 +125,14 @@ export async function POST(req: NextRequest) {
currency: order.currency, currency: order.currency,
total: Number(order.totals.total_charged), total: Number(order.totals.total_charged),
isFamily: order.is_family, 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, warn: verdict?.warn ?? false,
warnNote: verdict?.history.find((h) => h.note)?.note ?? null, warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
}) })
+16 -2
View File
@@ -4,6 +4,7 @@ import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verif
import { nudgeBlocks, detailsModal } from "@/lib/slack-blocks"; import { nudgeBlocks, detailsModal } from "@/lib/slack-blocks";
import { import {
RATINGS, RATINGS,
OWNER_PARTICIPANT_ID,
SECOND_CONSUMER_ID, SECOND_CONSUMER_ID,
merchantVerdict, merchantVerdict,
type Rating, type Rating,
@@ -200,10 +201,23 @@ async function handleModalSubmit(payload: {
* 2026-01-09 — so there is no settled historical obligation to lose. * 2026-01-09 — so there is no settled historical obligation to lose.
*/ */
async function toggleShare(transactionId: number) { async function toggleShare(transactionId: number) {
const existing = await queryRaw<{ participant_id: number }>( const existing = await queryRaw<{ participant_id: number; share_percent: string }>(
`SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`, `SELECT participant_id, share_percent FROM transaction_splits WHERE transaction_id = $1`,
[transactionId] [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)) { if (existing.some((e) => e.participant_id === SECOND_CONSUMER_ID)) {
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [ await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [
transactionId, transactionId,