@owlmeans/server-payment 0.1.18-rc.20 → 0.1.18-rc.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.
Files changed (144) hide show
  1. package/README.md +1 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +402 -260
  4. package/build/config.d.ts +15 -1
  5. package/build/config.d.ts.map +1 -1
  6. package/build/config.js +99 -3
  7. package/build/config.js.map +1 -1
  8. package/build/consts.d.ts +27 -0
  9. package/build/consts.d.ts.map +1 -1
  10. package/build/consts.js +27 -0
  11. package/build/consts.js.map +1 -1
  12. package/build/consumer/capture.d.ts +74 -0
  13. package/build/consumer/capture.d.ts.map +1 -0
  14. package/build/consumer/capture.js +291 -0
  15. package/build/consumer/capture.js.map +1 -0
  16. package/build/consumer/format.d.ts +27 -0
  17. package/build/consumer/format.d.ts.map +1 -0
  18. package/build/consumer/format.js +81 -0
  19. package/build/consumer/format.js.map +1 -0
  20. package/build/consumer/handlers.d.ts +28 -0
  21. package/build/consumer/handlers.d.ts.map +1 -0
  22. package/build/consumer/handlers.js +173 -0
  23. package/build/consumer/handlers.js.map +1 -0
  24. package/build/consumer/index.d.ts +7 -0
  25. package/build/consumer/index.d.ts.map +1 -0
  26. package/build/consumer/index.js +6 -0
  27. package/build/consumer/index.js.map +1 -0
  28. package/build/consumer/mail.d.ts +27 -0
  29. package/build/consumer/mail.d.ts.map +1 -0
  30. package/build/consumer/mail.js +314 -0
  31. package/build/consumer/mail.js.map +1 -0
  32. package/build/consumer/origin.d.ts +14 -0
  33. package/build/consumer/origin.d.ts.map +1 -0
  34. package/build/consumer/origin.js +47 -0
  35. package/build/consumer/origin.js.map +1 -0
  36. package/build/consumer/reconcile.d.ts +12 -0
  37. package/build/consumer/reconcile.d.ts.map +1 -0
  38. package/build/consumer/reconcile.js +317 -0
  39. package/build/consumer/reconcile.js.map +1 -0
  40. package/build/consumer/records.d.ts +78 -0
  41. package/build/consumer/records.d.ts.map +1 -0
  42. package/build/consumer/records.js +296 -0
  43. package/build/consumer/records.js.map +1 -0
  44. package/build/consumer/service.d.ts +51 -0
  45. package/build/consumer/service.d.ts.map +1 -0
  46. package/build/consumer/service.js +760 -0
  47. package/build/consumer/service.js.map +1 -0
  48. package/build/consumer/withdrawal.d.ts +57 -0
  49. package/build/consumer/withdrawal.d.ts.map +1 -0
  50. package/build/consumer/withdrawal.js +247 -0
  51. package/build/consumer/withdrawal.js.map +1 -0
  52. package/build/index.d.ts +4 -2
  53. package/build/index.d.ts.map +1 -1
  54. package/build/index.js +4 -2
  55. package/build/index.js.map +1 -1
  56. package/build/model.d.ts +6 -1
  57. package/build/model.d.ts.map +1 -1
  58. package/build/model.js +134 -4
  59. package/build/model.js.map +1 -1
  60. package/build/observer.d.ts +4 -0
  61. package/build/observer.d.ts.map +1 -1
  62. package/build/observer.js +13 -0
  63. package/build/observer.js.map +1 -1
  64. package/build/plugins/checkout-plugins.d.ts +49 -0
  65. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  66. package/build/plugins/checkout-plugins.js +124 -0
  67. package/build/plugins/checkout-plugins.js.map +1 -0
  68. package/build/plugins/estimate.d.ts.map +1 -1
  69. package/build/plugins/estimate.js +41 -8
  70. package/build/plugins/estimate.js.map +1 -1
  71. package/build/plugins/events.d.ts +2 -0
  72. package/build/plugins/events.d.ts.map +1 -1
  73. package/build/plugins/events.js +126 -9
  74. package/build/plugins/events.js.map +1 -1
  75. package/build/plugins/fx.d.ts +5 -0
  76. package/build/plugins/fx.d.ts.map +1 -1
  77. package/build/plugins/fx.js +25 -0
  78. package/build/plugins/fx.js.map +1 -1
  79. package/build/plugins/portal.d.ts.map +1 -1
  80. package/build/plugins/portal.js +11 -1
  81. package/build/plugins/portal.js.map +1 -1
  82. package/build/plugins/stripe.d.ts +21 -2
  83. package/build/plugins/stripe.d.ts.map +1 -1
  84. package/build/plugins/stripe.js +397 -61
  85. package/build/plugins/stripe.js.map +1 -1
  86. package/build/resource.d.ts +8 -1
  87. package/build/resource.d.ts.map +1 -1
  88. package/build/resource.js +59 -2
  89. package/build/resource.js.map +1 -1
  90. package/build/service.d.ts +2 -1
  91. package/build/service.d.ts.map +1 -1
  92. package/build/service.js +28 -5
  93. package/build/service.js.map +1 -1
  94. package/build/subscription.d.ts +6 -0
  95. package/build/subscription.d.ts.map +1 -1
  96. package/build/subscription.js +1 -0
  97. package/build/subscription.js.map +1 -1
  98. package/build/sync.d.ts +7 -0
  99. package/build/sync.d.ts.map +1 -1
  100. package/build/sync.js +108 -15
  101. package/build/sync.js.map +1 -1
  102. package/build/types.d.ts +707 -4
  103. package/build/types.d.ts.map +1 -1
  104. package/build/utils.d.ts +22 -1
  105. package/build/utils.d.ts.map +1 -1
  106. package/build/utils.js +27 -1
  107. package/build/utils.js.map +1 -1
  108. package/package.json +14 -13
  109. package/src/config.ts +111 -7
  110. package/src/consts.ts +34 -0
  111. package/src/consumer/capture.ts +362 -0
  112. package/src/consumer/format.ts +90 -0
  113. package/src/consumer/handlers.ts +211 -0
  114. package/src/consumer/index.ts +6 -0
  115. package/src/consumer/mail.ts +368 -0
  116. package/src/consumer/origin.ts +63 -0
  117. package/src/consumer/reconcile.ts +329 -0
  118. package/src/consumer/records.ts +374 -0
  119. package/src/consumer/service.ts +868 -0
  120. package/src/consumer/withdrawal.ts +302 -0
  121. package/src/index.ts +6 -4
  122. package/src/model.ts +148 -6
  123. package/src/observer.ts +15 -2
  124. package/src/plugins/checkout-plugins.ts +155 -0
  125. package/src/plugins/estimate.ts +49 -9
  126. package/src/plugins/events.ts +135 -11
  127. package/src/plugins/fx.ts +29 -0
  128. package/src/plugins/portal.ts +11 -1
  129. package/src/plugins/stripe.ts +476 -60
  130. package/src/resource.ts +87 -4
  131. package/src/service.ts +28 -6
  132. package/src/subscription.ts +7 -0
  133. package/src/sync.ts +124 -17
  134. package/src/types.ts +756 -6
  135. package/src/utils.ts +56 -7
  136. package/tests/checkout-consumer.spec.ts +348 -0
  137. package/tests/checkout-plugins.spec.ts +164 -0
  138. package/tests/consumer-events.spec.ts +218 -0
  139. package/tests/consumer-fixtures.ts +132 -0
  140. package/tests/consumer-ops.spec.ts +351 -0
  141. package/tests/consumer-rights.integration.spec.ts +150 -0
  142. package/tests/consumer-rights.spec.ts +501 -0
  143. package/tests/context.ts +20 -2
  144. package/tests/fake-stripe.ts +188 -18
@@ -0,0 +1,760 @@
1
+ import { createLazyService } from '@owlmeans/context';
2
+ import { MAILER_SERVICE } from '@owlmeans/mailer';
3
+ import { cancellationEffectiveAt, CancellationKind, CancellationStatus, CancellationUnavailable, CancellationUnavailableReason, ConsentKind, consentStatementOf, CONSUMER_RIGHTS_COPY_VERSION, ConsumerRightsError, DeclarationChannel, DeclarationKind, ENTITLING_STATUSES, inScope, linksOf, PaygateError, PerformanceConsentRequired, PurchaseKind, SubscriptionStartRequired, TERMINAL_STATUSES, UnknownPlan, WithdrawalStatus, WithdrawalUnavailable, WithdrawalUnavailableReason, } from '@owlmeans/payment';
4
+ import { CONSUMER_RIGHTS_SERVICE, STRIPE_PAYGATE_ALIAS } from '../consts.js';
5
+ import { findPlan } from '../plan.js';
6
+ import { CONSUMER_RIGHTS_RESOURCE_MAKERS } from '../resource.js';
7
+ import { billingProfiles, compact, conditionalSet, consumerConsents, consumerDeclarations, consumerEvents, consumerMailConfig, errorText, observer, paygateCustomers, payment, purchases, stripeClient, subscriptions, } from '../utils.js';
8
+ import { normalizeContractRef, normalizeEmail } from './format.js';
9
+ import { planTitleOf, sendConsumerMail, traderOf } from './mail.js';
10
+ import { originFields } from './origin.js';
11
+ import { emailMatches, entitlingStripeSubscription, lockProfile, patchPurchase, profileViewOf, purchaseRefOf, purchaseViewOf, recordEvent, unconsentedWindows, unlockProfile, windowOpen, } from './records.js';
12
+ import { reconcileConsumerRights } from './reconcile.js';
13
+ import { computeWithdrawal, executeWithdrawal } from './withdrawal.js';
14
+ const latestDeadline = (items) => {
15
+ const times = items.map(item => item.deadline != null ? new Date(item.deadline).getTime() : Number.NaN)
16
+ .filter(time => !Number.isNaN(time));
17
+ return times.length > 0 ? new Date(Math.max(...times)) : undefined;
18
+ };
19
+ const requirePolicy = async (ctx) => {
20
+ const policy = await payment(ctx).consumerRightsPolicy();
21
+ if (policy == null) {
22
+ throw new ConsumerRightsError('policy:none');
23
+ }
24
+ return policy;
25
+ };
26
+ /** Only what a person typed, echoed as the receipt's content — never enriched with a match. */
27
+ const contentOf = (fields) => Object.fromEntries(Object.entries(fields).filter((entry) => typeof entry[1] === 'string' && entry[1].trim() !== ''));
28
+ const publicReceipt = (declaration, content, mailed) => ({
29
+ declarationId: declaration.id,
30
+ receivedAt: new Date(declaration.receivedAt),
31
+ content,
32
+ mailed,
33
+ });
34
+ /** An e-mail value safe to prefill a form with. */
35
+ const prefillEmail = (email) => email != null && /^[^\s@]+@[^\s@]+$/.test(email) ? email : undefined;
36
+ /** A contract a person quoted — a contract reference, else an invoice number. */
37
+ const purchaseByContract = async (ctx, contract) => {
38
+ if (contract == null || contract.trim() === '') {
39
+ return null;
40
+ }
41
+ const reference = normalizeContractRef(contract);
42
+ return await purchases(ctx).load({ contractRef: reference })
43
+ ?? await purchases(ctx).load({ invoiceNumber: contract.trim() })
44
+ ?? await purchases(ctx).load({ invoiceNumber: contract.trim().toUpperCase() });
45
+ };
46
+ /** Public matching: the quoted contract, and an e-mail that belongs to it. */
47
+ const matchPublicPurchase = async (ctx, contract, email) => {
48
+ const purchase = await purchaseByContract(ctx, contract);
49
+ return purchase != null && await emailMatches(ctx, purchase, email) ? purchase : null;
50
+ };
51
+ /** The one organization whose paygate customer uses this e-mail — none when several do. */
52
+ const entityByEmail = async (ctx, email) => {
53
+ const wanted = normalizeEmail(email);
54
+ if (wanted === '') {
55
+ return null;
56
+ }
57
+ // Case-insensitive (`$ilike`, its `%`/`_`/`\` wildcards escaped): a paygate keeps the e-mail as typed.
58
+ const pattern = wanted.replace(/[\\%_]/g, match => `\\${match}`);
59
+ const { items } = await paygateCustomers(ctx).list({ paygate: STRIPE_PAYGATE_ALIAS, email: { $ilike: pattern } }, { size: 20 });
60
+ const entities = [...new Set(items.filter(item => item.deletedAt == null && item.entityId != null).map(item => item.entityId))];
61
+ return entities.length === 1 ? entities[0] : null;
62
+ };
63
+ /** The late-configuration seam of every service `makeConsumerRightsService` made — module-private. */
64
+ const plumbers = new WeakMap();
65
+ /** Run the consumer-rights observers of one act; a throw is recorded for `reconcile` to retry. */
66
+ export const runConsumerObservers = async (ctx, family, recordId, entityId, run) => {
67
+ const recordKind = family === 'consent' ? 'consent' : 'declaration';
68
+ try {
69
+ await run();
70
+ await recordEvent(ctx, { recordId, recordKind, entityId, action: 'observers', step: family, ok: true });
71
+ return true;
72
+ }
73
+ catch (error) {
74
+ console.error(`[payment] ${family} observers of "${recordId}" failed; reconcile retries them`, error);
75
+ await recordEvent(ctx, { recordId, recordKind, entityId, action: 'observers', step: family, ok: false, error: errorText(error) });
76
+ return false;
77
+ }
78
+ };
79
+ /** Tell the withdrawal observers — after the records and the paygate steps. */
80
+ export const notifyWithdrawal = async (ctx, declaration, purchase, status, computation, execution) => await runConsumerObservers(ctx, 'withdrawal', declaration.id, purchase.entityId, async () => {
81
+ await observer(ctx).propagateWithdrawal({
82
+ withdrawalId: declaration.id,
83
+ eventKey: `withdrawal:${declaration.id}`,
84
+ entityId: purchase.entityId,
85
+ channel: declaration.channel,
86
+ purchase: purchaseRefOf(purchase),
87
+ declaredAt: new Date(declaration.receivedAt),
88
+ status,
89
+ refund: compact({
90
+ amountMinor: execution?.refundedMinor ?? 0,
91
+ currency: purchase.currency,
92
+ netMinor: computation?.netMinor,
93
+ refundId: execution?.refundId,
94
+ creditNoteId: execution?.creditNoteId,
95
+ }),
96
+ units: computation != null
97
+ ? { granted: computation.reading.granted, used: computation.deducted, returned: computation.unitsReturned } : null,
98
+ subscriptionCanceled: execution?.subscriptionCanceled === true,
99
+ }, ctx);
100
+ });
101
+ /** Tell the cancellation observers. */
102
+ export const notifyCancellation = async (ctx, declaration, status) => await runConsumerObservers(ctx, 'cancellation', declaration.id, declaration.entityId ?? undefined, async () => {
103
+ await observer(ctx).propagateCancellation(compact({
104
+ cancellationId: declaration.id,
105
+ eventKey: `cancellation:${declaration.id}`,
106
+ entityId: declaration.entityId ?? undefined,
107
+ matched: declaration.matched,
108
+ channel: declaration.channel,
109
+ kind: declaration.cancellationKind ?? CancellationKind.Ordinary,
110
+ status,
111
+ subscriptionId: declaration.subscriptionId ?? undefined,
112
+ effectiveAt: declaration.effectiveAt != null ? new Date(declaration.effectiveAt) : undefined,
113
+ declaredAt: new Date(declaration.receivedAt),
114
+ }), ctx);
115
+ });
116
+ /** Tell the consent observers. */
117
+ export const notifyConsent = async (ctx, consent) => await runConsumerObservers(ctx, 'consent', consent.id, consent.entityId, async () => {
118
+ await observer(ctx).propagateConsent(compact({
119
+ kind: consent.kind, consentId: consent.id, entityId: consent.entityId, profileId: consent.profileId,
120
+ purchaseIds: [...consent.purchaseIds], planSku: consent.planSku, textVersion: consent.textVersion,
121
+ language: consent.language, decidedAt: new Date(consent.decidedAt),
122
+ expiresAt: consent.expiresAt != null ? new Date(consent.expiresAt) : undefined,
123
+ eventKey: `consent:${consent.id}`,
124
+ }), ctx);
125
+ });
126
+ /**
127
+ * Schedule an ordinary cancellation at the paygate: at the period end (`cancel_at_period_end`), or
128
+ * at a later boundary (`cancel_at`, no proration). Recorded as a `cancel-scheduled` event.
129
+ */
130
+ export const scheduleCancellation = async (ctx, stripe, declaration, row, attempt = 0) => {
131
+ const id = declaration.id;
132
+ const effectiveAt = declaration.effectiveAt != null ? new Date(declaration.effectiveAt) : null;
133
+ const atPeriodEnd = effectiveAt == null || row.periodEnd == null
134
+ || effectiveAt.getTime() === new Date(row.periodEnd).getTime();
135
+ const details = { comment: `cancellation:${id}` };
136
+ try {
137
+ await stripe.subscriptions.update(row.externalId, atPeriodEnd
138
+ ? { cancel_at_period_end: true, cancellation_details: details }
139
+ : { cancel_at: Math.floor(effectiveAt.getTime() / 1000), proration_behavior: 'none', cancellation_details: details }, { idempotencyKey: attempt > 0 ? `cancellation:${id}:schedule:${attempt}` : `cancellation:${id}:schedule` });
140
+ await recordEvent(ctx, {
141
+ recordId: id, recordKind: 'declaration', entityId: row.entityId, action: 'cancel-scheduled', ok: true,
142
+ externalId: row.externalId, detail: JSON.stringify({ atPeriodEnd, effectiveAt: effectiveAt?.toISOString() }),
143
+ });
144
+ return true;
145
+ }
146
+ catch (error) {
147
+ await recordEvent(ctx, {
148
+ recordId: id, recordKind: 'declaration', entityId: row.entityId, action: 'cancel-scheduled', ok: false,
149
+ externalId: row.externalId, error: errorText(error),
150
+ });
151
+ return false;
152
+ }
153
+ };
154
+ /**
155
+ * The consumer-rights service (`CONSUMER_RIGHTS_SERVICE`): the billing profile and its lock,
156
+ * purchases and their withdrawal windows, performance consent and subscription start requests,
157
+ * the withdrawal and cancellation functions with their paygate steps, the durable-medium mails,
158
+ * and `reconcile`. Unmanaged (`manage: false`) it still reads, records consents and asserts them;
159
+ * withdrawing and cancelling need the paygate.
160
+ *
161
+ * A lazy service, like the completion observer: `useMeter` / `useMailRenderer` work while the
162
+ * application is still being wired, before the context initializes.
163
+ */
164
+ export const makeConsumerRightsService = (alias = CONSUMER_RIGHTS_SERVICE, opts = {}) => {
165
+ // `manage` by precedence: the application's own, else the gateway's, else managed.
166
+ let manageOwn = opts.manage;
167
+ let manageGateway;
168
+ const isManaged = () => (manageOwn ?? manageGateway) !== false;
169
+ let meter = opts.usage ?? null;
170
+ let stripeFactory = opts.stripe;
171
+ let renderer = null;
172
+ const stripeOf = async (ctx) => stripeFactory != null ? await stripeFactory(ctx) : await stripeClient(ctx);
173
+ const internals = {
174
+ get managed() { return isManaged(); }, meter: () => meter, stripe: stripeOf,
175
+ };
176
+ const service = createLazyService(alias, {
177
+ get managed() { return isManaged(); },
178
+ policy: async () => await payment(service.assertCtx()).consumerRightsPolicy(),
179
+ profile: async (entityId) => {
180
+ const ctx = service.assertCtx();
181
+ const record = await billingProfiles(ctx).byEntity(entityId);
182
+ return record != null ? profileViewOf(record, await payment(ctx).consumerRightsPolicy()) : null;
183
+ },
184
+ lock: async (entityId, country, source, lockOpts = {}) => {
185
+ const ctx = service.assertCtx();
186
+ const policy = await payment(ctx).consumerRightsPolicy();
187
+ const { record } = await lockProfile(ctx, policy, { ...lockOpts, entityId, country, source });
188
+ return profileViewOf(record, policy);
189
+ },
190
+ unlock: async (entityId, unlockOpts = {}) => {
191
+ const ctx = service.assertCtx();
192
+ const record = await unlockProfile(ctx, entityId, unlockOpts);
193
+ return record != null ? profileViewOf(record, await payment(ctx).consumerRightsPolicy()) : null;
194
+ },
195
+ purchases: async (entityId, listOpts = {}) => {
196
+ const ctx = service.assertCtx();
197
+ const at = listOpts.at ?? new Date();
198
+ const { items } = await purchases(ctx).list({ entityId }, {
199
+ size: 200, sort: [{ field: 'purchasedAt', order: 'desc' }],
200
+ });
201
+ const views = [];
202
+ for (const purchase of items) {
203
+ const open = windowOpen(purchase, at);
204
+ if (listOpts.open === true && !open)
205
+ continue;
206
+ let withdrawable = open;
207
+ if (open && meter != null && purchase.kind === PurchaseKind.TopUp) {
208
+ try {
209
+ withdrawable = (await computeWithdrawal(ctx, meter, purchase, at)).refundMinor > 0;
210
+ }
211
+ catch (error) {
212
+ console.warn(`[payment] usage of "${purchase.purchaseId}" unreadable`, error);
213
+ }
214
+ }
215
+ views.push(purchaseViewOf(purchase, withdrawable));
216
+ }
217
+ return views;
218
+ },
219
+ consentView: async (entityId, at = new Date()) => {
220
+ const ctx = service.assertCtx();
221
+ const policy = await requirePolicy(ctx);
222
+ const profile = await billingProfiles(ctx).byEntity(entityId);
223
+ const windows = policy.mechanisms.performanceConsent ? await unconsentedWindows(ctx, entityId, at) : [];
224
+ const language = profile?.language ?? windows[0]?.language ?? policy.defaultLanguage;
225
+ const deadline = latestDeadline(windows);
226
+ const view = {
227
+ required: windows.length > 0,
228
+ region: profile?.region ?? windows[0]?.region ?? null,
229
+ country: profile?.country ?? windows[0]?.country ?? null,
230
+ language,
231
+ trader: traderOf(ctx, await consumerMailConfig(ctx)).name,
232
+ textVersion: policy.textVersion,
233
+ copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
234
+ links: linksOf(policy, language),
235
+ purchases: windows.map(window => purchaseViewOf(window, true)),
236
+ ...(deadline != null ? { deadline } : {}),
237
+ at,
238
+ };
239
+ return view;
240
+ },
241
+ recordConsent: async (subject, body, origin) => {
242
+ const ctx = service.assertCtx();
243
+ const policy = await requirePolicy(ctx);
244
+ if (!policy.mechanisms.performanceConsent) {
245
+ throw new ConsumerRightsError('mechanism:performance-consent');
246
+ }
247
+ const at = new Date();
248
+ const windows = await unconsentedWindows(ctx, subject.entityId, at);
249
+ const covered = windows.filter(window => body.purchaseIds.includes(window.purchaseId));
250
+ // A statement of another version, or one that saw none of what is open now, is asked again.
251
+ if (body.textVersion !== policy.textVersion || (covered.length === 0 && windows.length > 0)) {
252
+ throw new PerformanceConsentRequired({ pending: windows.length, ...(latestDeadline(windows) != null ? { deadline: latestDeadline(windows) } : {}) });
253
+ }
254
+ const trader = traderOf(ctx, await consumerMailConfig(ctx));
255
+ const deadline = latestDeadline(covered);
256
+ const consent = await consumerConsents(ctx).create(compact({
257
+ kind: ConsentKind.Performance,
258
+ entityId: subject.entityId,
259
+ profileId: subject.profileId,
260
+ name: subject.name,
261
+ email: prefillEmail(subject.email),
262
+ purchaseIds: covered.map(window => window.purchaseId),
263
+ textVersion: policy.textVersion,
264
+ copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
265
+ language: body.language,
266
+ uiLanguage: body.uiLanguage,
267
+ trader: trader.name,
268
+ text: consentStatementOf(body.language, ConsentKind.Performance, { trader: trader.name }),
269
+ links: linksOf(policy, body.language),
270
+ deadline,
271
+ decidedAt: at,
272
+ ...originFields(origin),
273
+ }));
274
+ for (const window of covered) {
275
+ await conditionalSet(purchases(ctx), { purchaseId: window.purchaseId, consentedAt: null }, {
276
+ consentedAt: at, consentId: consent.id, updatedAt: at,
277
+ });
278
+ }
279
+ const mailed = covered.length > 0 ? await sendConsumerMail(ctx, policy, 'consent', consent.id) : false;
280
+ await notifyConsent(ctx, consent);
281
+ return { consentId: consent.id, consentedAt: at, purchaseIds: [...consent.purchaseIds], mailed };
282
+ },
283
+ assertConsent: async (entityId, at = new Date()) => {
284
+ const ctx = service.assertCtx();
285
+ const policy = await payment(ctx).consumerRightsPolicy();
286
+ if (policy?.mechanisms.performanceConsent !== true) {
287
+ return;
288
+ }
289
+ const windows = await unconsentedWindows(ctx, entityId, at);
290
+ if (windows.length > 0) {
291
+ const deadline = latestDeadline(windows);
292
+ throw new PerformanceConsentRequired({ pending: windows.length, ...(deadline != null ? { deadline } : {}) });
293
+ }
294
+ },
295
+ startView: async (entityId, planSku, viewOpts = {}) => {
296
+ const ctx = service.assertCtx();
297
+ const policy = await requirePolicy(ctx);
298
+ const profile = await billingProfiles(ctx).byEntity(entityId);
299
+ const language = viewOpts.language ?? profile?.language ?? policy.defaultLanguage;
300
+ const view = {
301
+ required: policy.mechanisms.subscriptionStart
302
+ && (profile == null || inScope(profile.region, profile.country, policy)),
303
+ planSku,
304
+ language,
305
+ trader: traderOf(ctx, await consumerMailConfig(ctx)).name,
306
+ textVersion: policy.textVersion,
307
+ copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
308
+ links: linksOf(policy, language),
309
+ region: profile?.region ?? null,
310
+ };
311
+ return view;
312
+ },
313
+ recordStartRequest: async (subject, body, origin, startOpts = {}) => {
314
+ const ctx = service.assertCtx();
315
+ const policy = await requirePolicy(ctx);
316
+ if (!policy.mechanisms.subscriptionStart) {
317
+ throw new ConsumerRightsError('mechanism:subscription-start');
318
+ }
319
+ if (body.textVersion !== policy.textVersion) {
320
+ throw new SubscriptionStartRequired(body.planSku);
321
+ }
322
+ if (await findPlan(ctx, body.planSku) == null) {
323
+ throw new UnknownPlan(body.planSku);
324
+ }
325
+ const at = new Date();
326
+ const expiresAt = new Date(at.getTime() + (policy.startRequestTtlSeconds ?? 3600) * 1000);
327
+ const trader = traderOf(ctx, await consumerMailConfig(ctx));
328
+ const planName = startOpts.plan ?? await planTitleOf(ctx, body.planSku, body.language);
329
+ const consent = await consumerConsents(ctx).create(compact({
330
+ kind: ConsentKind.SubscriptionStart,
331
+ entityId: subject.entityId,
332
+ profileId: subject.profileId,
333
+ name: subject.name,
334
+ email: prefillEmail(subject.email),
335
+ purchaseIds: [],
336
+ planSku: body.planSku,
337
+ planName,
338
+ textVersion: policy.textVersion,
339
+ copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
340
+ language: body.language,
341
+ trader: trader.name,
342
+ text: consentStatementOf(body.language, ConsentKind.SubscriptionStart, { trader: trader.name, plan: planName }),
343
+ links: linksOf(policy, body.language),
344
+ decidedAt: at,
345
+ expiresAt,
346
+ ...originFields(origin),
347
+ }));
348
+ await sendConsumerMail(ctx, policy, 'start', consent.id);
349
+ await notifyConsent(ctx, consent);
350
+ return { startRequestId: consent.id, requestedAt: at, expiresAt };
351
+ },
352
+ assertStartRequest: async (entityId, planSku, startRequestId) => {
353
+ const ctx = service.assertCtx();
354
+ const policy = await payment(ctx).consumerRightsPolicy();
355
+ if (policy?.mechanisms.subscriptionStart !== true) {
356
+ return null;
357
+ }
358
+ const profile = await billingProfiles(ctx).byEntity(entityId);
359
+ // Only an organization already locked outside the territories goes without one: a country
360
+ // picked before checkout may differ from the address typed at the paygate.
361
+ if (profile != null && !inScope(profile.region, profile.country, policy)) {
362
+ return null;
363
+ }
364
+ const record = startRequestId != null && startRequestId !== ''
365
+ ? await consumerConsents(ctx).load(startRequestId).catch(() => null) : null;
366
+ if (record == null || record.kind !== ConsentKind.SubscriptionStart || record.entityId !== entityId
367
+ || record.planSku !== planSku || record.textVersion !== policy.textVersion
368
+ || record.expiresAt == null || new Date(record.expiresAt).getTime() <= Date.now()) {
369
+ throw new SubscriptionStartRequired(planSku);
370
+ }
371
+ return record;
372
+ },
373
+ withdrawalCandidates: async (entityId, subject = {}) => {
374
+ const ctx = service.assertCtx();
375
+ const policy = await requirePolicy(ctx);
376
+ const profile = await billingProfiles(ctx).byEntity(entityId);
377
+ const at = new Date();
378
+ const open = policy.mechanisms.withdrawal
379
+ ? (await purchases(ctx).list({
380
+ entityId, inScope: true, withdrawnAt: null, refundedAt: null, deadline: { $gt: at },
381
+ }, { size: 100, sort: [{ field: 'purchasedAt', order: 'desc' }] })).items
382
+ : [];
383
+ const candidates = [];
384
+ for (const purchase of open) {
385
+ let estimate = null;
386
+ if (meter != null) {
387
+ try {
388
+ estimate = (await computeWithdrawal(ctx, meter, purchase, at)).estimate;
389
+ }
390
+ catch (error) {
391
+ console.warn(`[payment] withdrawal estimate of "${purchase.purchaseId}" failed`, error);
392
+ }
393
+ }
394
+ // Credits fully used after consent: the right has expired, nothing would be reimbursed.
395
+ if (estimate != null && purchase.kind === PurchaseKind.TopUp && estimate.refundMinor === 0)
396
+ continue;
397
+ candidates.push({
398
+ purchaseId: purchase.purchaseId,
399
+ contractRef: purchase.contractRef,
400
+ kind: purchase.kind,
401
+ purchasedAt: new Date(purchase.purchasedAt),
402
+ deadline: new Date(purchase.deadline),
403
+ amountTotalMinor: purchase.amountTotalMinor,
404
+ currency: purchase.currency,
405
+ estimate,
406
+ automatic: policy.mechanisms.automaticRefunds && meter != null && isManaged(),
407
+ });
408
+ }
409
+ const language = profile?.language ?? open[0]?.language ?? policy.defaultLanguage;
410
+ return compact({
411
+ candidates, language, links: linksOf(policy, language),
412
+ name: subject.name != null && subject.name.trim() !== '' ? subject.name : undefined,
413
+ email: prefillEmail(subject.email),
414
+ });
415
+ },
416
+ withdraw: async (subject, body, origin) => {
417
+ const ctx = service.assertCtx();
418
+ const policy = await requirePolicy(ctx);
419
+ if (!policy.mechanisms.withdrawal) {
420
+ throw new ConsumerRightsError('mechanism:withdrawal');
421
+ }
422
+ if (!isManaged()) {
423
+ throw new PaygateError('unmanaged');
424
+ }
425
+ const at = new Date();
426
+ const channel = subject == null ? DeclarationChannel.Public : subject.channel ?? DeclarationChannel.InApp;
427
+ const disclose = channel === DeclarationChannel.InApp;
428
+ let purchase = null;
429
+ if (subject != null) {
430
+ purchase = body.purchaseId != null ? await purchases(ctx).byPurchaseId(body.purchaseId)
431
+ : await purchaseByContract(ctx, body.contractRef);
432
+ if (purchase != null && purchase.entityId !== subject.entityId)
433
+ purchase = null;
434
+ }
435
+ else {
436
+ purchase = await matchPublicPurchase(ctx, body.contractRef, body.email);
437
+ }
438
+ if (disclose) {
439
+ if (purchase == null)
440
+ throw new WithdrawalUnavailable(WithdrawalUnavailableReason.Unknown);
441
+ if (!purchase.inScope)
442
+ throw new WithdrawalUnavailable(WithdrawalUnavailableReason.NotInScope);
443
+ if (purchase.withdrawnAt == null && purchase.refundedAt != null) {
444
+ throw new WithdrawalUnavailable(WithdrawalUnavailableReason.Withdrawn);
445
+ }
446
+ }
447
+ const content = contentOf({
448
+ name: body.name, contract: body.contractRef ?? (disclose ? purchase?.contractRef : undefined), email: body.email,
449
+ });
450
+ const profile = purchase != null ? await billingProfiles(ctx).byEntity(purchase.entityId) : null;
451
+ const language = body.language ?? purchase?.language ?? profile?.language ?? policy.defaultLanguage;
452
+ const declarationBase = {
453
+ kind: DeclarationKind.Withdrawal, channel,
454
+ entityId: purchase?.entityId ?? subject?.entityId,
455
+ purchaseId: purchase?.purchaseId,
456
+ subscriptionId: purchase?.subscriptionId ?? undefined,
457
+ contractRef: body.contractRef ?? purchase?.contractRef,
458
+ name: body.name, email: body.email, language,
459
+ textVersion: policy.textVersion, copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
460
+ receivedAt: at, matched: purchase != null, profileId: subject?.profileId, ...originFields(origin),
461
+ };
462
+ // A repeated declaration of a contract already withdrawn from: recorded, answered with the original.
463
+ if (purchase?.withdrawnAt != null) {
464
+ const original = purchase.withdrawalId != null ? await consumerDeclarations(ctx).load(purchase.withdrawalId).catch(() => null) : null;
465
+ const repeated = await consumerDeclarations(ctx).create(compact({
466
+ ...declarationBase, duplicateOf: original?.id, status: original?.status ?? WithdrawalStatus.Received,
467
+ }));
468
+ if (!disclose) {
469
+ return publicReceipt(repeated, content, false);
470
+ }
471
+ const answered = original ?? repeated;
472
+ return receiptOf(answered, content, false, await executedStatusOf(ctx, answered));
473
+ }
474
+ let status;
475
+ let computation = null;
476
+ if (purchase == null || !purchase.inScope || purchase.refundedAt != null) {
477
+ status = WithdrawalStatus.Received;
478
+ }
479
+ else if (purchase.deadline == null || at.getTime() >= new Date(purchase.deadline).getTime()) {
480
+ status = WithdrawalStatus.Expired;
481
+ }
482
+ else if (meter != null && policy.mechanisms.automaticRefunds) {
483
+ try {
484
+ computation = await computeWithdrawal(ctx, meter, purchase, at);
485
+ }
486
+ catch (error) {
487
+ await recordEvent(ctx, {
488
+ recordId: purchase.purchaseId, recordKind: 'purchase', entityId: purchase.entityId, action: 'meter', ok: false,
489
+ error: errorText(error),
490
+ });
491
+ }
492
+ status = computation != null ? WithdrawalStatus.Processing : WithdrawalStatus.Review;
493
+ if (computation != null && purchase.kind === PurchaseKind.TopUp && computation.refundMinor === 0) {
494
+ if (disclose)
495
+ throw new WithdrawalUnavailable(WithdrawalUnavailableReason.Performed);
496
+ computation = null;
497
+ status = WithdrawalStatus.Review;
498
+ }
499
+ }
500
+ else {
501
+ status = WithdrawalStatus.Review;
502
+ }
503
+ const declaration = await consumerDeclarations(ctx).create(compact({
504
+ ...declarationBase, status,
505
+ refundMinor: computation?.refundMinor, currency: computation != null ? purchase?.currency : undefined,
506
+ }));
507
+ const withdrawalId = declaration.id;
508
+ if (purchase != null && (status === WithdrawalStatus.Processing || status === WithdrawalStatus.Review)) {
509
+ // The window closes now; only one declaration of a purchase wins it.
510
+ const won = await conditionalSet(purchases(ctx), { purchaseId: purchase.purchaseId, withdrawnAt: null }, {
511
+ withdrawnAt: at, withdrawalId, updatedAt: at,
512
+ });
513
+ if (!won) {
514
+ const winner = await purchases(ctx).byPurchaseId(purchase.purchaseId);
515
+ await recordEvent(ctx, {
516
+ recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'duplicate', ok: true,
517
+ detail: JSON.stringify({ of: winner?.withdrawalId }),
518
+ });
519
+ const original = winner?.withdrawalId != null ? await consumerDeclarations(ctx).load(winner.withdrawalId).catch(() => null) : null;
520
+ const answered = original ?? declaration;
521
+ return disclose
522
+ ? receiptOf(answered, content, false, await executedStatusOf(ctx, answered))
523
+ : publicReceipt(declaration, content, false);
524
+ }
525
+ purchase = { ...purchase, withdrawnAt: at, withdrawalId };
526
+ }
527
+ if (computation != null && purchase != null) {
528
+ await recordEvent(ctx, {
529
+ recordId: withdrawalId, recordKind: 'declaration', entityId: purchase.entityId, action: 'computed', ok: true,
530
+ amountMinor: computation.refundMinor, currency: purchase.currency,
531
+ detail: JSON.stringify({
532
+ reading: computation.reading, deducted: computation.deducted, netMinor: computation.netMinor,
533
+ unitsReturned: computation.unitsReturned, estimate: computation.estimate,
534
+ }),
535
+ });
536
+ }
537
+ const mailed = await sendConsumerMail(ctx, policy, 'withdrawal', withdrawalId);
538
+ let final = status;
539
+ let execution = null;
540
+ if (status === WithdrawalStatus.Processing && purchase != null && computation != null) {
541
+ execution = await executeWithdrawal(ctx, await stripeOf(ctx), declaration, purchase, computation);
542
+ final = execution.needsReview ? WithdrawalStatus.Review : execution.ok ? WithdrawalStatus.Refunded : WithdrawalStatus.Failed;
543
+ }
544
+ if (purchase != null && (final === WithdrawalStatus.Refunded || final === WithdrawalStatus.Review)
545
+ && (status === WithdrawalStatus.Processing || status === WithdrawalStatus.Review)) {
546
+ await notifyWithdrawal(ctx, declaration, purchase, final, computation, execution);
547
+ }
548
+ if (!disclose) {
549
+ return publicReceipt(declaration, content, mailed);
550
+ }
551
+ return compact({
552
+ ...receiptOf(declaration, content, mailed), status: final,
553
+ refundMinor: execution?.refundedMinor ?? computation?.refundMinor,
554
+ subscriptionCanceled: execution?.subscriptionCanceled,
555
+ });
556
+ },
557
+ cancel: async (subject, body, origin) => {
558
+ const ctx = service.assertCtx();
559
+ const policy = await requirePolicy(ctx);
560
+ if (!policy.mechanisms.cancellation) {
561
+ throw new ConsumerRightsError('mechanism:cancellation');
562
+ }
563
+ if (!isManaged()) {
564
+ throw new PaygateError('unmanaged');
565
+ }
566
+ const at = new Date();
567
+ const channel = subject == null ? DeclarationChannel.Public : subject.channel ?? DeclarationChannel.InApp;
568
+ const disclose = channel === DeclarationChannel.InApp;
569
+ let purchase = null;
570
+ let row = null;
571
+ if (subject != null) {
572
+ if (body.subscriptionId != null) {
573
+ row = await subscriptions(ctx).byExternalId(body.subscriptionId, STRIPE_PAYGATE_ALIAS);
574
+ }
575
+ else if (body.contractRef != null) {
576
+ purchase = await purchaseByContract(ctx, body.contractRef);
577
+ if (purchase != null && purchase.entityId === subject.entityId && purchase.subscriptionId != null) {
578
+ row = await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS);
579
+ }
580
+ }
581
+ row = row != null && row.entityId === subject.entityId ? row : await entitlingStripeSubscription(ctx, subject.entityId);
582
+ }
583
+ else {
584
+ purchase = await matchPublicPurchase(ctx, body.contractRef, body.email);
585
+ if (purchase?.subscriptionId != null) {
586
+ row = await subscriptions(ctx).byExternalId(purchase.subscriptionId, STRIPE_PAYGATE_ALIAS);
587
+ }
588
+ else if (purchase == null) {
589
+ const entityId = await entityByEmail(ctx, body.email);
590
+ row = entityId != null ? await entitlingStripeSubscription(ctx, entityId) : null;
591
+ }
592
+ }
593
+ const live = row != null && !TERMINAL_STATUSES.includes(row.status) && ENTITLING_STATUSES.includes(row.status);
594
+ if (disclose && row == null)
595
+ throw new CancellationUnavailable(CancellationUnavailableReason.NoSubscription);
596
+ if (disclose && !live)
597
+ throw new CancellationUnavailable(CancellationUnavailableReason.Ended);
598
+ if (row != null && purchase == null) {
599
+ purchase = await purchases(ctx).load({ subscriptionId: row.externalId });
600
+ }
601
+ const requested = body.effective === 'date' && body.date != null ? new Date(`${body.date}T00:00:00.000Z`) : null;
602
+ const plan = row != null ? await findPlan(ctx, row.planSku) : null;
603
+ let status;
604
+ let effectiveAt;
605
+ if (body.kind === CancellationKind.Extraordinary) {
606
+ // For cause: recorded and left to an operator — the paygate is not touched.
607
+ status = CancellationStatus.Review;
608
+ }
609
+ else if (row != null && live && row.periodEnd != null) {
610
+ effectiveAt = cancellationEffectiveAt(new Date(row.periodEnd), plan?.recurring?.interval ?? 'month', requested != null && !Number.isNaN(requested.getTime()) ? requested : null);
611
+ status = row.cancelAtPeriodEnd === true && effectiveAt.getTime() === new Date(row.periodEnd).getTime()
612
+ ? CancellationStatus.AlreadyScheduled : CancellationStatus.Scheduled;
613
+ }
614
+ else {
615
+ status = CancellationStatus.Received;
616
+ }
617
+ const profile = row != null ? await billingProfiles(ctx).byEntity(row.entityId) : null;
618
+ const language = body.language ?? purchase?.language ?? profile?.language ?? policy.defaultLanguage;
619
+ const declaration = await consumerDeclarations(ctx).create(compact({
620
+ kind: DeclarationKind.Cancellation, channel,
621
+ entityId: row?.entityId ?? subject?.entityId,
622
+ purchaseId: purchase?.purchaseId,
623
+ subscriptionId: row?.externalId,
624
+ contractRef: body.contractRef ?? purchase?.contractRef,
625
+ name: body.name, email: body.email,
626
+ cancellationKind: body.kind, reason: body.reason, effective: body.effective, requestedDate: body.date,
627
+ language, textVersion: policy.textVersion, copyVersion: CONSUMER_RIGHTS_COPY_VERSION,
628
+ receivedAt: at, matched: row != null && live, profileId: subject?.profileId, status, effectiveAt,
629
+ ...originFields(origin),
630
+ }));
631
+ let final = status;
632
+ if (status === CancellationStatus.Scheduled && row != null) {
633
+ final = await scheduleCancellation(ctx, await stripeOf(ctx), declaration, row) ? CancellationStatus.Scheduled : CancellationStatus.Received;
634
+ }
635
+ if (purchase != null && effectiveAt != null) {
636
+ await patchPurchase(ctx, purchase.purchaseId, { cancellationId: declaration.id, cancelEffectiveAt: effectiveAt });
637
+ }
638
+ const mailed = await sendConsumerMail(ctx, policy, 'cancellation', declaration.id);
639
+ await notifyCancellation(ctx, declaration, final);
640
+ const content = contentOf({
641
+ name: body.name, contract: body.contractRef ?? (disclose ? purchase?.contractRef ?? row?.externalId : undefined),
642
+ email: body.email, kind: body.kind, reason: body.reason, date: body.effective === 'date' ? body.date : undefined,
643
+ });
644
+ if (!disclose) {
645
+ return publicReceipt(declaration, content, mailed);
646
+ }
647
+ return compact({
648
+ ...publicReceipt(declaration, content, mailed), status: final, effectiveAt,
649
+ });
650
+ },
651
+ useMeter: next => { meter = next; },
652
+ useMailRenderer: next => { renderer = next; },
653
+ usageMeter: () => meter,
654
+ mailRenderer: () => renderer,
655
+ reconcile: async (reconcileOpts = {}) => await reconcileConsumerRights(service.assertCtx(), internals, reconcileOpts),
656
+ }, service => async () => {
657
+ service.initialized = true;
658
+ const ctx = service.assertCtx();
659
+ void ctx.waitForInitialized?.().then(async () => { await bootWarnings(ctx, isManaged(), () => meter); })
660
+ .catch(error => { console.error('[payment] consumer-rights boot check failed', error); });
661
+ });
662
+ plumbers.set(service, (plumbing, from) => {
663
+ if (plumbing.manage != null) {
664
+ if (from === 'application')
665
+ manageOwn = plumbing.manage;
666
+ else
667
+ manageGateway = plumbing.manage;
668
+ }
669
+ if (plumbing.usage != null)
670
+ meter = plumbing.usage;
671
+ if (plumbing.stripe != null)
672
+ stripeFactory = plumbing.stripe;
673
+ });
674
+ return service;
675
+ };
676
+ /** A withdrawal receipt from its declaration (in-app), `status` as executed so far. */
677
+ const receiptOf = (declaration, content, mailed, status) => compact({
678
+ ...publicReceipt(declaration, content, mailed),
679
+ status: status ?? declaration.status,
680
+ refundMinor: declaration.refundMinor ?? undefined,
681
+ currency: declaration.currency ?? undefined,
682
+ });
683
+ /**
684
+ * Where a withdrawal stands now: a `processing` declaration is `refunded` once its refund (and its
685
+ * subscription cancel) succeeded, `failed` after a failed attempt, else still `processing`.
686
+ */
687
+ const executedStatusOf = async (ctx, declaration) => {
688
+ if (declaration.status !== WithdrawalStatus.Processing) {
689
+ return declaration.status;
690
+ }
691
+ const id = declaration.id;
692
+ const refunded = (declaration.refundMinor ?? 0) <= 0 || await consumerEvents(ctx).load({ recordId: id, action: 'refund', ok: true }) != null;
693
+ if (refunded) {
694
+ return WithdrawalStatus.Refunded;
695
+ }
696
+ return await consumerEvents(ctx).load({ recordId: id, action: 'refund', ok: false }) != null
697
+ ? WithdrawalStatus.Failed : WithdrawalStatus.Processing;
698
+ };
699
+ const bootWarnings = async (ctx, managed, meter) => {
700
+ const policy = await payment(ctx).consumerRightsPolicy();
701
+ // An unmanaged process (a worker that asserts consent) neither mails nor refunds.
702
+ if (policy == null || !managed) {
703
+ return;
704
+ }
705
+ const { mechanisms } = policy;
706
+ const mailing = mechanisms.purchaseConfirmation || mechanisms.performanceConsent || mechanisms.subscriptionStart
707
+ || mechanisms.withdrawal || mechanisms.cancellation;
708
+ const mail = await consumerMailConfig(ctx);
709
+ if (mailing) {
710
+ const trader = mail?.trader;
711
+ if (trader?.address == null || trader.email == null) {
712
+ console.warn('[payment] consumer rights: the trader has no postal address or e-mail — the legal mails and the '
713
+ + 'withdrawal information go out without them');
714
+ }
715
+ const alias = mail?.alias ?? MAILER_SERVICE;
716
+ if (ctx.hasService?.(alias) !== true) {
717
+ console.warn(`[payment] consumer rights: no mailer "${alias}" — no durable-medium mail is sent`);
718
+ }
719
+ }
720
+ if (managed && mechanisms.withdrawal && mechanisms.automaticRefunds && meter() == null) {
721
+ console.warn('[payment] consumer rights: no usage meter — every withdrawal is left to an operator (review)');
722
+ }
723
+ };
724
+ /**
725
+ * Register the consumer-rights resources and service (each only when absent), or configure the
726
+ * service already registered — `from` says who asks. The order of the application's call and the
727
+ * gateway's is free: `usage` and `stripe` are installed whenever they come, and the application's
728
+ * explicit `manage` wins over the gateway's. The resources keep the first registration's aliases.
729
+ */
730
+ export const registerConsumerRights = (ctx, opts = {}, from = 'application') => {
731
+ for (const [alias, maker] of CONSUMER_RIGHTS_RESOURCE_MAKERS) {
732
+ if (!ctx.hasResource(alias)) {
733
+ ctx.registerResource(maker(opts.dbAlias, opts.serviceAlias));
734
+ }
735
+ }
736
+ const alias = opts.alias ?? CONSUMER_RIGHTS_SERVICE;
737
+ // A lazy service: reachable before the context initializes.
738
+ let service = ctx.hasService(alias) ? ctx.service(alias) : null;
739
+ if (service == null) {
740
+ service = makeConsumerRightsService(alias, { ...opts, manage: undefined, usage: undefined, stripe: undefined });
741
+ ctx.registerService(service);
742
+ }
743
+ const plumb = plumbers.get(service);
744
+ if (plumb != null) {
745
+ plumb(compact({ manage: opts.manage, usage: opts.usage, stripe: opts.stripe }), from);
746
+ }
747
+ else if (opts.usage != null) {
748
+ // A service made elsewhere: only its public seam.
749
+ service.useMeter(opts.usage);
750
+ }
751
+ return ctx;
752
+ };
753
+ /**
754
+ * Register the consumer-rights resources and service, each only when absent — the gateway
755
+ * registration does too. Called before or after the gateway, it installs `usage` and `stripe` on
756
+ * the service and its explicit `manage` wins over the gateway's; `useMeter` and `useMailRenderer`
757
+ * work on the service at any time.
758
+ */
759
+ export const appendConsumerRights = (ctx, opts = {}) => registerConsumerRights(ctx, opts, 'application');
760
+ //# sourceMappingURL=service.js.map