@pellux/goodvibes-daemon 1.28.20 → 1.28.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,41 +1,71 @@
1
1
  /**
2
2
  * register.ts, the `payments.*` handlers this daemon attaches.
3
3
  *
4
- * ── Why the handlers are written here and not imported ────────────────────
4
+ * ── The SDK now owns most of these bodies ──────────────────────────────────
5
5
  *
6
- * The SDK has these handlers already: `registerPaymentsGatewayMethods` in
7
- * `platform/control-plane/routes/payments.ts`. It cannot be called from here.
8
- * That module is not re-exported by `platform/control-plane/index.ts`, and the
9
- * SDK package's `exports` map publishes only the barrel, so the symbol exists in
10
- * the installed `dist` and no import path reaches it. `registerGatewayVerbGroups`
11
- * (the SDK's own composition entry, which the terminal-shell wrapper calls from
12
- * runtime/services.ts) carries no payments dependency either, so there is no
13
- * argument this daemon can pass that would make the SDK attach them.
6
+ * `registerPaymentsGatewayMethods` (platform/control-plane, exported from the
7
+ * barrel as of sdk 2.0.18) ships real handler bodies for all seven `payments.*`
8
+ * verbs, built over a `PaymentsGatewayService` seam. `budgetStatus`, `listCards`
9
+ * and `deleteCard` are answered from that seam directly below and attached
10
+ * through the SDK's registrar; the local handler bodies that used to duplicate
11
+ * them are gone.
14
12
  *
15
- * So this follows the idiom this repository already uses for every other family
16
- * it serves, stated at the top of handlers/index.ts and implemented by
17
- * `registerCatalogHandler`: the SDK owns the id, the descriptor, the schemas,
18
- * the scopes and the access level, and only the BEHAVIOUR is ours. Nothing
19
- * below authors a descriptor.
13
+ * Two verbs stay LOCAL rather than going through the SDK's own route handlers
14
+ * for it, and both are deliberate, not oversights:
20
15
  *
21
- * The right long-term fix is one line in the SDK's control-plane barrel. Until
22
- * that lands, the choice is these handlers or a 501 on every payments verb, and
23
- * a 501 is what the webui and the desktop app have been getting.
16
+ * - `payments.cards.create`: the SDK's `createPaymentsCardsCreateHandler`
17
+ * wraps the whole call to `service.createCard(...)` in one try/catch that
18
+ * replaces ANY thrown error, whatever its shape, with a fixed 500
19
+ * "Storing the card failed. Nothing was saved." This daemon's contract is
20
+ * narrower than the published input schema (a card number has to contain
21
+ * enough digits to be one, a CVV has to be three or four digits, an expiry
22
+ * month has to be 1-12, see the field readers below) and reports each of
23
+ * those with an honest 400 naming the field. Delegating create to the SDK
24
+ * handler would still validate the fields, but every refusal would answer
25
+ * 500 instead of 400, a real wire-behaviour regression, not merely an
26
+ * implementation detail. So this verb keeps its own thin wrapper, which does
27
+ * the narrowing and then calls the same `service.createCard` the SDK
28
+ * handler would have, for the store write and the response shape.
29
+ * - `payments.purchases.list`: the SDK's `createPaymentsPurchasesListHandler`
30
+ * only accepts `limit` when it arrives already typed as a JS number. A GET
31
+ * request's query string never is, `?limit=5` arrives as the string `"5"`,
32
+ * so every caller of the real REST route would silently lose the ability to
33
+ * bound the page size and always get the handler's own default. This
34
+ * daemon's contract reads a numeric-looking string the same way it reads a
35
+ * number (`optionalCount` below), so this verb also keeps its own thin
36
+ * wrapper, which does that reading and then calls `service.listPurchases`.
24
37
  *
25
- * ── What is attached, and what deliberately is not ────────────────────────
38
+ * `payments.checkout.begin` and `payments.checkout.fillCard` are now attached
39
+ * too, over the sdk 2.0.19 browser-checkout seam
40
+ * (`platform/control-plane`'s `composeDaemonBrowser`/`onBrowserCheckout`/
41
+ * `BrowserCheckoutSeam`). They do NOT go through
42
+ * `registerPaymentsGatewayMethods`'s own route handlers, for the same reason
43
+ * `payments.cards.create`/`payments.purchases.list` do not: those handlers
44
+ * call `service.beginCheckout(input)`/`service.fillCardIntoCheckout(input)`
45
+ * with no invocation context at all, and this composition's whole
46
+ * "approving a purchase is a distinct act" ruling (see `registerPaymentsMethods`
47
+ * below) needs `context.explicitUserRequest`, which only reaches a handler
48
+ * attached through this daemon's own `registerCatalogHandlers`. So both verbs
49
+ * are attached locally, alongside `cardsCreate`/`purchasesList`, reading and
50
+ * shaping the SAME wire shapes `routes/payments.ts` does (ported here rather
51
+ * than imported, since the SDK does not publish those parsing functions on
52
+ * their own), and calling into the ONE `PaymentsGatewayServiceImpl` this
53
+ * registration's checkout pair shares for its whole life (see
54
+ * checkout-handlers.ts's `CheckoutServiceHolder` for why one, not one per
55
+ * call: its own `CheckoutRegistry` is in-memory, per-instance state, and
56
+ * `begin` and `fillCard` are separate control-plane calls that both need to
57
+ * see it).
26
58
  *
27
- * Attached: budget.status, cards.list, cards.create, cards.delete,
28
- * purchases.list. Every one of them is answerable from stores this daemon owns.
29
- *
30
- * NOT attached: checkout.begin and checkout.fillCard. Both need a
31
- * `CheckoutPageDriver` bound to an open browser page, and this composition
32
- * cannot produce one: `createDaemonBrowserGatewayService` builds the engine
33
- * inside `registerGatewayVerbGroups` and returns only the `BrowserGatewayService`
34
- * slice, which exposes no page handle and no `fillSecret`; and that engine is
35
- * constructed with no `cardFieldGuard`, so its secret-fill path refuses by
36
- * design. Both verbs therefore keep answering 501 NOT_INVOKABLE, which is the
37
- * honest answer for a capability nothing here can perform, and a better one than
38
- * a handler that accepts the call and fails inside.
59
+ * `deps.checkout` (a `CheckoutComposition`, see below) is REQUIRED, not
60
+ * optional: in the real daemon it is always supplied
61
+ * (runtime/payments-composition.ts), and its own `seam()` getter is what may
62
+ * legitimately be absent, either because this composition never builds a
63
+ * browser at all (no `homeDirectory`, a narrow embed) or because
64
+ * `onBrowserCheckout` has not fired yet (see
65
+ * runtime/browser-checkout-seam-holder.ts for why that race is benign). Either
66
+ * way `payments.checkout.begin`/`.fillCard` answer an honest refusal rather
67
+ * than 501 NOT_INVOKABLE or a crash; see `checkoutBegin`/`checkoutFillCard`
68
+ * below.
39
69
  *
40
70
  * ── Containment ───────────────────────────────────────────────────────────
41
71
  *
@@ -47,18 +77,23 @@
47
77
  * from a call that had material in its arguments.
48
78
  */
49
79
  import type { BudgetLedger, PaymentsConfigReader } from '@pellux/goodvibes-sdk/platform/payments';
50
- import {
51
- readDefaultCardId,
52
- readPaymentsEnabled,
53
- readPaymentsServiceConfig,
54
- } from '@pellux/goodvibes-sdk/platform/payments';
80
+ import { readDefaultCardId, readPaymentsEnabled, readPaymentsServiceConfig } from '@pellux/goodvibes-sdk/platform/payments';
55
81
  import type { CardMetadata } from '@pellux/goodvibes-sdk/platform/payments';
56
- import type { GatewayMethodCatalog } from '../contracts.ts';
82
+ import {
83
+ registerPaymentsGatewayMethods,
84
+ type GatewayMethodCatalog,
85
+ type GatewayMethodDescriptor,
86
+ type PaymentPurchaseView,
87
+ type PaymentsGatewayService,
88
+ } from '../contracts.ts';
57
89
  import { HandlerError } from '../errors.ts';
58
90
  import { registerCatalogHandlers, type TypedHandler, type Unregister } from '../register.ts';
91
+ import { CheckoutServiceHolder, checkoutApproveHandler, checkoutBeginHandler, checkoutFillCardHandler, type CheckoutComposition } from './checkout-handlers.ts';
59
92
  import { CardStoreUnreadableError, type DaemonCardStore } from './card-store.ts';
60
93
  import { MAX_PURCHASE_LIST_LIMIT, type DaemonPurchaseLedger, type StoredPurchase } from './purchase-ledger.ts';
61
94
 
95
+ export type { CheckoutComposition } from './checkout-handlers.ts';
96
+
62
97
  /** The verbs this module attaches. Named so a test can assert the exact set. */
63
98
  export const ATTACHED_PAYMENTS_METHOD_IDS: readonly string[] = [
64
99
  'payments.budget.status',
@@ -66,41 +101,80 @@ export const ATTACHED_PAYMENTS_METHOD_IDS: readonly string[] = [
66
101
  'payments.cards.create',
67
102
  'payments.cards.delete',
68
103
  'payments.purchases.list',
104
+ 'payments.checkout.approve',
105
+ 'payments.checkout.begin',
106
+ 'payments.checkout.fillCard',
69
107
  ];
70
108
 
71
109
  /**
72
- * The verbs this composition leaves unattached, and the reason each one is.
73
- *
74
- * Exported so the refusal is testable: a change that wires a page driver has to
75
- * delete the entry, and a change that attaches one of these without wiring a
76
- * driver fails the same assertion.
110
+ * The one descriptor in this family this PRODUCT authors, because the id is
111
+ * product-owned: the SDK's catalog holds the seven `payments.*` verbs it
112
+ * ships and no `payments.checkout.approve`, and the approve act is this
113
+ * daemon's own composition (its store, its confirmation gate, its wire
114
+ * shape). contracts.ts's never-author-a-descriptor rule is about not
115
+ * RE-declaring an SDK id, which this is not; the parity test
116
+ * (gateway-verb-family-parity.test.ts) pins this id the same way it pins the
117
+ * rest, so it cannot drift in silently.
77
118
  */
78
- export const UNATTACHED_PAYMENTS_METHOD_IDS: readonly { readonly id: string; readonly reason: string }[] = [
79
- {
80
- id: 'payments.checkout.begin',
81
- reason:
82
- 'Needs a CheckoutPageDriver for an open browser page. The SDK builds the browser engine inside '
83
- + 'registerGatewayVerbGroups and hands back only BrowserGatewayService, which exposes no page handle, '
84
- + 'and builds it with no cardFieldGuard, so its secret-fill path refuses by design.',
119
+ const CHECKOUT_APPROVE_DESCRIPTOR: GatewayMethodDescriptor = {
120
+ id: 'payments.checkout.approve',
121
+ title: 'Approve One Purchase',
122
+ description:
123
+ 'Record that a human approves one specific purchase, out of band from the conversation that will run '
124
+ + 'it: the merchant\'s registrable domain, the item, and the amount (the same string a later begin call '
125
+ + 'passes as requestedMax). Mints a persisted, single-use approval bound to exactly those fields, '
126
+ + 'expiring in five minutes; payments.checkout.begin consumes it and refuses without one. Requires '
127
+ + 'confirm: true and the explicit-user-request context, the same confirmation gate every destructive '
128
+ + 'verb on this daemon uses. The response never carries card material; this verb never touches a card '
129
+ + 'at all. ws-only invoke verb; no REST binding: the gateway REST table is the daemon-sdk\'s and this '
130
+ + 'product cannot add rows to it, the same shape sessions.hosted.* already has.',
131
+ category: 'payments',
132
+ source: 'builtin',
133
+ access: 'admin',
134
+ transport: ['ws'],
135
+ scopes: ['write:payments'],
136
+ dangerous: true,
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ confirm: { type: 'boolean' },
141
+ merchantDomain: { type: 'string' },
142
+ item: { type: 'string' },
143
+ amount: { type: 'string' },
144
+ },
145
+ required: ['confirm', 'merchantDomain', 'item', 'amount'],
146
+ additionalProperties: false,
85
147
  },
86
- {
87
- id: 'payments.checkout.fillCard',
88
- reason: 'Same missing page driver; this is the verb that types the card into it.',
148
+ outputSchema: {
149
+ type: 'object',
150
+ properties: {
151
+ approved: { type: 'boolean' },
152
+ action: { type: 'string' },
153
+ merchantDomain: { type: 'string' },
154
+ item: { type: 'string' },
155
+ amount: { type: 'string' },
156
+ expiresAt: { type: 'string' },
157
+ },
158
+ required: ['approved', 'action', 'merchantDomain', 'item', 'amount', 'expiresAt'],
159
+ additionalProperties: false,
89
160
  },
90
- ];
161
+ };
162
+
163
+ /**
164
+ * Kept, empty, rather than deleted: `gateway-payments-verbs.test.ts` and
165
+ * `register.test.ts` iterate this to assert the unattached set, and an empty
166
+ * array keeps that assertion meaningful (a future verb added here without a
167
+ * handler still gets caught) instead of forcing every caller to delete the
168
+ * loop. Nothing in this module's registration reads it any more.
169
+ */
170
+ export const UNATTACHED_PAYMENTS_METHOD_IDS: readonly { readonly id: string; readonly reason: string }[] = [];
91
171
 
92
172
  const DEFAULT_PURCHASE_LIST_LIMIT = 100;
93
173
 
94
174
  export interface PaymentsHandlerDeps {
95
175
  readonly cards: DaemonCardStore;
96
176
  readonly purchases: DaemonPurchaseLedger;
97
- /**
98
- * Today's pools. Read-only in this composition: the only writer of a spend
99
- * record is the checkout flow, which is not attached, so this ledger reports
100
- * limits from live config against an empty spend history. Wiring checkout must
101
- * also make this ledger DURABLE, a ledger rebuilt at every boot would hand
102
- * back a daily budget that was already spent.
103
- */
177
+ /** Today's pools. The checkout flow is the sole writer; the composition root is responsible for making this durable. */
104
178
  readonly budget: BudgetLedger;
105
179
  readonly config: PaymentsConfigReader;
106
180
  /**
@@ -112,6 +186,7 @@ export interface PaymentsHandlerDeps {
112
186
  */
113
187
  readonly isPaymentsLeader: () => boolean;
114
188
  readonly now?: (() => number) | undefined;
189
+ readonly checkout: CheckoutComposition;
115
190
  }
116
191
 
117
192
  // ---------------------------------------------------------------------------
@@ -217,7 +292,7 @@ function cardView(card: CardMetadata, materialComplete: boolean): CardView {
217
292
  };
218
293
  }
219
294
 
220
- function purchaseView(row: StoredPurchase): Record<string, unknown> {
295
+ function purchaseView(row: StoredPurchase): PaymentPurchaseView {
221
296
  return {
222
297
  purchaseId: row.purchaseId,
223
298
  atUtc: row.atUtc,
@@ -251,14 +326,134 @@ function purchaseView(row: StoredPurchase): Record<string, unknown> {
251
326
  };
252
327
  }
253
328
 
329
+ /**
330
+ * A checkout verb reached through the service seam despite neither local
331
+ * checkout handler below ever calling it: both call into the ONE
332
+ * `PaymentsGatewayServiceImpl` this registration's checkout pair shares for
333
+ * its whole life, held by `CheckoutServiceHolder` (checkout-handlers.ts), not
334
+ * a fresh instance built per call. Only `begin` needs anything per-invocation,
335
+ * the gate-input cell (`CheckoutGateInputsCell`) it writes just before each
336
+ * call, since the shared service's `gates()` closure has no other way to see a
337
+ * given call's `context.explicitUserRequest` or card/address facts; `fillCard`
338
+ * reads nothing per-invocation at all, it types into fields the prior `begin`
339
+ * already found. Either way, `PaymentsGatewayService`'s plain
340
+ * `beginCheckout(input)`/`fillCardIntoCheckout(input)` shape has no room for
341
+ * that context, which is the actual reason these two verbs are attached as
342
+ * local wrappers rather than through this service (see this file's header).
343
+ * The stub below exists only so `PaymentsGatewayService` stays fully
344
+ * implemented for `registerPaymentsGatewayMethods`'s throwaway first
345
+ * attachment, immediately replaced by `registerPaymentsMethods`.
346
+ */
347
+ function checkoutNotWired(methodId: string): Error {
348
+ return new Error(
349
+ `${methodId} is served by this daemon's own local handler, never through this service. `
350
+ + 'See registerPaymentsMethods in register.ts.',
351
+ );
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+
356
+ /**
357
+ * The `PaymentsGatewayService` this daemon hands the SDK's registrar.
358
+ *
359
+ * `createCard` and `listPurchases` are real, not stubs: `payments.cards.create`
360
+ * and `payments.purchases.list` keep their own thin local wrappers (below) for
361
+ * the field-shape validation and the string-tolerant query reading the SDK's
362
+ * generic route handlers do not do, and both wrappers call straight into these
363
+ * same two methods for the store write and the response shape, so there is
364
+ * exactly one place that talks to `DaemonCardStore.create` and to
365
+ * `DaemonPurchaseLedger.list`.
366
+ */
367
+ function buildPaymentsGatewayService(deps: PaymentsHandlerDeps): PaymentsGatewayService {
368
+ const now = deps.now ?? Date.now;
369
+
370
+ return {
371
+ async budgetStatus() {
372
+ const config = readPaymentsServiceConfig(deps.config);
373
+ const nowMs = now();
374
+ const pools = deps.budget.snapshot(config.limits, nowMs, config.timezone);
375
+ const live = deps.budget.state().reservations.filter((entry) => entry.expiresAtMs > nowMs);
376
+ return {
377
+ enabled: readPaymentsEnabled(deps.config),
378
+ currency: String(config.budgetCurrency),
379
+ pools,
380
+ reservationCount: live.length,
381
+ isPaymentsLeader: deps.isPaymentsLeader(),
382
+ };
383
+ },
384
+
385
+ async listCards() {
386
+ return overStore('Listing the stored cards', async () => {
387
+ const built: CardView[] = [];
388
+ for (const card of deps.cards.list()) {
389
+ built.push(cardView(card, await deps.cards.materialComplete(card.id)));
390
+ }
391
+ return { cards: built, defaultCardId: readDefaultCardId(deps.config) };
392
+ });
393
+ },
394
+
395
+ async createCard(input) {
396
+ let card: CardMetadata;
397
+ try {
398
+ card = await deps.cards.create(input);
399
+ } catch (error) {
400
+ // A damaged card file is the operator's to fix and its message says how,
401
+ // so it is forwarded; see overStore. Everything else is discarded, because
402
+ // the failing call had the card in its arguments.
403
+ if (error instanceof CardStoreUnreadableError) {
404
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
405
+ }
406
+ void error;
407
+ throw new HandlerError('Storing the card failed. Nothing was saved.', 'INTERNAL_ERROR', 500);
408
+ }
409
+ return cardView(card, await overStore('Reading the card back', () => deps.cards.materialComplete(card.id)));
410
+ },
411
+
412
+ async deleteCard(id) {
413
+ return overStore('Deleting the card', () => deps.cards.remove(id));
414
+ },
415
+
416
+ async beginCheckout() {
417
+ throw checkoutNotWired('payments.checkout.begin');
418
+ },
419
+
420
+ async fillCardIntoCheckout() {
421
+ throw checkoutNotWired('payments.checkout.fillCard');
422
+ },
423
+
424
+ async listPurchases(input) {
425
+ const result = deps.purchases.list(input);
426
+ return { purchases: result.purchases.map(purchaseView), total: result.total };
427
+ },
428
+ };
429
+ }
430
+
254
431
  // ---------------------------------------------------------------------------
255
432
  // Registration
256
433
  // ---------------------------------------------------------------------------
257
434
 
258
435
  /**
259
- * Attach the five answerable `payments.*` handlers to the descriptors the SDK
260
- * catalog already holds. Returns the teardown, reverse order, like every other
261
- * surface in this layer.
436
+ * What `registerPaymentsMethods` returns now that attachment has an async
437
+ * phase. `unregister` is valid immediately, including before `ready` settles.
438
+ * `ready` resolves once every handler is attached and REJECTS if attaching the
439
+ * local handlers failed, so the caller that drops it must handle the
440
+ * rejection (daemon-handler-composition.ts logs it; tests await it).
441
+ */
442
+ export interface PaymentsRegistration {
443
+ readonly ready: Promise<void>;
444
+ readonly unregister: Unregister;
445
+ }
446
+
447
+ /**
448
+ * Attach the eight `payments.*` handlers: seven to the descriptors the SDK
449
+ * catalog already holds, and `payments.checkout.approve` to the one
450
+ * descriptor this product authors (see `CHECKOUT_APPROVE_DESCRIPTOR` above).
451
+ *
452
+ * The SDK's `registerPaymentsGatewayMethods` runs its boot recovery sweep
453
+ * before attaching, so it returns a promise and attaches a beat after this
454
+ * function returns. This module's five local handlers MUST attach after that,
455
+ * or the SDK's deferred attach would replace them, so they are chained on the
456
+ * SDK's promise and `ready` is how a caller observes the whole sequence.
262
457
  *
263
458
  * NOT gated on `payments.enabled`. That key defaults to false, and
264
459
  * `payments.cards.*` is how a surface CONFIGURES the capability, so gating
@@ -271,37 +466,57 @@ function purchaseView(row: StoredPurchase): Record<string, unknown> {
271
466
  export function registerPaymentsMethods(
272
467
  catalog: GatewayMethodCatalog,
273
468
  deps: PaymentsHandlerDeps,
274
- ): Unregister {
275
- const now = deps.now ?? Date.now;
469
+ ): PaymentsRegistration {
470
+ // Every descriptor this module attaches to, captured BEFORE any
471
+ // registration runs. `registerCatalogHandlers`' own teardown (used below for
472
+ // four of these) removes the DESCRIPTOR from the catalog entirely rather
473
+ // than merely clearing its handler slot (`GatewayMethodCatalog.register`'s
474
+ // returned teardown calls `unregister`, a `Map.delete`, not a handler
475
+ // reset), so restoring a handler-less descriptor after THIS module's own
476
+ // teardown, matching what the SDK's three descriptors are restored to below,
477
+ // needs the descriptor object captured here rather than re-fetched from the
478
+ // catalog afterward, when it may no longer be there to fetch.
479
+ const descriptors = new Map<string, GatewayMethodDescriptor>();
480
+ for (const id of ATTACHED_PAYMENTS_METHOD_IDS) {
481
+ const descriptor = catalog.get(id);
482
+ if (descriptor) descriptors.set(id, descriptor);
483
+ }
276
484
 
277
- const budgetStatus: TypedHandler<unknown, Record<string, unknown>> = async () => {
278
- const config = readPaymentsServiceConfig(deps.config);
279
- const nowMs = now();
280
- const pools = deps.budget.snapshot(config.limits, nowMs, config.timezone);
281
- const live = deps.budget.state().reservations.filter((entry) => entry.expiresAtMs > nowMs);
282
- return {
283
- enabled: readPaymentsEnabled(deps.config),
284
- dayKey: String(pools.dayKey),
285
- timezone: pools.timezone,
286
- currency: String(config.budgetCurrency),
287
- item: { ...pools.item },
288
- overage: { ...pools.overage },
289
- tolerance: { ...pools.tolerance },
290
- reservationCount: live.length,
291
- isPaymentsLeader: deps.isPaymentsLeader(),
292
- };
293
- };
485
+ const service = buildPaymentsGatewayService(deps);
294
486
 
295
- const cardsList: TypedHandler<unknown, Record<string, unknown>> = async () => {
296
- const views = await overStore('Listing the stored cards', async () => {
297
- const built: CardView[] = [];
298
- for (const card of deps.cards.list()) {
299
- built.push(cardView(card, await deps.cards.materialComplete(card.id)));
300
- }
301
- return built;
302
- });
303
- return { cards: views, defaultCardId: readDefaultCardId(deps.config) };
304
- };
487
+ // Attaches all seven `payments.*` descriptors once its boot recovery sweep
488
+ // finishes. budget/list/delete stay attached through this; create/purchases-
489
+ // list/checkout-begin/checkout-fillCard are transiently attached and then
490
+ // replaced with this daemon's own local handlers in the chain below. The
491
+ // hook payloads are the SDK's designed audit records: the failure callback
492
+ // never carries a notice body and the sweep envelope exists to be logged.
493
+ const sdkAttach = registerPaymentsGatewayMethods(catalog, service, {
494
+ onRecoveryFailure: (error) => {
495
+ console.error('payments boot recovery failed', { error });
496
+ },
497
+ onRecoverySettled: (sweep) => {
498
+ console.info('payments boot recovery settled', { sweep });
499
+ },
500
+ });
501
+
502
+ // The journal backing this registration's checkout pair's in-flight
503
+ // registry (the SDK's own `CheckoutRegistry`, built inside
504
+ // `PaymentsGatewayServiceImpl`'s constructor from whatever `CheckoutJournal`
505
+ // it is handed, see checkout-handlers.ts's `buildCheckoutService`). It comes
506
+ // from the composition (`deps.checkout.journal`), which in the real daemon
507
+ // is `DurableCheckoutJournal` (checkout-journal-store.ts): every phase write
508
+ // the registry makes, including the `submit-pending` flush checkout-flow.ts's
509
+ // step 9 issues right before the merchant submit, lands on disk before the
510
+ // submit happens, so a restart after a crash in that window can tell the
511
+ // owner "this purchase may already have been submitted, do not resubmit it"
512
+ // instead of having no record the purchase was ever in flight. Tests compose
513
+ // the SDK's `MemoryCheckoutJournal` here instead, which is what the seam in
514
+ // `CheckoutComposition` is for.
515
+ //
516
+ // The ONE checkout service instance this registration's approve/begin/
517
+ // fillCard verbs share for their whole life; see checkout-handlers.ts's own
518
+ // header.
519
+ const checkoutServiceHolder = new CheckoutServiceHolder(deps, deps.checkout.journal);
305
520
 
306
521
  const cardsCreate: TypedHandler<unknown, Record<string, unknown>> = async ({ body }) => {
307
522
  const params = asRecord(body);
@@ -332,41 +547,17 @@ export function registerPaymentsMethods(
332
547
  const cardholderName = readString(params, 'cardholderName');
333
548
  const rawCap = params['issuerCapMinorUnits'];
334
549
 
335
- let card: CardMetadata;
336
- try {
337
- card = await deps.cards.create({
338
- label,
339
- kind,
340
- number,
341
- expiryMonth,
342
- expiryYear,
343
- cvv,
344
- cardholderName,
345
- issuerCapMinorUnits: typeof rawCap === 'number' && Number.isInteger(rawCap) ? rawCap : null,
346
- });
347
- } catch (error) {
348
- // A damaged card file is the operator's to fix and its message says how,
349
- // so it is forwarded; see overStore. Everything else is discarded, because
350
- // the failing call had the card in its arguments.
351
- if (error instanceof CardStoreUnreadableError) {
352
- throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
353
- }
354
- void error;
355
- throw new HandlerError('Storing the card failed. Nothing was saved.', 'INTERNAL_ERROR', 500);
356
- }
357
- return {
358
- card: cardView(card, await overStore('Reading the card back', () => deps.cards.materialComplete(card.id))),
359
- };
360
- };
361
-
362
- const cardsDelete: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
363
- // The REST path is `/api/payments/cards/{id}`, whose path parameter the
364
- // dispatcher folds into BOTH query and body; the methodId-invoke endpoint
365
- // carries it in the body only. Reading both is what makes the two paths the
366
- // same verb rather than two.
367
- const id = readString({ ...query, ...asRecord(body) }, 'id');
368
- const result = await overStore('Deleting the card', () => deps.cards.remove(id));
369
- return { id, deleted: result.deleted, secretsCleared: result.secretsCleared };
550
+ const card = await service.createCard({
551
+ label,
552
+ kind,
553
+ number,
554
+ expiryMonth,
555
+ expiryYear,
556
+ cvv,
557
+ cardholderName,
558
+ issuerCapMinorUnits: typeof rawCap === 'number' && Number.isInteger(rawCap) ? rawCap : null,
559
+ });
560
+ return { card };
370
561
  };
371
562
 
372
563
  const purchasesList: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
@@ -374,18 +565,67 @@ export function registerPaymentsMethods(
374
565
  const requested = optionalCount(params['limit']);
375
566
  const rawDay = params['dayKey'];
376
567
  const dayKey = typeof rawDay === 'string' && rawDay.trim().length > 0 ? rawDay.trim() : undefined;
377
- const result = deps.purchases.list({
568
+ return service.listPurchases({
378
569
  limit: Math.min(requested ?? DEFAULT_PURCHASE_LIST_LIMIT, MAX_PURCHASE_LIST_LIMIT),
379
570
  dayKey,
380
571
  });
381
- return { purchases: result.purchases.map(purchaseView), total: result.total };
382
572
  };
383
573
 
384
- return registerCatalogHandlers(catalog, [
385
- { id: 'payments.budget.status', handler: budgetStatus as TypedHandler<unknown, unknown> },
386
- { id: 'payments.cards.list', handler: cardsList as TypedHandler<unknown, unknown> },
387
- { id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
388
- { id: 'payments.cards.delete', handler: cardsDelete as TypedHandler<unknown, unknown> },
389
- { id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
390
- ]);
574
+ // Restores EVERY descriptor this module attached to, handler-less, not
575
+ // only the three `registerPaymentsGatewayMethods` still holds a live
576
+ // handler on. The other four have their descriptor removed outright by
577
+ // `localTeardown()` (see the `descriptors` capture at the top of this
578
+ // function for why), so without this a re-registration on the SAME catalog
579
+ // (a second `registerPaymentsMethods` call, as a restart-without-recompose
580
+ // test does) would find those four ids gone from the catalog and throw
581
+ // `METHOD_NOT_FOUND` trying to attach to them, rather than finding the
582
+ // SDK's own builtin descriptor there to replace, exactly as it would on a
583
+ // catalog this module had never touched.
584
+ const restoreDescriptors = (): void => {
585
+ for (const [, descriptor] of descriptors) {
586
+ catalog.register(descriptor, undefined, { replace: true });
587
+ }
588
+ };
589
+
590
+ let torn = false;
591
+ let localTeardown: Unregister | undefined;
592
+
593
+ const ready = sdkAttach.then(() => {
594
+ // Teardown already ran: the SDK's attach (which resolved just before this
595
+ // callback) put live handlers back on descriptors the teardown had
596
+ // restored handler-less, so restore them again instead of attaching the
597
+ // local handlers to a surface that was already released.
598
+ if (torn) {
599
+ restoreDescriptors();
600
+ return;
601
+ }
602
+ // The approve descriptor is product-authored (see its declaration above),
603
+ // so it is placed on the catalog here, handler-less, exactly where the
604
+ // SDK's own descriptors already sit, and then attached through the same
605
+ // `registerCatalogHandlers` path as the other local wrappers. `replace:
606
+ // true` so a registration over a catalog that already carries it (a
607
+ // recompose that skipped teardown) replaces rather than throws.
608
+ catalog.register(CHECKOUT_APPROVE_DESCRIPTOR, undefined, { replace: true });
609
+
610
+ localTeardown = registerCatalogHandlers(catalog, [
611
+ { id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
612
+ { id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
613
+ // The confirmation gate (`confirm: true` AND the explicit-user-request
614
+ // context) is what makes this verb owner-direct: the handler then passes
615
+ // `surface: 'owner-direct'` from its own code path. See
616
+ // checkout-handlers.ts's `checkoutApproveHandler`.
617
+ { id: 'payments.checkout.approve', handler: checkoutApproveHandler(deps) as TypedHandler<unknown, unknown>, options: { confirm: true } },
618
+ { id: 'payments.checkout.begin', handler: checkoutBeginHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
619
+ { id: 'payments.checkout.fillCard', handler: checkoutFillCardHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
620
+ ]);
621
+ });
622
+
623
+ return {
624
+ ready,
625
+ unregister: () => {
626
+ torn = true;
627
+ localTeardown?.();
628
+ restoreDescriptors();
629
+ },
630
+ };
391
631
  }