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