@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.
- package/CHANGELOG.md +64 -0
- package/README.md +34 -13
- package/package.json +4 -4
- package/src/cli/command-catalog.ts +6 -5
- package/src/daemon/cli.ts +3 -3
- package/src/daemon/handlers/contracts.ts +15 -0
- package/src/daemon/handlers/index.ts +1 -1
- package/src/daemon/handlers/payments/address-store.ts +54 -0
- package/src/daemon/handlers/payments/approval-store.ts +275 -0
- package/src/daemon/handlers/payments/budget-store.ts +357 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +678 -0
- package/src/daemon/handlers/payments/checkout-journal-store.ts +162 -0
- package/src/daemon/handlers/payments/index.ts +14 -1
- package/src/daemon/handlers/payments/merchant-judge.ts +57 -0
- package/src/daemon/handlers/payments/notifier.ts +112 -0
- package/src/daemon/handlers/payments/register.ts +376 -136
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/daemon-handler-composition.ts +39 -13
- package/src/runtime/legacy-daemon-migration.ts +1 -1
- package/src/runtime/payments-composition.ts +95 -28
- package/src/runtime/services.ts +13 -6
|
@@ -1,41 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* register.ts, the `payments.*` handlers this daemon attaches.
|
|
3
3
|
*
|
|
4
|
-
* ──
|
|
4
|
+
* ── The SDK now owns most of these bodies ──────────────────────────────────
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
*
|
|
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
|
-
*
|
|
16
|
-
* it
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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):
|
|
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
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
-
):
|
|
275
|
-
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
}
|