fix(medications): every taken dose moves the stock, in units that match the product

Stock only decremented on the web card's take button — the status
toggle, the phone sync, the Telegram buttons and the token API all
recorded the dose and left the inventory untouched, which is why a
fresh pack kept reading full. Consumption now stamps the intake row
itself: every path that turns a row taken consumes through one module
— open container first, then earliest expiry with auto-open, spilling
into the next pack and flooring at zero — and every path that takes it
back (undo, delete, a take turned skip, a converged duplicate)
refunds exactly what the stamp recorded. Replays and pending sync
echoes can never double-charge, and the nightly convergence pass
refunds the rows it tombstones. A medication can declare units per
dose, so two 2 mg tablets book as one 4 mg dose and stock reads in
doses with the unit count alongside; containers carry a named type
including tablet packs, hold up to a thousand units for inhalers, and
the GLP-1 card reads the same stock as the inventory tab instead of a
separate ledger. The inventory endpoints join the documented API.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 17:42:31 +02:00
1 parent dacdcccfd2
commit 7d3557a3fd
53 files changed
+4118 -641

No files matched your search

@@ -55,6 +55,11 @@ vi.mock("@/lib/logging/context", () => ({
})),
}));
vi.mock("@/lib/medications/inventory/consumption", () => ({
consumeForIntake: vi.fn().mockResolvedValue(undefined),
restoreForIntake: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMedications: vi.fn(),
}));
@@ -257,6 +262,15 @@ describe("Telegram webhook — callback dispatch", () => {
});
// Intake write + snooze reset commit atomically.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
// v1.16.10 — the confirmed take consumes inventory units (after the
// intake transaction committed, on the created row).
const { consumeForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
expect(consumeForIntake).toHaveBeenCalledTimes(1);
expect(consumeForIntake).toHaveBeenCalledWith(
expect.objectContaining({ userId: "user-1", medicationId: "med-1" }),
);
expect(answerTelegramCallbackQuery).toHaveBeenCalledTimes(1);
expect(deleteMessage).toHaveBeenCalledWith(
@@ -329,6 +343,13 @@ describe("Telegram webhook — callback dispatch", () => {
expect(snoozedUntil.getMinutes()).toBe(59);
// Skip row + rest-of-day snooze commit atomically.
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
// v1.16.10 — an explicit skip refunds a previously-consumed stamp
// (no-op for a never-consumed row) and never consumes.
const { consumeForIntake, restoreForIntake } = await import(
"@/lib/medications/inventory/consumption"
);
expect(consumeForIntake).not.toHaveBeenCalled();
expect(restoreForIntake).toHaveBeenCalledTimes(1);
});
it("'ack:<medId>' answers the callback without creating an intake event", async () => {
+35 -4
View File
@@ -20,6 +20,10 @@ import {
resolveSlotForWriteByBand,
} from "@/lib/medications/scheduling/slot-upsert";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
consumeForIntake,
restoreForIntake,
} from "@/lib/medications/inventory/consumption";
import { localHmAsUtc } from "@/lib/tz/local-day";
import { wallClockInTz } from "@/lib/tz/wall-clock";
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
@@ -268,8 +272,9 @@ async function markMedicationTaken(
// an aborted Postgres transaction, so the rare create race rolls the
// whole write back instead of converging in place — Telegram
// redelivers the non-200 update and the retry finds the raced row.
scheduledFor = await prisma.$transaction(async (tx) => {
const written = await prisma.$transaction(async (tx) => {
let slotScheduledFor: Date;
let eventId: string;
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: tx,
@@ -285,6 +290,7 @@ async function markMedicationTaken(
attributionSource: "AUTO",
});
slotScheduledFor = applied.row.scheduledFor;
eventId = applied.row.id;
} else {
// Ad-hoc / PRN — standalone row under the documented contract
// (`scheduledFor = takenAt`).
@@ -300,12 +306,25 @@ async function markMedicationTaken(
},
});
slotScheduledFor = created.scheduledFor;
eventId = created.id;
}
await tx.medication.update({
where: { id: medication.id },
data: { snoozedUntil: null },
});
return slotScheduledFor;
return { slotScheduledFor, eventId };
});
scheduledFor = written.slotScheduledFor;
// v1.16.10 — the button confirmed a take; consume inventory units.
// Runs after the intake transaction committed (the dose record must
// never hinge on the stock write); the stamp keeps a Telegram
// redelivery exactly-once.
await consumeForIntake({
client: prisma,
userId,
medicationId: medication.id,
eventId: written.eventId,
intakeAt: takenAt,
});
// The confirmation must reflect on the next read (today feed, card
// pill, compliance) rather than wait out the cache TTL.
@@ -494,8 +513,9 @@ async function handleCallback(update: TelegramUpdate) {
// re-reminded for a recorded skip or silenced without one. Same
// P2002 trade-off as the take path — the rare create race rolls
// back and Telegram's redelivery converges on the raced row.
skippedScheduledFor = await prisma.$transaction(async (tx) => {
const writtenSkip = await prisma.$transaction(async (tx) => {
let slotScheduledFor: Date;
let eventId: string;
if (canonicalSlot) {
const applied = await applyCanonicalSlotWrite({
client: tx,
@@ -510,6 +530,7 @@ async function handleCallback(update: TelegramUpdate) {
createSource: "REMINDER",
});
slotScheduledFor = applied.row.scheduledFor;
eventId = applied.row.id;
} else {
const created = await tx.medicationIntakeEvent.create({
data: {
@@ -523,12 +544,22 @@ async function handleCallback(update: TelegramUpdate) {
},
});
slotScheduledFor = created.scheduledFor;
eventId = created.id;
}
await tx.medication.update({
where: { id: medication.id },
data: { snoozedUntil: endOfDay },
});
return slotScheduledFor;
return { slotScheduledFor, eventId };
});
skippedScheduledFor = writtenSkip.slotScheduledFor;
// v1.16.10 — an explicit skip can downgrade a previously-taken
// slot row (last-write-wins); refund whatever its consumption
// stamp recorded. No-op for a never-consumed row.
await restoreForIntake({
client: prisma,
userId: user.id,
eventId: writtenSkip.eventId,
});
invalidateUserMedications(user.id, { evict: true });
}