From f98dcc115ea77c31fd9c922cdad93a16b733b45e Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Sun, 27 Sep 2026 22:44:29 +0700 Subject: [PATCH 1/5] docs: add TRD for order handler boundary enforcement (#659) * docs: add TRD for order handler/usecase boundary Audits libs/ui/src/app/order and presentation/handlers/order for repositories and raw params leaking into handler props, and lays out a 4-phase plan to move that logic into the owning usecases. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ARwg5gU5WfUmWAPx8xq8XY * docs: seed paymentCancelUsecase from known payment where available D4 previously always seeded PaymentCancelUsecase with a placeholder reference/method and relied on SYNC_PARAMS to correct it. OrderStatus's loader already knows the payment on the common path, so seed from it there; MenuList/Cart still need the placeholder since the cart is deliberately never SSR-seeded (D5 of the composition/SSR TRD). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01ARwg5gU5WfUmWAPx8xq8XY --------- Co-authored-by: Claude --- docs/trd-order-handler-usecase-boundary.md | 519 +++++++++++++++++++++ 1 file changed, 519 insertions(+) create mode 100644 docs/trd-order-handler-usecase-boundary.md diff --git a/docs/trd-order-handler-usecase-boundary.md b/docs/trd-order-handler-usecase-boundary.md new file mode 100644 index 00000000..57464ce0 --- /dev/null +++ b/docs/trd-order-handler-usecase-boundary.md @@ -0,0 +1,519 @@ +# TRD — Order app handlers accept usecases, not repositories or raw params + +**Status:** proposed +**Scope:** `libs/ui/src/domain/usecases/{tableResolve,orderHistory,orderStatus,checkout}.ts`, +`libs/ui/src/presentation/handlers/order/**`, `libs/ui/src/presentation/handlers/hooks/**`, +`libs/ui/src/app/order/**`, `docs/handlers.md` +**Non-scope:** `libs/ui/src/presentation/views/**` (no screen prop changes), `libs/ui/src/app/pos/**` +and `libs/ui/src/presentation/handlers/pos/**` (POS is not audited here — see §9), any repository +interface (`domain/repositories/**`), `apps/order-web/**` (no page/route changes), any new feature +or UI behaviour +**Date of research:** 2026-09-27 — every count below measured against `claude/relaxed-hawking-ng1h7j` +@ `508f878` + +--- + +## 1. Problem statement + +`libs/ui/src/app/order/` holds five composition roots (`MenuList`, `Cart`, `OrderHistory`, +`OrderStatus`, `TableScan`) and, per `docs/trd-order-app-composition-and-ssr.md` §3.1, each is +supposed to do exactly one thing: `new Api*Repository()` → `new *Usecase(repo, params)` → return +one Handler. That TRD's own reference shape (§2.3a, `app/pos/ProductList.tsx` → +`ProductListHandler`) hands the Handler **usecases only** — no repository, no raw config value. + +Four of the five order roots don't hold that line: + +| Handler | Usecase props | Non-usecase props | Total | +| --- | ---: | ---: | ---: | +| `MenuListHandler` | 4 | 5 (`cartRepository`, `paymentRepository`, `sessionRepository`, `tableCode`, `preparingCount`) | 9 | +| `CartHandler` | 3 | 7 (`paymentRepository`, `sessionRepository`, `enabled`, `enabledMethods`, `cashierLocation`, `tableCode`, `preparingCount`) | 10 | +| `OrderStatusHandler` | 1 | 3 (`paymentRepository`, `sessionRepository`, `cashierLocation`) | 4 | +| `OrderHistoryHandler` | 1 | 1 (`sessionRepository`) | 2 | +| `TableScanHandler` | — | — (no handler; `TableScan.tsx` returns `TableScanScreen` directly) | — | + +16 non-usecase props across 4 handlers, of which 8 are repositories. Each repository prop exists +because a handler needs to run one more side effect or read one more value that its usecases don't +expose — for example (`MenuListHandler.tsx:269-286`): + +```tsx +useEffect(() => { + if (tableResolve.state.type === 'resolved' && tableResolve.state.code) { + sessionRepository.setTableCode(tableResolve.state.code); + } +}, [tableResolve.state, sessionRepository]); + +const boundTableCodeRef = useRef(null); +useEffect(() => { + if (tableResolve.state.type !== 'resolved' || !tableResolve.state.code) return; + const code = tableResolve.state.code; + if (boundTableCodeRef.current === code) return; + boundTableCodeRef.current = code; + cartRepository.updateTable(code).catch(() => { boundTableCodeRef.current = null; }); +}, [tableResolve.state, cartRepository]); +``` + +Both effects react to `tableResolveUsecase`'s own state and call a repository directly from the +component tree — the exact shape `libs/ui/CLAUDE.md` calls out as the single most expensive +mistake in this codebase ("writing `useState`/`useEffect` in a handler instead of [the usecase]"). +`CartHandler` repeats the first effect verbatim (`CartHandler.tsx:86-90`). + +A second shape repeats three times almost byte-for-byte — reactively rebuilding a +`PaymentCancelUsecase` from a `pendingPayment`/`payment` that a *different* usecase's state just +produced (`MenuListHandler.tsx:210-230`, `CartHandler.tsx:106-126`, +`OrderStatusHandler.tsx:46-61`): + +```tsx +const [paymentCancelUsecase, setPaymentCancelUsecase] = useState( + () => new PaymentCancelUsecase(paymentRepository, { + reference: pendingPayment?.partnerReferenceNo ?? '', + method: pendingPayment?.method ?? 'qris', + }) +); +if (pendingPayment && (pendingPayment.partnerReferenceNo !== paymentCancelUsecase.params.reference + || pendingPayment.method !== paymentCancelUsecase.params.method)) { + setPaymentCancelUsecase(new PaymentCancelUsecase(paymentRepository, { + reference: pendingPayment.partnerReferenceNo, + method: pendingPayment.method, + })); +} +``` + +This is why `paymentRepository` reaches three handlers: it exists solely to let the handler +construct a usecase, which is composition-root work everywhere else in the codebase (55 `app/pos/*` +roots, and `app/order`'s own other four usecase constructions). + +A third shape is plain misplaced navigation logic: `OrderHistoryHandler` and `OrderStatusHandler` +call `sessionRepository.getTableCode()` directly to build a router target +(`OrderHistoryHandler.tsx:38-39`, `OrderStatusHandler.tsx:40-41`), and `CartHandler`/`MenuListHandler` +receive a `tableCode` prop that duplicates a value their own `tableResolveUsecase` already carries in +`params.code`. And `enabled`/`enabledMethods`/`cashierLocation` in `CartHandler`, and +`cashierLocation` in `OrderStatusHandler`, are feature-flag/config values read from +`process.env` in the composition root (`Cart.tsx:45-56`, `OrderStatus.tsx:30-31`) and threaded past +the handler as bare props instead of through the usecase `Params` object that already carries +comparable config (`CheckoutUsecase.params.customerName`). + +### Root cause + +Every one of these repositories and raw params is standing in for state or a side effect that +belongs to a usecase the handler *already holds a reference to*. None of the four handlers is +missing a usecase — they're each doing one usecase's job (or one repository call a usecase should +own) inline, because nothing currently stops it. + +--- + +## 2. Goals and non-goals + +### Goals + +- **G1.** No handler under `presentation/handlers/order/` receives a repository as a prop. +- **G2.** No handler under `presentation/handlers/order/` receives a raw param that duplicates a + value already available on a usecase it holds (`tableCode` vs. `tableResolveUsecase.params.code`). +- **G3.** Feature-flag/config values that drive a usecase's own state (`enabled`, `enabledMethods`, + `cashierLocation`) travel through that usecase's `Params`, not a separate handler prop. +- **G4.** Each phase is one small, reviewable PR, `nx run ui:test` and `npm run lint` green + throughout, no behaviour change. +- **G5.** `docs/handlers.md` states the rule this TRD establishes, so it doesn't regress silently + the next time an order screen is touched. + +### Non-goals + +- **N1. Not touching `libs/ui/src/app/pos/**` or `presentation/handlers/pos/**`.** Nothing in this + audit claims POS handlers are clean; it is simply out of scope here, named per the user's request + to scope this to the order app. A POS audit is a separate document if wanted. +- **N2. No repository interface changes.** `CartRepository`, `PaymentRepository`, + `SessionRepository` keep their current shape; only *who calls them* moves. + `libs/ui/src/domain/repositories/**` is untouched. +- **N3. `preparingCount` stays a plain prop** — see D6. Not every non-usecase value is a defect. +- **N4. No screen or component prop changes.** Every `*Screen` keeps its current prop surface; + this is entirely a `handlers/order` + `domain/usecases` + `app/order` change. +- **N5. No SSR/routing changes.** `docs/trd-order-app-composition-and-ssr.md`'s target tree already + holds; this TRD works inside it. + +--- + +## 3. Current-state audit + +### 3.1 Non-usecase handler props, by origin + +| Value | Handlers it reaches | What it's for today | Root cause | +| --- | --- | --- | --- | +| `sessionRepository` | MenuList, Cart | `setTableCode(code)` on table resolve | Side effect belongs to `tableResolveUsecase.onStateChange` | +| `cartRepository` | MenuList | `updateTable(code)` on table resolve | Same — a second side effect of the same state transition | +| `sessionRepository` | OrderHistory, OrderStatus | `getTableCode()` to build a nav path | A synchronous repo read that belongs in `getInitialState()`, same pattern `CartUsecase` already uses for `cartQueryRepository.getSelectedItemId()` (`cart.ts:104`) | +| `paymentRepository` | MenuList, Cart, OrderStatus | Construct `PaymentCancelUsecase` reactively from another usecase's derived payment | Usecase construction is composition-root work everywhere else in the codebase | +| `tableCode` | MenuList, Cart | Nav paths + display | Duplicates `tableResolveUsecase.params.code` | +| `enabled`, `enabledMethods`, `cashierLocation` | Cart | Checkout feature flags/config | Belongs in `CheckoutUsecase.params`, not a bare prop, same as `customerName`/`customerWhatsappNumber` already do | +| `cashierLocation` | OrderStatus | Cash-payment instructions | Belongs in `OrderStatusUsecase.params` | +| `preparingCount` | MenuList, Cart | SSR-computed badge count, no client fetch, no repository | Kept — see D6 | + +### 3.2 The repeated `PaymentCancelUsecase` reconstruction + +The `useState` + conditional `setState` block quoted in §1 is **19–21 lines, duplicated three +times** (`MenuListHandler.tsx:210-236`, `CartHandler.tsx:106-128`, +`OrderStatusHandler.tsx:46-63`), each keyed off a different source usecase's derived payment +(`cart.state.cart?.pendingPayment`, `cart.state.cart?.pendingPayment`, +`orderStatus.state.payment`). `usePaymentCancel` (`handlers/hooks/usePaymentCancel.ts`) already +exists precisely to re-sync a **stable** `PaymentCancelUsecase` instance via a `SYNC_PARAMS` action +(`domain/usecases/paymentCancel.ts:33`, `:98-101`) — every call site bypasses that mechanism by +building a brand-new instance instead, which is the only reason `paymentRepository` needs to reach +the handler at all. + +### 3.3 What already holds and is the reference shape + +- `menuItemDetailUsecase` and `cartUsecase` are constructed once in the composition root and handed + to the Handler as plain usecases — no repository follows them in. +- `CartUsecase.getInitialState()` reads `this.cartQueryRepository.getSelectedItemId()` synchronously + at construction time (`cart.ts:104`) — the exact pattern §4 proposes for `tableCode` in + `OrderHistoryUsecase`/`OrderStatusUsecase`. +- `usePaymentCancel`'s `SYNC_PARAMS` re-sync mechanism (`usePaymentCancel.ts:9-26`) is unused by any + of its three call sites today; this TRD is the first thing that exercises it as designed. +- `TableScan.tsx` → `TableScanScreen` is already the target shape with no handler at all; nothing to + do there. + +--- + +## 4. Target architecture + +``` +app/order/MenuList.tsx + new Api*Repository() × 5, new CookieSessionRepository() + new TableResolveUsecase(publicTableRepository, { code, table }, { + sessionRepository, cartRepository, // optional deps, D1 + }) + new MenuListUsecase(...) + new MenuItemDetailUsecase(...) + new CartUsecase(...) + new PaymentCancelUsecase(paymentRepository, { reference: '', method: 'qris' }) // D4 — pendingPayment is never SSR-known here + → + +presentation/handlers/order/MenuListHandler.tsx + reads tableResolve.state.code instead of a `tableCode` prop (D3) + no `useEffect` calling a repository — tableResolveUsecase owns both (D1) + usePaymentCancelSyncedTo(paymentCancelUsecase, pendingPayment) (D4, new shared hook) + no repository import at all +``` + +Same shape for `Cart.tsx`/`CartHandler`, `OrderHistory.tsx`/`OrderHistoryHandler`, +`OrderStatus.tsx`/`OrderStatusHandler`. End state, by handler: + +| Handler | Props after this TRD | +| --- | --- | +| `MenuListHandler` | `tableResolveUsecase`, `menuListUsecase`, `menuItemDetailUsecase`, `cartUsecase`, `paymentCancelUsecase`, `preparingCount?` | +| `CartHandler` | `tableResolveUsecase`, `cartUsecase`, `checkoutUsecase`, `paymentCancelUsecase`, `preparingCount?` | +| `OrderStatusHandler` | `orderStatusUsecase`, `paymentCancelUsecase` | +| `OrderHistoryHandler` | `orderHistoryUsecase` | + +Zero repositories in any handler's prop list. `preparingCount` is the one documented, deliberate +exception (D6). + +--- + +## 5. Design decisions + +**D1 — `TableResolveUsecase` takes two optional repository dependencies and owns both table-binding +side effects.** + +```ts +constructor( + repository: PublicTableRepository, + params: TableResolveParams, + dependencies: { sessionRepository?: SessionRepository; cartRepository?: CartRepository } = {} +) +``` + +`onStateChange`, on `{ type: 'resolved', code }`, calls `sessionRepository?.setTableCode(code)` +unconditionally (matches today's every-render-that-changes behaviour) and `cartRepository +?.updateTable(code)` once per resolved code (the existing `boundTableCodeRef` de-dupe moves into the +usecase as an instance field, since the usecase — not the handler — is what must not repeat the +call). `Cart.tsx`'s root passes only `sessionRepository`; `MenuList.tsx`'s passes both, preserving +today's asymmetry (§3.1 in `trd-order-app-composition-and-ssr.md` never bound the cart from the Cart +route either — this TRD does not change that, only where the call lives). + +**Alternative rejected: two usecases** (`TableResolveUsecase` + a `TableBindUsecase` wrapping it). +Rejected — it would need its own state machine for what is, today, an unconditional fire-and-forget +call with no visible state, and it would still need to be constructed and wired by every root that +uses `TableResolveUsecase`, buying no clarity over an optional constructor field. + +**Alternative rejected: required (non-optional) dependencies.** Would force `OrderHistory.tsx`, +which never constructs a `TableResolveUsecase`, and other hypothetical future callers to pass a +repository they don't use. Optional dependencies default to `{}` and no-op, which is what the four +existing single-purpose repository fields on other usecases (`CartUsecase`'s two, `CheckoutUsecase`'s +one) don't need to model since none of them are conditionally used — this is the first usecase with +a genuinely optional side effect, so it is the first with an optional dependency. If a second +usecase needs the same shape, promote the `{ sessionRepository?, cartRepository? }` object to a +named type instead of inlining it twice. + +**D2 — `OrderHistoryUsecase` and `OrderStatusUsecase` take an optional `SessionRepository` and read +`tableCode` once, synchronously, in `getInitialState()`.** + +```ts +constructor( + repository: PaymentRepository, + params: OrderStatusParams, + dependencies: { sessionRepository?: SessionRepository } = {} +) { ... } + +getInitialState(): OrderStatusState { + return { ..., tableCode: this.sessionRepository?.getTableCode() ?? null }; +} +``` + +`tableCode` joins `Context` on both usecases. The handler replaces +`sessionRepository.getTableCode()` with `orderStatus.state.tableCode` / +`orderHistory.state.tableCode` when building `menuPath`/`cartPath` — the `/t/${tableCode}` template +literal itself stays exactly where it is (in the handler; it's navigation-target composition, not a +repository read, and screens must not import `next`/`solito`). + +This is the same idiom `CartUsecase.getInitialState()` already uses for +`cartQueryRepository.getSelectedItemId()` (`cart.ts:104`) — a synchronous repository read at +construction, not an async fetch — so it introduces no new pattern to the codebase, only extends an +existing one to a second repository interface. + +**D3 — `tableCode` stops being a separate `MenuListHandler`/`CartHandler` prop; the handler reads +`tableResolveUsecase.params.code` instead.** The composition root already passes the same `code` +value to both the handler and `tableResolveUsecase`; the second copy is redundant. `params.code` is +readonly and set once at construction, so reading it directly (rather than `tableResolve.state.code`, +which is `null` before resolution) is exactly the intended use of a usecase's `params` field +(`OrderStatusHandler` already reads `orderStatusUsecase.params.reference` this way, +`OrderStatusHandler.tsx:49`). + +**D4 — the composition root constructs `paymentCancelUsecase` once, seeded from whatever it already +knows, and a new shared hook re-syncs it — the handler never sees `paymentRepository`.** + +The two order-affected roots are not in the same position. `OrderStatus.tsx` is handed an +already-known `payment` on the common path — its loader (`apps/order-web/src/pages/orders/[reference] +.tsx:23-27`) fetches it server-side before the composition root ever runs — so it can seed the real +`reference`/`method` immediately and never dispatch a startup `SYNC_PARAMS` at all: + +```ts +// app/order/OrderStatus.tsx +const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: payment?.reference ?? '', + method: payment?.method ?? 'qris', +}); +``` + +`MenuList.tsx`/`Cart.tsx` cannot do the same for `pendingPayment`, and not because anyone forgot to +wire it through: `pendingPayment` lives on the cart, and `docs/trd-order-app-composition-and-ssr.md` +D5 deliberately keeps the cart **out** of server rendering (a session-specific cart in the response +would make it private and uncacheable). There is no server-side moment on those two routes where a +pending payment could be known, so they fall back to the placeholder: + +```ts +// app/order/MenuList.tsx, Cart.tsx +const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: '', method: 'qris', +}); +``` + +Either way, the sync hook is still required, for three reasons that don't go away just because +`OrderStatus.tsx` starts with the right values in the common case: (a) `OrderStatus.tsx`'s loader can +still hand back `payment: undefined` (a non-"not found" fetch error, same file, line 26), in which +case the real value is only known after the client-side fetch that follows; (b) `MenuList.tsx`/ +`Cart.tsx` need it on every load, unconditionally, per D5 above; and (c) even a correctly-seeded +`OrderStatus.tsx` can see a *second*, different pending payment appear later in the same page +session — cancel one QRIS payment, check out again, and the new payment has a new `reference` that +no server-side seed could have anticipated. New hook, +`presentation/handlers/hooks/usePaymentCancelSyncedTo.ts`: + +```ts +export function usePaymentCancelSyncedTo( + usecase: PaymentCancelUsecase, + target: { reference: string; method: PaymentMethod } | null +) { + const binding = usePaymentCancel(usecase); + useEffect(() => { + if (!target) return; + if ( + target.reference === binding.state.reference && + target.method === binding.state.method + ) return; + binding.dispatch({ type: 'SYNC_PARAMS', ...target }); + }, [target?.reference, target?.method, binding.state.reference, binding.state.method, binding.dispatch]); + return binding; +} +``` + +Each of the three handlers replaces its ~20-line `useState`/conditional-`setState` block with: + +```ts +const paymentCancel = usePaymentCancelSyncedTo( + paymentCancelUsecase, + pendingPayment && { reference: pendingPayment.partnerReferenceNo, method: pendingPayment.method } +); +``` + +`PaymentCancelUsecase.getNextState`'s existing `SYNC_PARAMS` handler only fires `[{ type: 'idle' }, +{ type: 'SYNC_PARAMS' }]` (`paymentCancel.ts:98-101`) — unchanged by this TRD, since the object being +synced was always constructed at `idle` in every existing call site too (a re-sync while +`confirming`/`cancelling` never happened before and isn't asked for now). + +**Alternative rejected: let the handler keep constructing `PaymentCancelUsecase`, just import it +from a helper function instead of inlining it.** This still needs `paymentRepository` in the +handler's props to call the helper — it launders the repository through a function instead of +removing it. D4 removes it because the *construction* moves to the composition root, where every +other usecase in this codebase is already built. + +**D5 — `CheckoutUsecase` and `OrderStatusUsecase` take their feature-flag/config values as +`Params`, not as separate handler props.** + +`CheckoutParams` gains `enabled: boolean`, `enabledMethods: PaymentMethod[]`, `cashierLocation: +string`; `CheckoutState`'s `Context` carries them through unchanged (same shape `customerName` +already has — a static value seeded once from params, read by the handler off `state`, never +mutated by `getNextState`). `OrderStatusParams` gains `cashierLocation: string` the same way. +`Cart.tsx`/`OrderStatus.tsx` build these from `process.env` exactly as today and pass them as part +of the usecase's `params` object instead of a sibling handler prop. `CartHandler` computes +`isCheckoutEnabled={checkout.state.enabled && !hasUnavailableItems(cart.state.cart)}` — the +`hasUnavailableItems` half is genuinely a `cartUsecase`-derived value the handler is right to combine +here (§docs/handlers.md: a handler maps *multiple* usecases' states into one screen's props; that's +its job, not a violation). + +**D6 — `preparingCount` stays a plain `MenuListHandler`/`CartHandler` prop.** It is computed +server-side in `apps/order-web/src/pages/t/[code]/{index,cart/index}.tsx` by filtering an +already-fetched payments list (`preparingCount: payments.filter(...)`) — there is no client +repository call, no state machine, nothing that changes after mount. Wrapping a single read-only +SSR-seeded integer in a usecase to satisfy G1 literally would add a class, a state type, an action +type and a barrel entry for a value that never transitions — the kind of abstraction +`libs/ui/CLAUDE.md`'s parent file warns against adding "beyond what the task requires." G1/G2 are +about repositories and duplicated usecase state; a prop that is neither is not the problem this TRD +is scoped to fix. If `preparingCount` ever needs a client-side refresh (e.g., live polling), it +becomes a real usecase at that point, on its own merits. + +**Alternative considered: fold it into `MenuListUsecase`/`CartUsecase`'s `Params`.** Rejected — +those usecases would carry a field their own state machine never reads or transitions on, purely to +satisfy a "handler = usecases only" rule for a value with no behaviour. D6 accepts one named, +justified exception over a cosmetic one. + +--- + +## 6. Phased delivery + +Four PRs, independently revertable, in the order below (each is easiest to review once the previous +one has landed, but none blocks on the others' *code* — only the running total in §4's table assumes +this order). Every phase: `npx nx run ui:test`, `npm run lint` green; no handler test's assertions +change, only its setup (constructing usecases/dependencies differently) — a behaviour change is a +review flag per this TRD's non-goals. + +| Phase | Content | Handlers touched | +| --- | --- | --- | +| **P1** | `TableResolveUsecase` absorbs the session/cart table-binding side effects (D1, D3) | MenuList, Cart | +| **P2** | `OrderHistoryUsecase`/`OrderStatusUsecase` absorb `sessionRepository` for `tableCode`; `OrderStatusUsecase` absorbs `cashierLocation` (D2, half of D5) | OrderHistory, OrderStatus | +| **P3** | `CheckoutUsecase` absorbs `enabled`/`enabledMethods`/`cashierLocation` (rest of D5) | Cart | +| **P4** | Shared `usePaymentCancelSyncedTo` hook; composition roots construct `paymentCancelUsecase` up front (D4) | MenuList, Cart, OrderStatus | + +### P1 — `TableResolveUsecase` owns table binding + +**Touches:** `domain/usecases/tableResolve.ts` (+ `.test.ts`, new dependency-injection tests: a +resolve calls `sessionRepository.setTableCode` and `cartRepository.updateTable` once per code, and +neither when the dependency is omitted); `app/order/{MenuList,Cart}.tsx` (pass the dependencies +object; drop the `tableCode` prop, since D3 reads `tableResolveUsecase.params.code`); +`presentation/handlers/order/{MenuList,Cart}Handler.tsx` (delete both `useEffect`s and the +`sessionRepository`/`cartRepository`/`tableCode` props; read `tableResolveUsecase.params.code` +wherever `tableCode` was used) + their `.test.tsx` (constructor call sites change, no assertion +changes). + +**Check:** `MenuListHandler.test.tsx` and `CartHandler.test.tsx` pass unmodified in their +assertions; a new `tableResolve.test.ts` case asserts `MockSessionRepository.setTableCode` and +`MockCartRepository.updateTable` are each called exactly once across two consecutive `resolved` +states with the same code (the de-dupe moving from `boundTableCodeRef` to the usecase must not +regress it). + +### P2 — `OrderHistoryUsecase`/`OrderStatusUsecase` own `tableCode` and `cashierLocation` + +**Touches:** `domain/usecases/{orderHistory,orderStatus}.ts` (+ tests: `getInitialState().tableCode` +reflects the injected repository; omitted dependency yields `null`); `app/order/{OrderHistory, +OrderStatus}.tsx` (pass `sessionRepository` as a dependency; `OrderStatus.tsx` also moves +`cashierLocation` into `orderStatusUsecase`'s params instead of a sibling prop); +`presentation/handlers/order/{OrderHistory,OrderStatus}Handler.tsx` (drop `sessionRepository` prop +from both, drop `cashierLocation` prop from `OrderStatusHandler`; read `orderHistory.state.tableCode` +/ `orderStatus.state.tableCode` / `orderStatus.state.cashierLocation`) + their `.test.tsx`. + +**Check:** existing handler test assertions unchanged; `menuPath`/`cartPath` in both handlers resolve +identically to a table code seeded via the usecase's dependency instead of a direct repository prop. + +### P3 — `CheckoutUsecase` owns its feature flags + +**Touches:** `domain/usecases/checkout.ts` (`CheckoutParams` gains `enabled`, `enabledMethods`, +`cashierLocation`; `Context`/`getInitialState` carry them through) + `.test.ts`; `app/order/Cart.tsx` +(the three `process.env` reads move from being separate handler-bound locals to fields on +`checkoutUsecase`'s params); `presentation/handlers/order/CartHandler.tsx` (drop the three props, +read `checkout.state.enabled`/`enabledMethods`/`cashierLocation`) + `CartHandler.test.tsx`. + +**Check:** `CartHandler.test.tsx`'s existing checkout-flow assertions (payment method selection, +COD cashier-location copy, checkout-button enablement) pass unmodified. + +### P4 — Shared `usePaymentCancelSyncedTo`, `paymentRepository` leaves every handler + +**Touches:** new `presentation/handlers/hooks/usePaymentCancelSyncedTo.ts` + `.test.ts` (unit-tests +the hook in isolation: dispatches `SYNC_PARAMS` exactly once per distinct `{reference, method}`, not +on every render); `presentation/handlers/hooks/index.ts` (barrel); +`app/order/{MenuList,Cart,OrderStatus}.tsx` (construct `paymentCancelUsecase` alongside every other +usecase, pass it as a handler prop instead of `paymentRepository`; `OrderStatus.tsx` seeds it from +`payment?.reference`/`payment?.method` where known, per D4, instead of the empty placeholder +`MenuList.tsx`/`Cart.tsx` use); +`presentation/handlers/order/{MenuList,Cart,OrderStatus}Handler.tsx` (delete the three duplicated +`useState`/conditional-`setState` blocks, call `usePaymentCancelSyncedTo` instead, drop the +`paymentRepository` prop) + their `.test.tsx` (mock/real `PaymentCancelUsecase` is now constructed +by the test's composition helper, same as the other four usecases already are). + +**Check:** the payment-cancel flows in all three handler tests (request → confirm → cancelled/paid, +dismiss) pass unmodified; `rg -n "paymentRepository" libs/ui/src/presentation/handlers/order` +returns nothing; `rg -n "Repository" libs/ui/src/presentation/handlers/order/*.tsx` (excluding +`.test.tsx`) returns nothing at all — the acceptance check for G1. A new `OrderStatusHandler.test.tsx` +case seeds `payment` with a `pending`/cancellable status and asserts no `SYNC_PARAMS`-driven state +change is observable before the cancel button is even pressed — i.e. the seeded value, not the +placeholder, is what's live from first render. + +**Also in this phase:** update `docs/handlers.md` — add the rule this TRD establishes ("a handler's +props are usecases, plus rarely a single documented pure display value (D6); a repository never +appears in a Handler's prop list — build any usecase that needs one in the composition root, and +resync it via a dispatched action, not by reconstructing it in the handler"). + +--- + +## 7. Risks + +| Risk | Likelihood | Mitigation | +| --- | --- | --- | +| Moving `cartRepository.updateTable`'s de-dupe (`boundTableCodeRef`) into `TableResolveUsecase` changes when it fires, since a class field survives across renders differently than a `useRef` | Low | P1's new test asserts the call count directly across two `resolved` dispatches with the same code; a handler test route through `MenuListHandler.test.tsx` also exercises it end to end | +| `usePaymentCancelSyncedTo`'s dependency array (`binding.state.reference`/`method`) causes an extra dispatch on the render where `PaymentCancelUsecase` first mounts at `idle` with empty `reference` | Medium | The hook's guard compares against `binding.state`, not `usecase.params` — on mount, `target` (from `pendingPayment`) is usually `null` until the cart/order-status usecase resolves, so no `SYNC_PARAMS` fires before a real payment exists; P4's hook unit test covers the "no target yet" and "target arrives" transitions explicitly | +| `OrderStatusUsecase`/`OrderHistoryUsecase` now read a repository synchronously inside `getInitialState()`, which SSR-seeded instances didn't do before | Low | `CartUsecase` already does this for `cartQueryRepository` (D2); `SessionRepository.getTableCode()` reads a cookie/localStorage value, not the network, so it's safe both server- and client-side exactly as `CartUsecase`'s existing call is | +| A reviewer reads `enabled`/`enabledMethods`/`cashierLocation` moving into `CheckoutParams` as scope creep on `CheckoutState.Context`, which so far only held customer-input fields | Low | D5 states the precedent (`customerName`/`customerWhatsappNumber` are already static seed values threaded the same way); the PR description for P3 should point at that existing field, not just at this TRD | + +--- + +## 8. Rollback + +Every phase is one PR and one `git revert`; phases are independent enough that reverting P3 (say) +does not require reverting P4, since P4 only touches `paymentCancelUsecase`/`paymentRepository`, a +disjoint prop set from P3's `enabled`/`enabledMethods`/`cashierLocation`. No phase touches a +repository interface, a migration, or a build config, so a revert restores the previous tree exactly. + +--- + +## 9. Deferred + +- **A POS-side audit of the same rule.** This TRD is scoped to `app/order` per the request that + produced it; `app/pos`'s 55 composition roots and their handlers were not audited for the same + repository-leak pattern. If the same shape exists there, it's the same fix, in a separate document + — POS's usecases (`ProductListUsecase` and friends) already skew toward more single-repository + constructors than order's do, so the blast radius may differ enough to warrant its own audit. +- **Whether `PaymentCancelUsecase`'s cancel flow should instead be absorbed as actions on + `CartUsecase`/`OrderStatusUsecase` directly**, removing the extra usecase (and this TRD's D4 + entirely) in favour of one state machine per screen. Considered and set aside: `PaymentCancelUsecase` + is already shared source-of-truth for a UI-independent confirm/cancel flow reused by three + different parent usecases, and folding it into each of them would triplicate its + `confirming`/`cancelling`/`settled` states instead of the ~20 lines of handler glue this TRD + removes. Worth a second look only if a fourth caller ever appears. +- **`preparingCount` becoming a real usecase** if it ever needs a client-side refresh — see D6's + closing note. + +## 10. Settled in review + +| Question | Outcome | +| --- | --- | +| Why not seed `paymentCancelUsecase.params.reference`/`method` from the server instead of a `SYNC_PARAMS` dispatch after mount? | Do both — `OrderStatus.tsx` seeds from its already-known `payment` where the loader has it (D4, updated), which was a real gap in the first draft. The hook stays regardless: `MenuList.tsx`/`Cart.tsx` never have a server-known `pendingPayment` at all (cart is deliberately not SSR'd, per D5 of `trd-order-app-composition-and-ssr.md`), `OrderStatus.tsx`'s loader can itself return `payment: undefined` on a non-"not found" error, and even a correctly-seeded value can be superseded by a second pending payment later in the same page session. | From 0503fa5d88d4e62532aad1d3c8da3da040108fe7 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Sun, 27 Sep 2026 23:04:20 +0700 Subject: [PATCH 2/5] refactor(order): move table-binding side effects into TableResolveUsecase (#660) Phase 1 of docs/trd-order-handler-usecase-boundary.md (D1, D3). MenuListHandler and CartHandler no longer receive cartRepository/sessionRepository/tableCode props: TableResolveUsecase now takes optional sessionRepository/cartRepository dependencies and owns setTableCode/updateTable as part of its own onStateChange, with the cart-bind de-dupe moved from a handler useRef to an instance field. Handlers read tableResolveUsecase.params.code instead of a duplicated tableCode prop. Claude-Session: https://claude.ai/code/session_01YWhceHti3NXs7xRt5uw5o3 Co-authored-by: Claude --- libs/ui/src/app/order/Cart.tsx | 11 ++--- libs/ui/src/app/order/MenuList.tsx | 12 ++--- .../src/domain/usecases/tableResolve.test.ts | 46 ++++++++++++++++++- libs/ui/src/domain/usecases/tableResolve.ts | 30 +++++++++++- .../handlers/order/CartHandler.test.tsx | 10 ++-- .../handlers/order/CartHandler.tsx | 15 ++---- .../handlers/order/MenuListHandler.test.tsx | 11 ++--- .../handlers/order/MenuListHandler.tsx | 31 ++----------- 8 files changed, 99 insertions(+), 67 deletions(-) diff --git a/libs/ui/src/app/order/Cart.tsx b/libs/ui/src/app/order/Cart.tsx index 148a8c44..a92a4e29 100644 --- a/libs/ui/src/app/order/Cart.tsx +++ b/libs/ui/src/app/order/Cart.tsx @@ -33,10 +33,11 @@ export function Cart({ const cartQueryRepository = new UrlCartQueryRepository(); const paymentRepository = new ApiPaymentRepository(sessionRepository); - const tableResolveUsecase = new TableResolveUsecase(publicTableRepository, { - code, - table, - }); + const tableResolveUsecase = new TableResolveUsecase( + publicTableRepository, + { code, table }, + { sessionRepository } + ); const cartUsecase = new CartUsecase(cartRepository, cartQueryRepository); const checkoutUsecase = new CheckoutUsecase(paymentRepository, { customerName, @@ -60,12 +61,10 @@ export function Cart({ tableResolveUsecase={tableResolveUsecase} cartUsecase={cartUsecase} checkoutUsecase={checkoutUsecase} - sessionRepository={sessionRepository} paymentRepository={paymentRepository} enabled={enabled} enabledMethods={enabledMethods} cashierLocation={cashierLocation} - tableCode={code} preparingCount={preparingCount} /> ); diff --git a/libs/ui/src/app/order/MenuList.tsx b/libs/ui/src/app/order/MenuList.tsx index 709de5de..b07cba53 100644 --- a/libs/ui/src/app/order/MenuList.tsx +++ b/libs/ui/src/app/order/MenuList.tsx @@ -45,10 +45,11 @@ export function MenuList({ const paymentRepository = new ApiPaymentRepository(sessionRepository); const menuListQueryRepository = new UrlMenuListQueryRepository(); - const tableResolveUsecase = new TableResolveUsecase(publicTableRepository, { - code, - table, - }); + const tableResolveUsecase = new TableResolveUsecase( + publicTableRepository, + { code, table }, + { sessionRepository, cartRepository } + ); const menuListUsecase = new MenuListUsecase( menuRepository, menuListQueryRepository, @@ -70,10 +71,7 @@ export function MenuList({ menuListUsecase={menuListUsecase} menuItemDetailUsecase={menuItemDetailUsecase} cartUsecase={cartUsecase} - cartRepository={cartRepository} paymentRepository={paymentRepository} - sessionRepository={sessionRepository} - tableCode={code} preparingCount={preparingCount} /> ); diff --git a/libs/ui/src/domain/usecases/tableResolve.test.ts b/libs/ui/src/domain/usecases/tableResolve.test.ts index 27143b65..011c320c 100644 --- a/libs/ui/src/domain/usecases/tableResolve.test.ts +++ b/libs/ui/src/domain/usecases/tableResolve.test.ts @@ -4,7 +4,11 @@ import { TableResolveAction, TableResolveParams, } from './tableResolve'; -import { MockPublicTableRepository } from '../../data/mock'; +import { + MockCartRepository, + MockPublicTableRepository, + MockSessionRepository, +} from '../../data/mock'; import { UsecaseTester, flushPromises } from '../../utils/usecase'; const createTester = ( @@ -146,4 +150,44 @@ describe('TableResolveUsecase', () => { await flushPromises(); expect(resolveSpy).not.toHaveBeenCalled(); }); + + it('binds the resolved table to the session and cart once, and re-sets the session table code on every resolved state', async () => { + const repository = new MockPublicTableRepository(); + const sessionRepository = new MockSessionRepository(); + const cartRepository = new MockCartRepository(); + const setTableCodeSpy = jest.spyOn(sessionRepository, 'setTableCode'); + const updateTableSpy = jest.spyOn(cartRepository, 'updateTable'); + const code = repository.codes[0]; + + const usecase = new TableResolveUsecase( + repository, + { code, table: repository.tables[code] }, + { sessionRepository, cartRepository } + ); + const state = usecase.getInitialState(); + expect(state.type).toEqual('resolved'); + + usecase.onStateChange(state, jest.fn()); + usecase.onStateChange(state, jest.fn()); + await flushPromises(); + + expect(setTableCodeSpy).toHaveBeenCalledTimes(2); + expect(setTableCodeSpy).toHaveBeenCalledWith(code); + expect(updateTableSpy).toHaveBeenCalledTimes(1); + expect(updateTableSpy).toHaveBeenCalledWith(code); + }); + + it('does not touch the session or cart repository when neither dependency is provided', async () => { + const repository = new MockPublicTableRepository(); + const code = repository.codes[0]; + + const usecase = new TableResolveUsecase(repository, { + code, + table: repository.tables[code], + }); + const state = usecase.getInitialState(); + + expect(() => usecase.onStateChange(state, jest.fn())).not.toThrow(); + await flushPromises(); + }); }); diff --git a/libs/ui/src/domain/usecases/tableResolve.ts b/libs/ui/src/domain/usecases/tableResolve.ts index d0e07483..2eb42bcb 100644 --- a/libs/ui/src/domain/usecases/tableResolve.ts +++ b/libs/ui/src/domain/usecases/tableResolve.ts @@ -1,6 +1,11 @@ import { match } from 'ts-pattern'; import { PublicTable } from '../entities'; -import { PublicTableRepository, TableNotFoundError } from '../repositories'; +import { + CartRepository, + PublicTableRepository, + SessionRepository, + TableNotFoundError, +} from '../repositories'; import { Usecase } from './IUsecase'; type Context = { @@ -37,11 +42,23 @@ export class TableResolveUsecase extends Usecase< > { params: TableResolveParams; repository: PublicTableRepository; + private sessionRepository?: SessionRepository; + private cartRepository?: CartRepository; + private boundTableCode: string | null = null; - constructor(repository: PublicTableRepository, params: TableResolveParams) { + constructor( + repository: PublicTableRepository, + params: TableResolveParams, + dependencies: { + sessionRepository?: SessionRepository; + cartRepository?: CartRepository; + } = {} + ) { super(); this.repository = repository; this.params = params; + this.sessionRepository = dependencies.sessionRepository; + this.cartRepository = dependencies.cartRepository; } getInitialState(): TableResolveState { @@ -121,6 +138,15 @@ export class TableResolveUsecase extends Usecase< } }); }) + .with({ type: 'resolved' }, ({ code }) => { + if (!code) return; + this.sessionRepository?.setTableCode(code); + if (this.boundTableCode === code) return; + this.boundTableCode = code; + this.cartRepository?.updateTable(code).catch(() => { + this.boundTableCode = null; + }); + }) .otherwise(() => { // No action needed for other states }); diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx index 138946da..0e0577e6 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx @@ -55,9 +55,11 @@ const renderHandler = ({ customerWhatsappNumber?: string; preparingCount?: number; } = {}) => { - const tableResolveUsecase = new TableResolveUsecase(tableRepository, { - code: TABLE_CODE, - }); + const tableResolveUsecase = new TableResolveUsecase( + tableRepository, + { code: TABLE_CODE }, + { sessionRepository } + ); const cartUsecase = new CartUsecase(cartRepository, cartQueryRepository); const checkoutUsecase = new CheckoutUsecase(paymentRepository, { customerName, @@ -75,11 +77,9 @@ const renderHandler = ({ cartUsecase={cartUsecase} checkoutUsecase={checkoutUsecase} paymentRepository={paymentRepository} - sessionRepository={sessionRepository} enabled={enabled} enabledMethods={enabledMethods} cashierLocation={cashierLocation} - tableCode={TABLE_CODE} preparingCount={preparingCount} /> ), diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.tsx index b376cff3..8c864b41 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'solito/router'; import { Cart } from '../../../domain/entities/Cart'; import { PaymentMethod } from '../../../domain/entities/Payment'; import { PaymentRepository } from '../../../domain/repositories/payment'; -import { SessionRepository } from '../../../domain/repositories/session'; import { CartState, CartUsecase } from '../../../domain/usecases/cart'; import { CheckoutUsecase } from '../../../domain/usecases/checkout'; import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; @@ -27,11 +26,9 @@ export type CartHandlerProps = { cartUsecase: CartUsecase; checkoutUsecase: CheckoutUsecase; paymentRepository: PaymentRepository; - sessionRepository: SessionRepository; enabled: boolean; enabledMethods?: PaymentMethod[]; cashierLocation?: string; - tableCode: string; preparingCount?: number; }; @@ -70,11 +67,9 @@ export const CartHandler = ({ cartUsecase, checkoutUsecase, paymentRepository, - sessionRepository, enabled, enabledMethods = ['qris'], cashierLocation = 'Lantai 1', - tableCode, preparingCount, }: CartHandlerProps) => { const tableResolve = useTableResolve(tableResolveUsecase); @@ -83,12 +78,6 @@ export const CartHandler = ({ const router = useRouter(); const [isClearConfirmationOpen, setIsClearConfirmationOpen] = useState(false); - useEffect(() => { - if (tableResolve.state.type === 'resolved' && tableResolve.state.code) { - sessionRepository.setTableCode(tableResolve.state.code); - } - }, [tableResolve.state, sessionRepository]); - useEffect(() => { if (checkout.state.type !== 'created' || !checkout.state.payment) return; @@ -311,7 +300,9 @@ export const CartHandler = ({ }} onClearCancel={() => setIsClearConfirmationOpen(false)} onClearConfirmationOpenChange={setIsClearConfirmationOpen} - onAddMoreItemsPress={() => router.push(`/t/${tableCode}`)} + onAddMoreItemsPress={() => + router.push(`/t/${tableResolveUsecase.params.code}`) + } onRetryButtonPress={() => cart.dispatch({ type: 'FETCH' })} itemEdit={itemEdit} isCheckoutEnabled={enabled && !hasUnavailableItems(cart.state.cart)} diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx index 1cec244e..55649b40 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx @@ -59,9 +59,11 @@ const renderHandler = ({ sessionRepository?: MockSessionRepository; preparingCount?: number; } = {}) => { - const tableResolveUsecase = new TableResolveUsecase(tableRepository, { - code: TABLE_CODE, - }); + const tableResolveUsecase = new TableResolveUsecase( + tableRepository, + { code: TABLE_CODE }, + { sessionRepository, cartRepository } + ); const menuListUsecase = new MenuListUsecase( menuRepository, menuListQueryRepository, @@ -87,10 +89,7 @@ const renderHandler = ({ menuListUsecase={menuListUsecase} menuItemDetailUsecase={menuItemDetailUsecase} cartUsecase={cartUsecase} - cartRepository={cartRepository} paymentRepository={paymentRepository} - sessionRepository={sessionRepository} - tableCode={TABLE_CODE} preparingCount={preparingCount} /> ), diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx index c9948ff2..0c456662 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx @@ -4,9 +4,7 @@ import { useRouter } from 'solito/router'; import { Category } from '../../../domain/entities/Category'; import { Product } from '../../../domain/entities/Product'; import { Variant } from '../../../domain/entities/Variant'; -import { CartRepository } from '../../../domain/repositories/cart'; import { PaymentRepository } from '../../../domain/repositories/payment'; -import { SessionRepository } from '../../../domain/repositories/session'; import { CartUsecase } from '../../../domain/usecases/cart'; import { MenuItemDetailState, @@ -37,10 +35,7 @@ export type MenuListHandlerProps = { menuListUsecase: MenuListUsecase; menuItemDetailUsecase: MenuItemDetailUsecase; cartUsecase: CartUsecase; - cartRepository: CartRepository; paymentRepository: PaymentRepository; - sessionRepository: SessionRepository; - tableCode: string; preparingCount?: number; }; @@ -189,10 +184,7 @@ export const MenuListHandler = ({ menuListUsecase, menuItemDetailUsecase, cartUsecase, - cartRepository, paymentRepository, - sessionRepository, - tableCode, preparingCount, }: MenuListHandlerProps) => { const tableResolve = useTableResolve(tableResolveUsecase); @@ -266,25 +258,6 @@ export const MenuListHandler = ({ menuList.dispatch({ type: 'CLEAR_ITEM' }); }, [cart.state.type, cart.dispatch, menuItemDetail.state, menuList.dispatch]); - useEffect(() => { - if (tableResolve.state.type === 'resolved' && tableResolve.state.code) { - sessionRepository.setTableCode(tableResolve.state.code); - } - }, [tableResolve.state, sessionRepository]); - - const boundTableCodeRef = useRef(null); - useEffect(() => { - if (tableResolve.state.type !== 'resolved' || !tableResolve.state.code) { - return; - } - const code = tableResolve.state.code; - if (boundTableCodeRef.current === code) return; - boundTableCodeRef.current = code; - cartRepository.updateTable(code).catch(() => { - boundTableCodeRef.current = null; - }); - }, [tableResolve.state, cartRepository]); - useEffect(() => { const { selectedProductId, query } = menuList.state; if (selectedProductId !== null) { @@ -343,7 +316,9 @@ export const MenuListHandler = ({ ) : currentCart && currentCart.itemCount > 0 ? ( router.push(`/t/${tableCode}/cart`)} + onPress={() => + router.push(`/t/${tableResolveUsecase.params.code}/cart`) + } /> ) : null; From c6389885560418c3253df76665beecdd847f1e3e Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Sun, 27 Sep 2026 23:21:23 +0700 Subject: [PATCH 3/5] refactor(order): move tableCode/cashierLocation into order status/history usecases (#661) Implements P2 of docs/trd-order-handler-usecase-boundary.md. OrderHistoryUsecase and OrderStatusUsecase now take an optional SessionRepository dependency and read tableCode once in getInitialState(), the same idiom CartUsecase already uses for cartQueryRepository. OrderStatusUsecase also takes cashierLocation as a Params field carried through Context, the same shape CheckoutUsecase already uses for customerName. OrderHistoryHandler and OrderStatusHandler no longer receive sessionRepository/cashierLocation as props; both are read off the usecase's own state instead. Claude-Session: https://claude.ai/code/session_01Gj1cLXBEeLrUTrhhJ1QeJB Co-authored-by: Claude --- libs/ui/src/app/order/OrderHistory.tsx | 15 ++-- libs/ui/src/app/order/OrderStatus.tsx | 11 ++- .../src/domain/usecases/orderHistory.test.ts | 40 ++++++++++- libs/ui/src/domain/usecases/orderHistory.ts | 12 +++- .../src/domain/usecases/orderStatus.test.ts | 72 ++++++++++++++++++- libs/ui/src/domain/usecases/orderStatus.ts | 19 ++++- .../order/OrderHistoryHandler.test.tsx | 13 ++-- .../handlers/order/OrderHistoryHandler.tsx | 5 +- .../order/OrderStatusHandler.test.tsx | 10 +-- .../handlers/order/OrderStatusHandler.tsx | 9 +-- 10 files changed, 161 insertions(+), 45 deletions(-) diff --git a/libs/ui/src/app/order/OrderHistory.tsx b/libs/ui/src/app/order/OrderHistory.tsx index 1050ba13..af0090f7 100644 --- a/libs/ui/src/app/order/OrderHistory.tsx +++ b/libs/ui/src/app/order/OrderHistory.tsx @@ -13,14 +13,11 @@ export function OrderHistory({ sessionId, payments }: OrderHistoryProps) { const sessionRepository = new CookieSessionRepository(sessionId); const paymentRepository = new ApiPaymentRepository(sessionRepository); - const orderHistoryUsecase = new OrderHistoryUsecase(paymentRepository, { - payments, - }); - - return ( - + const orderHistoryUsecase = new OrderHistoryUsecase( + paymentRepository, + { payments }, + { sessionRepository } ); + + return ; } diff --git a/libs/ui/src/app/order/OrderStatus.tsx b/libs/ui/src/app/order/OrderStatus.tsx index 044f7f1c..b21d4b1d 100644 --- a/libs/ui/src/app/order/OrderStatus.tsx +++ b/libs/ui/src/app/order/OrderStatus.tsx @@ -23,19 +23,18 @@ export function OrderStatus({ accessKey ); - const orderStatusUsecase = new OrderStatusUsecase(paymentRepository, { - reference, - payment, - }); const cashierLocation = process.env['NEXT_PUBLIC_ORDER_CASHIER_LOCATION'] || 'Lantai 1'; + const orderStatusUsecase = new OrderStatusUsecase( + paymentRepository, + { reference, payment, cashierLocation }, + { sessionRepository } + ); return ( ); } diff --git a/libs/ui/src/domain/usecases/orderHistory.test.ts b/libs/ui/src/domain/usecases/orderHistory.test.ts index 451133d8..7b5acc59 100644 --- a/libs/ui/src/domain/usecases/orderHistory.test.ts +++ b/libs/ui/src/domain/usecases/orderHistory.test.ts @@ -4,7 +4,7 @@ import { OrderHistoryState, OrderHistoryParams, } from './orderHistory'; -import { MockPaymentRepository } from '../../data/mock'; +import { MockPaymentRepository, MockSessionRepository } from '../../data/mock'; import { UsecaseTester, flushPromises } from '../../utils/usecase'; const createTester = ( @@ -28,6 +28,7 @@ describe('OrderHistoryUsecase', () => { type: 'loading', payments: [], errorMessage: null, + tableCode: null, }); await flushPromises(); @@ -35,6 +36,7 @@ describe('OrderHistoryUsecase', () => { type: 'loaded', payments: repository.payments, errorMessage: null, + tableCode: null, }); orderHistory.dispatch({ type: 'FETCH' }); @@ -42,6 +44,7 @@ describe('OrderHistoryUsecase', () => { type: 'revalidating', payments: repository.payments, errorMessage: null, + tableCode: null, }); await flushPromises(); @@ -49,6 +52,7 @@ describe('OrderHistoryUsecase', () => { type: 'loaded', payments: repository.payments, errorMessage: null, + tableCode: null, }); }); }); @@ -63,6 +67,7 @@ describe('OrderHistoryUsecase', () => { type: 'loading', payments: [], errorMessage: null, + tableCode: null, }); await flushPromises(); @@ -70,6 +75,7 @@ describe('OrderHistoryUsecase', () => { type: 'error', payments: [], errorMessage: 'Failed to fetch orders', + tableCode: null, }); repository.setShouldFailFetchPayments(false); @@ -78,6 +84,7 @@ describe('OrderHistoryUsecase', () => { type: 'loading', payments: [], errorMessage: null, + tableCode: null, }); await flushPromises(); @@ -85,6 +92,7 @@ describe('OrderHistoryUsecase', () => { type: 'loaded', payments: repository.payments, errorMessage: null, + tableCode: null, }); }); @@ -97,6 +105,7 @@ describe('OrderHistoryUsecase', () => { type: 'loaded', payments: repository.payments, errorMessage: null, + tableCode: null, }); repository.setShouldFailFetchPayments(true); @@ -105,6 +114,7 @@ describe('OrderHistoryUsecase', () => { type: 'revalidating', payments: repository.payments, errorMessage: null, + tableCode: null, }); await flushPromises(); @@ -112,6 +122,7 @@ describe('OrderHistoryUsecase', () => { type: 'loaded', payments: repository.payments, errorMessage: null, + tableCode: null, }); }); }); @@ -126,6 +137,7 @@ describe('OrderHistoryUsecase', () => { type: 'loaded', payments, errorMessage: null, + tableCode: null, }); }); @@ -139,6 +151,7 @@ describe('OrderHistoryUsecase', () => { type: 'loading', payments: [], errorMessage: null, + tableCode: null, }); await flushPromises(); @@ -146,6 +159,31 @@ describe('OrderHistoryUsecase', () => { type: 'loaded', payments: [], errorMessage: null, + tableCode: null, + }); + }); + + describe('tableCode', () => { + it('reads the table code from the injected session repository', () => { + const repository = new MockPaymentRepository(); + const sessionRepository = new MockSessionRepository(); + sessionRepository.setTableCode('3F7H9K2M5P'); + + const usecase = new OrderHistoryUsecase( + repository, + { payments: [] }, + { sessionRepository } + ); + + expect(usecase.getInitialState().tableCode).toBe('3F7H9K2M5P'); + }); + + it('is null when no session repository dependency is provided', () => { + const repository = new MockPaymentRepository(); + + const usecase = new OrderHistoryUsecase(repository, { payments: [] }); + + expect(usecase.getInitialState().tableCode).toBeNull(); }); }); }); diff --git a/libs/ui/src/domain/usecases/orderHistory.ts b/libs/ui/src/domain/usecases/orderHistory.ts index bba9f858..3ad2a656 100644 --- a/libs/ui/src/domain/usecases/orderHistory.ts +++ b/libs/ui/src/domain/usecases/orderHistory.ts @@ -1,6 +1,6 @@ import { match, P } from 'ts-pattern'; import { PaymentSummary } from '../entities'; -import { PaymentRepository } from '../repositories'; +import { PaymentRepository, SessionRepository } from '../repositories'; import { Usecase } from './IUsecase'; export const ORDER_HISTORY_LIMIT = 20; @@ -8,6 +8,7 @@ export const ORDER_HISTORY_LIMIT = 20; type Context = { payments: PaymentSummary[]; errorMessage: string | null; + tableCode: string | null; }; export type OrderHistoryState = ( @@ -36,11 +37,17 @@ export class OrderHistoryUsecase extends Usecase< > { params: OrderHistoryParams; repository: PaymentRepository; + private sessionRepository?: SessionRepository; - constructor(repository: PaymentRepository, params: OrderHistoryParams) { + constructor( + repository: PaymentRepository, + params: OrderHistoryParams, + dependencies: { sessionRepository?: SessionRepository } = {} + ) { super(); this.repository = repository; this.params = params; + this.sessionRepository = dependencies.sessionRepository; } getInitialState() { @@ -48,6 +55,7 @@ export class OrderHistoryUsecase extends Usecase< type: this.params.payments.length >= 1 ? 'loaded' : 'idle', errorMessage: null, payments: this.params.payments, + tableCode: this.sessionRepository?.getTableCode() ?? null, }; return state; } diff --git a/libs/ui/src/domain/usecases/orderStatus.test.ts b/libs/ui/src/domain/usecases/orderStatus.test.ts index ffce7b51..ad3e64f3 100644 --- a/libs/ui/src/domain/usecases/orderStatus.test.ts +++ b/libs/ui/src/domain/usecases/orderStatus.test.ts @@ -4,10 +4,12 @@ import { OrderStatusAction, OrderStatusParams, } from './orderStatus'; -import { MockPaymentRepository } from '../../data/mock'; +import { MockPaymentRepository, MockSessionRepository } from '../../data/mock'; import { Payment } from '../entities'; import { UsecaseTester } from '../../utils/usecase'; +const CASHIER_LOCATION = 'Lantai 1'; + const flushMicrotasks = () => jest.advanceTimersByTimeAsync(0); const createTester = (repository: MockPaymentRepository, reference: string) => @@ -16,7 +18,12 @@ const createTester = (repository: MockPaymentRepository, reference: string) => OrderStatusState, OrderStatusAction, OrderStatusParams - >(new OrderStatusUsecase(repository, { reference })); + >( + new OrderStatusUsecase(repository, { + reference, + cashierLocation: CASHIER_LOCATION, + }) + ); const createSeededTester = ( repository: MockPaymentRepository, @@ -31,6 +38,7 @@ const createSeededTester = ( new OrderStatusUsecase(repository, { reference: payment?.reference ?? 'UNKNOWNREF', payment, + cashierLocation: CASHIER_LOCATION, }) ); @@ -53,6 +61,8 @@ describe('OrderStatusUsecase', () => { payment: null, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -62,6 +72,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); }); @@ -77,6 +89,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); }); @@ -91,6 +105,8 @@ describe('OrderStatusUsecase', () => { payment: null, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); }); @@ -106,6 +122,8 @@ describe('OrderStatusUsecase', () => { payment: null, errorMessage: 'Failed to fetch order status', isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); repository.setShouldFailFetch(false); @@ -116,6 +134,8 @@ describe('OrderStatusUsecase', () => { payment: null, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -125,6 +145,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); }); @@ -162,6 +184,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -181,6 +205,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -204,6 +230,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -227,6 +255,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -246,6 +276,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -268,6 +300,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); }); @@ -288,6 +322,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -306,6 +342,8 @@ describe('OrderStatusUsecase', () => { payment: null, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); await flushMicrotasks(); @@ -564,6 +602,8 @@ describe('OrderStatusUsecase', () => { payment: repository.payment, errorMessage: null, isPolling: false, + tableCode: null, + cashierLocation: CASHIER_LOCATION, }); }); @@ -732,4 +772,32 @@ describe('OrderStatusUsecase', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); }); + + describe('tableCode and cashierLocation', () => { + it('reads the table code from the injected session repository', () => { + const repository = new MockPaymentRepository(); + const sessionRepository = new MockSessionRepository(); + sessionRepository.setTableCode('3F7H9K2M5P'); + + const usecase = new OrderStatusUsecase( + repository, + { reference: repository.payment.reference, cashierLocation: CASHIER_LOCATION }, + { sessionRepository } + ); + + expect(usecase.getInitialState().tableCode).toBe('3F7H9K2M5P'); + expect(usecase.getInitialState().cashierLocation).toBe(CASHIER_LOCATION); + }); + + it('is null when no session repository dependency is provided', () => { + const repository = new MockPaymentRepository(); + + const usecase = new OrderStatusUsecase(repository, { + reference: repository.payment.reference, + cashierLocation: CASHIER_LOCATION, + }); + + expect(usecase.getInitialState().tableCode).toBeNull(); + }); + }); }); diff --git a/libs/ui/src/domain/usecases/orderStatus.ts b/libs/ui/src/domain/usecases/orderStatus.ts index 60277942..bc3735ed 100644 --- a/libs/ui/src/domain/usecases/orderStatus.ts +++ b/libs/ui/src/domain/usecases/orderStatus.ts @@ -1,6 +1,10 @@ import { match, P } from 'ts-pattern'; import { Payment } from '../entities'; -import { PaymentNotFoundError, PaymentRepository } from '../repositories'; +import { + PaymentNotFoundError, + PaymentRepository, + SessionRepository, +} from '../repositories'; import { Usecase } from './IUsecase'; const AWAITING_PAYMENT_POLL_INTERVAL_MS = 3000; @@ -11,6 +15,8 @@ type Context = { payment: Payment | null; errorMessage: string | null; isPolling: boolean; + tableCode: string | null; + cashierLocation: string; }; export type OrderStatusState = ( @@ -42,6 +48,7 @@ export type OrderStatusAction = export type OrderStatusParams = { reference: string; payment?: Payment | null; + cashierLocation: string; }; function stateTypeForPayment( @@ -83,13 +90,19 @@ export class OrderStatusUsecase extends Usecase< > { params: OrderStatusParams; private repository: PaymentRepository; + private sessionRepository?: SessionRepository; private pollTimerId: ReturnType | null = null; private pollIntervalMs: number | null = null; - constructor(repository: PaymentRepository, params: OrderStatusParams) { + constructor( + repository: PaymentRepository, + params: OrderStatusParams, + dependencies: { sessionRepository?: SessionRepository } = {} + ) { super(); this.repository = repository; this.params = params; + this.sessionRepository = dependencies.sessionRepository; } getInitialState(): OrderStatusState { @@ -98,6 +111,8 @@ export class OrderStatusUsecase extends Usecase< payment: this.params.payment ?? null, errorMessage: null, isPolling: false, + tableCode: this.sessionRepository?.getTableCode() ?? null, + cashierLocation: this.params.cashierLocation, }; if (this.params.payment !== undefined) { diff --git a/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.test.tsx b/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.test.tsx index 89f69327..a8371954 100644 --- a/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.test.tsx @@ -26,18 +26,17 @@ const renderHandler = ({ paymentRepository?: MockPaymentRepository; sessionRepository?: MockSessionRepository; } = {}) => { - const orderHistoryUsecase = new OrderHistoryUsecase(paymentRepository, { - payments: [], - }); + const orderHistoryUsecase = new OrderHistoryUsecase( + paymentRepository, + { payments: [] }, + { sessionRepository } + ); return { paymentRepository, sessionRepository, ...render( - + ), }; }; diff --git a/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.tsx b/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.tsx index 58da3640..0b0b3c18 100644 --- a/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/OrderHistoryHandler.tsx @@ -1,7 +1,6 @@ import { match, P } from 'ts-pattern'; import { useRouter } from 'solito/router'; import { PaymentSummary } from '../../../domain/entities/Payment'; -import { SessionRepository } from '../../../domain/repositories/session'; import { OrderHistoryUsecase } from '../../../domain/usecases/orderHistory'; import { useUsecase } from '../hooks/useUsecase'; import { @@ -11,12 +10,10 @@ import { export type OrderHistoryHandlerProps = { orderHistoryUsecase: OrderHistoryUsecase; - sessionRepository: SessionRepository; }; export const OrderHistoryHandler = ({ orderHistoryUsecase, - sessionRepository, }: OrderHistoryHandlerProps) => { const orderHistory = useUsecase(orderHistoryUsecase); const router = useRouter(); @@ -35,7 +32,7 @@ export const OrderHistoryHandler = ({ ) .exhaustive(); - const tableCode = sessionRepository.getTableCode(); + const tableCode = orderHistory.state.tableCode; const menuPath = tableCode ? `/t/${tableCode}` : '/'; return ( diff --git a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx index 55324a87..6a951d60 100644 --- a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx @@ -60,9 +60,11 @@ const renderHandler = ({ sessionRepository?: MockSessionRepository; cashierLocation?: string; }) => { - const orderStatusUsecase = new OrderStatusUsecase(paymentRepository, { - reference, - }); + const orderStatusUsecase = new OrderStatusUsecase( + paymentRepository, + { reference, cashierLocation }, + { sessionRepository } + ); return { paymentRepository, @@ -71,8 +73,6 @@ const renderHandler = ({ ), }; diff --git a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx index 4fa92392..67c22fc6 100644 --- a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx @@ -3,7 +3,6 @@ import { match, P } from 'ts-pattern'; import { useRouter } from 'solito/router'; import { Payment } from '../../../domain/entities/Payment'; import { PaymentRepository } from '../../../domain/repositories/payment'; -import { SessionRepository } from '../../../domain/repositories/session'; import { OrderStatusUsecase } from '../../../domain/usecases/orderStatus'; import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; import { useBackNavigationGuard } from '../hooks/useBackNavigationGuard'; @@ -24,20 +23,16 @@ function payAtPickupAmount(payment: Payment): number | null { export type OrderStatusHandlerProps = { orderStatusUsecase: OrderStatusUsecase; paymentRepository: PaymentRepository; - sessionRepository: SessionRepository; - cashierLocation: string; }; export const OrderStatusHandler = ({ orderStatusUsecase, paymentRepository, - sessionRepository, - cashierLocation, }: OrderStatusHandlerProps) => { const orderStatus = useOrderStatus(orderStatusUsecase); const router = useRouter(); - const tableCode = sessionRepository.getTableCode(); + const tableCode = orderStatus.state.tableCode; const menuPath = tableCode ? `/t/${tableCode}` : '/'; const cartPath = tableCode ? `/t/${tableCode}/cart` : '/'; @@ -136,7 +131,7 @@ export const OrderStatusHandler = ({ ? { type: 'awaitingCashPayment', payment: state.payment, - cashierLocation, + cashierLocation: state.cashierLocation, onCountdownElapsed: () => orderStatus.dispatch({ type: 'COUNTDOWN_ELAPSED' }), canCancel: state.payment.canCancel, From 3410aa2ac924ded8d45978ee9b7f9379ae794063 Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Sun, 27 Sep 2026 23:43:07 +0700 Subject: [PATCH 4/5] refactor(order): move checkout feature flags into CheckoutUsecase params (#662) Per docs/trd-order-handler-usecase-boundary.md phase 3 (D5): enabled, enabledMethods, and cashierLocation are checkout-flow config values that belong on CheckoutUsecase.params, the same way customerName already does, instead of being threaded past CartHandler as bare props read from process.env in the composition root. CartHandler now reads these off checkout.state instead of receiving them as separate props. Claude-Session: https://claude.ai/code/session_01Ete6mVquK6t44UQsxf8xjG Co-authored-by: Claude --- libs/ui/src/app/order/Cart.tsx | 14 ++++++------ libs/ui/src/domain/usecases/checkout.test.ts | 22 +++++++++++++++++++ libs/ui/src/domain/usecases/checkout.ts | 9 ++++++++ .../handlers/order/CartHandler.test.tsx | 6 ++--- .../handlers/order/CartHandler.tsx | 15 +++++-------- 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/libs/ui/src/app/order/Cart.tsx b/libs/ui/src/app/order/Cart.tsx index a92a4e29..1d2959c5 100644 --- a/libs/ui/src/app/order/Cart.tsx +++ b/libs/ui/src/app/order/Cart.tsx @@ -39,10 +39,6 @@ export function Cart({ { sessionRepository } ); const cartUsecase = new CartUsecase(cartRepository, cartQueryRepository); - const checkoutUsecase = new CheckoutUsecase(paymentRepository, { - customerName, - customerWhatsappNumber, - }); const enabled = process.env['NEXT_PUBLIC_ORDER_CHECKOUT_ENABLED'] === 'true'; const isCashPaymentEnabled = process.env['NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED'] === 'true'; @@ -55,6 +51,13 @@ export function Cart({ ]; const cashierLocation = process.env['NEXT_PUBLIC_ORDER_CASHIER_LOCATION'] || 'Lantai 1'; + const checkoutUsecase = new CheckoutUsecase(paymentRepository, { + customerName, + customerWhatsappNumber, + enabled, + enabledMethods, + cashierLocation, + }); return ( ); diff --git a/libs/ui/src/domain/usecases/checkout.test.ts b/libs/ui/src/domain/usecases/checkout.test.ts index d799a1d8..39d55c1b 100644 --- a/libs/ui/src/domain/usecases/checkout.test.ts +++ b/libs/ui/src/domain/usecases/checkout.test.ts @@ -158,6 +158,28 @@ describe('CheckoutUsecase', () => { expect(checkout.state.whatsappNumber).toBe('0812345678'); }); + it('should default enabled to false, enabledMethods to [qris], and cashierLocation to Lantai 1', () => { + const repository = new MockPaymentRepository(); + const checkout = createTester(repository); + + expect(checkout.state.enabled).toBe(false); + expect(checkout.state.enabledMethods).toEqual(['qris']); + expect(checkout.state.cashierLocation).toBe('Lantai 1'); + }); + + it('should seed enabled, enabledMethods, and cashierLocation from params', () => { + const repository = new MockPaymentRepository(); + const checkout = createTester(repository, { + enabled: true, + enabledMethods: ['qris', 'cash', 'cod'], + cashierLocation: 'Lantai 2', + }); + + expect(checkout.state.enabled).toBe(true); + expect(checkout.state.enabledMethods).toEqual(['qris', 'cash', 'cod']); + expect(checkout.state.cashierLocation).toBe('Lantai 2'); + }); + it('should reach creatingPayment keeping the raw number as typed, and submit the normalized number', async () => { const repository = new MockPaymentRepository(); const checkoutSpy = jest.spyOn(repository, 'checkout'); diff --git a/libs/ui/src/domain/usecases/checkout.ts b/libs/ui/src/domain/usecases/checkout.ts index e07f2612..acee9c13 100644 --- a/libs/ui/src/domain/usecases/checkout.ts +++ b/libs/ui/src/domain/usecases/checkout.ts @@ -24,6 +24,9 @@ type Context = { nameErrorMessage: string | null; whatsappNumberErrorMessage: string | null; errorMessage: string | null; + enabled: boolean; + enabledMethods: PaymentMethod[]; + cashierLocation: string; }; export type CheckoutState = ( @@ -57,6 +60,9 @@ export type CheckoutAction = export type CheckoutParams = { customerName?: string; customerWhatsappNumber?: string; + enabled?: boolean; + enabledMethods?: PaymentMethod[]; + cashierLocation?: string; }; function validateName(name: string): string | null { @@ -126,6 +132,9 @@ export class CheckoutUsecase extends Usecase< nameErrorMessage: null, whatsappNumberErrorMessage: null, errorMessage: null, + enabled: this.params.enabled ?? false, + enabledMethods: this.params.enabledMethods ?? ['qris'], + cashierLocation: this.params.cashierLocation ?? 'Lantai 1', }; } diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx index 0e0577e6..3690337c 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx @@ -64,6 +64,9 @@ const renderHandler = ({ const checkoutUsecase = new CheckoutUsecase(paymentRepository, { customerName, customerWhatsappNumber, + enabled, + enabledMethods, + cashierLocation, }); return { @@ -77,9 +80,6 @@ const renderHandler = ({ cartUsecase={cartUsecase} checkoutUsecase={checkoutUsecase} paymentRepository={paymentRepository} - enabled={enabled} - enabledMethods={enabledMethods} - cashierLocation={cashierLocation} preparingCount={preparingCount} /> ), diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.tsx index 8c864b41..d60168ff 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, useState } from 'react'; import { match, P } from 'ts-pattern'; import { useRouter } from 'solito/router'; import { Cart } from '../../../domain/entities/Cart'; -import { PaymentMethod } from '../../../domain/entities/Payment'; import { PaymentRepository } from '../../../domain/repositories/payment'; import { CartState, CartUsecase } from '../../../domain/usecases/cart'; import { CheckoutUsecase } from '../../../domain/usecases/checkout'; @@ -26,9 +25,6 @@ export type CartHandlerProps = { cartUsecase: CartUsecase; checkoutUsecase: CheckoutUsecase; paymentRepository: PaymentRepository; - enabled: boolean; - enabledMethods?: PaymentMethod[]; - cashierLocation?: string; preparingCount?: number; }; @@ -67,9 +63,6 @@ export const CartHandler = ({ cartUsecase, checkoutUsecase, paymentRepository, - enabled, - enabledMethods = ['qris'], - cashierLocation = 'Lantai 1', preparingCount, }: CartHandlerProps) => { const tableResolve = useTableResolve(tableResolveUsecase); @@ -222,14 +215,14 @@ export const CartHandler = ({ }), onSubmitPress: () => checkout.dispatch({ type: 'SUBMIT_DETAILS' }), onCancelPress: () => checkout.dispatch({ type: 'CANCEL_DETAILS' }), - enabledMethods, + enabledMethods: checkout.state.enabledMethods, method: checkout.state.method, onMethodChange: (method) => checkout.dispatch({ type: 'CHANGE_METHOD', method }), diningOption: checkout.state.diningOption, onDiningOptionChange: (diningOption) => checkout.dispatch({ type: 'CHANGE_DINING_OPTION', diningOption }), - cashierLocation, + cashierLocation: checkout.state.cashierLocation, verificationPhoto: checkout.state.verificationPhoto, onOpenPhotoCapture: () => checkout.dispatch({ type: 'OPEN_PHOTO_CAPTURE' }), @@ -305,7 +298,9 @@ export const CartHandler = ({ } onRetryButtonPress={() => cart.dispatch({ type: 'FETCH' })} itemEdit={itemEdit} - isCheckoutEnabled={enabled && !hasUnavailableItems(cart.state.cart)} + isCheckoutEnabled={ + checkout.state.enabled && !hasUnavailableItems(cart.state.cart) + } checkoutErrorMessage={ checkout.state.type === 'error' ? checkout.state.errorMessage : null } From 1b8365641d704b8de29495d11d52a96a6b2a39eb Mon Sep 17 00:00:00 2001 From: M Nindra Zaka Date: Mon, 28 Sep 2026 00:10:19 +0700 Subject: [PATCH 5/5] refactor(order): construct paymentCancelUsecase in composition roots, not handlers (#663) Removes the last repository (paymentRepository) from every order handler's prop list. MenuListHandler, CartHandler, and OrderStatusHandler each reconstructed a PaymentCancelUsecase inline via useState + a conditional setState whenever their own pendingPayment/payment changed; that ~20-line block is now a single new usePaymentCancelSyncedTo hook that re-syncs a usecase built once by the composition root via a dispatched SYNC_PARAMS action, matching how PaymentCancelUsecase already supported this. Also documents the rule in docs/handlers.md so this doesn't regress the next time an order screen is touched. Implements Phase 4 of docs/trd-order-handler-usecase-boundary.md. Claude-Session: https://claude.ai/code/session_01RFonbEaS6P4NfMBuhJWK2p Co-authored-by: Claude --- docs/handlers.md | 17 ++++ libs/ui/src/app/order/Cart.tsx | 7 +- libs/ui/src/app/order/MenuList.tsx | 7 +- libs/ui/src/app/order/OrderStatus.tsx | 7 +- .../src/presentation/handlers/hooks/index.ts | 1 + .../hooks/usePaymentCancelSyncedTo.test.ts | 85 +++++++++++++++++++ .../hooks/usePaymentCancelSyncedTo.ts | 35 ++++++++ .../handlers/order/CartHandler.test.tsx | 7 +- .../handlers/order/CartHandler.tsx | 35 ++------ .../handlers/order/MenuListHandler.test.tsx | 7 +- .../handlers/order/MenuListHandler.tsx | 35 ++------ .../order/OrderStatusHandler.test.tsx | 40 ++++++++- .../handlers/order/OrderStatusHandler.tsx | 32 +++---- 13 files changed, 233 insertions(+), 82 deletions(-) create mode 100644 libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.test.ts create mode 100644 libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.ts diff --git a/docs/handlers.md b/docs/handlers.md index caca0dcb..6a50823c 100644 --- a/docs/handlers.md +++ b/docs/handlers.md @@ -47,6 +47,23 @@ export const AuthLoginHandler = ({ authLoginUsecase }: AuthLoginHandlerProps) => Note the effects that fire on the same state end up in the same `useEffect`, instead of split across a handler and a controller in two files. +## A handler's props are usecases, not repositories (D-order-boundary) + +Per `docs/trd-order-handler-usecase-boundary.md`: a handler's props are usecases, plus rarely a +single documented pure display value (e.g. `MenuListHandler`'s SSR-seeded `preparingCount` — a +read-only integer with no repository call or state transition behind it, see that TRD's D6). A +repository never appears in a Handler's prop list. If a handler needs one more side effect or +value than its usecases already expose, that's a usecase missing the responsibility, not a reason +to hand the handler a repository: + +- Build the usecase that needs the repository in the composition root (`app/{pos,order}/.tsx`), + the same place every other usecase in the codebase is constructed, and pass the *usecase* down. +- If a usecase must be reconstructed later once a value becomes known (e.g. a payment reference + discovered only after another usecase's fetch resolves), don't rebuild it inline in the handler. + Construct it once in the composition root and re-sync it via a dispatched action instead — see + `usePaymentCancelSyncedTo` (`presentation/handlers/hooks/usePaymentCancelSyncedTo.ts`) and + `PaymentCancelUsecase`'s `SYNC_PARAMS` action for the reference shape. + ## Browser-lifecycle guards live in `utils/`, not `handlers/hooks/` `beforeunload` and Next's `router.events` are browser/Next-only APIs, and `.eslintrc.json` bans diff --git a/libs/ui/src/app/order/Cart.tsx b/libs/ui/src/app/order/Cart.tsx index 1d2959c5..2ce0cd0b 100644 --- a/libs/ui/src/app/order/Cart.tsx +++ b/libs/ui/src/app/order/Cart.tsx @@ -7,6 +7,7 @@ import { PaymentMethod } from '../../domain/entities/Payment'; import { PublicTable } from '../../domain/entities/PublicTable'; import { CartUsecase } from '../../domain/usecases/cart'; import { CheckoutUsecase } from '../../domain/usecases/checkout'; +import { PaymentCancelUsecase } from '../../domain/usecases/paymentCancel'; import { TableResolveUsecase } from '../../domain/usecases/tableResolve'; import { CartHandler } from '../../presentation/handlers/order/CartHandler'; @@ -58,13 +59,17 @@ export function Cart({ enabledMethods, cashierLocation, }); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: '', + method: 'qris', + }); return ( ); diff --git a/libs/ui/src/app/order/MenuList.tsx b/libs/ui/src/app/order/MenuList.tsx index b07cba53..6c6f457f 100644 --- a/libs/ui/src/app/order/MenuList.tsx +++ b/libs/ui/src/app/order/MenuList.tsx @@ -13,6 +13,7 @@ import { Variant } from '../../domain/entities/Variant'; import { CartUsecase } from '../../domain/usecases/cart'; import { MenuItemDetailUsecase } from '../../domain/usecases/menuItemDetail'; import { MenuListUsecase } from '../../domain/usecases/menuList'; +import { PaymentCancelUsecase } from '../../domain/usecases/paymentCancel'; import { TableResolveUsecase } from '../../domain/usecases/tableResolve'; import { MenuListHandler } from '../../presentation/handlers/order/MenuListHandler'; @@ -64,6 +65,10 @@ export function MenuList({ product: selectedProduct, }); const cartUsecase = new CartUsecase(cartRepository, new UrlCartQueryRepository()); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: '', + method: 'qris', + }); return ( ); diff --git a/libs/ui/src/app/order/OrderStatus.tsx b/libs/ui/src/app/order/OrderStatus.tsx index b21d4b1d..df550dbc 100644 --- a/libs/ui/src/app/order/OrderStatus.tsx +++ b/libs/ui/src/app/order/OrderStatus.tsx @@ -2,6 +2,7 @@ import { ApiPaymentRepository } from '../../data/api/payment'; import { CookieSessionRepository } from '../../data/session/CookieSessionRepository'; import { Payment } from '../../domain/entities/Payment'; import { OrderStatusUsecase } from '../../domain/usecases/orderStatus'; +import { PaymentCancelUsecase } from '../../domain/usecases/paymentCancel'; import { OrderStatusHandler } from '../../presentation/handlers/order/OrderStatusHandler'; export type OrderStatusProps = { @@ -30,11 +31,15 @@ export function OrderStatus({ { reference, payment, cashierLocation }, { sessionRepository } ); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: payment?.reference ?? '', + method: payment?.method ?? 'qris', + }); return ( ); } diff --git a/libs/ui/src/presentation/handlers/hooks/index.ts b/libs/ui/src/presentation/handlers/hooks/index.ts index 08ac613c..2acd74f4 100644 --- a/libs/ui/src/presentation/handlers/hooks/index.ts +++ b/libs/ui/src/presentation/handlers/hooks/index.ts @@ -8,6 +8,7 @@ export * from './useCouponList'; export * from './useMaterialList'; export * from './useOrderStatus'; export * from './usePaymentCancel'; +export * from './usePaymentCancelSyncedTo'; export * from './useRentalList'; export * from './useSupplierList'; export * from './useTableResolve'; diff --git a/libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.test.ts b/libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.test.ts new file mode 100644 index 00000000..47b47bdf --- /dev/null +++ b/libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.test.ts @@ -0,0 +1,85 @@ +import { renderHook } from '@testing-library/react'; +import { MockPaymentRepository } from '../../../data/mock'; +import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; +import { + PaymentCancelSyncTarget, + usePaymentCancelSyncedTo, +} from './usePaymentCancelSyncedTo'; + +describe('usePaymentCancelSyncedTo', () => { + it('does not sync when no target is known yet', () => { + const usecase = new PaymentCancelUsecase(new MockPaymentRepository(), { + reference: '', + method: 'qris', + }); + const getNextStateSpy = jest.spyOn(usecase, 'getNextState'); + + const { result } = renderHook(() => + usePaymentCancelSyncedTo(usecase, null) + ); + + expect(getNextStateSpy).not.toHaveBeenCalled(); + expect(result.current.state.reference).toBe(''); + expect(result.current.state.method).toBe('qris'); + }); + + it('syncs once a target arrives, and does not re-dispatch on an identical rerender', () => { + const usecase = new PaymentCancelUsecase(new MockPaymentRepository(), { + reference: '', + method: 'qris', + }); + const getNextStateSpy = jest.spyOn(usecase, 'getNextState'); + + const { result, rerender } = renderHook< + ReturnType, + { target: PaymentCancelSyncTarget } + >(({ target }) => usePaymentCancelSyncedTo(usecase, target), { + initialProps: { target: null }, + }); + + rerender({ target: { reference: 'ORDER-1', method: 'qris' } }); + expect(getNextStateSpy).toHaveBeenCalledTimes(1); + expect(result.current.state.reference).toBe('ORDER-1'); + + rerender({ target: { reference: 'ORDER-1', method: 'qris' } }); + expect(getNextStateSpy).toHaveBeenCalledTimes(1); + }); + + it('re-syncs when a second, different target replaces the first', () => { + const usecase = new PaymentCancelUsecase(new MockPaymentRepository(), { + reference: '', + method: 'qris', + }); + const getNextStateSpy = jest.spyOn(usecase, 'getNextState'); + + const { result, rerender } = renderHook< + ReturnType, + { target: PaymentCancelSyncTarget } + >(({ target }) => usePaymentCancelSyncedTo(usecase, target), { + initialProps: { target: { reference: 'ORDER-1', method: 'qris' } }, + }); + expect(getNextStateSpy).toHaveBeenCalledTimes(1); + + rerender({ target: { reference: 'ORDER-2', method: 'cash' } }); + expect(getNextStateSpy).toHaveBeenCalledTimes(2); + expect(result.current.state.reference).toBe('ORDER-2'); + expect(result.current.state.method).toBe('cash'); + }); + + it('does not sync when the target already matches the running state', () => { + const usecase = new PaymentCancelUsecase(new MockPaymentRepository(), { + reference: 'ORDER-1', + method: 'qris', + }); + const getNextStateSpy = jest.spyOn(usecase, 'getNextState'); + + renderHook(() => + usePaymentCancelSyncedTo(usecase, { + reference: 'ORDER-1', + method: 'qris', + }) + ); + + expect(getNextStateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.ts b/libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.ts new file mode 100644 index 00000000..28fd3fac --- /dev/null +++ b/libs/ui/src/presentation/handlers/hooks/usePaymentCancelSyncedTo.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; +import { PaymentMethod } from '../../../domain/entities/Payment'; +import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; +import { usePaymentCancel } from './usePaymentCancel'; + +export type PaymentCancelSyncTarget = { + reference: string; + method: PaymentMethod; +} | null; + +export const usePaymentCancelSyncedTo = ( + usecase: PaymentCancelUsecase, + target: PaymentCancelSyncTarget +) => { + const binding = usePaymentCancel(usecase); + + useEffect(() => { + if (!target) return; + if ( + target.reference === binding.state.reference && + target.method === binding.state.method + ) { + return; + } + binding.dispatch({ type: 'SYNC_PARAMS', ...target }); + }, [ + target?.reference, + target?.method, + binding.state.reference, + binding.state.method, + binding.dispatch, + ]); + + return binding; +}; diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx index 3690337c..cf5ced3f 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.test.tsx @@ -13,6 +13,7 @@ import { CartUsecase, CheckoutUsecase, Payment, + PaymentCancelUsecase, PaymentMethod, PendingPayment, TableResolveUsecase, @@ -68,6 +69,10 @@ const renderHandler = ({ enabledMethods, cashierLocation, }); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: '', + method: 'qris', + }); return { cartRepository, @@ -79,7 +84,7 @@ const renderHandler = ({ tableResolveUsecase={tableResolveUsecase} cartUsecase={cartUsecase} checkoutUsecase={checkoutUsecase} - paymentRepository={paymentRepository} + paymentCancelUsecase={paymentCancelUsecase} preparingCount={preparingCount} /> ), diff --git a/libs/ui/src/presentation/handlers/order/CartHandler.tsx b/libs/ui/src/presentation/handlers/order/CartHandler.tsx index d60168ff..ad8a9713 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.tsx @@ -2,14 +2,13 @@ import { useEffect, useRef, useState } from 'react'; import { match, P } from 'ts-pattern'; import { useRouter } from 'solito/router'; import { Cart } from '../../../domain/entities/Cart'; -import { PaymentRepository } from '../../../domain/repositories/payment'; import { CartState, CartUsecase } from '../../../domain/usecases/cart'; import { CheckoutUsecase } from '../../../domain/usecases/checkout'; import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; import { TableResolveUsecase } from '../../../domain/usecases/tableResolve'; import { useCart } from '../hooks/useCart'; import { useCheckout } from '../hooks/useCheckout'; -import { usePaymentCancel } from '../hooks/usePaymentCancel'; +import { usePaymentCancelSyncedTo } from '../hooks/usePaymentCancelSyncedTo'; import { useTableResolve } from '../hooks/useTableResolve'; import { CodPhotoCaptureSheetProps } from '../../views/components/checkout/CodPhotoCaptureSheet'; import { CustomerDetailsSheetProps } from '../../views/components/checkout/CustomerDetailsSheet'; @@ -24,7 +23,7 @@ export type CartHandlerProps = { tableResolveUsecase: TableResolveUsecase; cartUsecase: CartUsecase; checkoutUsecase: CheckoutUsecase; - paymentRepository: PaymentRepository; + paymentCancelUsecase: PaymentCancelUsecase; preparingCount?: number; }; @@ -62,7 +61,7 @@ export const CartHandler = ({ tableResolveUsecase, cartUsecase, checkoutUsecase, - paymentRepository, + paymentCancelUsecase, preparingCount, }: CartHandlerProps) => { const tableResolve = useTableResolve(tableResolveUsecase); @@ -85,29 +84,13 @@ export const CartHandler = ({ const mutating = isMutating(cart.state); const pendingPayment = cart.state.cart?.pendingPayment ?? null; - const [paymentCancelUsecase, setPaymentCancelUsecase] = useState( - () => - new PaymentCancelUsecase(paymentRepository, { - reference: pendingPayment?.partnerReferenceNo ?? '', - method: pendingPayment?.method ?? 'qris', - }) + const paymentCancel = usePaymentCancelSyncedTo( + paymentCancelUsecase, + pendingPayment && { + reference: pendingPayment.partnerReferenceNo, + method: pendingPayment.method, + } ); - - if ( - pendingPayment && - (pendingPayment.partnerReferenceNo !== - paymentCancelUsecase.params.reference || - pendingPayment.method !== paymentCancelUsecase.params.method) - ) { - setPaymentCancelUsecase( - new PaymentCancelUsecase(paymentRepository, { - reference: pendingPayment.partnerReferenceNo, - method: pendingPayment.method, - }) - ); - } - - const paymentCancel = usePaymentCancel(paymentCancelUsecase); const handledCancelResultRef = useRef(null); diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx index 55649b40..77493081 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx @@ -16,6 +16,7 @@ import { MenuItemDetailUsecase, MenuListParams, MenuListUsecase, + PaymentCancelUsecase, PendingPayment, TableResolveUsecase, } from '../../../domain'; @@ -76,6 +77,10 @@ const renderHandler = ({ cartRepository, new MockCartQueryRepository() ); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: '', + method: 'qris', + }); return { menuRepository, @@ -89,7 +94,7 @@ const renderHandler = ({ menuListUsecase={menuListUsecase} menuItemDetailUsecase={menuItemDetailUsecase} cartUsecase={cartUsecase} - paymentRepository={paymentRepository} + paymentCancelUsecase={paymentCancelUsecase} preparingCount={preparingCount} /> ), diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx index 0c456662..55bd9b10 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx @@ -4,7 +4,6 @@ import { useRouter } from 'solito/router'; import { Category } from '../../../domain/entities/Category'; import { Product } from '../../../domain/entities/Product'; import { Variant } from '../../../domain/entities/Variant'; -import { PaymentRepository } from '../../../domain/repositories/payment'; import { CartUsecase } from '../../../domain/usecases/cart'; import { MenuItemDetailState, @@ -21,7 +20,7 @@ import { CartBar } from '../../views/components/cart/CartBar'; import { PendingPaymentBar } from '../../views/components/cart/PendingPaymentBar'; import { useUsecase } from '../hooks/useUsecase'; import { useCart } from '../hooks/useCart'; -import { usePaymentCancel } from '../hooks/usePaymentCancel'; +import { usePaymentCancelSyncedTo } from '../hooks/usePaymentCancelSyncedTo'; import { useTableResolve } from '../hooks/useTableResolve'; import { MenuItemDetailScreenProps } from '../../views/screens/order/MenuItemDetailScreen'; import { @@ -35,7 +34,7 @@ export type MenuListHandlerProps = { menuListUsecase: MenuListUsecase; menuItemDetailUsecase: MenuItemDetailUsecase; cartUsecase: CartUsecase; - paymentRepository: PaymentRepository; + paymentCancelUsecase: PaymentCancelUsecase; preparingCount?: number; }; @@ -184,7 +183,7 @@ export const MenuListHandler = ({ menuListUsecase, menuItemDetailUsecase, cartUsecase, - paymentRepository, + paymentCancelUsecase, preparingCount, }: MenuListHandlerProps) => { const tableResolve = useTableResolve(tableResolveUsecase); @@ -199,29 +198,13 @@ export const MenuListHandler = ({ const currentCart = cart.state.cart; const pendingPayment = currentCart?.pendingPayment ?? null; - const [paymentCancelUsecase, setPaymentCancelUsecase] = useState( - () => - new PaymentCancelUsecase(paymentRepository, { - reference: pendingPayment?.partnerReferenceNo ?? '', - method: pendingPayment?.method ?? 'qris', - }) + const paymentCancel = usePaymentCancelSyncedTo( + paymentCancelUsecase, + pendingPayment && { + reference: pendingPayment.partnerReferenceNo, + method: pendingPayment.method, + } ); - - if ( - pendingPayment && - (pendingPayment.partnerReferenceNo !== - paymentCancelUsecase.params.reference || - pendingPayment.method !== paymentCancelUsecase.params.method) - ) { - setPaymentCancelUsecase( - new PaymentCancelUsecase(paymentRepository, { - reference: pendingPayment.partnerReferenceNo, - method: pendingPayment.method, - }) - ); - } - - const paymentCancel = usePaymentCancel(paymentCancelUsecase); const handledCancelResultRef = useRef< typeof paymentCancel.state.result >(null); diff --git a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx index 6a951d60..8ba7a66d 100644 --- a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx +++ b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.test.tsx @@ -5,7 +5,11 @@ import { MockPaymentRepository, MockSessionRepository, } from '../../../data/mock'; -import { OrderStatusUsecase } from '../../../domain'; +import { + OrderStatusUsecase, + Payment, + PaymentCancelUsecase, +} from '../../../domain'; import { PaymentRepository } from '../../../domain/repositories/payment'; import { formatRupiah } from '../../../utils/currency'; import { flushPromises } from '../../../utils/testUtils'; @@ -51,20 +55,26 @@ const createSessionRepositoryWithTableCode = () => { const renderHandler = ({ reference, + payment, paymentRepository = new MockPaymentRepository(), sessionRepository = createSessionRepositoryWithTableCode(), cashierLocation = CASHIER_LOCATION, }: { reference: string; + payment?: Payment | null; paymentRepository?: MockPaymentRepository; sessionRepository?: MockSessionRepository; cashierLocation?: string; }) => { const orderStatusUsecase = new OrderStatusUsecase( paymentRepository, - { reference, cashierLocation }, + { reference, payment, cashierLocation }, { sessionRepository } ); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: payment?.reference ?? '', + method: payment?.method ?? 'qris', + }); return { paymentRepository, @@ -72,7 +82,7 @@ const renderHandler = ({ ...render( ), }; @@ -504,6 +514,30 @@ describe('OrderStatusHandler', () => { }); describe('cancelling a pending payment', () => { + it('cancels using the seeded payment reference from the first render, with no fetch needed first', async () => { + const paymentRepository = new MockPaymentRepository(); + const seededPayment: Payment = { + ...paymentRepository.payment, + canCancel: true, + }; + const cancelSpy = jest.spyOn(paymentRepository, 'cancelPayment'); + + const { getByRole } = renderHandler({ + reference: seededPayment.reference, + payment: seededPayment, + paymentRepository, + }); + + await act(async () => { + getByRole('button', { name: 'Batalkan pembayaran' }).click(); + }); + await act(async () => { + getByRole('button', { name: 'Ya' }).click(); + }); + + expect(cancelSpy).toHaveBeenCalledWith(seededPayment.reference); + }); + it('hides the cancel button when the payment cannot be cancelled', async () => { const paymentRepository = new MockPaymentRepository(); paymentRepository.payment = { diff --git a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx index 67c22fc6..40bcd1a6 100644 --- a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx @@ -1,13 +1,12 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { match, P } from 'ts-pattern'; import { useRouter } from 'solito/router'; import { Payment } from '../../../domain/entities/Payment'; -import { PaymentRepository } from '../../../domain/repositories/payment'; import { OrderStatusUsecase } from '../../../domain/usecases/orderStatus'; import { PaymentCancelUsecase } from '../../../domain/usecases/paymentCancel'; import { useBackNavigationGuard } from '../hooks/useBackNavigationGuard'; import { useOrderStatus } from '../hooks/useOrderStatus'; -import { usePaymentCancel } from '../hooks/usePaymentCancel'; +import { usePaymentCancelSyncedTo } from '../hooks/usePaymentCancelSyncedTo'; import { OrderStatusCancelConfirmation, OrderStatusScreen, @@ -22,12 +21,12 @@ function payAtPickupAmount(payment: Payment): number | null { export type OrderStatusHandlerProps = { orderStatusUsecase: OrderStatusUsecase; - paymentRepository: PaymentRepository; + paymentCancelUsecase: PaymentCancelUsecase; }; export const OrderStatusHandler = ({ orderStatusUsecase, - paymentRepository, + paymentCancelUsecase, }: OrderStatusHandlerProps) => { const orderStatus = useOrderStatus(orderStatusUsecase); const router = useRouter(); @@ -38,25 +37,14 @@ export const OrderStatusHandler = ({ const payment = orderStatus.state.payment; - const [paymentCancelUsecase, setPaymentCancelUsecase] = useState( - () => - new PaymentCancelUsecase(paymentRepository, { - reference: orderStatusUsecase.params.reference, - method: payment?.method ?? 'qris', - }) + const paymentCancel = usePaymentCancelSyncedTo( + paymentCancelUsecase, + payment && { + reference: orderStatusUsecase.params.reference, + method: payment.method, + } ); - if (payment && payment.method !== paymentCancelUsecase.params.method) { - setPaymentCancelUsecase( - new PaymentCancelUsecase(paymentRepository, { - reference: orderStatusUsecase.params.reference, - method: payment.method, - }) - ); - } - - const paymentCancel = usePaymentCancel(paymentCancelUsecase); - useEffect(() => { if (paymentCancel.state.type !== 'settled' || !paymentCancel.state.result) { return;