@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.
- package/CHANGELOG.md +167 -109
- package/README.md +111 -8
- package/dist/account-support/accountReadFacade.d.ts +8 -0
- package/dist/account-support/accountReadFacade.js +10 -0
- package/dist/account-support/accountReadModels.d.ts +69 -0
- package/dist/account-support/accountReadModels.js +272 -0
- package/dist/account-support/bindings.d.ts +23 -0
- package/dist/account-support/bindings.js +16 -0
- package/dist/account-support/contracts/account.d.ts +281 -0
- package/dist/account-support/contracts/account.js +1 -0
- package/dist/account-support/contracts/capabilities.d.ts +44 -0
- package/dist/account-support/contracts/capabilities.js +1 -0
- package/dist/account-support/contracts/commands.d.ts +307 -0
- package/dist/account-support/contracts/commands.js +1 -0
- package/dist/account-support/contracts/customer360.d.ts +154 -0
- package/dist/account-support/contracts/customer360.js +1 -0
- package/dist/account-support/contracts/index.d.ts +10 -0
- package/dist/account-support/contracts/index.js +1 -0
- package/dist/account-support/contracts/invoice.d.ts +79 -0
- package/dist/account-support/contracts/invoice.js +1 -0
- package/dist/account-support/contracts/operator.d.ts +289 -0
- package/dist/account-support/contracts/operator.js +1 -0
- package/dist/account-support/contribution.d.ts +24 -0
- package/dist/account-support/contribution.js +22 -0
- package/dist/account-support/customer360.d.ts +106 -0
- package/dist/account-support/customer360.js +163 -0
- package/dist/account-support/customerAccountReadDiagnostics.d.ts +11 -0
- package/dist/account-support/customerAccountReadDiagnostics.js +71 -0
- package/dist/account-support/index.d.ts +26 -0
- package/dist/account-support/index.js +15 -0
- package/dist/account-support/invoiceAccess.d.ts +57 -0
- package/dist/account-support/invoiceAccess.js +40 -0
- package/dist/account-support/migrations.d.ts +14 -0
- package/dist/account-support/migrations.js +7 -0
- package/dist/account-support/operatorAuthority.d.ts +67 -0
- package/dist/account-support/operatorAuthority.js +379 -0
- package/dist/account-support/operatorRecovery.d.ts +66 -0
- package/dist/account-support/operatorRecovery.js +129 -0
- package/dist/account-support/orderHistory.d.ts +50 -0
- package/dist/account-support/orderHistory.js +188 -0
- package/dist/account-support/paymentMethodComposition.d.ts +5 -0
- package/dist/account-support/paymentMethodComposition.js +4 -0
- package/dist/account-support/postgresOperatorAuthority.d.ts +16 -0
- package/dist/account-support/postgresOperatorAuthority.js +93 -0
- package/dist/account-support/selfService.d.ts +6 -0
- package/dist/account-support/selfService.js +81 -0
- package/dist/account-support/subscriptionCommandCodec.d.ts +10 -0
- package/dist/account-support/subscriptionCommandCodec.js +38 -0
- package/dist/catalog/draftSchemas.d.ts +1 -1
- package/dist/catalog/foundationSchemas.d.ts +3 -3
- package/dist/catalog/resolvedSkuProjection.d.ts +2 -2
- package/dist/catalog/sellableSchemas.d.ts +2 -2
- package/dist/commerce/checkoutCommandSchemas.d.ts +2 -2
- package/dist/commerce/checkoutResumeSchemas.d.ts +6 -6
- package/dist/commerce/checkoutSchemas.d.ts +12 -12
- package/dist/commerce/commerceReturnsPort.d.ts +2 -2
- package/dist/commerce/configuratorIntentSchemas.d.ts +2 -2
- package/dist/commerce/contractPrimitives.d.ts +8 -8
- package/dist/commerce/customerDefaultsSnapshotSchemas.d.ts +4 -4
- package/dist/commerce/model/omsReadModelHelpers.d.ts +4 -4
- package/dist/commerce/offerAvailabilitySchemas.d.ts +1 -1
- package/dist/commerce/oms/omsContractBase.d.ts +15 -15
- package/dist/commerce/oms/omsContractReadAuxSchemas.d.ts +11 -11
- package/dist/commerce/oms/omsContractReadSchemas.d.ts +121 -121
- package/dist/commerce/oms/omsContractRequests.d.ts +13 -13
- package/dist/commerce/oms/omsControlPlane.d.ts +22 -22
- package/dist/commerce/oms/omsFulfillmentContractSchemas.d.ts +12 -12
- package/dist/commerce/oms/omsFulfillmentDebugSchemas.d.ts +12 -12
- package/dist/commerce/orderReviewContracts.d.ts +33 -33
- package/dist/commerce/outboxEventSchemas.d.ts +4 -4
- package/dist/commerce/schemas.d.ts +21 -21
- package/dist/commerce/server/orderReviewHandlers.d.ts +10 -10
- package/dist/communications/consentPurposeRegistry.d.ts +1 -1
- package/dist/communications/newsletterIntegrationContracts.d.ts +8 -8
- package/dist/composition/accountSupportContribution.d.ts +18 -0
- package/dist/composition/accountSupportContribution.js +21 -0
- package/dist/composition/index.d.ts +1 -0
- package/dist/composition/index.js +1 -0
- package/dist/fulfillment/handlerSchemas.d.ts +40 -40
- package/dist/identity/customerContracts.d.ts +2 -2
- package/dist/invoicing/snapshot.d.ts +1 -1
- package/dist/pricing/acquisitionQuoteBinding.d.ts +1 -1
- package/dist/pricing/catalogFactsSchemas.d.ts +1 -1
- package/dist/pricing/quotePromotionEvidence.d.ts +1 -1
- package/dist/pricing/quoteSchemas.d.ts +56 -56
- package/dist/promotions/adminPromotionCodesContracts.d.ts +30 -30
- package/dist/promotions/adminPromotionsContracts.d.ts +14 -14
- package/package.json +12 -2
- package/release-gates.json +30 -3
- package/sql/20261026120000_account_support_operator_authority.sql +693 -0
- package/src/account-support/accountReadFacade.ts +15 -0
- package/src/account-support/accountReadModels.ts +371 -0
- package/src/account-support/bindings.ts +28 -0
- package/src/account-support/contracts/account.ts +306 -0
- package/src/account-support/contracts/capabilities.ts +30 -0
- package/src/account-support/contracts/commands.ts +243 -0
- package/src/account-support/contracts/customer360.ts +150 -0
- package/src/account-support/contracts/index.ts +141 -0
- package/src/account-support/contracts/invoice.ts +44 -0
- package/src/account-support/contracts/operator.ts +123 -0
- package/src/account-support/contribution.ts +35 -0
- package/src/account-support/customer360.ts +268 -0
- package/src/account-support/customerAccountReadDiagnostics.ts +83 -0
- package/src/account-support/index.ts +72 -0
- package/src/account-support/invoiceAccess.ts +63 -0
- package/src/account-support/migrations.ts +7 -0
- package/src/account-support/operatorAuthority.ts +393 -0
- package/src/account-support/operatorRecovery.ts +136 -0
- package/src/account-support/orderHistory.ts +309 -0
- package/src/account-support/paymentMethodComposition.ts +8 -0
- package/src/account-support/postgresOperatorAuthority.ts +92 -0
- package/src/account-support/selfService.ts +83 -0
- package/src/account-support/subscriptionCommandCodec.ts +48 -0
- package/src/composition/accountSupportContribution.ts +28 -0
- 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
|
+
}
|