fix(medications): stock claims are atomic and only a dose becoming taken consumes

Two hardening passes on the consumption stamp:

- consumeForIntake claims the stamp with a single conditional UPDATE
  (NULL-stamp predicate + row lock) instead of read-then-write, so a
  double tap racing a sync replay decrements exactly once; the loser
  sees count 0. restoreForIntake clears the stamp the same way, so
  concurrent restores cannot refund twice.
- Every seam consumes only on the pending-to-taken transition. A sync
  replay that re-posts an already-taken history row, or an edit that
  corrects the timestamp of an old dose, never touches today's stock.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 18:48:30 +02:00
1 parent e21dfd040e
commit 7eeab2ae98
9 files changed
+345 -90

No files matched your search

+19 -8
View File
@@ -191,6 +191,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
let event;
// v1.16.10 — only the genuine pending→taken transition consumes
// inventory units (the slot upsert's `consumedTransition` flag). A
// re-post converging onto an already-taken slot row — including a
// pre-v1.16.10 row that predates the consumption stamp — must not
// decrement stock again. A fresh ad-hoc create is always a take.
let consumed = true;
if (attribution.slotInstant) {
// Scheduled dose — converge onto the one canonical slot row (the
// pending REMINDER row when one exists) through the shared upsert:
@@ -209,6 +215,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
attributionSource: "AUTO",
});
event = applied.row;
consumed = applied.consumedTransition;
} else {
// Ad-hoc / PRN / off-window — standalone row under the documented
// contract (`scheduledFor = takenAt`).
@@ -227,14 +234,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.16.10 — the external ingest records a take; consume inventory
// units. The stamp on the row keeps a retried POST exactly-once (the
// idempotency probe above already short-circuits the common replay).
await consumeForIntake({
client: prisma,
userId: apiToken.userId,
medicationId: medication.id,
eventId: event.id,
intakeAt: effectiveTakenAt,
});
// idempotency probe above already short-circuits the common replay)
// and the transition gate keeps a slot-converged re-post from
// double-decrementing through a pre-stamp row.
if (consumed) {
await consumeForIntake({
client: prisma,
userId: apiToken.userId,
medicationId: medication.id,
eventId: event.id,
intakeAt: effectiveTakenAt,
});
}
await auditLog("medication.ingest.external", {
userId: apiToken.userId,