@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.
- package/CHANGELOG.md +34 -0
- package/README.md +11 -6
- package/package.json +4 -4
- 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/budget-store.ts +356 -0
- package/src/daemon/handlers/payments/checkout-handlers.ts +526 -0
- package/src/daemon/handlers/payments/index.ts +7 -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 +261 -134
- package/src/runtime/browser-checkout-seam-holder.ts +55 -0
- package/src/runtime/daemon-handler-composition.ts +28 -11
- package/src/runtime/payments-composition.ts +70 -26
- package/src/runtime/services.ts +13 -6
|
@@ -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
|
-
* ──
|
|
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';
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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):
|
|
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
|
|
260
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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 };
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|