@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,270 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type Stripe from 'stripe'
3
+ import { makeSecurityHelper } from '@owlmeans/config'
4
+ import type { CommonServiceRoute } from '@owlmeans/route'
5
+ import { normalizePath } from '@owlmeans/route'
6
+ import { WebhookSetupError } from '@owlmeans/payment'
7
+ import type { Context as ApiContext } from '@owlmeans/server-api'
8
+ import {
9
+ paymentGate, STRIPE_OWNER_KEY, STRIPE_OWNER_VALUE, STRIPE_PAYGATE_ALIAS, WEBHOOK_EVENTS,
10
+ } from '../consts.js'
11
+ import { apiVersionOf, isDuplicateKey, isMissingObject, paymentWebhooks, stripeConfig } from '../utils.js'
12
+ import type { PaymentWebhookRecord } from '../types.js'
13
+
14
+ /** The path the webhook route answers for one paygate: `/<base>/webhook/<paygate>`. */
15
+ export const webhookPathOf = (paygate: string): string =>
16
+ '/' + normalizePath(normalizePath(paymentGate.base.route.route.path) + '/'
17
+ + normalizePath(paymentGate.webhook.route.route.path).replace(':paygate', paygate))
18
+
19
+ /** This deployment's public webhook URL for a paygate. */
20
+ export const webhookUrlOf = (ctx: ApiContext, paygate: string = STRIPE_PAYGATE_ALIAS): string => {
21
+ const route = ctx.cfg.services?.[ctx.cfg.service] as CommonServiceRoute | undefined
22
+ if (route == null) {
23
+ throw new WebhookSetupError(`service:${ctx.cfg.service}`)
24
+ }
25
+
26
+ return makeSecurityHelper(ctx as never).makeUrl(route, webhookPathOf(paygate))
27
+ }
28
+
29
+ /** A URL a paygate can deliver to: https on a public, dotted host name. */
30
+ export const isDeliverableUrl = (url: string): boolean => {
31
+ let parsed: URL
32
+ try {
33
+ parsed = new URL(url)
34
+ } catch {
35
+ return false
36
+ }
37
+ const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase()
38
+
39
+ return parsed.protocol === 'https:' && host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
40
+ && !host.endsWith('.local') && host.includes('.')
41
+ }
42
+
43
+ /** An operator's label on an endpoint this package created — never evidence of which deployment owns it. */
44
+ const ownerMetadata = (service: string): Record<string, string> =>
45
+ ({ [STRIPE_OWNER_KEY]: STRIPE_OWNER_VALUE, service })
46
+
47
+ const hashOf = (url: string, apiVersion: string, events: string[]): string =>
48
+ createHash('sha256').update(JSON.stringify({ url, apiVersion, events })).digest('hex')
49
+
50
+ const listEndpoints = async (stripe: Stripe): Promise<Stripe.WebhookEndpoint[]> => {
51
+ const endpoints: Stripe.WebhookEndpoint[] = []
52
+ let startingAfter: string | undefined
53
+ for (;;) {
54
+ const page = await stripe.webhookEndpoints.list({
55
+ limit: 100, ...(startingAfter != null ? { starting_after: startingAfter } : {}),
56
+ })
57
+ endpoints.push(...page.data)
58
+ if (!page.has_more || page.data.length === 0) {
59
+ return endpoints
60
+ }
61
+ startingAfter = page.data[page.data.length - 1].id
62
+ }
63
+ }
64
+
65
+ const dropEndpoint = async (stripe: Stripe, id: string): Promise<void> => {
66
+ try {
67
+ await stripe.webhookEndpoints.del(id)
68
+ } catch (error) {
69
+ if (!isMissingObject(error)) {
70
+ throw error
71
+ }
72
+ }
73
+ }
74
+
75
+ /** Field-encrypt the secret where the database is configured for it; store it plain otherwise. */
76
+ const sealed = async (row: Partial<PaymentWebhookRecord>, ctx: ApiContext): Promise<Partial<PaymentWebhookRecord>> => {
77
+ const resource = paymentWebhooks(ctx)
78
+ if (typeof resource.lock !== 'function') {
79
+ return row
80
+ }
81
+ try {
82
+ return await resource.lock(row, ['secret']) as Partial<PaymentWebhookRecord>
83
+ } catch {
84
+ return row
85
+ }
86
+ }
87
+
88
+ const opened = async (row: PaymentWebhookRecord, ctx: ApiContext): Promise<string> => {
89
+ const resource = paymentWebhooks(ctx)
90
+ if (typeof resource.unlock !== 'function') {
91
+ return row.secret
92
+ }
93
+ try {
94
+ return (await resource.unlock(row, ['secret']) as PaymentWebhookRecord).secret
95
+ } catch {
96
+ return row.secret
97
+ }
98
+ }
99
+
100
+ export interface EnsureWebhookOptions {
101
+ /** Verify the stored endpoint still exists even when nothing changed locally. */
102
+ force?: boolean
103
+ }
104
+
105
+ /**
106
+ * Delete the endpoints this deployment created at URLs it has left: each `payment-webhook` row of
107
+ * this paygate and service at another URL drops the endpoint it names (one already gone is fine),
108
+ * then the row itself. `live` — the endpoint just put in place — is never dropped.
109
+ */
110
+ const pruneFormerUrls = async (
111
+ ctx: ApiContext, stripe: Stripe, service: string, url: string, live: string,
112
+ ): Promise<void> => {
113
+ const resource = paymentWebhooks(ctx)
114
+ const { items } = await resource.list(
115
+ { paygate: STRIPE_PAYGATE_ALIAS, service, url: { $ne: url } }, { size: 0 },
116
+ )
117
+ for (const row of items) {
118
+ if (row.externalId !== live) {
119
+ await dropEndpoint(stripe, row.externalId)
120
+ }
121
+ await resource.delete(row.id as string)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Keep exactly one Stripe webhook endpoint for this deployment at its own URL, subscribed to
127
+ * `WEBHOOK_EVENTS` on the client's API version, and persist its signing secret — which Stripe
128
+ * returns only when an endpoint is created.
129
+ *
130
+ * A deployment's identity is its webhook URL. Several deployments of one service may share a Stripe
131
+ * account — a test-mode account commonly serves every non-production deployment — each with its own
132
+ * database and URL, so a deployment deletes only the endpoints its own `payment-webhook` rows name.
133
+ *
134
+ * - A URL Stripe cannot deliver to (not https, a loopback or dotless host) is skipped.
135
+ * - An unchanged `{ url, apiVersion, events }` fingerprint makes no paygate call at all.
136
+ * - `force` first verifies the stored endpoint still exists; one deleted from outside loses its row
137
+ * and is created again.
138
+ * - Changed events on the same API version update the endpoint in place.
139
+ * - A changed API version (create-only on an endpoint) deletes and recreates it.
140
+ * - An endpoint already at this exact URL that no row names is replaced: its secret is unknown here.
141
+ * - A row of this paygate and service at another URL is a URL this deployment has left: once the
142
+ * endpoint at the current URL is in place, the endpoint that row names is deleted (already gone is
143
+ * fine), then the row.
144
+ * - An endpoint at another URL that no row of this deployment names belongs to another deployment
145
+ * and is never deleted — whatever its metadata says.
146
+ * - Replicas racing to persist one row: the endpoint created last is the live one.
147
+ * - A created endpoint carries `{ owlmeans: 'payment', service }` metadata for operators.
148
+ */
149
+ export const ensureWebhookEndpoint = async (
150
+ ctx: ApiContext, stripe: Stripe, opts: EnsureWebhookOptions = {},
151
+ ): Promise<PaymentWebhookRecord | null> => {
152
+ const url = webhookUrlOf(ctx)
153
+ if (!isDeliverableUrl(url)) {
154
+ console.info(`[payment] webhook endpoint not managed: "${url}" is not a public https URL`)
155
+ return null
156
+ }
157
+ const service = ctx.cfg.service
158
+ const apiVersion = apiVersionOf(stripe)
159
+ const events = [...WEBHOOK_EVENTS].sort()
160
+ const hash = hashOf(url, apiVersion, events)
161
+ const resource = paymentWebhooks(ctx)
162
+ const description = `owlmeans:${service}`
163
+ const metadata = ownerMetadata(service)
164
+ const now = new Date()
165
+
166
+ let stored = await resource.load({ paygate: STRIPE_PAYGATE_ALIAS, service, url })
167
+ if (stored != null && opts.force === true) {
168
+ try {
169
+ await stripe.webhookEndpoints.retrieve(stored.externalId)
170
+ } catch (error) {
171
+ if (!isMissingObject(error)) {
172
+ throw error
173
+ }
174
+ await resource.delete(stored.id as string)
175
+ stored = null
176
+ }
177
+ }
178
+ if (stored != null && stored.hash === hash && opts.force !== true) {
179
+ return stored
180
+ }
181
+
182
+ let live: PaymentWebhookRecord | null = null
183
+ if (stored != null && stored.apiVersion === apiVersion) {
184
+ try {
185
+ await stripe.webhookEndpoints.update(stored.externalId, {
186
+ url, enabled_events: events as Stripe.WebhookEndpointUpdateParams.EnabledEvent[],
187
+ description, metadata, disabled: false,
188
+ })
189
+ live = await resource.update({ ...stored, events, hash, updatedAt: now })
190
+ } catch (error) {
191
+ if (!isMissingObject(error)) {
192
+ throw error
193
+ }
194
+ }
195
+ }
196
+
197
+ if (live == null) {
198
+ if (stored != null) {
199
+ await dropEndpoint(stripe, stored.externalId)
200
+ }
201
+ for (const endpoint of await listEndpoints(stripe)) {
202
+ if (endpoint.url === url) {
203
+ await dropEndpoint(stripe, endpoint.id)
204
+ }
205
+ }
206
+
207
+ const created = await stripe.webhookEndpoints.create({
208
+ url, enabled_events: events as Stripe.WebhookEndpointCreateParams.EnabledEvent[],
209
+ api_version: apiVersion as Stripe.WebhookEndpointCreateParams.ApiVersion, description, metadata,
210
+ })
211
+ if (created.secret == null || created.secret === '') {
212
+ throw new WebhookSetupError('secret-missing')
213
+ }
214
+
215
+ const row = await sealed({
216
+ paygate: STRIPE_PAYGATE_ALIAS, service, url, externalId: created.id, secret: created.secret,
217
+ apiVersion, events, hash, createdAt: stored?.createdAt ?? now, updatedAt: now,
218
+ }, ctx)
219
+ try {
220
+ live = stored != null
221
+ ? await resource.update({ ...row, id: stored.id } as PaymentWebhookRecord)
222
+ : await resource.create(row)
223
+ } catch (error) {
224
+ if (!isDuplicateKey(error)) {
225
+ throw error
226
+ }
227
+ // Another replica persisted its endpoint meanwhile; the one created last is the live one.
228
+ const winner = await resource.load({ paygate: STRIPE_PAYGATE_ALIAS, service, url })
229
+ live = await resource.update({ ...row, id: winner?.id } as PaymentWebhookRecord)
230
+ }
231
+ }
232
+
233
+ await pruneFormerUrls(ctx, stripe, service, url, live.externalId)
234
+
235
+ return live
236
+ }
237
+
238
+ /**
239
+ * The secrets a Stripe signature may be verified with, in order: the configured file secret (an
240
+ * override), then the one stored for the managed endpoint.
241
+ */
242
+ export const stripeWebhookSecrets = async (ctx: ApiContext): Promise<string[]> => {
243
+ const secrets: string[] = []
244
+ const configured = await stripeConfig(ctx).then(config => config.webhook, () => undefined)
245
+ if (typeof configured === 'string' && configured.trim() !== '') {
246
+ secrets.push(configured.trim())
247
+ }
248
+ const stored = await paymentWebhooks(ctx).load(
249
+ { paygate: STRIPE_PAYGATE_ALIAS, service: ctx.cfg.service },
250
+ { sort: [{ field: 'updatedAt', order: 'desc' }] },
251
+ )
252
+ if (stored != null) {
253
+ const secret = await opened(stored, ctx)
254
+ if (secret !== '' && !secrets.includes(secret)) {
255
+ secrets.push(secret)
256
+ }
257
+ }
258
+
259
+ return secrets
260
+ }
261
+
262
+ /** The secret a Stripe signature is verified with. @throws WebhookSetupError('secret') */
263
+ export const stripeWebhookSecret = async (ctx: ApiContext): Promise<string> => {
264
+ const [secret] = await stripeWebhookSecrets(ctx)
265
+ if (secret == null) {
266
+ throw new WebhookSetupError('secret')
267
+ }
268
+
269
+ return secret
270
+ }
@@ -0,0 +1,103 @@
1
+ import { PlanRequired } from '@owlmeans/payment'
2
+ import type { EntitlementView } from '@owlmeans/payment'
3
+ import type { Context as ApiContext } from '@owlmeans/server-api'
4
+ import { entitlements, gateway, subscriptions, usageEvents } from './utils.js'
5
+ import type { ReconcileAllOptions, ReconcileAllResult, ReconcileEntityOptions } from './types.js'
6
+
7
+ /** Grant the free plan when the entity holds no entitling subscription. @returns whether it granted */
8
+ const backfillFreePlan = async (ctx: ApiContext, entityId: string, freePlanSku: string): Promise<boolean> => {
9
+ try {
10
+ const { subscription } = await entitlements(ctx).effectivePlan(entityId)
11
+ if (subscription != null) {
12
+ return false
13
+ }
14
+ } catch (error) {
15
+ if (!(error instanceof PlanRequired)) {
16
+ throw error
17
+ }
18
+ }
19
+ await gateway(ctx).grantInternalPlan(ctx, entityId, freePlanSku)
20
+
21
+ return true
22
+ }
23
+
24
+ const resyncEntity = async (ctx: ApiContext, entityId: string): Promise<void> => {
25
+ const service = gateway(ctx)
26
+ if (!service.managed) {
27
+ return
28
+ }
29
+ try {
30
+ await service.resyncSubscription(ctx, { entityId })
31
+ } catch (error) {
32
+ console.error(`[payment] reconcile: resync of "${entityId}" failed`, error)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Repair one entity: optionally re-read its paygate subscriptions, grant the free plan when it holds
38
+ * nothing entitling, recompute its limit counters from the ledger, and return its view.
39
+ */
40
+ export const reconcileEntity = async (
41
+ ctx: ApiContext, entityId: string, opts: ReconcileEntityOptions = {},
42
+ ): Promise<EntitlementView> => {
43
+ if (opts.resync === true) {
44
+ await resyncEntity(ctx, entityId)
45
+ }
46
+ if (opts.freePlanSku != null) {
47
+ await backfillFreePlan(ctx, entityId, opts.freePlanSku)
48
+ }
49
+ await entitlements(ctx).reconcileCounters(entityId)
50
+
51
+ return await entitlements(ctx).entitlements(entityId)
52
+ }
53
+
54
+ type Distinct = { collection: { distinct: (field: string, filter: object) => Promise<unknown[]> } }
55
+
56
+ const knownEntities = async (ctx: ApiContext): Promise<Set<string>> => {
57
+ const ids = new Set<string>()
58
+ for (const resource of [subscriptions(ctx), usageEvents(ctx)] as unknown as Distinct[]) {
59
+ for (const id of await resource.collection.distinct('entityId', {})) {
60
+ if (typeof id === 'string' && id !== '') {
61
+ ids.add(id)
62
+ }
63
+ }
64
+ }
65
+
66
+ return ids
67
+ }
68
+
69
+ /**
70
+ * Repair every entity the payment store knows, plus `entities`: counters from the ledger in one
71
+ * pass, then per entity the optional resync and the free-plan backfill. A failing entity is counted
72
+ * and logged, never fatal.
73
+ */
74
+ export const reconcileAll = async (
75
+ ctx: ApiContext, opts: ReconcileAllOptions = {},
76
+ ): Promise<ReconcileAllResult> => {
77
+ const ids = await knownEntities(ctx)
78
+ if (opts.entities != null) {
79
+ for await (const id of opts.entities) {
80
+ ids.add(id)
81
+ }
82
+ }
83
+
84
+ const result: ReconcileAllResult = { entities: ids.size, granted: 0, counters: 0, repaired: 0, failed: 0 }
85
+ for (const entityId of ids) {
86
+ try {
87
+ if (opts.resync === true) {
88
+ await resyncEntity(ctx, entityId)
89
+ }
90
+ if (opts.freePlanSku != null && await backfillFreePlan(ctx, entityId, opts.freePlanSku)) {
91
+ result.granted++
92
+ }
93
+ const counters = await entitlements(ctx).reconcileCounters(entityId)
94
+ result.counters += counters.counters
95
+ result.repaired += counters.repaired
96
+ } catch (error) {
97
+ result.failed++
98
+ console.error(`[payment] reconcile of "${entityId}" failed`, error)
99
+ }
100
+ }
101
+
102
+ return result
103
+ }
package/src/resource.ts CHANGED
@@ -1,10 +1,22 @@
1
1
  import type { ResourceMaker } from '@owlmeans/resource'
2
2
  import { makeMongoResource } from '@owlmeans/mongo-resource'
3
- import { RES_PAYGATE_CUSTOMER, RES_PAYMENT_FINGERPRINT, RES_PAYMENT_SUBSCRIPTION } from './consts.js'
4
- import { FingerprintSchema, PaygateCustomerSchema, PaymentSubscriptionSchema } from './model.js'
3
+ import {
4
+ RES_BILLING_PROFILE, RES_CONSUMER_CONSENT, RES_CONSUMER_DECLARATION, RES_CONSUMER_EVENT, RES_PAYGATE_CUSTOMER,
5
+ RES_PAYMENT_FINGERPRINT, RES_PAYMENT_FULFILLMENT, RES_PAYMENT_PURCHASE, RES_PAYMENT_SUBSCRIPTION,
6
+ RES_PAYMENT_USAGE, RES_PAYMENT_USAGE_COUNTER, RES_PAYMENT_WEBHOOK,
7
+ } from './consts.js'
8
+ import {
9
+ BillingProfileSchema, ConsumerConsentSchema, ConsumerDeclarationSchema, ConsumerEventSchema, FingerprintSchema,
10
+ PaygateCustomerSchema, PaymentFulfillmentSchema, PaymentSubscriptionSchema, PaymentUsageCounterSchema,
11
+ PaymentUsageSchema, PaymentWebhookSchema, PurchaseSchema,
12
+ } from './model.js'
5
13
  import type {
14
+ BillingProfileRecord, BillingProfileResource, ConsumerConsentRecord, ConsumerConsentResource,
15
+ ConsumerDeclarationRecord, ConsumerDeclarationResource, ConsumerEventRecord, ConsumerEventResource,
6
16
  FingerprintRecord, FingerprintResource, PaygateCustomerRecord, PaygateCustomerResource,
7
- PaymentSubscriptionRecord, PaymentSubscriptionResource,
17
+ PaymentFulfillmentRecord, PaymentFulfillmentResource, PaymentSubscriptionRecord,
18
+ PaymentSubscriptionResource, PaymentUsageCounterRecord, PaymentUsageCounterResource, PaymentUsageRecord,
19
+ PaymentUsageResource, PaymentWebhookRecord, PaymentWebhookResource, PurchaseRecord, PurchaseResource,
8
20
  } from './types.js'
9
21
 
10
22
  export const makePaygateCustomerResource: ResourceMaker<PaygateCustomerRecord, PaygateCustomerResource> = (
@@ -29,11 +41,65 @@ export const makeSubscriptionResource: ResourceMaker<PaymentSubscriptionRecord,
29
41
  RES_PAYMENT_SUBSCRIPTION, dbAlias, serviceAlias,
30
42
  )
31
43
  resource.byExternalId = async (externalId, paygate) => await resource.load({ externalId, paygate })
44
+ resource.byItemId = async (itemId, paygate) => await resource.load({ itemId, paygate })
32
45
  resource.schema = PaymentSubscriptionSchema
33
- resource.index('entity', { entityId: 1 })
34
- resource.index('entitySku', { entityId: 1, sku: 1 })
35
- resource.index('external', { externalId: 1, paygate: 1 }, { unique: true })
36
- resource.index('status', { status: 1 })
46
+ resource.index('external', { paygate: 1, externalId: 1 }, { unique: true })
47
+ resource.index('entityStatusRank', { entityId: 1, status: 1, rank: -1 })
48
+ resource.index('entityPlan', { entityId: 1, planSku: 1 })
49
+ resource.index('customer', { paygate: 1, customerId: 1 })
50
+ resource.index('item', { paygate: 1, itemId: 1 }, { sparse: true })
51
+ resource.index('statusUpdated', { paygate: 1, status: 1, updatedAt: 1 })
52
+ return resource
53
+ }
54
+
55
+ export const makeFulfillmentResource: ResourceMaker<PaymentFulfillmentRecord, PaymentFulfillmentResource> = (
56
+ dbAlias, serviceAlias,
57
+ ) => {
58
+ const resource = makeMongoResource<PaymentFulfillmentRecord, PaymentFulfillmentResource>(
59
+ RES_PAYMENT_FULFILLMENT, dbAlias, serviceAlias,
60
+ )
61
+ resource.byExternalId = async (externalId, paygate) => await resource.load({ externalId, paygate })
62
+ resource.schema = PaymentFulfillmentSchema
63
+ resource.index('external', { paygate: 1, externalId: 1 }, { unique: true })
64
+ resource.index('paymentIntent', { paygate: 1, paymentIntentId: 1 }, { sparse: true })
65
+ resource.index('charge', { paygate: 1, chargeId: 1 }, { sparse: true })
66
+ resource.index('entityCreated', { entityId: 1, createdAt: -1 })
67
+ return resource
68
+ }
69
+
70
+ export const makeWebhookResource: ResourceMaker<PaymentWebhookRecord, PaymentWebhookResource> = (
71
+ dbAlias, serviceAlias,
72
+ ) => {
73
+ const resource = makeMongoResource<PaymentWebhookRecord, PaymentWebhookResource>(
74
+ RES_PAYMENT_WEBHOOK, dbAlias, serviceAlias,
75
+ )
76
+ resource.schema = PaymentWebhookSchema
77
+ resource.index('endpoint', { paygate: 1, service: 1, url: 1 }, { unique: true })
78
+ return resource
79
+ }
80
+
81
+ export const makeUsageResource: ResourceMaker<PaymentUsageRecord, PaymentUsageResource> = (
82
+ dbAlias, serviceAlias,
83
+ ) => {
84
+ const resource = makeMongoResource<PaymentUsageRecord, PaymentUsageResource>(
85
+ RES_PAYMENT_USAGE, dbAlias, serviceAlias,
86
+ )
87
+ resource.schema = PaymentUsageSchema
88
+ resource.index('event', { entityId: 1, limitKey: 1, eventKey: 1 }, { unique: true })
89
+ resource.index('window', { entityId: 1, limitKey: 1, window: 1 })
90
+ resource.index('ref', { entityId: 1, limitKey: 1, ref: 1 }, { sparse: true })
91
+ resource.index('entityCreated', { entityId: 1, createdAt: -1 })
92
+ return resource
93
+ }
94
+
95
+ export const makeUsageCounterResource: ResourceMaker<PaymentUsageCounterRecord, PaymentUsageCounterResource> = (
96
+ dbAlias, serviceAlias,
97
+ ) => {
98
+ const resource = makeMongoResource<PaymentUsageCounterRecord, PaymentUsageCounterResource>(
99
+ RES_PAYMENT_USAGE_COUNTER, dbAlias, serviceAlias,
100
+ )
101
+ resource.schema = PaymentUsageCounterSchema
102
+ resource.index('window', { entityId: 1, limitKey: 1, window: 1 }, { unique: true })
37
103
  return resource
38
104
  }
39
105
 
@@ -49,3 +115,82 @@ export const makeFingerprintResource: ResourceMaker<FingerprintRecord, Fingerpri
49
115
  resource.index('sku', { sku: 1 }, { unique: true })
50
116
  return resource
51
117
  }
118
+
119
+ export const makeBillingProfileResource: ResourceMaker<BillingProfileRecord, BillingProfileResource> = (
120
+ dbAlias, serviceAlias,
121
+ ) => {
122
+ const resource = makeMongoResource<BillingProfileRecord, BillingProfileResource>(
123
+ RES_BILLING_PROFILE, dbAlias, serviceAlias,
124
+ )
125
+ resource.byEntity = async entityId => await resource.load({ entityId })
126
+ resource.schema = BillingProfileSchema
127
+ // One profile per organization: the unique index is what makes the first lock the only one.
128
+ resource.index('entity', { entityId: 1 }, { unique: true })
129
+ resource.index('customer', { paygate: 1, customerId: 1 }, { sparse: true })
130
+ return resource
131
+ }
132
+
133
+ export const makePurchaseResource: ResourceMaker<PurchaseRecord, PurchaseResource> = (dbAlias, serviceAlias) => {
134
+ const resource = makeMongoResource<PurchaseRecord, PurchaseResource>(RES_PAYMENT_PURCHASE, dbAlias, serviceAlias)
135
+ resource.byPurchaseId = async purchaseId => await resource.load({ purchaseId })
136
+ resource.schema = PurchaseSchema
137
+ resource.index('purchase', { purchaseId: 1 }, { unique: true })
138
+ resource.index('contract', { contractRef: 1 }, { unique: true })
139
+ resource.index('entityDeadline', { entityId: 1, deadline: -1 })
140
+ resource.index('entityPurchased', { entityId: 1, purchasedAt: -1 })
141
+ // Single-field on purpose: a compound sparse index still indexes rows missing only `sessionId`
142
+ // (subscription purchases before their checkout is captured), and unique would collide on them.
143
+ resource.index('session', { sessionId: 1 }, { unique: true, sparse: true })
144
+ resource.index('subscription', { paygate: 1, subscriptionId: 1 }, { sparse: true })
145
+ resource.index('invoice', { invoiceId: 1 }, { sparse: true })
146
+ resource.index('invoiceNumber', { invoiceNumber: 1 }, { sparse: true })
147
+ resource.index('paymentIntent', { paygate: 1, paymentIntentId: 1 }, { sparse: true })
148
+ return resource
149
+ }
150
+
151
+ export const makeConsumerConsentResource: ResourceMaker<ConsumerConsentRecord, ConsumerConsentResource> = (
152
+ dbAlias, serviceAlias,
153
+ ) => {
154
+ const resource = makeMongoResource<ConsumerConsentRecord, ConsumerConsentResource>(
155
+ RES_CONSUMER_CONSENT, dbAlias, serviceAlias,
156
+ )
157
+ resource.schema = ConsumerConsentSchema
158
+ resource.index('entityDecided', { entityId: 1, decidedAt: -1 })
159
+ resource.index('kindPlan', { kind: 1, entityId: 1, planSku: 1, decidedAt: -1 })
160
+ return resource
161
+ }
162
+
163
+ export const makeConsumerDeclarationResource: ResourceMaker<ConsumerDeclarationRecord, ConsumerDeclarationResource> = (
164
+ dbAlias, serviceAlias,
165
+ ) => {
166
+ const resource = makeMongoResource<ConsumerDeclarationRecord, ConsumerDeclarationResource>(
167
+ RES_CONSUMER_DECLARATION, dbAlias, serviceAlias,
168
+ )
169
+ resource.schema = ConsumerDeclarationSchema
170
+ resource.index('kindReceived', { kind: 1, receivedAt: -1 })
171
+ resource.index('entityReceived', { entityId: 1, receivedAt: -1 }, { sparse: true })
172
+ resource.index('purchase', { purchaseId: 1 }, { sparse: true })
173
+ return resource
174
+ }
175
+
176
+ export const makeConsumerEventResource: ResourceMaker<ConsumerEventRecord, ConsumerEventResource> = (
177
+ dbAlias, serviceAlias,
178
+ ) => {
179
+ const resource = makeMongoResource<ConsumerEventRecord, ConsumerEventResource>(
180
+ RES_CONSUMER_EVENT, dbAlias, serviceAlias,
181
+ )
182
+ resource.schema = ConsumerEventSchema
183
+ resource.index('record', { recordId: 1, at: 1 })
184
+ resource.index('entityAt', { entityId: 1, at: -1 }, { sparse: true })
185
+ resource.index('actionOk', { action: 1, ok: 1, at: 1 })
186
+ return resource
187
+ }
188
+
189
+ /** The consumer-rights resources, alias → maker — registered by the gateway, each only when absent. */
190
+ export const CONSUMER_RIGHTS_RESOURCE_MAKERS = [
191
+ [RES_BILLING_PROFILE, makeBillingProfileResource],
192
+ [RES_PAYMENT_PURCHASE, makePurchaseResource],
193
+ [RES_CONSUMER_CONSENT, makeConsumerConsentResource],
194
+ [RES_CONSUMER_DECLARATION, makeConsumerDeclarationResource],
195
+ [RES_CONSUMER_EVENT, makeConsumerEventResource],
196
+ ] as const