@owlmeans/server-payment 0.1.18-rc.2 → 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.
- package/README.md +109 -25
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +475 -52
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +60 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +237 -5
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +75 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +94 -5
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +18 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +10 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +215 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +9 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +38 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +268 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +45 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +605 -136
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +31 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +81 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +265 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +36 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +492 -97
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +12 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +97 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +28 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +146 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +48 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +176 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +28 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +208 -31
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +1115 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +52 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +69 -7
- package/build/utils.js.map +1 -1
- package/package.json +17 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +264 -8
- package/src/consts.ts +114 -6
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +24 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +237 -18
- package/src/observer.ts +44 -11
- package/src/plan.ts +89 -0
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +339 -0
- package/src/plugins/events.ts +677 -121
- package/src/plugins/fx.ts +122 -0
- package/src/plugins/portal.ts +306 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +581 -96
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +152 -7
- package/src/service.ts +174 -18
- package/src/subscription.ts +231 -0
- package/src/sync.ts +249 -29
- package/src/types.ts +1227 -32
- package/src/usage.ts +453 -0
- package/src/utils.ts +127 -10
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/checkout.spec.ts +184 -83
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +109 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +972 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +23 -6
- package/tests/sync.spec.ts +114 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- package/tests/webhook-manager.spec.ts +152 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { AuthForbidden } from '@owlmeans/auth'
|
|
3
|
+
import type { AbstractRequest, EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
|
|
4
|
+
import { ConsumerRightsError, DeclarationChannel } from '@owlmeans/payment'
|
|
5
|
+
import type {
|
|
6
|
+
CheckoutReadProtocols, ConsumerRightsAccountProtocols, ConsumerRightsPublicProtocols, ConsumerRightsPublicView,
|
|
7
|
+
DeclarationReceipt,
|
|
8
|
+
} from '@owlmeans/payment'
|
|
9
|
+
import { handlers } from '@owlmeans/server-api'
|
|
10
|
+
import type { Context as ApiContext } from '@owlmeans/server-api'
|
|
11
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
12
|
+
import type { ServerProtocolEntrypoint } from '@owlmeans/server-entrypoint'
|
|
13
|
+
import { CONSUMER_RIGHTS_SERVICE, GATEWAY_SERVICE } from '../consts.js'
|
|
14
|
+
import { payment } from '../utils.js'
|
|
15
|
+
import { requestOriginOf } from './origin.js'
|
|
16
|
+
import { unlockedProfileView } from './records.js'
|
|
17
|
+
import type {
|
|
18
|
+
CheckoutReadHandlerOptions, ConsumerRightsHandlerOptions, ConsumerRightsService, ConsumerSubject, Context,
|
|
19
|
+
GatewayService, RequestOrigin,
|
|
20
|
+
} from '../types.js'
|
|
21
|
+
|
|
22
|
+
type Bound = ServerProtocolEntrypoint<EntrypointProtocolDeclaration>
|
|
23
|
+
|
|
24
|
+
/** The protocol tree `makeConsumerRightsProtocols` builds, with or without its public subtree. */
|
|
25
|
+
export type ConsumerRightsTree = ConsumerRightsAccountProtocols & { public?: ConsumerRightsPublicProtocols }
|
|
26
|
+
|
|
27
|
+
const defaultEntity = (req: AbstractRequest, _ctx?: ApiContext): string | null => req.entity?.id ?? null
|
|
28
|
+
|
|
29
|
+
const DEFAULT_PUBLIC_MIN_MS = 1000
|
|
30
|
+
|
|
31
|
+
const sleep = async (ms: number): Promise<void> => await new Promise(resolve => setTimeout(resolve, ms))
|
|
32
|
+
|
|
33
|
+
/** Answer no sooner than `minMs` after the start — a matched declaration takes as long as an unmatched one. */
|
|
34
|
+
const padded = async <T>(minMs: number, run: () => Promise<T>): Promise<T> => {
|
|
35
|
+
const started = Date.now()
|
|
36
|
+
try {
|
|
37
|
+
return await run()
|
|
38
|
+
} finally {
|
|
39
|
+
const left = minMs - (Date.now() - started)
|
|
40
|
+
if (left > 0) await sleep(left)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The public answer: what was declared and when — never a status, an amount or a match. */
|
|
45
|
+
const publicPart = (receipt: DeclarationReceipt): DeclarationReceipt => ({
|
|
46
|
+
declarationId: receipt.declarationId, receivedAt: receipt.receivedAt, content: receipt.content, mailed: receipt.mailed,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
/** A form a bot filled (the honeypot) gets the same shape of answer, and nothing is recorded or sent. */
|
|
50
|
+
const decoyReceipt = (content: Record<string, string>): DeclarationReceipt => ({
|
|
51
|
+
declarationId: randomBytes(12).toString('hex'), receivedAt: new Date(), content, mailed: false,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const typed = (fields: Record<string, string | undefined>): Record<string, string> =>
|
|
55
|
+
Object.fromEntries(Object.entries(fields).filter((entry): entry is [string, string] =>
|
|
56
|
+
typeof entry[1] === 'string' && entry[1].trim() !== ''))
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Server bindings of `makeConsumerRightsProtocols`' tree over the consumer-rights service. The
|
|
60
|
+
* account routes act for the request's organization (`resolveEntity`, default `req.entity.id`);
|
|
61
|
+
* the money-moving acts (consent, start request, withdrawal, cancellation) pass `guardMoney` first
|
|
62
|
+
* (refuse an API key there). The public routes — the statutory functions without a login — need
|
|
63
|
+
* `throttle` (a wiring error otherwise), drop a filled honeypot silently, and answer every
|
|
64
|
+
* declaration with the same receipt shape after at least `publicMinMs`, matched or not. Every hook
|
|
65
|
+
* gets the request's context as its last argument.
|
|
66
|
+
*
|
|
67
|
+
* @throws SyntaxError when the tree has a public subtree and no `throttle` is given
|
|
68
|
+
*/
|
|
69
|
+
export const consumerRightsEntrypoints = (
|
|
70
|
+
protocols: ConsumerRightsTree, opts: ConsumerRightsHandlerOptions = {},
|
|
71
|
+
): Bound[] => {
|
|
72
|
+
if (protocols.public != null && opts.throttle == null) {
|
|
73
|
+
throw new SyntaxError('consumer-rights: the public routes need a throttle')
|
|
74
|
+
}
|
|
75
|
+
const api = handlers<Context>()
|
|
76
|
+
const serviceOf = (ctx: Context): ConsumerRightsService =>
|
|
77
|
+
ctx.service<ConsumerRightsService>(opts.serviceAlias ?? CONSUMER_RIGHTS_SERVICE)
|
|
78
|
+
const apiCtx = (ctx: Context): ApiContext => ctx as unknown as ApiContext
|
|
79
|
+
const entityOf = (req: AbstractRequest, ctx: Context): string => {
|
|
80
|
+
const entityId = (opts.resolveEntity ?? defaultEntity)(req, apiCtx(ctx))
|
|
81
|
+
if (entityId == null || entityId === '') {
|
|
82
|
+
throw new AuthForbidden('entity')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return entityId
|
|
86
|
+
}
|
|
87
|
+
const subjectFor = async (req: AbstractRequest, ctx: Context): Promise<ConsumerSubject> => ({
|
|
88
|
+
...(await opts.subjectOf?.(req, apiCtx(ctx)) ?? {}),
|
|
89
|
+
entityId: entityOf(req, ctx),
|
|
90
|
+
channel: DeclarationChannel.InApp,
|
|
91
|
+
})
|
|
92
|
+
const metaOf = (req: AbstractRequest, ctx: Context): RequestOrigin =>
|
|
93
|
+
opts.metaOf != null ? opts.metaOf(req, apiCtx(ctx)) : requestOriginOf(req)
|
|
94
|
+
const guard = async (req: AbstractRequest, action: string, ctx: Context): Promise<void> => {
|
|
95
|
+
await opts.guardMoney?.(req, action, apiCtx(ctx))
|
|
96
|
+
}
|
|
97
|
+
const minMs = opts.publicMinMs ?? DEFAULT_PUBLIC_MIN_MS
|
|
98
|
+
|
|
99
|
+
const bound: Bound[] = [
|
|
100
|
+
bind(protocols.base),
|
|
101
|
+
bind(protocols.profile, api.request(protocols.profile, async (req, ctx) =>
|
|
102
|
+
await serviceOf(ctx).profile(entityOf(req, ctx))
|
|
103
|
+
?? unlockedProfileView(await payment(ctx as unknown as ApiContext).consumerRightsPolicy()))),
|
|
104
|
+
bind(protocols.purchases, api.request(protocols.purchases, async (req, ctx) =>
|
|
105
|
+
({ purchases: await serviceOf(ctx).purchases(entityOf(req, ctx)) }))),
|
|
106
|
+
bind(protocols.consent, api.request(protocols.consent, async (req, ctx) =>
|
|
107
|
+
await serviceOf(ctx).consentView(entityOf(req, ctx)))),
|
|
108
|
+
bind(protocols.giveConsent, api.body(protocols.giveConsent, async (body, ctx, req) => {
|
|
109
|
+
await guard(req, 'consent', ctx)
|
|
110
|
+
return await serviceOf(ctx).recordConsent(await subjectFor(req, ctx), body, metaOf(req, ctx))
|
|
111
|
+
})),
|
|
112
|
+
bind(protocols.start, api.request(protocols.start, async (req, ctx) =>
|
|
113
|
+
await serviceOf(ctx).startView(entityOf(req, ctx), String(req.query.planSku)))),
|
|
114
|
+
bind(protocols.requestStart, api.body(protocols.requestStart, async (body, ctx, req) => {
|
|
115
|
+
await guard(req, 'start', ctx)
|
|
116
|
+
const plan = await opts.planNameOf?.(body.planSku, body.language, req, apiCtx(ctx))
|
|
117
|
+
return await serviceOf(ctx).recordStartRequest(await subjectFor(req, ctx), body, metaOf(req, ctx), plan != null ? { plan } : {})
|
|
118
|
+
})),
|
|
119
|
+
bind(protocols.withdrawals, api.request(protocols.withdrawals, async (req, ctx) => {
|
|
120
|
+
const subject = await subjectFor(req, ctx)
|
|
121
|
+
return await serviceOf(ctx).withdrawalCandidates(subject.entityId, subject)
|
|
122
|
+
})),
|
|
123
|
+
bind(protocols.withdraw, api.body(protocols.withdraw, async (body, ctx, req) => {
|
|
124
|
+
await guard(req, 'withdraw', ctx)
|
|
125
|
+
return await serviceOf(ctx).withdraw(await subjectFor(req, ctx), body, metaOf(req, ctx))
|
|
126
|
+
})),
|
|
127
|
+
bind(protocols.cancel, api.body(protocols.cancel, async (body, ctx, req) => {
|
|
128
|
+
await guard(req, 'cancel', ctx)
|
|
129
|
+
return await serviceOf(ctx).cancel(await subjectFor(req, ctx), body, metaOf(req, ctx))
|
|
130
|
+
})),
|
|
131
|
+
] as Bound[]
|
|
132
|
+
|
|
133
|
+
const pub = protocols.public
|
|
134
|
+
if (pub == null) {
|
|
135
|
+
return bound
|
|
136
|
+
}
|
|
137
|
+
const throttle = opts.throttle as NonNullable<ConsumerRightsHandlerOptions['throttle']>
|
|
138
|
+
|
|
139
|
+
return [
|
|
140
|
+
...bound,
|
|
141
|
+
bind(pub.base),
|
|
142
|
+
bind(pub.policy, api.request(pub.policy, async (_req, ctx) => {
|
|
143
|
+
const policy = await payment(ctx as unknown as ApiContext).consumerRightsPolicy()
|
|
144
|
+
if (policy == null) {
|
|
145
|
+
throw new ConsumerRightsError('policy:none')
|
|
146
|
+
}
|
|
147
|
+
const view: ConsumerRightsPublicView = {
|
|
148
|
+
mechanisms: { withdrawal: policy.mechanisms.withdrawal, cancellation: policy.mechanisms.cancellation },
|
|
149
|
+
languages: Object.keys(policy.links),
|
|
150
|
+
links: policy.links,
|
|
151
|
+
textVersion: policy.textVersion,
|
|
152
|
+
}
|
|
153
|
+
return view
|
|
154
|
+
})),
|
|
155
|
+
bind(pub.withdraw, api.body(pub.withdraw, async (body, ctx, req) => {
|
|
156
|
+
const origin = metaOf(req, ctx)
|
|
157
|
+
await throttle(req, { action: 'withdrawal', email: body.email, ...(origin.ip != null ? { ip: origin.ip } : {}) }, apiCtx(ctx))
|
|
158
|
+
return await padded(minMs, async () => {
|
|
159
|
+
const content = typed({ name: body.name, contract: body.contractRef, email: body.email })
|
|
160
|
+
if (body.honeypot != null && body.honeypot !== '') {
|
|
161
|
+
return decoyReceipt(content)
|
|
162
|
+
}
|
|
163
|
+
return publicPart(await serviceOf(ctx).withdraw(null, body, origin))
|
|
164
|
+
})
|
|
165
|
+
})),
|
|
166
|
+
bind(pub.cancel, api.body(pub.cancel, async (body, ctx, req) => {
|
|
167
|
+
const origin = metaOf(req, ctx)
|
|
168
|
+
await throttle(req, { action: 'cancellation', email: body.email, ...(origin.ip != null ? { ip: origin.ip } : {}) }, apiCtx(ctx))
|
|
169
|
+
return await padded(minMs, async () => {
|
|
170
|
+
const content = typed({
|
|
171
|
+
name: body.name, contract: body.contractRef, email: body.email, kind: body.kind, reason: body.reason,
|
|
172
|
+
date: body.effective === 'date' ? body.date : undefined,
|
|
173
|
+
})
|
|
174
|
+
if (body.honeypot != null && body.honeypot !== '') {
|
|
175
|
+
return decoyReceipt(content)
|
|
176
|
+
}
|
|
177
|
+
return publicPart(await serviceOf(ctx).cancel(null, body, origin))
|
|
178
|
+
})
|
|
179
|
+
})),
|
|
180
|
+
] as Bound[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Server bindings of `makeCheckoutReadProtocols`' tree: the entity's amount policy as the checkout
|
|
185
|
+
* plugins narrow it now (the same computation the checkout enforces) and the synced plan prices.
|
|
186
|
+
*/
|
|
187
|
+
export const checkoutReadEntrypoints = (protocols: CheckoutReadProtocols, opts: CheckoutReadHandlerOptions = {}): Bound[] => {
|
|
188
|
+
const api = handlers<Context>()
|
|
189
|
+
const gatewayOf = (ctx: Context): GatewayService => ctx.service<GatewayService>(opts.gatewayAlias ?? GATEWAY_SERVICE)
|
|
190
|
+
const entityOf = (req: AbstractRequest, ctx: Context): string => {
|
|
191
|
+
const entityId = (opts.resolveEntity ?? defaultEntity)(req, ctx as unknown as ApiContext)
|
|
192
|
+
if (entityId == null || entityId === '') {
|
|
193
|
+
throw new AuthForbidden('entity')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return entityId
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return [
|
|
200
|
+
bind(protocols.base),
|
|
201
|
+
bind(protocols.amountPolicy, api.request(protocols.amountPolicy, async (req, ctx) => {
|
|
202
|
+
const { productSku, planSku } = req.query as { productSku: string, planSku?: string }
|
|
203
|
+
return await gatewayOf(ctx).amountPolicy(ctx as unknown as ApiContext, entityOf(req, ctx), productSku, planSku ?? undefined)
|
|
204
|
+
})),
|
|
205
|
+
bind(protocols.planPrices, api.request(protocols.planPrices, async (req, ctx) => {
|
|
206
|
+
entityOf(req, ctx)
|
|
207
|
+
const { productSku } = req.query as { productSku: string }
|
|
208
|
+
return { prices: await gatewayOf(ctx).planPrices(ctx as unknown as ApiContext, productSku) }
|
|
209
|
+
})),
|
|
210
|
+
] as Bound[]
|
|
211
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { requestOriginOf } from './origin.js'
|
|
2
|
+
export { appendConsumerRights, makeConsumerRightsService } from './service.js'
|
|
3
|
+
export { consumerRightsEntrypoints, checkoutReadEntrypoints } from './handlers.js'
|
|
4
|
+
export type { ConsumerRightsTree } from './handlers.js'
|
|
5
|
+
export { traderIdentityOf } from './mail.js'
|
|
6
|
+
export { makeContractRef, isReservedAddress } from './format.js'
|
|
@@ -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
|