@openlup/platform 0.27.0 → 0.28.0

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.
Files changed (115) hide show
  1. package/CHANGELOG.md +167 -109
  2. package/README.md +111 -8
  3. package/dist/account-support/accountReadFacade.d.ts +8 -0
  4. package/dist/account-support/accountReadFacade.js +10 -0
  5. package/dist/account-support/accountReadModels.d.ts +69 -0
  6. package/dist/account-support/accountReadModels.js +272 -0
  7. package/dist/account-support/bindings.d.ts +23 -0
  8. package/dist/account-support/bindings.js +16 -0
  9. package/dist/account-support/contracts/account.d.ts +281 -0
  10. package/dist/account-support/contracts/account.js +1 -0
  11. package/dist/account-support/contracts/capabilities.d.ts +44 -0
  12. package/dist/account-support/contracts/capabilities.js +1 -0
  13. package/dist/account-support/contracts/commands.d.ts +307 -0
  14. package/dist/account-support/contracts/commands.js +1 -0
  15. package/dist/account-support/contracts/customer360.d.ts +154 -0
  16. package/dist/account-support/contracts/customer360.js +1 -0
  17. package/dist/account-support/contracts/index.d.ts +10 -0
  18. package/dist/account-support/contracts/index.js +1 -0
  19. package/dist/account-support/contracts/invoice.d.ts +79 -0
  20. package/dist/account-support/contracts/invoice.js +1 -0
  21. package/dist/account-support/contracts/operator.d.ts +289 -0
  22. package/dist/account-support/contracts/operator.js +1 -0
  23. package/dist/account-support/contribution.d.ts +24 -0
  24. package/dist/account-support/contribution.js +22 -0
  25. package/dist/account-support/customer360.d.ts +106 -0
  26. package/dist/account-support/customer360.js +163 -0
  27. package/dist/account-support/customerAccountReadDiagnostics.d.ts +11 -0
  28. package/dist/account-support/customerAccountReadDiagnostics.js +71 -0
  29. package/dist/account-support/index.d.ts +26 -0
  30. package/dist/account-support/index.js +15 -0
  31. package/dist/account-support/invoiceAccess.d.ts +57 -0
  32. package/dist/account-support/invoiceAccess.js +40 -0
  33. package/dist/account-support/migrations.d.ts +14 -0
  34. package/dist/account-support/migrations.js +7 -0
  35. package/dist/account-support/operatorAuthority.d.ts +67 -0
  36. package/dist/account-support/operatorAuthority.js +379 -0
  37. package/dist/account-support/operatorRecovery.d.ts +66 -0
  38. package/dist/account-support/operatorRecovery.js +129 -0
  39. package/dist/account-support/orderHistory.d.ts +50 -0
  40. package/dist/account-support/orderHistory.js +188 -0
  41. package/dist/account-support/paymentMethodComposition.d.ts +5 -0
  42. package/dist/account-support/paymentMethodComposition.js +4 -0
  43. package/dist/account-support/postgresOperatorAuthority.d.ts +16 -0
  44. package/dist/account-support/postgresOperatorAuthority.js +93 -0
  45. package/dist/account-support/selfService.d.ts +6 -0
  46. package/dist/account-support/selfService.js +81 -0
  47. package/dist/account-support/subscriptionCommandCodec.d.ts +10 -0
  48. package/dist/account-support/subscriptionCommandCodec.js +38 -0
  49. package/dist/catalog/draftSchemas.d.ts +1 -1
  50. package/dist/catalog/foundationSchemas.d.ts +3 -3
  51. package/dist/catalog/resolvedSkuProjection.d.ts +2 -2
  52. package/dist/catalog/sellableSchemas.d.ts +2 -2
  53. package/dist/commerce/checkoutCommandSchemas.d.ts +2 -2
  54. package/dist/commerce/checkoutResumeSchemas.d.ts +6 -6
  55. package/dist/commerce/checkoutSchemas.d.ts +12 -12
  56. package/dist/commerce/commerceReturnsPort.d.ts +2 -2
  57. package/dist/commerce/configuratorIntentSchemas.d.ts +2 -2
  58. package/dist/commerce/contractPrimitives.d.ts +8 -8
  59. package/dist/commerce/customerDefaultsSnapshotSchemas.d.ts +4 -4
  60. package/dist/commerce/model/omsReadModelHelpers.d.ts +4 -4
  61. package/dist/commerce/offerAvailabilitySchemas.d.ts +1 -1
  62. package/dist/commerce/oms/omsContractBase.d.ts +15 -15
  63. package/dist/commerce/oms/omsContractReadAuxSchemas.d.ts +11 -11
  64. package/dist/commerce/oms/omsContractReadSchemas.d.ts +121 -121
  65. package/dist/commerce/oms/omsContractRequests.d.ts +13 -13
  66. package/dist/commerce/oms/omsControlPlane.d.ts +22 -22
  67. package/dist/commerce/oms/omsFulfillmentContractSchemas.d.ts +12 -12
  68. package/dist/commerce/oms/omsFulfillmentDebugSchemas.d.ts +12 -12
  69. package/dist/commerce/orderReviewContracts.d.ts +33 -33
  70. package/dist/commerce/outboxEventSchemas.d.ts +4 -4
  71. package/dist/commerce/schemas.d.ts +21 -21
  72. package/dist/commerce/server/orderReviewHandlers.d.ts +10 -10
  73. package/dist/communications/consentPurposeRegistry.d.ts +1 -1
  74. package/dist/communications/newsletterIntegrationContracts.d.ts +8 -8
  75. package/dist/composition/accountSupportContribution.d.ts +18 -0
  76. package/dist/composition/accountSupportContribution.js +21 -0
  77. package/dist/composition/index.d.ts +1 -0
  78. package/dist/composition/index.js +1 -0
  79. package/dist/fulfillment/handlerSchemas.d.ts +40 -40
  80. package/dist/identity/customerContracts.d.ts +2 -2
  81. package/dist/invoicing/snapshot.d.ts +1 -1
  82. package/dist/pricing/acquisitionQuoteBinding.d.ts +1 -1
  83. package/dist/pricing/catalogFactsSchemas.d.ts +1 -1
  84. package/dist/pricing/quotePromotionEvidence.d.ts +1 -1
  85. package/dist/pricing/quoteSchemas.d.ts +56 -56
  86. package/dist/promotions/adminPromotionCodesContracts.d.ts +30 -30
  87. package/dist/promotions/adminPromotionsContracts.d.ts +14 -14
  88. package/package.json +12 -2
  89. package/release-gates.json +30 -3
  90. package/sql/20261026120000_account_support_operator_authority.sql +693 -0
  91. package/src/account-support/accountReadFacade.ts +15 -0
  92. package/src/account-support/accountReadModels.ts +371 -0
  93. package/src/account-support/bindings.ts +28 -0
  94. package/src/account-support/contracts/account.ts +306 -0
  95. package/src/account-support/contracts/capabilities.ts +30 -0
  96. package/src/account-support/contracts/commands.ts +243 -0
  97. package/src/account-support/contracts/customer360.ts +150 -0
  98. package/src/account-support/contracts/index.ts +141 -0
  99. package/src/account-support/contracts/invoice.ts +44 -0
  100. package/src/account-support/contracts/operator.ts +123 -0
  101. package/src/account-support/contribution.ts +35 -0
  102. package/src/account-support/customer360.ts +268 -0
  103. package/src/account-support/customerAccountReadDiagnostics.ts +83 -0
  104. package/src/account-support/index.ts +72 -0
  105. package/src/account-support/invoiceAccess.ts +63 -0
  106. package/src/account-support/migrations.ts +7 -0
  107. package/src/account-support/operatorAuthority.ts +393 -0
  108. package/src/account-support/operatorRecovery.ts +136 -0
  109. package/src/account-support/orderHistory.ts +309 -0
  110. package/src/account-support/paymentMethodComposition.ts +8 -0
  111. package/src/account-support/postgresOperatorAuthority.ts +92 -0
  112. package/src/account-support/selfService.ts +83 -0
  113. package/src/account-support/subscriptionCommandCodec.ts +48 -0
  114. package/src/composition/accountSupportContribution.ts +28 -0
  115. package/src/composition/index.ts +1 -0
@@ -0,0 +1,15 @@
1
+ import type { AccountReadPort } from "./contracts/index.js";
2
+
3
+ /** Pure delegation to selected owner reads. Authorization, defaults, diagnostics,
4
+ * private fields and transport refusal mapping remain with the injected owners.
5
+ */
6
+ export function createAccountReadFacade<Account, OrderList, OrderDetail>(
7
+ ports: { account: Pick<AccountReadPort<Account, OrderList, OrderDetail>, "getAccount">;
8
+ orders: Pick<AccountReadPort<Account, OrderList, OrderDetail>, "listOrders" | "getOrderDetail"> },
9
+ ): AccountReadPort<Account, OrderList, OrderDetail> {
10
+ return {
11
+ getAccount(userId) { return ports.account.getAccount(userId); },
12
+ listOrders(userId, input) { return ports.orders.listOrders(userId, input); },
13
+ getOrderDetail(userId, orderId) { return ports.orders.getOrderDetail(userId, orderId); },
14
+ };
15
+ }
@@ -0,0 +1,371 @@
1
+ import type {
2
+ AccountActionRequired,
3
+ AccountSnapshot,
4
+ AccountSubscription,
5
+ AccountSubscriptionLine,
6
+ AccountOrderSummary,
7
+ AccountProfile
8
+ } from "./contracts/account.js";
9
+ import type { SubscriptionPaymentMethodStatus } from "./contracts/commands.js";
10
+
11
+ export const CUSTOMER_ACCOUNT_V2_CONTRACT_VERSION = "customer.account.v3" as const;
12
+ type Row = Record<string, unknown>;
13
+
14
+ export interface CustomerSubscriptionEligibilityInput {
15
+ subscriptionId: string;
16
+ status: AccountSubscription["status"];
17
+ nextCycleAt: string | null;
18
+ editCutoffAt: string | null;
19
+ paymentMethodKind: string | null;
20
+ paymentMethodRefPresent: boolean;
21
+ paymentMethodStatus?: SubscriptionPaymentMethodStatus;
22
+ }
23
+
24
+ export interface CustomerSubscriptionEligibility {
25
+ canEdit: boolean;
26
+ reason: AccountSubscription["editBlockedReason"]
27
+ }
28
+
29
+ export interface SubscriptionActionState {
30
+ subscriptionId: string;
31
+ orderId: string | null;
32
+ blockedReason: AccountActionRequired["blockedReason"];
33
+ failureCause: AccountActionRequired["failureCause"];
34
+ recoveryEligible: boolean;
35
+ dueAt: string | null;
36
+ nextRetryAt: string | null;
37
+ }
38
+
39
+ export interface AccountReadSources<Extension, ChargeEvidence = AccountSubscription["nextCharge"], PaymentEvidence = unknown> {
40
+ readExtension(clientId: string): Promise<Extension>;
41
+ /** The selected store applies the bound BEFORE visibility filtering. */
42
+ readSubscriptionRows(clientId: string, limit: number): Promise<Row[]>;
43
+ readAddresses(clientId: string): Promise<AccountSnapshot["addresses"]>;
44
+ readOrdererProfiles(clientId: string): Promise<AccountSnapshot["ordererProfiles"]>;
45
+ readPaymentPreferences(clientId: string): Promise<AccountSnapshot["paymentPreferences"]>;
46
+ readDeliveryPreferences(clientId: string): Promise<AccountSnapshot["deliveryPreferences"]>;
47
+ readOrders(clientId: string, limit: number): Promise<{ orders: AccountOrderSummary[] }>;
48
+ readEvents(clientId: string): Promise<AccountSnapshot["events"]>;
49
+ readSubscriptionLines(ids: string[]): Promise<Map<string, AccountSubscriptionLine[]>>;
50
+ readSubscriptionBlockers(
51
+ ids: string[],
52
+ nextCycles: Map<string, string | null>
53
+ ): Promise<Map<string, NonNullable<AccountSubscription["editBlockedReason"]>>>;
54
+ readPauseWindows(ids: string[]): Promise<Map<string, Pick<AccountSubscription, "pausePreset" | "pauseStartedAt" | "pauseEndsAt">>>;
55
+ readPaymentEvidence(ids: string[]): Promise<Map<string, PaymentEvidence>>;
56
+ /** Owner interprets M08 refusal; unsupported MUST throw, never return an empty Map. */
57
+ readDeliveryAlignments(nextCycles: Map<string, string | null>): Promise<Map<string, { state: "protected" | "aligned" }>>;
58
+ readNextCharges(rows: Row[]): Promise<Map<string, ChargeEvidence>>;
59
+ readActionStates(ids: string[]): Promise<Map<string, SubscriptionActionState>>;
60
+ }
61
+
62
+ export interface AccountReadProjectionPolicy<ChargeEvidence = AccountSubscription["nextCharge"], PaymentEvidence = unknown> {
63
+ paymentMethodStatus(
64
+ input: { clientId: string; clientEmail: string | null; row: Row; evidence: PaymentEvidence | undefined }
65
+ ): SubscriptionPaymentMethodStatus;
66
+ canUsePaymentMethodForRenewal(status: SubscriptionPaymentMethodStatus): boolean;
67
+ packageSummary(lines: AccountSubscriptionLine[], constraint: unknown): string | null;
68
+ /** Pricing authority selects payable-cycle/acquisition shipping precedence. */
69
+ withNextCharge(
70
+ recurring: AccountSubscription["recurringPrice"],
71
+ next: ChargeEvidence | undefined
72
+ ): Pick<AccountSubscription, "recurringPrice" | "nextCharge">;
73
+ }
74
+
75
+ /** Two observation stages, including the original nested subscription fan-out. Auth/ownership precedes this call. */
76
+ export async function readAccountSnapshot<Extension, ChargeEvidence, PaymentEvidence>(
77
+ sources: AccountReadSources<Extension, ChargeEvidence, PaymentEvidence>,
78
+ policy: AccountReadProjectionPolicy<ChargeEvidence, PaymentEvidence>,
79
+ client: Row
80
+ ): Promise<AccountSnapshot<Extension>> {
81
+ const clientId = text(client.id);
82
+ const [extension, allRows, addresses, ordererProfiles, paymentPreferences, deliveryPreferences, orders, events] = await Promise.all([
83
+ sources.readExtension(clientId),
84
+ sources.readSubscriptionRows(clientId, 50),
85
+ sources.readAddresses(clientId),
86
+ sources.readOrdererProfiles(clientId),
87
+ sources.readPaymentPreferences(clientId),
88
+ sources.readDeliveryPreferences(clientId),
89
+ sources.readOrders(clientId, 20),
90
+ sources.readEvents(clientId),
91
+ ]);
92
+ const rows = allRows.filter(isCustomerVisibleSubscription);
93
+ const ids = rows.map(row => text(row.id));
94
+ const [subscriptions, states] = await Promise.all([
95
+ assemble(sources, policy, rows, clientId, nullableText(client.email)),
96
+ sources.readActionStates(ids),
97
+ ]);
98
+ return {
99
+ contractVersion: CUSTOMER_ACCOUNT_V2_CONTRACT_VERSION,
100
+ profile: profile(client),
101
+ extension,
102
+ subscriptions,
103
+ addresses,
104
+ ordererProfiles,
105
+ billingProfiles: ordererProfiles,
106
+ paymentPreferences,
107
+ deliveryPreferences,
108
+ recentOrders: orders.orders,
109
+ events,
110
+ actionRequired: buildActionRequired(subscriptions, orders.orders, states, policy.canUsePaymentMethodForRenewal)
111
+ };
112
+ }
113
+
114
+ async function assemble<Extension, ChargeEvidence, PaymentEvidence>(
115
+ sources: AccountReadSources<Extension, ChargeEvidence, PaymentEvidence>,
116
+ policy: AccountReadProjectionPolicy<ChargeEvidence, PaymentEvidence>,
117
+ rows: Row[],
118
+ clientId: string,
119
+ clientEmail: string | null
120
+ ): Promise<AccountSubscription[]> {
121
+ const ids = rows.map(row => text(row.id));
122
+ const cycles = new Map(rows.map(row => [text(row.id), nullableText(row.next_cycle_at)]));
123
+ const [lines, blockers, pauses, evidence, alignments, next] = await Promise.all([
124
+ sources.readSubscriptionLines(ids),
125
+ sources.readSubscriptionBlockers(ids, cycles),
126
+ sources.readPauseWindows(ids),
127
+ sources.readPaymentEvidence(ids),
128
+ sources.readDeliveryAlignments(cycles),
129
+ sources.readNextCharges(rows),
130
+ ]);
131
+ return rows.map(row => {
132
+ const subscriptionId = text(row.id), status = text(row.status) as AccountSubscription["status"];
133
+ const cutoff = editCutoff(
134
+ nullableText(row.next_cycle_at),
135
+ typeof row.edit_window_hours === "number" ? row.edit_window_hours : null
136
+ );
137
+ const paymentMethodStatus = policy.paymentMethodStatus({ clientId, clientEmail, row, evidence: evidence.get(subscriptionId) });
138
+ const edit = computeEditState(
139
+ {
140
+ subscriptionId,
141
+ status,
142
+ nextCycleAt: nullableText(row.next_cycle_at),
143
+ editCutoffAt: cutoff,
144
+ paymentMethodKind: nullableText(row.payment_method_kind),
145
+ paymentMethodRefPresent: Boolean(nullableText(row.payment_method_ref)),
146
+ paymentMethodStatus
147
+ },
148
+ blockers,
149
+ policy.canUsePaymentMethodForRenewal
150
+ );
151
+ const items = lines.get(subscriptionId) ?? [], pause = pauses.get(subscriptionId);
152
+ return {
153
+ subscriptionId,
154
+ shippingAddressId: nullableText(row.shipping_address_id),
155
+ status,
156
+ pausePreset: pause?.pausePreset ?? null,
157
+ pauseStartedAt: pause?.pauseStartedAt ?? null,
158
+ pauseEndsAt: pause?.pauseEndsAt ?? null,
159
+ cadenceDays: Number(row.cadence_days),
160
+ nextCycleAt: nullableText(row.next_cycle_at),
161
+ ...(alignments.has(subscriptionId) ? { deliveryAlignment: alignments.get(subscriptionId) } : {}),
162
+ editCutoffAt: cutoff,
163
+ canEditUpcomingPackage: edit.canEdit,
164
+ editBlockedReason: edit.reason,
165
+ paymentMethodKind: nullableText(row.payment_method_kind),
166
+ paymentMethodStatus,
167
+ templateVersion: Number(row.template_version),
168
+ sizeConstraint: record(row.size_constraint),
169
+ packageSummary: policy.packageSummary(items, row.size_constraint),
170
+ ...policy.withNextCharge(recurringPrice(items), next.get(subscriptionId)),
171
+ lines: items
172
+ };
173
+ });
174
+ }
175
+
176
+ export function isCustomerVisibleSubscription(row: Row): boolean {
177
+ return ["active", "paused", "pending_activation"].includes(text(row.status))
178
+ || (row.status === "cancelled" && row.cancellation_source === "customer_self_service");
179
+ }
180
+
181
+ export function recurringPrice(lines: AccountSubscriptionLine[]): AccountSubscription["recurringPrice"] {
182
+ if (!lines.length) return null;
183
+ let amount = 0, currency: string | null = null;
184
+ for (const line of lines) {
185
+ const money = line.lineSubtotal;
186
+ if (typeof money?.amountMinor !== "number" || typeof money.currency !== "string"
187
+ || (currency !== null && currency !== money.currency)) return null;
188
+ currency = money.currency;
189
+ amount += money.amountMinor;
190
+ }
191
+ if (currency === null) return null;
192
+ return {
193
+ subtotalGross: { amountMinor: amount, currency },
194
+ totalGross: { amountMinor: amount, currency },
195
+ currency,
196
+ source: "frozen_quote_line"
197
+ };
198
+ }
199
+
200
+ export function editCutoff(nextCycleAt: string | null, editWindowHours: number | null): string | null {
201
+ if (!nextCycleAt) return null;
202
+ const date = new Date(nextCycleAt);
203
+ date.setHours(date.getHours() - (editWindowHours ?? 72));
204
+ return date.toISOString();
205
+ }
206
+
207
+ export function computeEditState(
208
+ input: CustomerSubscriptionEligibilityInput,
209
+ blockers: ReadonlyMap<string, NonNullable<AccountSubscription["editBlockedReason"]>>,
210
+ canUse: AccountReadProjectionPolicy["canUsePaymentMethodForRenewal"]
211
+ ): CustomerSubscriptionEligibility {
212
+ if (input.status !== "active") return { canEdit: false, reason: "not_active" };
213
+ const reason = blockers.get(input.subscriptionId);
214
+ if (reason) return { canEdit: false, reason };
215
+ const usable = input.paymentMethodStatus
216
+ ? canUse(input.paymentMethodStatus)
217
+ : Boolean(input.paymentMethodKind && input.paymentMethodRefPresent);
218
+ if (!usable) return { canEdit: false, reason: "missing_payment_method" };
219
+ if (input.editCutoffAt && new Date(input.editCutoffAt).getTime() <= Date.now()) return { canEdit: false, reason: "edit_window_closed" };
220
+ return { canEdit: true, reason: null };
221
+ }
222
+
223
+ export function buildActionRequired(
224
+ subscriptions: AccountSubscription[],
225
+ orders: AccountOrderSummary[],
226
+ states = new Map<string, SubscriptionActionState>(),
227
+ canUse: AccountReadProjectionPolicy["canUsePaymentMethodForRenewal"]
228
+ ): AccountActionRequired[] {
229
+ const actions: AccountActionRequired[] = [];
230
+ for (const s of subscriptions) {
231
+ const state = states.get(s.subscriptionId);
232
+ const base = {
233
+ entityType: "subscription" as const,
234
+ entityId: s.subscriptionId,
235
+ subscriptionId: s.subscriptionId,
236
+ orderId: state?.orderId ?? null,
237
+ failureCause: state?.failureCause ?? "unknown",
238
+ dueAt: state?.dueAt ?? s.nextCycleAt,
239
+ nextRetryAt: state?.nextRetryAt ?? null,
240
+ recoveryEligible: state?.recoveryEligible ?? false
241
+ };
242
+ if (state?.blockedReason === "subscription_activation_missing_mandate") {
243
+ actions.push({
244
+ ...base,
245
+ actionId: `subscription:${s.subscriptionId}:activation_missing_mandate`,
246
+ kind: "payment_method_missing",
247
+ severity: "critical",
248
+ messageCode: "subscription_activation_missing_mandate",
249
+ blockedReason: "subscription_activation_missing_mandate",
250
+ failureCause: "unknown",
251
+ title: "First order paid, subscription needs a payment method",
252
+ body: "The first order is paid. Add a card to enable future renewals without charging that order again.",
253
+ cta: "repair_payment",
254
+ recoveryEligible: false,
255
+ dueAt: state.dueAt,
256
+ nextRetryAt: null
257
+ });
258
+ continue;
259
+ }
260
+ if (s.status !== "active" && s.status !== "paused") continue;
261
+ if (state?.blockedReason === "payment_expired") {
262
+ actions.push({
263
+ ...base,
264
+ actionId: `subscription:${s.subscriptionId}:payment_expired`,
265
+ kind: "payment_recovery",
266
+ severity: "critical",
267
+ messageCode: "payment_expired",
268
+ blockedReason: "payment_expired",
269
+ title: "Subscription paused after payment could not be collected",
270
+ body: "Every retry has been used, so nothing else will be attempted. Update the payment method to resume; the unpaid cycle is skipped and a new one starts from the resume date.",
271
+ cta: "repair_payment",
272
+ dueAt: state.dueAt,
273
+ nextRetryAt: null
274
+ });
275
+ continue;
276
+ }
277
+ if (state) {
278
+ actions.push({
279
+ ...base,
280
+ actionId: `subscription:${s.subscriptionId}:payment_blocked`,
281
+ kind: state.recoveryEligible ? "payment_recovery" : "subscription_blocked",
282
+ severity: "critical",
283
+ messageCode: state.blockedReason ?? "payment_blocked",
284
+ blockedReason: state.blockedReason,
285
+ title: "Payment needs attention",
286
+ body: state.recoveryEligible
287
+ ? "Your subscription is blocked until the payment method is recovered."
288
+ : "Your subscription has a payment issue that needs support review.",
289
+ cta: state.recoveryEligible ? "repair_payment" : "contact_support"
290
+ });
291
+ continue;
292
+ }
293
+ if (!(s.paymentMethodStatus ? canUse(s.paymentMethodStatus) : Boolean(s.paymentMethodKind))) actions.push({
294
+ ...base,
295
+ actionId: `subscription:${s.subscriptionId}:payment_method_missing`,
296
+ kind: "payment_method_missing",
297
+ severity: "warning",
298
+ messageCode: "missing_payment_method",
299
+ blockedReason: "missing_payment_method",
300
+ title: "Payment method missing",
301
+ body: "Add or recover a payment method before the next subscription cycle.",
302
+ cta: "contact_support"
303
+ });
304
+ }
305
+ for (const order of orders) {
306
+ const status = order.paymentStatus?.toLowerCase() ?? "",
307
+ failed = ["failed", "payment_failed", "requires_payment_method"].includes(status),
308
+ required = ["requires_action", "requires_confirmation"].includes(status);
309
+ if (failed || required) actions.push({
310
+ actionId: `order:${order.orderId}:${required ? "requires_action" : "payment_failed"}`,
311
+ kind: "order_attention",
312
+ severity: "critical",
313
+ entityType: "order",
314
+ entityId: order.orderId,
315
+ subscriptionId: null,
316
+ orderId: order.orderId,
317
+ messageCode: required ? "payment_requires_action" : "payment_failed",
318
+ blockedReason: required ? "payment_requires_action" : "payment_failed",
319
+ failureCause: "unknown",
320
+ title: required ? "Payment requires action" : "Payment failed",
321
+ body: "Open the order details to review the payment status and next step.",
322
+ cta: "view_order",
323
+ recoveryEligible: false,
324
+ dueAt: order.updatedAt,
325
+ nextRetryAt: null
326
+ });
327
+ const invoice = order.invoice;
328
+ if (invoice && !invoice.downloadAvailable && ["issued", "accepted"].includes(invoice.status)) actions.push({
329
+ actionId: `invoice:${invoice.invoiceId}:pdf_unavailable`,
330
+ kind: "invoice_attention",
331
+ severity: "info",
332
+ entityType: "invoice",
333
+ entityId: invoice.invoiceId,
334
+ subscriptionId: null,
335
+ orderId: order.orderId,
336
+ messageCode: "invoice_pdf_unavailable",
337
+ blockedReason: "invoice_pdf_unavailable",
338
+ failureCause: "unknown",
339
+ title: "Invoice PDF unavailable",
340
+ body: "The invoice is visible, but the PDF cannot be downloaded from the account yet.",
341
+ cta: "contact_support",
342
+ recoveryEligible: false,
343
+ dueAt: invoice.issuedAt ?? invoice.createdAt,
344
+ nextRetryAt: null
345
+ });
346
+ }
347
+ return actions.slice(0, 8);
348
+ }
349
+
350
+ function profile(row: Row): AccountProfile {
351
+ return {
352
+ clientId: text(row.id),
353
+ email: text(row.email),
354
+ firstName: nullableText(row.first_name),
355
+ lastName: nullableText(row.last_name),
356
+ phone: nullableText(row.phone),
357
+ lifecycleStage: text(row.lifecycle_stage) as AccountProfile["lifecycleStage"]
358
+ };
359
+ }
360
+
361
+ function text(value: unknown): string {
362
+ return typeof value === "string" ? value : "";
363
+ }
364
+
365
+ function nullableText(value: unknown): string | null {
366
+ return typeof value === "string" && value ? value : null;
367
+ }
368
+
369
+ function record(value: unknown): Row | null {
370
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Row : null;
371
+ }
@@ -0,0 +1,28 @@
1
+ import type { AccountSupportCapabilities } from "./contracts/index.js";
2
+
3
+ /** Already bound owner methods; the host retains authentication and dispatch. */
4
+ export type AccountSupportCallable = (...args: never[]) => unknown;
5
+ export type AccountSupportOperations = Readonly<Record<string, AccountSupportCallable | null>>;
6
+ export type AccountSupportHostBindingRequired = { readonly kind: "unsupported"; readonly code: "host_binding_required" };
7
+ type CommerceArguments<F> = F extends AccountSupportCallable ? Parameters<F> : unknown[];
8
+ const HOST_BINDING_REQUIRED: AccountSupportHostBindingRequired = Object.freeze({ kind: "unsupported", code: "host_binding_required" });
9
+
10
+ /** Capture one graph and one executor per logical operation, without calling either. */
11
+ export function createAccountSupportBindings<O extends AccountSupportOperations, C extends AccountSupportOperations = AccountSupportOperations>(input: {
12
+ graph: "managed" | "native";
13
+ capabilities: AccountSupportCapabilities;
14
+ operations: O;
15
+ commerce?: C | null;
16
+ }) {
17
+ if (input.graph !== input.capabilities.graph) throw new Error("customer_graph_mismatch");
18
+ const capabilities: Readonly<AccountSupportCapabilities> = Object.freeze({ ...input.capabilities, subscriptions: Object.freeze({ ...input.capabilities.subscriptions }) });
19
+ const operations = Object.freeze({ ...input.operations });
20
+ const commerce = Object.freeze({ ...input.commerce });
21
+ return Object.freeze({ graph: input.graph, capabilities, operations,
22
+ /** No draft commerce import or fallback executor. An absent record/member is explicit. */
23
+ async commerce<K extends keyof C & string>(key: K, ...args: CommerceArguments<C[K]>): Promise<Awaited<ReturnType<Extract<C[K], AccountSupportCallable>>> | AccountSupportHostBindingRequired> {
24
+ const operation = Object.hasOwn(commerce, key) ? commerce[key] : null;
25
+ return operation ? operation(...args as never[]) as Awaited<ReturnType<Extract<C[K], AccountSupportCallable>>> : HOST_BINDING_REQUIRED;
26
+ },
27
+ });
28
+ }