@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.
Files changed (222) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +475 -52
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. 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