@pellux/goodvibes-daemon 1.28.20 → 1.28.21

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.
@@ -0,0 +1,112 @@
1
+ /**
2
+ * notifier.ts, sending a purchase notice over this daemon's channels.
3
+ *
4
+ * `createChannelPaymentNotifier` (platform/payments/notice-delivery.ts) wants
5
+ * a router, a target per configured channel, and a `PaymentReplySource`. The
6
+ * first two are real: `payments.notifyChannels` names which
7
+ * `CommandAuthorityChannel`s to notify, and delivery goes out over this
8
+ * daemon's own `ChannelDeliveryRouter`, the SAME router every other channel
9
+ * send in this daemon uses (see services.ts's comment on why there is exactly
10
+ * one).
11
+ *
12
+ * ── What is deliberately NOT wired in this pass ───────────────────────────
13
+ *
14
+ * `PaymentReplySource.waitForAnswer` always resolves `null`. That is not a
15
+ * stub standing in for something broken, it is the documented meaning of
16
+ * SILENCE (platform/payments/windows.ts): an approval window's silence DENIES
17
+ * and a veto window's silence PROCEEDS, both already correct, tested behaviors
18
+ * the decision layer exercises with no reply source at all. What is missing is
19
+ * the OTHER path, an inbound reply on a channel resolving the window before its
20
+ * deadline, "approve"/"yes"/"stop" arriving back from wherever the notice went.
21
+ * Building that needs an inbound-message correlation path this daemon does not
22
+ * have yet (there is no `payments.*` counterpart to
23
+ * `tryResolveApprovalReplyFromChannel`/`tryResolveWorkProposalReplyFromChannel`
24
+ * in `platform/daemon/surface-actions.ts`, which resolve DIFFERENT kinds of
25
+ * reply against a DIFFERENT store). Wiring it is a distinct, sizeable piece of
26
+ * work and is left for a later pass, exactly like `describeSubmission` above
27
+ * it; every purchase in the meantime is decided by budget and by the windows'
28
+ * own silence rules, with the notice actually reaching the owner's configured
29
+ * channels.
30
+ */
31
+ import type { ChannelDeliveryRouter, ChannelDeliveryTarget } from '@pellux/goodvibes-sdk/platform/channels';
32
+ import {
33
+ createChannelPaymentNotifier,
34
+ parseCommandAuthorityChannel,
35
+ readNotifyChannels,
36
+ } from '@pellux/goodvibes-sdk/platform/payments';
37
+ import type {
38
+ PaymentNotifier,
39
+ PaymentNoticeRouter,
40
+ PaymentNoticeTarget,
41
+ PaymentReplySource,
42
+ PaymentsConfigReader,
43
+ } from '@pellux/goodvibes-sdk/platform/payments';
44
+ import { logger } from '@pellux/goodvibes-sdk/platform/utils';
45
+
46
+ const PAYMENTS_NOTICE_JOB_ID = 'payments-notice';
47
+
48
+ /**
49
+ * The channel name (`payments.notifyChannels` entry) turned into the router's
50
+ * own addressing shape. `parseChannelDeliveryTarget` (platform/channels'
51
+ * internal delivery/types.ts) is not on the published subpath, so this mirrors
52
+ * its `surface` construction for the plain channel names `readNotifyChannels`
53
+ * produces (no `kind:address` suffix, `CommandAuthorityChannel` carries none).
54
+ */
55
+ function surfaceTarget(surfaceKind: string): ChannelDeliveryTarget {
56
+ return { kind: 'surface', surfaceKind: surfaceKind as ChannelDeliveryTarget['surfaceKind'] };
57
+ }
58
+
59
+ /** Adapts this daemon's router to the notifier's narrow, opaque-`request` shape. */
60
+ function daemonNoticeRouter(router: Pick<ChannelDeliveryRouter, 'deliver'>): PaymentNoticeRouter {
61
+ return {
62
+ deliver: async (request) => {
63
+ const merged = request as unknown as Record<string, unknown> & { readonly content: string };
64
+ return router.deliver({
65
+ target: merged['target'] as ChannelDeliveryTarget,
66
+ body: merged.content,
67
+ title: 'Purchase',
68
+ jobId: PAYMENTS_NOTICE_JOB_ID,
69
+ runId: `${PAYMENTS_NOTICE_JOB_ID}-${String(Date.now())}`,
70
+ includeLinks: false,
71
+ });
72
+ },
73
+ };
74
+ }
75
+
76
+ /** No live reply integration yet; see this module's header for why silence is still correct. */
77
+ const NO_REPLIES: PaymentReplySource = {
78
+ async waitForAnswer() {
79
+ return null;
80
+ },
81
+ };
82
+
83
+ export function channelBackedPaymentNotifier(
84
+ config: PaymentsConfigReader,
85
+ router: Pick<ChannelDeliveryRouter, 'deliver'>,
86
+ ): PaymentNotifier {
87
+ const targets: PaymentNoticeTarget[] = [];
88
+ for (const name of readNotifyChannels(config)) {
89
+ const channel = parseCommandAuthorityChannel(name);
90
+ if (channel === null) {
91
+ logger.warn('payments.notifyChannels names a channel this daemon does not recognise; it will not be notified', { channel: name });
92
+ continue;
93
+ }
94
+ targets.push({
95
+ channel,
96
+ request: { target: surfaceTarget(name) },
97
+ // No backfill path is wired (see this module's header): a notice missed
98
+ // while the daemon was down cannot be recovered by re-reading history it
99
+ // never asked this router to keep.
100
+ backfillable: false,
101
+ });
102
+ }
103
+
104
+ return createChannelPaymentNotifier({
105
+ router: daemonNoticeRouter(router),
106
+ targets,
107
+ replies: NO_REPLIES,
108
+ onDeliveryFailure: ({ channel, reason }) => {
109
+ logger.warn('A payments notice could not be delivered on a configured channel', { channel, reason });
110
+ },
111
+ });
112
+ }
@@ -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';
80
+ import { MemoryCheckoutJournal, readDefaultCardId, readPaymentsEnabled, readPaymentsServiceConfig } from '@pellux/goodvibes-sdk/platform/payments';
81
+ import type { CardMetadata, CheckoutJournal } from '@pellux/goodvibes-sdk/platform/payments';
50
82
  import {
51
- readDefaultCardId,
52
- readPaymentsEnabled,
53
- readPaymentsServiceConfig,
54
- } from '@pellux/goodvibes-sdk/platform/payments';
55
- import type { CardMetadata } from '@pellux/goodvibes-sdk/platform/payments';
56
- import type { GatewayMethodCatalog } from '../contracts.ts';
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, 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,25 @@ 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.begin',
105
+ 'payments.checkout.fillCard',
69
106
  ];
70
107
 
71
108
  /**
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.
109
+ * Kept, empty, rather than deleted: `gateway-payments-verbs.test.ts` and
110
+ * `register.test.ts` iterate this to assert the unattached set, and an empty
111
+ * array keeps that assertion meaningful (a future verb added here without a
112
+ * handler still gets caught) instead of forcing every caller to delete the
113
+ * loop. Nothing in this module's registration reads it any more.
77
114
  */
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.',
85
- },
86
- {
87
- id: 'payments.checkout.fillCard',
88
- reason: 'Same missing page driver; this is the verb that types the card into it.',
89
- },
90
- ];
115
+ export const UNATTACHED_PAYMENTS_METHOD_IDS: readonly { readonly id: string; readonly reason: string }[] = [];
91
116
 
92
117
  const DEFAULT_PURCHASE_LIST_LIMIT = 100;
93
118
 
94
119
  export interface PaymentsHandlerDeps {
95
120
  readonly cards: DaemonCardStore;
96
121
  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
- */
122
+ /** Today's pools. The checkout flow is the sole writer; the composition root is responsible for making this durable. */
104
123
  readonly budget: BudgetLedger;
105
124
  readonly config: PaymentsConfigReader;
106
125
  /**
@@ -112,6 +131,7 @@ export interface PaymentsHandlerDeps {
112
131
  */
113
132
  readonly isPaymentsLeader: () => boolean;
114
133
  readonly now?: (() => number) | undefined;
134
+ readonly checkout: CheckoutComposition;
115
135
  }
116
136
 
117
137
  // ---------------------------------------------------------------------------
@@ -217,7 +237,7 @@ function cardView(card: CardMetadata, materialComplete: boolean): CardView {
217
237
  };
218
238
  }
219
239
 
220
- function purchaseView(row: StoredPurchase): Record<string, unknown> {
240
+ function purchaseView(row: StoredPurchase): PaymentPurchaseView {
221
241
  return {
222
242
  purchaseId: row.purchaseId,
223
243
  atUtc: row.atUtc,
@@ -251,14 +271,115 @@ function purchaseView(row: StoredPurchase): Record<string, unknown> {
251
271
  };
252
272
  }
253
273
 
274
+ /**
275
+ * A checkout verb reached through the service seam despite neither local
276
+ * checkout handler below ever calling it: both call into the ONE
277
+ * `PaymentsGatewayServiceImpl` this registration's checkout pair shares for
278
+ * its whole life, held by `CheckoutServiceHolder` (checkout-handlers.ts), not
279
+ * a fresh instance built per call. Only `begin` needs anything per-invocation,
280
+ * the gate-input cell (`CheckoutGateInputsCell`) it writes just before each
281
+ * call, since the shared service's `gates()` closure has no other way to see a
282
+ * given call's `context.explicitUserRequest` or card/address facts; `fillCard`
283
+ * reads nothing per-invocation at all, it types into fields the prior `begin`
284
+ * already found. Either way, `PaymentsGatewayService`'s plain
285
+ * `beginCheckout(input)`/`fillCardIntoCheckout(input)` shape has no room for
286
+ * that context, which is the actual reason these two verbs are attached as
287
+ * local wrappers rather than through this service (see this file's header).
288
+ * The stub below exists only so `PaymentsGatewayService` stays fully
289
+ * implemented for `registerPaymentsGatewayMethods`'s throwaway first
290
+ * attachment, immediately replaced by `registerPaymentsMethods`.
291
+ */
292
+ function checkoutNotWired(methodId: string): Error {
293
+ return new Error(
294
+ `${methodId} is served by this daemon's own local handler, never through this service. `
295
+ + 'See registerPaymentsMethods in register.ts.',
296
+ );
297
+ }
298
+
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * The `PaymentsGatewayService` this daemon hands the SDK's registrar.
303
+ *
304
+ * `createCard` and `listPurchases` are real, not stubs: `payments.cards.create`
305
+ * and `payments.purchases.list` keep their own thin local wrappers (below) for
306
+ * the field-shape validation and the string-tolerant query reading the SDK's
307
+ * generic route handlers do not do, and both wrappers call straight into these
308
+ * same two methods for the store write and the response shape, so there is
309
+ * exactly one place that talks to `DaemonCardStore.create` and to
310
+ * `DaemonPurchaseLedger.list`.
311
+ */
312
+ function buildPaymentsGatewayService(deps: PaymentsHandlerDeps): PaymentsGatewayService {
313
+ const now = deps.now ?? Date.now;
314
+
315
+ return {
316
+ async budgetStatus() {
317
+ const config = readPaymentsServiceConfig(deps.config);
318
+ const nowMs = now();
319
+ const pools = deps.budget.snapshot(config.limits, nowMs, config.timezone);
320
+ const live = deps.budget.state().reservations.filter((entry) => entry.expiresAtMs > nowMs);
321
+ return {
322
+ enabled: readPaymentsEnabled(deps.config),
323
+ currency: String(config.budgetCurrency),
324
+ pools,
325
+ reservationCount: live.length,
326
+ isPaymentsLeader: deps.isPaymentsLeader(),
327
+ };
328
+ },
329
+
330
+ async listCards() {
331
+ return overStore('Listing the stored cards', async () => {
332
+ const built: CardView[] = [];
333
+ for (const card of deps.cards.list()) {
334
+ built.push(cardView(card, await deps.cards.materialComplete(card.id)));
335
+ }
336
+ return { cards: built, defaultCardId: readDefaultCardId(deps.config) };
337
+ });
338
+ },
339
+
340
+ async createCard(input) {
341
+ let card: CardMetadata;
342
+ try {
343
+ card = await deps.cards.create(input);
344
+ } catch (error) {
345
+ // A damaged card file is the operator's to fix and its message says how,
346
+ // so it is forwarded; see overStore. Everything else is discarded, because
347
+ // the failing call had the card in its arguments.
348
+ if (error instanceof CardStoreUnreadableError) {
349
+ throw new HandlerError(error.message, 'FAILED_PRECONDITION', 409);
350
+ }
351
+ void error;
352
+ throw new HandlerError('Storing the card failed. Nothing was saved.', 'INTERNAL_ERROR', 500);
353
+ }
354
+ return cardView(card, await overStore('Reading the card back', () => deps.cards.materialComplete(card.id)));
355
+ },
356
+
357
+ async deleteCard(id) {
358
+ return overStore('Deleting the card', () => deps.cards.remove(id));
359
+ },
360
+
361
+ async beginCheckout() {
362
+ throw checkoutNotWired('payments.checkout.begin');
363
+ },
364
+
365
+ async fillCardIntoCheckout() {
366
+ throw checkoutNotWired('payments.checkout.fillCard');
367
+ },
368
+
369
+ async listPurchases(input) {
370
+ const result = deps.purchases.list(input);
371
+ return { purchases: result.purchases.map(purchaseView), total: result.total };
372
+ },
373
+ };
374
+ }
375
+
254
376
  // ---------------------------------------------------------------------------
255
377
  // Registration
256
378
  // ---------------------------------------------------------------------------
257
379
 
258
380
  /**
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.
381
+ * Attach the seven `payments.*` handlers to the descriptors the SDK catalog
382
+ * already holds. Returns the teardown.
262
383
  *
263
384
  * NOT gated on `payments.enabled`. That key defaults to false, and
264
385
  * `payments.cards.*` is how a surface CONFIGURES the capability, so gating
@@ -272,36 +393,51 @@ export function registerPaymentsMethods(
272
393
  catalog: GatewayMethodCatalog,
273
394
  deps: PaymentsHandlerDeps,
274
395
  ): Unregister {
275
- const now = deps.now ?? Date.now;
396
+ // Every descriptor this module attaches to, captured BEFORE any
397
+ // registration runs. `registerCatalogHandlers`' own teardown (used below for
398
+ // four of these) removes the DESCRIPTOR from the catalog entirely rather
399
+ // than merely clearing its handler slot (`GatewayMethodCatalog.register`'s
400
+ // returned teardown calls `unregister`, a `Map.delete`, not a handler
401
+ // reset), so restoring a handler-less descriptor after THIS module's own
402
+ // teardown, matching what the SDK's three descriptors are restored to below,
403
+ // needs the descriptor object captured here rather than re-fetched from the
404
+ // catalog afterward, when it may no longer be there to fetch.
405
+ const descriptors = new Map<string, GatewayMethodDescriptor>();
406
+ for (const id of ATTACHED_PAYMENTS_METHOD_IDS) {
407
+ const descriptor = catalog.get(id);
408
+ if (descriptor) descriptors.set(id, descriptor);
409
+ }
276
410
 
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
- };
411
+ const service = buildPaymentsGatewayService(deps);
294
412
 
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
- };
413
+ // Attaches all seven `payments.*` descriptors. budget/list/delete stay
414
+ // attached through this; create/purchases-list/checkout-begin/checkout-
415
+ // fillCard are all transiently attached here and immediately replaced below
416
+ // with this daemon's own local handlers. See the header comment for why
417
+ // each group needs its own wrapper.
418
+ registerPaymentsGatewayMethods(catalog, service);
419
+
420
+ // The journal backing this registration's checkout pair's in-flight
421
+ // registry (the SDK's own `CheckoutRegistry`, built inside
422
+ // `PaymentsGatewayServiceImpl`'s constructor from whatever `CheckoutJournal`
423
+ // it is handed, see checkout-handlers.ts's `buildCheckoutService`; this
424
+ // value here is the journal, not the registry itself). `MemoryCheckoutJournal`
425
+ // (the sdk's own, checkout-registry.ts, documented "durable across nothing")
426
+ // is a deliberate choice for THIS pass, not an oversight: a durable journal
427
+ // is real work (a file or store write on every `registry.advance`, including
428
+ // the `submit-pending` flush checkout-flow.ts's step 9 makes right before the
429
+ // merchant submit) that has not been done yet. The gap it leaves is exactly
430
+ // the one that flush exists to close: if this process crashes between that
431
+ // `submit-pending` write and seeing the merchant's response, a restart with a
432
+ // durable journal could tell the owner "this purchase may already have been
433
+ // submitted, do not resubmit it"; with this in-memory journal, that record is
434
+ // gone the moment the process is, and a restart has no way to know the
435
+ // purchase was ever in flight at all. Recorded as future work in
436
+ // `.goodvibes/memory/decisions.json`, not implied to be solved here.
437
+ const checkoutJournal: CheckoutJournal = new MemoryCheckoutJournal();
438
+ // The ONE checkout service instance this registration's begin/fillCard pair
439
+ // share for their whole life; see checkout-handlers.ts's own header.
440
+ const checkoutServiceHolder = new CheckoutServiceHolder(deps, checkoutJournal);
305
441
 
306
442
  const cardsCreate: TypedHandler<unknown, Record<string, unknown>> = async ({ body }) => {
307
443
  const params = asRecord(body);
@@ -332,41 +468,17 @@ export function registerPaymentsMethods(
332
468
  const cardholderName = readString(params, 'cardholderName');
333
469
  const rawCap = params['issuerCapMinorUnits'];
334
470
 
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 };
471
+ const card = await service.createCard({
472
+ label,
473
+ kind,
474
+ number,
475
+ expiryMonth,
476
+ expiryYear,
477
+ cvv,
478
+ cardholderName,
479
+ issuerCapMinorUnits: typeof rawCap === 'number' && Number.isInteger(rawCap) ? rawCap : null,
480
+ });
481
+ return { card };
370
482
  };
371
483
 
372
484
  const purchasesList: TypedHandler<unknown, Record<string, unknown>> = async ({ body, query }) => {
@@ -374,18 +486,33 @@ export function registerPaymentsMethods(
374
486
  const requested = optionalCount(params['limit']);
375
487
  const rawDay = params['dayKey'];
376
488
  const dayKey = typeof rawDay === 'string' && rawDay.trim().length > 0 ? rawDay.trim() : undefined;
377
- const result = deps.purchases.list({
489
+ return service.listPurchases({
378
490
  limit: Math.min(requested ?? DEFAULT_PURCHASE_LIST_LIMIT, MAX_PURCHASE_LIST_LIMIT),
379
491
  dayKey,
380
492
  });
381
- return { purchases: result.purchases.map(purchaseView), total: result.total };
382
493
  };
383
494
 
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> },
495
+ const localTeardown = registerCatalogHandlers(catalog, [
387
496
  { id: 'payments.cards.create', handler: cardsCreate as TypedHandler<unknown, unknown> },
388
- { id: 'payments.cards.delete', handler: cardsDelete as TypedHandler<unknown, unknown> },
389
497
  { id: 'payments.purchases.list', handler: purchasesList as TypedHandler<unknown, unknown> },
498
+ { id: 'payments.checkout.begin', handler: checkoutBeginHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
499
+ { id: 'payments.checkout.fillCard', handler: checkoutFillCardHandler(deps, checkoutServiceHolder) as TypedHandler<unknown, unknown> },
390
500
  ]);
501
+
502
+ return () => {
503
+ localTeardown();
504
+ // Restores EVERY descriptor this module attached to, handler-less, not
505
+ // only the three `registerPaymentsGatewayMethods` still holds a live
506
+ // handler on. The other four had their descriptor removed outright by
507
+ // `localTeardown()` above (see the `descriptors` capture at the top of
508
+ // this function for why), so without this a re-registration on the SAME
509
+ // catalog (a second `registerPaymentsMethods` call, as a restart-without-
510
+ // recompose test does) would find those four ids gone from the catalog
511
+ // and throw `METHOD_NOT_FOUND` trying to attach to them, rather than
512
+ // finding the SDK's own builtin descriptor there to replace, exactly as
513
+ // it would on a catalog this module had never touched.
514
+ for (const [, descriptor] of descriptors) {
515
+ catalog.register(descriptor, undefined, { replace: true });
516
+ }
517
+ };
391
518
  }