@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,368 @@
1
+ import { MAILER_SERVICE } from '@owlmeans/mailer'
2
+ import type { MailerService, MailMessage } from '@owlmeans/mailer'
3
+ import {
4
+ CancellationStatus, consumerText, linksOf, PurchaseKind, WithdrawalStatus,
5
+ } from '@owlmeans/payment'
6
+ import type { ConsumerRightsLinks, ConsumerRightsPolicy, CopyValues } from '@owlmeans/payment'
7
+ import type { Context as ApiContext } from '@owlmeans/server-api'
8
+ import { findPlan, findProduct } from '../plan.js'
9
+ import {
10
+ compact, consumerConsents, consumerDeclarations, consumerMailConfig, consumerRightsOf, errorText, payment,
11
+ purchases,
12
+ } from '../utils.js'
13
+ import {
14
+ escapeHtml, formatDate, formatDateTime, formatDeadline, formatMoney, isReservedAddress,
15
+ } from './format.js'
16
+ import { contactEmailOf, recordEvent } from './records.js'
17
+ import type {
18
+ ConsumerConsentRecord, ConsumerDeclarationRecord, ConsumerMailData, ConsumerMailKind, ConsumerMailPluginConfig,
19
+ ConsumerRecordKind, PurchaseRecord, TraderDef,
20
+ } from '../types.js'
21
+
22
+ const RECORD_KIND: Record<ConsumerMailKind, ConsumerRecordKind> = {
23
+ purchase: 'purchase', consent: 'consent', start: 'consent', withdrawal: 'declaration', cancellation: 'declaration',
24
+ }
25
+
26
+ /** A paragraph, or a labelled link rendered as an anchor in HTML. */
27
+ type Block = { text: string } | { label: string, url: string }
28
+
29
+ interface Rendered {
30
+ subject: string
31
+ blocks: Block[]
32
+ }
33
+
34
+ const text = (lng: string, path: string, vars: CopyValues = {}): string => consumerText(lng, path, vars)
35
+
36
+ const textOf = (blocks: Block[], lng: string): string => blocks
37
+ .map(block => 'text' in block ? block.text : text(lng, 'email.common.link', { label: block.label, url: block.url }))
38
+ .join('\n\n')
39
+
40
+ const htmlOf = (blocks: Block[]): string => blocks.map(block => 'text' in block
41
+ ? `<p>${escapeHtml(block.text).replace(/\n/g, '<br>')}</p>`
42
+ : `<p>${escapeHtml(block.label)}: <a href="${escapeHtml(block.url)}">${escapeHtml(block.url)}</a></p>`,
43
+ ).join('\n')
44
+
45
+ const linkBlocks = (lng: string, links: ConsumerRightsLinks, which: Array<keyof ConsumerRightsLinks>): Block[] => {
46
+ const labels: Record<keyof ConsumerRightsLinks, string> = {
47
+ billingTerms: 'links.billing-terms',
48
+ withdrawalInformation: 'links.withdrawal-information',
49
+ withdrawalForm: 'links.withdrawal-form',
50
+ withdrawalFunction: 'links.withdrawal-function',
51
+ cancellation: 'links.cancellation',
52
+ }
53
+
54
+ return which
55
+ .filter(key => links[key] != null && links[key] !== '')
56
+ .map(key => ({ label: text(lng, labels[key]), url: links[key] as string }))
57
+ }
58
+
59
+ /** The trader as the mails and the withdrawal information name it: legal name, address, e-mail. */
60
+ export const traderIdentityOf = (trader: TraderDef): string =>
61
+ [trader.legalName, trader.address, trader.email].filter(part => part != null && part.trim() !== '').join(', ')
62
+
63
+ /** The declared trader; an undeclared one falls back to the service name (no mechanism is on then). */
64
+ export const traderOf = (ctx: ApiContext, mail: ConsumerMailPluginConfig | null): TraderDef =>
65
+ mail?.trader ?? { name: ctx.cfg.service, legalName: ctx.cfg.service }
66
+
67
+ /** A plan's title in a language: its localization, else its catalogue title, else its sku. */
68
+ export const planTitleOf = async (ctx: ApiContext, planSku: string | undefined, lng: string): Promise<string> => {
69
+ if (planSku == null) {
70
+ return ''
71
+ }
72
+ const plan = await findPlan(ctx, planSku)
73
+ const localized = plan != null ? await payment(ctx).localize(lng, plan).catch(() => null) : null
74
+
75
+ return localized?.title ?? plan?.title ?? planSku
76
+ }
77
+
78
+ const productTitleOf = async (ctx: ApiContext, purchase: PurchaseRecord, lng: string): Promise<string> => {
79
+ const plan = purchase.planSku != null ? await findPlan(ctx, purchase.planSku) : null
80
+ const product = await findProduct(ctx, purchase.productSku)
81
+ const entity = plan ?? product
82
+ const localized = entity != null ? await payment(ctx).localize(lng, entity).catch(() => null) : null
83
+
84
+ return localized?.title ?? plan?.title ?? product?.title ?? purchase.productSku
85
+ }
86
+
87
+ const renderPurchase = async (ctx: ApiContext, data: ConsumerMailData): Promise<Rendered> => {
88
+ const lng = data.language
89
+ const purchase = data.purchase as PurchaseRecord
90
+ const blocks: Block[] = [{
91
+ text: text(lng, 'email.purchase.body', {
92
+ contractRef: purchase.contractRef,
93
+ date: formatDateTime(new Date(purchase.purchasedAt), lng),
94
+ product: await productTitleOf(ctx, purchase, lng),
95
+ total: formatMoney(purchase.amountTotalMinor, purchase.currency, lng),
96
+ tax: formatMoney(purchase.amountTaxMinor, purchase.currency, lng),
97
+ }),
98
+ }]
99
+ if (data.consent != null) {
100
+ blocks.push({ text: text(lng, 'email.purchase.start-request', { statement: data.consent.text.checkbox }) })
101
+ }
102
+ const identity = traderIdentityOf(data.trader)
103
+ blocks.push({
104
+ text: text(lng, 'email.purchase.withdrawal-information', {
105
+ trader: identity,
106
+ withdrawalFunction: data.links.withdrawalFunction ?? data.links.withdrawalInformation ?? data.links.billingTerms,
107
+ }),
108
+ })
109
+ blocks.push({ text: text(lng, 'email.purchase.model-form', { trader: identity }) })
110
+ blocks.push(...linkBlocks(lng, data.links, [
111
+ 'billingTerms', 'withdrawalInformation', 'withdrawalForm', 'withdrawalFunction',
112
+ ...(purchase.kind === PurchaseKind.Subscription ? ['cancellation' as const] : []),
113
+ ]))
114
+
115
+ return { subject: text(lng, 'email.purchase.subject', { contractRef: purchase.contractRef }), blocks }
116
+ }
117
+
118
+ const renderConsent = (data: ConsumerMailData): Rendered => {
119
+ const lng = data.language
120
+ const consent = data.consent as ConsumerConsentRecord
121
+ const lines = (data.purchases ?? []).map(purchase => text(lng, 'email.consent.purchase', {
122
+ contractRef: purchase.contractRef,
123
+ date: formatDate(new Date(purchase.purchasedAt), lng),
124
+ amount: formatMoney(purchase.amountTotalMinor, purchase.currency, lng),
125
+ deadline: purchase.deadline != null ? formatDeadline(new Date(purchase.deadline), lng) : '—',
126
+ }))
127
+
128
+ return {
129
+ subject: text(lng, 'email.consent.subject'),
130
+ blocks: [
131
+ {
132
+ text: text(lng, 'email.consent.body', {
133
+ date: formatDateTime(new Date(consent.decidedAt), lng), statement: consent.text.checkbox,
134
+ purchases: lines.join('\n'),
135
+ }),
136
+ },
137
+ { text: text(lng, 'email.consent.unused') },
138
+ ...linkBlocks(lng, data.links, ['billingTerms', 'withdrawalInformation', 'withdrawalFunction']),
139
+ ],
140
+ }
141
+ }
142
+
143
+ const renderStart = async (ctx: ApiContext, data: ConsumerMailData): Promise<Rendered> => {
144
+ const lng = data.language
145
+ const consent = data.consent as ConsumerConsentRecord
146
+ const title = consent.planName ?? await planTitleOf(ctx, consent.planSku, lng)
147
+
148
+ return {
149
+ subject: text(lng, 'email.start.subject', { plan: title }),
150
+ blocks: [
151
+ {
152
+ text: text(lng, 'email.start.body', {
153
+ date: formatDateTime(new Date(consent.decidedAt), lng), plan: title, statement: consent.text.checkbox,
154
+ }),
155
+ },
156
+ { text: text(lng, 'email.start.rule') },
157
+ ...linkBlocks(lng, data.links, ['billingTerms', 'withdrawalInformation', 'withdrawalFunction']),
158
+ ],
159
+ }
160
+ }
161
+
162
+ const renderWithdrawal = (data: ConsumerMailData): Rendered => {
163
+ const lng = data.language
164
+ const declaration = data.declaration as ConsumerDeclarationRecord
165
+ const blocks: Block[] = [{
166
+ text: text(lng, 'email.withdrawal.body', {
167
+ date: formatDateTime(new Date(declaration.receivedAt), lng), name: declaration.name,
168
+ contract: declaration.contractRef ?? data.purchase?.contractRef ?? '—', email: declaration.email,
169
+ }),
170
+ }]
171
+ const status = declaration.status
172
+ // `received`: nothing is executed on this declaration (no match, or no statutory right) — the
173
+ // receipt says what happens if it does match, exactly as for an unknown contract.
174
+ if (!declaration.matched || declaration.duplicateOf != null || status === WithdrawalStatus.Received) {
175
+ blocks.push({ text: text(lng, 'email.withdrawal.unmatched') })
176
+ } else if (status === WithdrawalStatus.Expired) {
177
+ const deadline = data.purchase?.deadline
178
+ blocks.push({
179
+ text: text(lng, 'email.withdrawal.expired', {
180
+ deadline: deadline != null ? formatDeadline(new Date(deadline), lng) : '—',
181
+ }),
182
+ })
183
+ } else if (
184
+ (status === WithdrawalStatus.Processing || status === WithdrawalStatus.Refunded)
185
+ && declaration.refundMinor != null && declaration.currency != null
186
+ ) {
187
+ blocks.push({ text: text(lng, 'email.withdrawal.refund', { amount: formatMoney(declaration.refundMinor, declaration.currency, lng) }) })
188
+ } else {
189
+ blocks.push({ text: text(lng, 'email.withdrawal.review') })
190
+ }
191
+ blocks.push(...linkBlocks(lng, data.links, ['billingTerms', 'withdrawalInformation']))
192
+
193
+ return { subject: text(lng, 'email.withdrawal.subject'), blocks }
194
+ }
195
+
196
+ const renderCancellation = (data: ConsumerMailData): Rendered => {
197
+ const lng = data.language
198
+ const declaration = data.declaration as ConsumerDeclarationRecord
199
+ const requested = declaration.effective === 'date' && declaration.requestedDate != null
200
+ ? formatDate(new Date(`${declaration.requestedDate}T00:00:00Z`), lng)
201
+ : text(lng, 'cancellation.effective-earliest')
202
+ const blocks: Block[] = [{
203
+ text: text(lng, 'email.cancellation.body', {
204
+ date: formatDateTime(new Date(declaration.receivedAt), lng), name: declaration.name,
205
+ contract: declaration.contractRef ?? declaration.subscriptionId ?? '—',
206
+ kind: text(lng, `cancellation.kind-${declaration.cancellationKind ?? 'ordinary'}`),
207
+ requested, email: declaration.email,
208
+ }),
209
+ }]
210
+ if (declaration.status === CancellationStatus.Review) {
211
+ blocks.push({ text: text(lng, 'email.cancellation.review') })
212
+ } else if (declaration.matched && declaration.effectiveAt != null) {
213
+ blocks.push({ text: text(lng, 'email.cancellation.effective', { effectiveAt: formatDate(new Date(declaration.effectiveAt), lng) }) })
214
+ } else {
215
+ blocks.push({ text: text(lng, 'email.cancellation.unmatched') })
216
+ }
217
+ blocks.push(...linkBlocks(lng, data.links, ['billingTerms']))
218
+
219
+ return { subject: text(lng, 'email.cancellation.subject'), blocks }
220
+ }
221
+
222
+ /**
223
+ * Everything a mail of `kind` about `recordId` is rendered from, read back from the records — the
224
+ * mail repeats exactly what was recorded, and a retry renders the same message. `null` when there
225
+ * is nobody to write to.
226
+ */
227
+ export const mailDataOf = async (
228
+ ctx: ApiContext, policy: ConsumerRightsPolicy, trader: TraderDef, kind: ConsumerMailKind, recordId: string,
229
+ ): Promise<ConsumerMailData | null> => {
230
+ const base = { kind, recordId, trader }
231
+ switch (kind) {
232
+ case 'purchase': {
233
+ const purchase = await purchases(ctx).byPurchaseId(recordId)
234
+ if (purchase?.email == null || purchase.email === '') return null
235
+ const consent = purchase.startRequestId != null
236
+ ? await consumerConsents(ctx).load(purchase.startRequestId).catch(() => null) : null
237
+
238
+ return compact({
239
+ ...base, entityId: purchase.entityId, language: purchase.language, to: purchase.email,
240
+ name: purchase.name ?? undefined, links: linksOf(policy, purchase.language), purchase,
241
+ consent: consent ?? undefined,
242
+ }) as ConsumerMailData
243
+ }
244
+ case 'consent':
245
+ case 'start': {
246
+ const consent = await consumerConsents(ctx).load(recordId)
247
+ if (consent == null) return null
248
+ const to = consent.email ?? await contactEmailOf(ctx, consent.entityId)
249
+ if (to == null) return null
250
+ const covered = consent.purchaseIds.length > 0
251
+ ? (await purchases(ctx).list({ purchaseId: consent.purchaseIds }, { size: 0 })).items : []
252
+
253
+ return compact({
254
+ ...base, entityId: consent.entityId, language: consent.language, to, name: consent.name ?? undefined,
255
+ links: consent.links, consent, purchases: covered,
256
+ }) as ConsumerMailData
257
+ }
258
+ default: {
259
+ const declaration = await consumerDeclarations(ctx).load(recordId)
260
+ if (declaration == null) return null
261
+ const purchase = declaration.purchaseId != null ? await purchases(ctx).byPurchaseId(declaration.purchaseId) : null
262
+
263
+ return compact({
264
+ ...base, entityId: declaration.entityId ?? undefined, language: declaration.language, to: declaration.email,
265
+ name: declaration.name, links: linksOf(policy, declaration.language), declaration,
266
+ purchase: purchase ?? undefined,
267
+ }) as ConsumerMailData
268
+ }
269
+ }
270
+ }
271
+
272
+ const render = async (
273
+ ctx: ApiContext, data: ConsumerMailData, mail: ConsumerMailPluginConfig | null,
274
+ ): Promise<MailMessage> => {
275
+ const lng = data.language
276
+ const body = data.kind === 'purchase' ? await renderPurchase(ctx, data)
277
+ : data.kind === 'consent' ? renderConsent(data)
278
+ : data.kind === 'start' ? await renderStart(ctx, data)
279
+ : data.kind === 'withdrawal' ? renderWithdrawal(data)
280
+ : renderCancellation(data)
281
+ const blocks: Block[] = [
282
+ { text: data.name != null && data.name !== '' ? text(lng, 'email.common.greeting-named', { name: data.name }) : text(lng, 'email.common.greeting') },
283
+ ...body.blocks,
284
+ { text: text(lng, 'email.common.trader', { trader: traderIdentityOf(data.trader) }) },
285
+ { text: text(lng, 'email.common.footer') },
286
+ ]
287
+
288
+ return compact({
289
+ to: data.to,
290
+ subject: body.subject,
291
+ text: textOf(blocks, lng),
292
+ html: htmlOf(blocks),
293
+ from: mail?.from,
294
+ replyTo: mail?.replyTo,
295
+ }) as MailMessage
296
+ }
297
+
298
+ const mailerOf = (ctx: ApiContext, alias: string): MailerService | null =>
299
+ (ctx as unknown as { hasService?: (alias: string) => boolean }).hasService?.(alias) === true
300
+ ? ctx.service<MailerService>(alias) : null
301
+
302
+ /**
303
+ * Send one consumer-rights mail on a durable medium, in the language the consumer was shown, and
304
+ * record the outcome as a `mail` event (step = the mail kind): sent, skipped (a reserved domain
305
+ * such as `.test` / `.example` / `.invalid` / `.localhost`, or a renderer that suppressed it) or
306
+ * failed (a transport error, no mailer registered — retried by `reconcile`). The application's
307
+ * renderer (`useMailRenderer`) may replace the message. Each archive (`bcc`) address gets its own
308
+ * copy. Never throws.
309
+ *
310
+ * @returns whether the consumer's own copy was handed to the mailer
311
+ */
312
+ export const sendConsumerMail = async (
313
+ ctx: ApiContext, policy: ConsumerRightsPolicy, kind: ConsumerMailKind, recordId: string,
314
+ ): Promise<boolean> => {
315
+ const recordKind = RECORD_KIND[kind]
316
+ let entityId: string | undefined
317
+ try {
318
+ const mail = await consumerMailConfig(ctx)
319
+ const data = await mailDataOf(ctx, policy, traderOf(ctx, mail), kind, recordId)
320
+ entityId = data?.entityId
321
+ if (data == null) {
322
+ await recordEvent(ctx, { recordId, recordKind, action: 'mail', step: kind, ok: true, skipped: true, detail: '{"reason":"no-recipient"}' })
323
+ return false
324
+ }
325
+ if (isReservedAddress(data.to)) {
326
+ await recordEvent(ctx, {
327
+ recordId, recordKind, entityId, action: 'mail', step: kind, ok: true, skipped: true,
328
+ detail: JSON.stringify({ reason: 'reserved-domain', to: data.to }),
329
+ })
330
+ return false
331
+ }
332
+ const rendered = await render(ctx, data, mail)
333
+ const renderer = consumerRightsOf(ctx)?.mailRenderer() ?? null
334
+ const replaced = renderer != null ? await renderer(kind, data, rendered) : undefined
335
+ if (replaced === null) {
336
+ await recordEvent(ctx, {
337
+ recordId, recordKind, entityId, action: 'mail', step: kind, ok: true, skipped: true, detail: '{"reason":"renderer"}',
338
+ })
339
+ return false
340
+ }
341
+ const message = replaced ?? rendered
342
+ const mailer = mailerOf(ctx, mail?.alias ?? MAILER_SERVICE)
343
+ if (mailer == null) {
344
+ await recordEvent(ctx, { recordId, recordKind, entityId, action: 'mail', step: kind, ok: false, error: 'mailer:absent' })
345
+ return false
346
+ }
347
+ await mailer.send(message)
348
+ const archive: string[] = []
349
+ for (const address of mail?.bcc ?? []) {
350
+ try {
351
+ await mailer.send({ ...message, to: address })
352
+ } catch (error) {
353
+ archive.push(`${address}: ${errorText(error)}`)
354
+ }
355
+ }
356
+ await recordEvent(ctx, {
357
+ recordId, recordKind, entityId, action: 'mail', step: kind, ok: true,
358
+ detail: JSON.stringify(compact({ to: message.to, subject: message.subject, archiveFailures: archive.length > 0 ? archive : undefined })),
359
+ })
360
+
361
+ return true
362
+ } catch (error) {
363
+ console.error(`[payment] consumer mail "${kind}" of "${recordId}" failed`, error)
364
+ await recordEvent(ctx, { recordId, recordKind, entityId, action: 'mail', step: kind, ok: false, error: errorText(error) })
365
+
366
+ return false
367
+ }
368
+ }
@@ -0,0 +1,63 @@
1
+ import type { AbstractRequest } from '@owlmeans/entrypoint'
2
+ import type { RequestOrigin } from '../types.js'
3
+
4
+ const USER_AGENT_MAX = 512
5
+ const HEADER_MAX = 1024
6
+
7
+ type Headers = AbstractRequest['headers']
8
+
9
+ /** A header's first value (Fastify lower-cases names; a repeated header arrives as an array). */
10
+ const headerOf = (headers: Headers | undefined, name: string): string | undefined => {
11
+ const raw = headers?.[name]
12
+ const value = Array.isArray(raw) ? raw[0] : raw
13
+ if (typeof value !== 'string') {
14
+ return undefined
15
+ }
16
+ const trimmed = value.trim()
17
+
18
+ return trimmed === '' ? undefined : trimmed
19
+ }
20
+
21
+ /** The socket peer of the original server request, when the transport exposes one. */
22
+ const socketOf = (req: AbstractRequest): string | undefined => {
23
+ const original = req.original as {
24
+ socket?: { remoteAddress?: string }, raw?: { socket?: { remoteAddress?: string } }, ip?: string,
25
+ } | undefined
26
+ const address = original?.socket?.remoteAddress ?? original?.raw?.socket?.remoteAddress ?? original?.ip
27
+
28
+ return typeof address === 'string' && address !== '' ? address : undefined
29
+ }
30
+
31
+ /**
32
+ * The evidence a consumer act is recorded with. `ip` is `cf-connecting-ip` (a Cloudflare edge
33
+ * sets it and strips a client's own), else the LAST `x-forwarded-for` entry (the one the nearest
34
+ * proxy appended — the first is whatever the client claimed), else `x-real-ip`, else the socket
35
+ * peer. The raw `x-forwarded-for`, the `user-agent` (at most 512 characters), `cf-ipcountry`
36
+ * (the geolocated country — second location evidence for VAT) and `accept-language` are kept as
37
+ * they arrived. The default `metaOf` of the consumer-rights handlers.
38
+ */
39
+ export const requestOriginOf = (req: AbstractRequest): RequestOrigin => {
40
+ const headers = req.headers
41
+ const forwardedFor = headerOf(headers, 'x-forwarded-for')
42
+ const forwarded = forwardedFor?.split(',').map(entry => entry.trim()).filter(entry => entry !== '')
43
+ const ip = headerOf(headers, 'cf-connecting-ip') ?? forwarded?.[forwarded.length - 1]
44
+ ?? headerOf(headers, 'x-real-ip') ?? socketOf(req)
45
+ const userAgent = headerOf(headers, 'user-agent')
46
+ const ipCountry = headerOf(headers, 'cf-ipcountry')
47
+ const acceptLanguage = headerOf(headers, 'accept-language')
48
+
49
+ return {
50
+ ...(ip != null ? { ip: ip.slice(0, HEADER_MAX) } : {}),
51
+ ...(forwardedFor != null ? { forwardedFor: forwardedFor.slice(0, HEADER_MAX) } : {}),
52
+ ...(userAgent != null ? { userAgent: userAgent.slice(0, USER_AGENT_MAX) } : {}),
53
+ ...(ipCountry != null && /^[A-Za-z]{2}$/.test(ipCountry) ? { ipCountry: ipCountry.toUpperCase() } : {}),
54
+ ...(acceptLanguage != null ? { acceptLanguage: acceptLanguage.slice(0, 256) } : {}),
55
+ }
56
+ }
57
+
58
+ /** The origin fields of a record: only those with a value. */
59
+ export const originFields = (origin: RequestOrigin | undefined): RequestOrigin => Object.fromEntries(
60
+ Object.entries(origin ?? {}).filter(([key, value]) =>
61
+ ['ip', 'forwardedFor', 'userAgent', 'ipCountry', 'acceptLanguage', 'via'].includes(key)
62
+ && typeof value === 'string' && value !== ''),
63
+ ) as RequestOrigin