diff --git a/CONTEXT.md b/CONTEXT.md index 71a35e8..6fcae81 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,12 +16,20 @@ _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. **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**: -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) +**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) + **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. _Avoid_: Stage (use Step consistently) diff --git a/docs/adr/0002-order-items-are-independent.md b/docs/adr/0002-order-items-are-independent.md index 68dfbfb..7cb8182 100644 --- a/docs/adr/0002-order-items-are-independent.md +++ b/docs/adr/0002-order-items-are-independent.md @@ -1,3 +1,3 @@ # 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. diff --git a/docs/adr/0004-single-charge-per-order.md b/docs/adr/0004-single-charge-per-order.md new file mode 100644 index 0000000..1dfbc8b --- /dev/null +++ b/docs/adr/0004-single-charge-per-order.md @@ -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. diff --git a/src/domain/ordering.ts b/src/domain/ordering.ts index 197b809..09d03f4 100644 --- a/src/domain/ordering.ts +++ b/src/domain/ordering.ts @@ -413,9 +413,23 @@ export const cancelOrderItem = async ( throw new OrderItemNotCancellableError('it has already started cooking') } - await tx + // Re-check status = 'queued' as part of the delete itself, atomically: + // a Machine may have claimed this Step (assignQueuedStepsForTag) between + // the read above and this statement. If so, zero rows match and we must + // not refund a Step that's already cooking. + const deletedSteps = await tx .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 .update(schema.orderItems)