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/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. | diff --git a/libs/ui/src/app/order/Cart.tsx b/libs/ui/src/app/order/Cart.tsx index 148a8c44..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'; @@ -33,15 +34,12 @@ 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, - customerWhatsappNumber, - }); const enabled = process.env['NEXT_PUBLIC_ORDER_CHECKOUT_ENABLED'] === 'true'; const isCashPaymentEnabled = process.env['NEXT_PUBLIC_ORDER_CASH_PAYMENT_ENABLED'] === 'true'; @@ -54,18 +52,24 @@ export function Cart({ ]; const cashierLocation = process.env['NEXT_PUBLIC_ORDER_CASHIER_LOCATION'] || 'Lantai 1'; + const checkoutUsecase = new CheckoutUsecase(paymentRepository, { + customerName, + customerWhatsappNumber, + enabled, + 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 709de5de..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'; @@ -45,10 +46,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, @@ -63,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/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..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 = { @@ -23,19 +24,22 @@ 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 } + ); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: payment?.reference ?? '', + method: payment?.method ?? 'qris', + }); 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/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/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/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 138946da..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, @@ -55,13 +56,22 @@ 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, customerWhatsappNumber, + enabled, + enabledMethods, + cashierLocation, + }); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: '', + method: 'qris', }); return { @@ -74,12 +84,7 @@ const renderHandler = ({ tableResolveUsecase={tableResolveUsecase} cartUsecase={cartUsecase} checkoutUsecase={checkoutUsecase} - paymentRepository={paymentRepository} - sessionRepository={sessionRepository} - enabled={enabled} - enabledMethods={enabledMethods} - cashierLocation={cashierLocation} - tableCode={TABLE_CODE} + 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 b376cff3..ad8a9713 100644 --- a/libs/ui/src/presentation/handlers/order/CartHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/CartHandler.tsx @@ -2,16 +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 { 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'; 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'; @@ -26,12 +23,7 @@ export type CartHandlerProps = { tableResolveUsecase: TableResolveUsecase; cartUsecase: CartUsecase; checkoutUsecase: CheckoutUsecase; - paymentRepository: PaymentRepository; - sessionRepository: SessionRepository; - enabled: boolean; - enabledMethods?: PaymentMethod[]; - cashierLocation?: string; - tableCode: string; + paymentCancelUsecase: PaymentCancelUsecase; preparingCount?: number; }; @@ -69,12 +61,7 @@ export const CartHandler = ({ tableResolveUsecase, cartUsecase, checkoutUsecase, - paymentRepository, - sessionRepository, - enabled, - enabledMethods = ['qris'], - cashierLocation = 'Lantai 1', - tableCode, + paymentCancelUsecase, preparingCount, }: CartHandlerProps) => { const tableResolve = useTableResolve(tableResolveUsecase); @@ -83,12 +70,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; @@ -103,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); @@ -233,14 +198,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' }), @@ -311,10 +276,14 @@ 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)} + isCheckoutEnabled={ + checkout.state.enabled && !hasUnavailableItems(cart.state.cart) + } checkoutErrorMessage={ checkout.state.type === 'error' ? checkout.state.errorMessage : null } diff --git a/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx b/libs/ui/src/presentation/handlers/order/MenuListHandler.test.tsx index 1cec244e..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'; @@ -59,9 +60,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, @@ -74,6 +77,10 @@ const renderHandler = ({ cartRepository, new MockCartQueryRepository() ); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: '', + method: 'qris', + }); return { menuRepository, @@ -87,10 +94,7 @@ const renderHandler = ({ menuListUsecase={menuListUsecase} menuItemDetailUsecase={menuItemDetailUsecase} cartUsecase={cartUsecase} - cartRepository={cartRepository} - paymentRepository={paymentRepository} - sessionRepository={sessionRepository} - tableCode={TABLE_CODE} + 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 c9948ff2..55bd9b10 100644 --- a/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/MenuListHandler.tsx @@ -4,9 +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 { 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, @@ -23,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 { @@ -37,10 +34,7 @@ export type MenuListHandlerProps = { menuListUsecase: MenuListUsecase; menuItemDetailUsecase: MenuItemDetailUsecase; cartUsecase: CartUsecase; - cartRepository: CartRepository; - paymentRepository: PaymentRepository; - sessionRepository: SessionRepository; - tableCode: string; + paymentCancelUsecase: PaymentCancelUsecase; preparingCount?: number; }; @@ -189,10 +183,7 @@ export const MenuListHandler = ({ menuListUsecase, menuItemDetailUsecase, cartUsecase, - cartRepository, - paymentRepository, - sessionRepository, - tableCode, + paymentCancelUsecase, preparingCount, }: MenuListHandlerProps) => { const tableResolve = useTableResolve(tableResolveUsecase); @@ -207,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); @@ -266,25 +241,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 +299,9 @@ export const MenuListHandler = ({ ) : currentCart && currentCart.itemCount > 0 ? ( router.push(`/t/${tableCode}/cart`)} + onPress={() => + router.push(`/t/${tableResolveUsecase.params.code}/cart`) + } /> ) : null; 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..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,17 +55,25 @@ 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, + const orderStatusUsecase = new OrderStatusUsecase( + paymentRepository, + { reference, payment, cashierLocation }, + { sessionRepository } + ); + const paymentCancelUsecase = new PaymentCancelUsecase(paymentRepository, { + reference: payment?.reference ?? '', + method: payment?.method ?? 'qris', }); return { @@ -70,9 +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 4fa92392..40bcd1a6 100644 --- a/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx +++ b/libs/ui/src/presentation/handlers/order/OrderStatusHandler.tsx @@ -1,14 +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 { SessionRepository } from '../../../domain/repositories/session'; 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, @@ -23,45 +21,30 @@ function payAtPickupAmount(payment: Payment): number | null { export type OrderStatusHandlerProps = { orderStatusUsecase: OrderStatusUsecase; - paymentRepository: PaymentRepository; - sessionRepository: SessionRepository; - cashierLocation: string; + paymentCancelUsecase: PaymentCancelUsecase; }; export const OrderStatusHandler = ({ orderStatusUsecase, - paymentRepository, - sessionRepository, - cashierLocation, + paymentCancelUsecase, }: 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` : '/'; 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; @@ -136,7 +119,7 @@ export const OrderStatusHandler = ({ ? { type: 'awaitingCashPayment', payment: state.payment, - cashierLocation, + cashierLocation: state.cashierLocation, onCountdownElapsed: () => orderStatus.dispatch({ type: 'COUNTDOWN_ELAPSED' }), canCancel: state.payment.canCancel,