Compare commits

...
4 Commits
Author SHA1 Message Date
lukasandClaude Sonnet 5 b7fdbd4779 Don't let a compensating payment call swallow the original error
Both placeOrder and cancelOrderItem call a compensating payment action
(refund / re-charge) after catching a failure, then rethrow. If that
compensating call itself threw, its error replaced the original one,
hiding the actual root cause behind whatever the payment provider
happened to fail with on the second attempt. Wrap each compensation in
its own try/catch, log the compensation failure, and always rethrow
the original error.

Also fixes a stale comment left over from renaming
createFirstStepForOrderItem to initializeOrderItemRecipe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:07:14 +02:00
lukasandClaude Sonnet 5 506319ce4f Refund before cancelling, not after, with compensation on failure
cancelOrderItem committed the cancellation (freeing the Machine slot,
marking the item cancelled) before calling the payment provider's
refund — so a refund failure (gateway timeout, provider outage) left
the item cancelled with no refund ever issued, and no record that one
was owed. placeOrder already avoids the equivalent problem by charging
before its DB transaction and refunding if the transaction then fails;
apply the same pattern here for consistency.

Refund now happens first; the DB transaction's atomic queued-status
check (added for the earlier cancellation race) doubles as the signal
that the refund needs to be undone if this transaction loses the race
or otherwise fails, so the catch block re-charges to compensate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:01:55 +02:00
lukasandClaude Sonnet 5 9c3ad51686 Freeze an Order Item's Recipe at order time
Price was already snapshotted per Order Item so later menu changes
don't rewrite history, but the Recipe (Step list) wasn't: advancing an
Order Item looked up its next Step from the live menuItemSteps table,
so an operator editing a Menu Item's Recipe mid-cook could silently
reshape an Order already in flight (different Machine Tag, duration,
or Step order than what the customer paid for).

Adds order_item_steps as a per-Order-Item copy of the Recipe, taken at
placement time; placeOrder, advanceOrderItemStep, and getOrderStatus
now all read from that snapshot instead of the live menu. Recorded as
docs/adr/0005 and added Recipe as a formal CONTEXT.md term.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 19:55:05 +02:00
lukasandClaude Sonnet 5 d291312139 Sharpen ordering domain model and close a cancellation race
CONTEXT.md described Order Item cancellability and Order completion
in ways the code didn't actually implement (cancellability only
covered the first Preparation Step; completion required pickup, not
any terminal state). Corrected the glossary to match intended
behavior, added Guest Access Token and Charge as formal terms, and
recorded the single-Charge-per-Order design as an ADR.

Also closes a real race in cancelOrderItem: a Machine could claim a
queued Step between the cancellability check and the delete, letting
an already-cooking item get cancelled and refunded. The delete now
atomically re-checks status = 'queued'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 19:48:04 +02:00
10 changed files with 942 additions and 95 deletions
+13 -2
View File
@@ -16,12 +16,23 @@ _Avoid_: Store, location (as a separate multi-tenant concept — this project ha
The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens. The single restaurant concept (menu, name, branding) served by one Kitchen. One Brand per Kitchen — not a separate entity shared across multiple kitchens.
**Order**: **Order**:
A customer's placed, paid request, containing one or more Order Items. An Order is complete only once every Order Item within it has been picked up. A customer's placed, paid request, containing one or more Order Items. An Order is complete once every Order Item within it has reached a terminal state — picked up or cancelled. (A fully-cancelled Order is still "complete": there is no separate status for that outcome.)
**Charge**:
The single payment transaction covering an Order's full total, created once at placement time. Can be partially refunded multiple times over the Order's life — once per Order Item that gets cancelled — rather than each Order Item carrying its own separate payment.
_Avoid_: Payment (too generic — this project always means one Charge per Order, not a collection of per-item payments)
**Order Item**: **Order Item**:
A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time before its first Preparation Step begins; locked once that step starts. A single menu item within an Order. Progresses through its own Preparation Steps independently of the other items in the same Order, and can be picked up separately from them. Cancellable with refund any time its current Preparation Step has not yet been claimed by a Machine — including the gap between one Step finishing and the next Step being claimed. Locked as soon as a Step is claimed by a Machine (in progress).
_Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state) _Avoid_: Line item (implies a billing-only concept; this also carries preparation/pickup state)
**Guest Access Token**:
A bearer credential issued to a guest customer (one without an account) at Order placement time. Since a guest has no session, this token is the only proof of ownership accepted on later calls against that Order (status lookup, cancellation, pickup).
_Avoid_: Session (guests never get a session; this is a different, bearer-credential mechanism)
**Recipe**:
The ordered list of Preparation Steps required to prepare a Menu Item. A Menu Item's Recipe can be edited later by an operator; an Order Item freezes a copy of the Recipe at the moment it's ordered, so a later Recipe edit never reshapes a Step list a customer already paid for.
**Preparation Step**: **Preparation Step**:
One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion. One stage of preparing an Order Item, requiring a Machine carrying a specific Machine Tag, with an estimated duration. An Order Item consists of one or more Steps performed in sequence — the next Step cannot begin until the previous one finishes. The customer can see their Order Item's full Step list, current queue position, and approximate time to completion.
_Avoid_: Stage (use Step consistently) _Avoid_: Stage (use Step consistently)
+1 -1
View File
@@ -1,3 +1,3 @@
# Order Items are independent, not the Order # Order Items are independent, not the Order
Preparation, queuing, pickup, and cancellation are all tracked per Order Item, not per Order. An Order is just a bundle that is "complete" once every Order Item in it has been picked up — but each Order Item cooks on its own Machine, becomes ready on its own, gets notified and picked up on its own, and can be cancelled on its own (locked only once its own first Preparation Step starts). We chose this over treating the Order as the atomic unit because items in the same order can have very different cook times (a pizza vs. a burger); forcing them to stay in lockstep would mean a finished item goes cold waiting on a slower one, or a customer loses the ability to cancel a not-yet-started item just because a sibling item already started cooking. Preparation, queuing, pickup, and cancellation are all tracked per Order Item, not per Order. An Order is just a bundle that is "complete" once every Order Item in it has reached a terminal state — but each Order Item cooks on its own Machine, becomes ready on its own, gets notified and picked up on its own, and can be cancelled on its own (locked only once its own current Preparation Step is claimed by a Machine). We chose this over treating the Order as the atomic unit because items in the same order can have very different cook times (a pizza vs. a burger); forcing them to stay in lockstep would mean a finished item goes cold waiting on a slower one, or a customer loses the ability to cancel a not-yet-started item just because a sibling item already started cooking.
+3
View File
@@ -0,0 +1,3 @@
# Single Charge per Order, refunded incrementally per cancelled Item
An Order is charged once, in full, at placement time — before any Order Item exists — rather than opening a separate charge per Order Item. When an Order Item is later cancelled, its share of the total is refunded against that same Charge, so one Charge can be partially refunded multiple times over an Order's life, once per cancelled Item. We chose one Charge per Order because it means a single payment authorization per customer checkout, keeping the payment-provider integration and the customer-facing checkout flow simple; the alternative — a Charge per Order Item — would let items be refunded fully independently of one another, but would mean authorizing the customer's card multiple times per Order and tracking a payment-provider transaction per Item instead of per Order.
@@ -0,0 +1,3 @@
# Recipe snapshot per Order Item
An Order Item's full Recipe (every Preparation Step, in order) is copied from the Menu Item's live Recipe into a per-Order-Item `orderItemSteps` table at order-placement time, rather than each Step being looked up from the live Recipe as the Order Item progresses. We chose this because price is already snapshotted per Order Item for the same reason ("later menu changes don't rewrite history") — an operator editing a Menu Item's Recipe (reordering Steps, changing a Step's Machine Tag or duration) should never reshape an Order Item a customer already paid for and is partway through cooking. The live `menuItemSteps` table remains the source of truth for menu browsing and for new Orders; only `orderItemSteps` is read once an Order Item exists.
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE "order_item_steps" (
"id" serial PRIMARY KEY NOT NULL,
"order_item_id" integer NOT NULL,
"sequence" integer NOT NULL,
"machine_tag_id" integer NOT NULL,
"estimated_duration_seconds" integer NOT NULL
);
--> statement-breakpoint
ALTER TABLE "order_item_steps" ADD CONSTRAINT "order_item_steps_order_item_id_order_items_id_fk" FOREIGN KEY ("order_item_id") REFERENCES "public"."order_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "order_item_steps" ADD CONSTRAINT "order_item_steps_machine_tag_id_machine_tags_id_fk" FOREIGN KEY ("machine_tag_id") REFERENCES "public"."machine_tags"("id") ON DELETE no action ON UPDATE no action;
+590
View File
@@ -0,0 +1,590 @@
{
"id": "aecfb37f-fb88-44ba-8e02-f290a3929293",
"prevId": "816e0a1c-3005-433c-911b-2e006cc9e91b",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.brands": {
"name": "brands",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machine_tags": {
"name": "machine_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"machine_tags_name_idx": {
"name": "machine_tags_name_idx",
"columns": [
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.machines": {
"name": "machines",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"machines_machine_tag_id_machine_tags_id_fk": {
"name": "machines_machine_tag_id_machine_tags_id_fk",
"tableFrom": "machines",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_item_steps": {
"name": "menu_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"menu_item_steps_menu_item_id_menu_items_id_fk": {
"name": "menu_item_steps_menu_item_id_menu_items_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"menu_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "menu_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "menu_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.menu_items": {
"name": "menu_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"brand_id": {
"name": "brand_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"menu_items_brand_id_brands_id_fk": {
"name": "menu_items_brand_id_brands_id_fk",
"tableFrom": "menu_items",
"tableTo": "brands",
"columnsFrom": [
"brand_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_item_steps": {
"name": "order_item_steps",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"order_item_steps_order_item_id_order_items_id_fk": {
"name": "order_item_steps_order_item_id_order_items_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_item_steps_machine_tag_id_machine_tags_id_fk": {
"name": "order_item_steps_machine_tag_id_machine_tags_id_fk",
"tableFrom": "order_item_steps",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.order_items": {
"name": "order_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_id": {
"name": "order_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"menu_item_id": {
"name": "menu_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"price_cents": {
"name": "price_cents",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"order_items_order_id_orders_id_fk": {
"name": "order_items_order_id_orders_id_fk",
"tableFrom": "order_items",
"tableTo": "orders",
"columnsFrom": [
"order_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"order_items_menu_item_id_menu_items_id_fk": {
"name": "order_items_menu_item_id_menu_items_id_fk",
"tableFrom": "order_items",
"tableTo": "menu_items",
"columnsFrom": [
"menu_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.orders": {
"name": "orders",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"customer_id": {
"name": "customer_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_name": {
"name": "guest_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_contact": {
"name": "guest_contact",
"type": "text",
"primaryKey": false,
"notNull": false
},
"guest_access_token": {
"name": "guest_access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'placed'"
},
"payment_charge_id": {
"name": "payment_charge_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.preparation_step_instances": {
"name": "preparation_step_instances",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"order_item_id": {
"name": "order_item_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"sequence": {
"name": "sequence",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"machine_tag_id": {
"name": "machine_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"estimated_duration_seconds": {
"name": "estimated_duration_seconds",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"machine_id": {
"name": "machine_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"done_at": {
"name": "done_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"preparation_step_instances_order_item_id_order_items_id_fk": {
"name": "preparation_step_instances_order_item_id_order_items_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "order_items",
"columnsFrom": [
"order_item_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_tag_id_machine_tags_id_fk": {
"name": "preparation_step_instances_machine_tag_id_machine_tags_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machine_tags",
"columnsFrom": [
"machine_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"preparation_step_instances_machine_id_machines_id_fk": {
"name": "preparation_step_instances_machine_id_machines_id_fk",
"tableFrom": "preparation_step_instances",
"tableTo": "machines",
"columnsFrom": [
"machine_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1788972318759, "when": 1788972318759,
"tag": "0001_graceful_frank_castle", "tag": "0001_graceful_frank_castle",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1788976393301,
"tag": "0002_large_lake",
"breakpoints": true
} }
] ]
} }
+16
View File
@@ -102,6 +102,22 @@ export const orderItems = pgTable('order_items', {
createdAt: timestamp('created_at').defaultNow().notNull(), createdAt: timestamp('created_at').defaultNow().notNull(),
}) })
// A frozen copy of a Menu Item's Recipe as it existed when a given Order
// Item was placed. A later edit to the live Recipe (menuItemSteps) never
// reshapes an Order Item already in flight — see
// docs/adr/0005-recipe-snapshot-per-order-item.md.
export const orderItemSteps = pgTable('order_item_steps', {
id: serial().primaryKey(),
orderItemId: integer('order_item_id')
.notNull()
.references(() => orderItems.id),
sequence: integer().notNull(),
machineTagId: integer('machine_tag_id')
.notNull()
.references(() => machineTags.id),
estimatedDurationSeconds: integer('estimated_duration_seconds').notNull(),
})
export const preparationStepStatusValues = [ export const preparationStepStatusValues = [
'queued', 'queued',
'in_progress', 'in_progress',
+105 -50
View File
@@ -170,31 +170,48 @@ export const assignQueuedStepsForTag = async (
} }
} }
const createFirstStepForOrderItem = async ( // Copies the Menu Item's live Recipe into orderItemSteps for this Order
// Item, then creates its first Preparation Step instance from that copy —
// so a later edit to the live Recipe never reshapes an Order Item already
// in flight (see docs/adr/0005-recipe-snapshot-per-order-item.md).
const initializeOrderItemRecipe = async (
db: DbOrTx, db: DbOrTx,
orderItemId: number, orderItemId: number,
menuItemId: number, menuItemId: number,
) => { ) => {
const firstStep = firstRow( const liveSteps = await db
await db
.select() .select()
.from(schema.menuItemSteps) .from(schema.menuItemSteps)
.where( .where(eq(schema.menuItemSteps.menuItemId, menuItemId))
and( .orderBy(asc(schema.menuItemSteps.sequence))
eq(schema.menuItemSteps.menuItemId, menuItemId),
eq(schema.menuItemSteps.sequence, 1),
),
),
)
// A menu item with no Preparation Steps at all is a data integrity bug // A menu item with no Preparation Steps at all is a data integrity bug
// (see spec: every menu item's Recipe has at least one Step), not a valid // (see spec: every menu item's Recipe has at least one Step), not a valid
// "nothing to do" case — surfacing it loudly here beats leaving the Order // "nothing to do" case — surfacing it loudly here beats leaving the Order
// Item permanently stuck with no Step and no way to progress or cancel. // Item permanently stuck with no Step and no way to progress or cancel.
if (!firstStep) { if (liveSteps.length === 0) {
throw new Error(`Menu item ${menuItemId} has no Preparation Steps defined`) throw new Error(`Menu item ${menuItemId} has no Preparation Steps defined`)
} }
const recipeSteps = await db
.insert(schema.orderItemSteps)
.values(
liveSteps.map((step) => ({
orderItemId,
sequence: step.sequence,
machineTagId: step.machineTagId,
estimatedDurationSeconds: step.estimatedDurationSeconds,
})),
)
.returning()
const firstStep = recipeSteps.find((step) => step.sequence === 1)
if (!firstStep) {
throw new Error(
`Menu item ${menuItemId} has no Preparation Step with sequence 1`,
)
}
const [instance] = await db const [instance] = await db
.insert(schema.preparationStepInstances) .insert(schema.preparationStepInstances)
.values({ .values({
@@ -240,7 +257,7 @@ export const placeOrder = async (
// Catch the "menu item has no Recipe Steps" data bug before charging // Catch the "menu item has no Recipe Steps" data bug before charging
// anyone, rather than charging first and refunding once // anyone, rather than charging first and refunding once
// createFirstStepForOrderItem discovers it mid-transaction. // initializeOrderItemRecipe discovers it mid-transaction.
const itemIdsWithSteps = new Set( const itemIdsWithSteps = new Set(
( (
await db await db
@@ -304,7 +321,7 @@ export const placeOrder = async (
}) })
.returning() .returning()
const step = await createFirstStepForOrderItem( const step = await initializeOrderItemRecipe(
tx, tx,
orderItem.id, orderItem.id,
menuItem.id, menuItem.id,
@@ -320,8 +337,17 @@ export const placeOrder = async (
}) })
} catch (error) { } catch (error) {
// The charge already happened; a failed transaction must not leave the // The charge already happened; a failed transaction must not leave the
// customer paying for an Order that doesn't exist. // customer paying for an Order that doesn't exist. If the compensating
// refund itself fails, the transaction failure is still the real cause —
// log the refund failure rather than letting it hide that error.
try {
await paymentProvider.refund(charge.chargeId, totalCents) await paymentProvider.refund(charge.chargeId, totalCents)
} catch (refundError) {
console.error(
`Failed to refund charge ${charge.chargeId} after a failed order transaction:`,
refundError,
)
}
throw error throw error
} }
} }
@@ -383,9 +409,8 @@ export const cancelOrderItem = async (
orderItemId: number, orderItemId: number,
access: OrderAccess, access: OrderAccess,
) => { ) => {
const result = await db.transaction(async (tx) => {
const orderItem = firstRow( const orderItem = firstRow(
await tx await db
.select() .select()
.from(schema.orderItems) .from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)), .where(eq(schema.orderItems.id, orderItemId)),
@@ -393,7 +418,7 @@ export const cancelOrderItem = async (
if (!orderItem) throw new Error('Order item not found') if (!orderItem) throw new Error('Order item not found')
const order = firstRow( const order = firstRow(
await tx await db
.select() .select()
.from(schema.orders) .from(schema.orders)
.where(eq(schema.orders.id, orderItem.orderId)), .where(eq(schema.orders.id, orderItem.orderId)),
@@ -408,14 +433,41 @@ export const cancelOrderItem = async (
throw new OrderItemNotCancellableError('it is already done cooking') throw new OrderItemNotCancellableError('it is already done cooking')
} }
const currentStep = await getCurrentStepInstance(tx, orderItemId) const currentStep = await getCurrentStepInstance(db, orderItemId)
if (!currentStep || currentStep.status !== 'queued') { if (!currentStep || currentStep.status !== 'queued') {
throw new OrderItemNotCancellableError('it has already started cooking') throw new OrderItemNotCancellableError('it has already started cooking')
} }
await tx // Refund before touching any state, mirroring placeOrder's charge-first
// pattern: if the refund itself fails, nothing about this cancellation is
// ever recorded, exactly like a declined charge leaves no Order behind.
if (order.paymentChargeId) {
await paymentProvider.refund(order.paymentChargeId, orderItem.priceCents)
}
try {
await db.transaction(async (tx) => {
// Re-check status = 'queued' as part of the delete itself, atomically:
// a Machine may have claimed this Step (assignQueuedStepsForTag), or a
// concurrent cancelOrderItem call may have already deleted it, in the
// time since the read above (which can include the refund call, an
// external network round trip). If so, zero rows match and the catch
// below re-charges to undo the refund we already issued.
const deletedSteps = await tx
.delete(schema.preparationStepInstances) .delete(schema.preparationStepInstances)
.where(eq(schema.preparationStepInstances.id, currentStep.id)) .where(
and(
eq(schema.preparationStepInstances.id, currentStep.id),
eq(schema.preparationStepInstances.status, 'queued'),
),
)
.returning({ id: schema.preparationStepInstances.id })
if (deletedSteps.length === 0) {
throw new OrderItemNotCancellableError(
'it has already started cooking',
)
}
await tx await tx
.update(schema.orderItems) .update(schema.orderItems)
@@ -423,17 +475,26 @@ export const cancelOrderItem = async (
.where(eq(schema.orderItems.id, orderItemId)) .where(eq(schema.orderItems.id, orderItemId))
await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId) await updateOrderCompleteIfEverySiblingIsDone(tx, orderItem.orderId)
return { order, orderItem }
}) })
} catch (error) {
if (result.order.paymentChargeId) { // The refund already happened; an aborted cancellation must not leave
await paymentProvider.refund( // the customer refunded for an item that's still cooking. If the
result.order.paymentChargeId, // compensating re-charge itself fails, the cancellation failure is
result.orderItem.priceCents, // still the real cause — log the re-charge failure rather than letting
// it hide that error.
if (order.paymentChargeId) {
try {
await paymentProvider.charge(orderItem.priceCents)
} catch (rechargeError) {
console.error(
`Failed to re-charge ${orderItem.priceCents} cents for order item ${orderItemId} after an aborted cancellation:`,
rechargeError,
) )
} }
} }
throw error
}
}
export const advanceOrderItemStep = async (db: Db, orderItemId: number) => { export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
@@ -447,22 +508,14 @@ export const advanceOrderItemStep = async (db: Db, orderItemId: number) => {
.set({ status: 'done', doneAt: new Date() }) .set({ status: 'done', doneAt: new Date() })
.where(eq(schema.preparationStepInstances.id, currentStep.id)) .where(eq(schema.preparationStepInstances.id, currentStep.id))
const orderItem = firstRow(
await tx
.select()
.from(schema.orderItems)
.where(eq(schema.orderItems.id, orderItemId)),
)
if (!orderItem) throw new Error('Order item not found')
const nextStepDefinition = firstRow( const nextStepDefinition = firstRow(
await tx await tx
.select() .select()
.from(schema.menuItemSteps) .from(schema.orderItemSteps)
.where( .where(
and( and(
eq(schema.menuItemSteps.menuItemId, orderItem.menuItemId), eq(schema.orderItemSteps.orderItemId, orderItemId),
eq(schema.menuItemSteps.sequence, currentStep.sequence + 1), eq(schema.orderItemSteps.sequence, currentStep.sequence + 1),
), ),
), ),
) )
@@ -565,21 +618,25 @@ export const getOrderStatus = async (
).map((row) => [row.id, row]), ).map((row) => [row.id, row]),
) )
const recipeRows = await db const recipeRows =
orderItemIds.length === 0
? []
: await db
.select({ .select({
menuItemId: schema.menuItemSteps.menuItemId, orderItemId: schema.orderItemSteps.orderItemId,
sequence: schema.menuItemSteps.sequence, sequence: schema.orderItemSteps.sequence,
machineTag: schema.machineTags.name, machineTag: schema.machineTags.name,
machineTagId: schema.menuItemSteps.machineTagId, machineTagId: schema.orderItemSteps.machineTagId,
estimatedDurationSeconds: schema.menuItemSteps.estimatedDurationSeconds, estimatedDurationSeconds:
schema.orderItemSteps.estimatedDurationSeconds,
}) })
.from(schema.menuItemSteps) .from(schema.orderItemSteps)
.innerJoin( .innerJoin(
schema.machineTags, schema.machineTags,
eq(schema.menuItemSteps.machineTagId, schema.machineTags.id), eq(schema.orderItemSteps.machineTagId, schema.machineTags.id),
) )
.where(inArray(schema.menuItemSteps.menuItemId, menuItemIds)) .where(inArray(schema.orderItemSteps.orderItemId, orderItemIds))
.orderBy(asc(schema.menuItemSteps.sequence)) .orderBy(asc(schema.orderItemSteps.sequence))
const allInstances = const allInstances =
orderItemIds.length === 0 orderItemIds.length === 0
@@ -638,9 +695,7 @@ export const getOrderStatus = async (
const itemViews: Array<OrderItemStatusView> = items.map((item) => { const itemViews: Array<OrderItemStatusView> = items.map((item) => {
const menuItem = menuItemsById.get(item.menuItemId) const menuItem = menuItemsById.get(item.menuItemId)
const recipe = recipeRows.filter( const recipe = recipeRows.filter((step) => step.orderItemId === item.id)
(step) => step.menuItemId === item.menuItemId,
)
const instances = instancesByOrderItem.get(item.id) ?? [] const instances = instancesByOrderItem.get(item.id) ?? []
const instanceBySequence = new Map( const instanceBySequence = new Map(
instances.map((row) => [row.sequence, row]), instances.map((row) => [row.sequence, row]),
+152
View File
@@ -1,3 +1,4 @@
import { and, eq } from 'drizzle-orm'
import { import {
afterAll, afterAll,
afterEach, afterEach,
@@ -6,6 +7,7 @@ import {
describe, describe,
expect, expect,
it, it,
vi,
} from 'vitest' } from 'vitest'
import * as schema from '#/db/schema.ts' import * as schema from '#/db/schema.ts'
@@ -38,6 +40,7 @@ beforeEach(() => {
// between tests instead of leaking a busy state into the next one. // between tests instead of leaking a busy state into the next one.
afterEach(async () => { afterEach(async () => {
await testDb.db.delete(schema.preparationStepInstances) await testDb.db.delete(schema.preparationStepInstances)
await testDb.db.delete(schema.orderItemSteps)
await testDb.db.delete(schema.orderItems) await testDb.db.delete(schema.orderItems)
await testDb.db.delete(schema.orders) await testDb.db.delete(schema.orders)
}) })
@@ -157,6 +160,34 @@ describe('placing an order', () => {
seed.menuItems.pizza.priceCents, seed.menuItems.pizza.priceCents,
) )
}) })
it('surfaces the original transaction failure even if the compensating refund also fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const failingRefundPayment: typeof payment = {
...payment,
refund: async () => {
throw new Error('simulated payment provider outage')
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after payment')
}
await expect(
ordering.placeOrder(dbWithFailingTransaction, failingRefundPayment, {
items: [{ menuItemId: seed.menuItems.pizza.id }],
customer: { type: 'guest', name: 'Ada', contact: 'ada@example.com' },
}),
).rejects.toThrow('simulated DB failure after payment')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
}) })
describe('cancelling an Order Item', () => { describe('cancelling an Order Item', () => {
@@ -247,6 +278,80 @@ describe('cancelling an Order Item', () => {
).rejects.toThrow(ordering.ForbiddenError) ).rejects.toThrow(ordering.ForbiddenError)
}) })
it('recharges the customer if the DB transaction fails after the refund has already gone through', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const itemId = (await getStatus(order)).items[0].id
let rechargedAmountCents: number | undefined
const spyingPayment: typeof payment = {
...payment,
charge: async (amountCents) => {
rechargedAmountCents = amountCents
return payment.charge(amountCents)
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after refund')
}
await expect(
ordering.cancelOrderItem(
dbWithFailingTransaction,
spyingPayment,
itemId,
accessFor(order),
),
).rejects.toThrow('simulated DB failure after refund')
expect(payment.refundsFor(order.paymentChargeId!)).toBe(
seed.menuItems.pizza.priceCents,
)
expect(rechargedAmountCents).toBe(seed.menuItems.pizza.priceCents)
})
it('surfaces the original cancellation failure even if the compensating re-charge also fails', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
await placeGuestOrder([{ menuItemId: seed.menuItems.pizza.id }]) // occupies the only oven
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.pizza.id },
])
const itemId = (await getStatus(order)).items[0].id
const failingRechargePayment: typeof payment = {
...payment,
charge: async () => {
throw new Error('simulated payment provider outage')
},
}
const dbWithFailingTransaction = Object.create(
testDb.db,
) as typeof testDb.db
dbWithFailingTransaction.transaction = () => {
throw new Error('simulated DB failure after refund')
}
await expect(
ordering.cancelOrderItem(
dbWithFailingTransaction,
failingRechargePayment,
itemId,
accessFor(order),
),
).rejects.toThrow('simulated DB failure after refund')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
it('gives an accurate message for a double-cancel', async () => { it('gives an accurate message for a double-cancel', async () => {
await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill await placeGuestOrder([{ menuItemId: seed.menuItems.burger.id }]) // occupies the only grill
const order = await placeGuestOrder([ const order = await placeGuestOrder([
@@ -335,6 +440,53 @@ describe('advancing steps and pickup', () => {
}) })
}) })
describe('recipe snapshot', () => {
it("keeps an Order Item on the Recipe as it existed at order time, even if the Menu Item's live Recipe is edited afterwards", async () => {
const order = await placeGuestOrder([
{ menuItemId: seed.menuItems.burger.id },
])
const burgerSecondStepEdit = and(
eq(schema.menuItemSteps.menuItemId, seed.menuItems.burger.id),
eq(schema.menuItemSteps.sequence, 2),
)
// Edit the live Recipe after the Order was placed: swap the burger's
// second Step to a different Machine Tag and duration.
await testDb.db
.update(schema.menuItemSteps)
.set({
machineTagId: seed.machineTags.oven.id,
estimatedDurationSeconds: 999,
})
.where(burgerSecondStepEdit)
try {
const beforeAdvance = await getStatus(order)
const burgerItemId = beforeAdvance.items[0].id
const pendingStep = beforeAdvance.items[0].steps[1]
expect(pendingStep.status).toBe('pending')
expect(pendingStep.machineTag).toBe('assembly')
expect(pendingStep.estimatedDurationSeconds).toBe(90)
await ordering.advanceOrderItemStep(testDb.db, burgerItemId)
const afterAdvance = await getStatus(order)
const secondStep = afterAdvance.items[0].steps[1]
expect(secondStep.status).toBe('in_progress')
expect(secondStep.machineTag).toBe('assembly')
expect(secondStep.estimatedDurationSeconds).toBe(90)
} finally {
await testDb.db
.update(schema.menuItemSteps)
.set({
machineTagId: seed.machineTags.assembly.id,
estimatedDurationSeconds: 90,
})
.where(burgerSecondStepEdit)
}
})
})
describe('access control', () => { describe('access control', () => {
it('refuses to show order status to someone without the access token', async () => { it('refuses to show order status to someone without the access token', async () => {
const order = await placeGuestOrder([ const order = await placeGuestOrder([