@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
@@ -1,97 +1,87 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
- import { AppType, makeBasicContext } from '@owlmeans/context'
3
- import type { BasicConfig } from '@owlmeans/context'
4
2
  import type { AbstractRequest, AbstractResponse, GateService } from '@owlmeans/entrypoint'
5
- import type { Auth } from '@owlmeans/auth'
3
+ import type { Auth, PermissionSet } from '@owlmeans/auth'
6
4
  import { AuthForbidden, AuthRole } from '@owlmeans/auth'
7
- import { CAPABILITY_FEATURE_SCOPE, ENTITLEMENT_GATE } from '@owlmeans/payment'
8
- import { SubscriptionStatus } from '@owlmeans/payment'
9
- import { filterRecords } from '@owlmeans/resource'
10
- import type { Criteria, ResourceRecord } from '@owlmeans/resource'
11
- import { RES_PAYMENT_SUBSCRIPTION } from '../src/consts.js'
12
- import { makeEntitlementGate } from '../src/gate.js'
13
-
14
- const record = (status: SubscriptionStatus, capabilities?: unknown[]) => ({
15
- entityId: 'entity-1',
16
- productSku: 'pro',
17
- status,
18
- capabilities: capabilities ?? [
19
- { scope: 'renewable', permissions: { production: 1 } },
20
- { scope: CAPABILITY_FEATURE_SCOPE, permissions: { 'branding--whitelabel': true } },
21
- ],
22
- })
23
-
24
- /** The gate must fail closed, including when its subscription store is unreadable. */
25
- const makeGate = async (answer: () => unknown): Promise<GateService> => {
26
- const ctx = makeBasicContext<BasicConfig>({
27
- ready: false,
28
- service: 'server-payment-tests',
29
- type: AppType.Backend,
30
- })
31
- const resource = {
32
- alias: RES_PAYMENT_SUBSCRIPTION,
33
- list: async (where?: Criteria<ResourceRecord>) => {
34
- const items = filterRecords(answer() as ResourceRecord[], where)
35
- return { items, total: items.length }
36
- },
37
- registerContext: () => resource,
38
- init: async () => undefined,
39
- ready: async () => true,
40
- }
41
- ctx.registerResource(resource as never)
42
- ctx.registerService(makeEntitlementGate())
43
- ctx.configure()
44
- await ctx.init()
45
-
46
- return ctx.service<GateService>(ENTITLEMENT_GATE)
47
- }
5
+ import { CapabilityRequired, ENTITLEMENT_GATE, SubscriptionStatus } from '@owlmeans/payment'
6
+ import { entitlementsOf, makeCapabilityGate } from '../src/gate.js'
7
+ import { gateway } from '../src/utils.js'
8
+ import { CAP_BASIC, CAP_PREVIEW, CAP_WHITELABEL, makeFakeContext, past, PRO } from './fake-stripe.js'
9
+ import type { FakeContext } from './fake-stripe.js'
48
10
 
49
11
  const request = (auth: Partial<Auth> | null = {}): AbstractRequest => ({
50
12
  alias: 'test', headers: {}, params: {}, query: {}, body: {}, path: '/',
51
13
  ...(auth != null ? {
52
14
  auth: {
53
15
  type: 'ed25519-basic-token', token: 't', userId: 'u', role: AuthRole.User,
54
- scopes: [], entitySlug: 'entity-1', isUser: true, createdAt: new Date(), ...auth,
16
+ scopes: ['*'], entitySlug: 'entity-1', isUser: true, createdAt: new Date(), ...auth,
55
17
  } satisfies Auth,
56
18
  } : {}),
57
19
  }) as unknown as AbstractRequest
58
20
 
59
21
  const res = {} as AbstractResponse<unknown>
60
22
 
61
- describe('@owlmeans/server-payment — the entitlement gate', () => {
62
- test('passes capabilities from active and trial subscriptions', async () => {
63
- for (const status of [SubscriptionStatus.Active, SubscriptionStatus.Trial]) {
64
- const gate = await makeGate(() => [record(status)])
65
- await gate.assert(request(), res, ['feature:branding--whitelabel'])
23
+ const withPro = async (status: SubscriptionStatus = SubscriptionStatus.Active): Promise<FakeContext> => {
24
+ const fake = await makeFakeContext()
25
+ await gateway(fake.ctx).grantInternalPlan(fake.ctx, 'entity-1', PRO, { force: true })
26
+ fake.stores['payment-subscription'].rows[0].status = status
27
+ return fake
28
+ }
29
+
30
+ const gateOf = (fake: FakeContext, alias: string = ENTITLEMENT_GATE): GateService => fake.ctx.service<GateService>(alias)
31
+
32
+ describe('@owlmeans/server-payment — the capability gate', () => {
33
+ test('passes a capability of an active, trial or past-due plan; parameters are any-of', async () => {
34
+ for (const status of [SubscriptionStatus.Active, SubscriptionStatus.Trial, SubscriptionStatus.PastDue]) {
35
+ const fake = await withPro(status)
36
+ await gateOf(fake).assert(request(), res, [CAP_WHITELABEL])
37
+ await gateOf(fake).assert(request(), res, ['feature:nothing--here', CAP_WHITELABEL])
66
38
  }
67
39
  })
68
40
 
69
- test('refuses cancelled, missing, and unrelated capabilities', async () => {
70
- const canceled = await makeGate(() => [record(SubscriptionStatus.Canceled)])
71
- await expect(canceled.assert(request(), res, ['feature:branding--whitelabel']))
72
- .rejects.toBeInstanceOf(AuthForbidden)
73
- const unrelated = await makeGate(() => [record(SubscriptionStatus.Active)])
74
- await expect(unrelated.assert(request(), res, ['feature:domain--custom']))
75
- .rejects.toBeInstanceOf(AuthForbidden)
76
- })
41
+ test('refuses a canceled plan, an unrelated capability and a lapsed promo with CapabilityRequired (a 403)', async () => {
42
+ const canceled = await withPro(SubscriptionStatus.Canceled)
43
+ const refusal = await gateOf(canceled).assert(request(), res, [CAP_WHITELABEL]).catch(error => error)
44
+ expect(refusal).toBeInstanceOf(CapabilityRequired)
45
+ expect(refusal).toBeInstanceOf(AuthForbidden)
46
+ expect(refusal.params).toEqual([CAP_WHITELABEL])
47
+ await gateOf(canceled).assert(request(), res, [CAP_BASIC])
77
48
 
78
- test('treats gate parameters as any-of', async () => {
79
- const gate = await makeGate(() => [record(SubscriptionStatus.Active)])
80
- await gate.assert(request(), res, ['feature:nothing--here', 'feature:branding--whitelabel'])
49
+ const unrelated = await withPro()
50
+ await expect(gateOf(unrelated).assert(request(), res, ['feature:domain--custom'])).rejects.toBeInstanceOf(CapabilityRequired)
51
+
52
+ const lapsed = await makeFakeContext({ catalogue: { previewUntil: past(1) } })
53
+ await expect(gateOf(lapsed).assert(request(), res, [CAP_PREVIEW])).rejects.toBeInstanceOf(CapabilityRequired)
54
+ expect((await entitlementsOf(lapsed.ctx, 'entity-1')).some(set => set.permissions.preview != null)).toBe(false)
81
55
  })
82
56
 
83
- test('refuses missing authentication or organization before reading storage', async () => {
84
- const unread = await makeGate(() => { throw new Error('must not be reached') })
85
- await expect(unread.assert(request(null), res, ['feature:branding--whitelabel']))
86
- .rejects.toBeInstanceOf(AuthForbidden)
87
- const noEntity = await makeGate(() => [])
88
- await expect(noEntity.assert(request({ entitySlug: undefined }), res, ['feature:branding--whitelabel']))
89
- .rejects.toBeInstanceOf(AuthForbidden)
57
+ test('refuses a missing authentication or organization before reading storage, and an unreadable store', async () => {
58
+ const fake = await withPro()
59
+ fake.stores['payment-subscription'].failing.add('list')
60
+ const noAuth = await gateOf(fake).assert(request(null), res, [CAP_WHITELABEL]).catch(error => error)
61
+ expect(noAuth).toBeInstanceOf(AuthForbidden)
62
+ expect(noAuth).not.toBeInstanceOf(CapabilityRequired)
63
+ const noEntity = await gateOf(fake).assert(request({ entitySlug: undefined }), res, [CAP_WHITELABEL]).catch(error => error)
64
+ expect(noEntity).not.toBeInstanceOf(CapabilityRequired)
65
+ expect(noEntity).toBeInstanceOf(AuthForbidden)
66
+
67
+ await expect(gateOf(fake).assert(request(), res, [CAP_WHITELABEL])).rejects.toBeInstanceOf(CapabilityRequired)
90
68
  })
91
69
 
92
- test('refuses when the subscription store is unreadable', async () => {
93
- const gate = await makeGate(() => { throw new Error('mongo is down') })
94
- await expect(gate.assert(request(), res, ['feature:branding--whitelabel']))
95
- .rejects.toBeInstanceOf(AuthForbidden)
70
+ test('the plan is the authority: a token false denies, a token grant alone never allows, requirePermission is opt-in', async () => {
71
+ const deny: PermissionSet[] = [{ scope: 'feature', permissions: { whitelabel: false } }]
72
+ const grant: PermissionSet[] = [{ scope: 'feature', permissions: { whitelabel: true } }]
73
+
74
+ const fake = await withPro()
75
+ await expect(gateOf(fake).assert(request({ permissions: deny }), res, [CAP_WHITELABEL]))
76
+ .rejects.toBeInstanceOf(CapabilityRequired)
77
+
78
+ const free = await makeFakeContext()
79
+ await expect(gateOf(free).assert(request({ permissions: grant }), res, [CAP_WHITELABEL]))
80
+ .rejects.toBeInstanceOf(CapabilityRequired)
81
+
82
+ fake.ctx.registerService(makeCapabilityGate('strict-gate', { requirePermission: true }))
83
+ await expect(gateOf(fake, 'strict-gate').assert(request(), res, [CAP_WHITELABEL]))
84
+ .rejects.toBeInstanceOf(CapabilityRequired)
85
+ await gateOf(fake, 'strict-gate').assert(request({ permissions: grant }), res, [CAP_WHITELABEL])
96
86
  })
97
87
  })
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { AbstractRequest, AbstractResponse, GateService } from '@owlmeans/entrypoint'
3
+ import type { Auth } from '@owlmeans/auth'
4
+ import { AuthForbidden, AuthRole } from '@owlmeans/auth'
5
+ import {
6
+ formatLimitParam, LIMIT_GATE, LimitExhausted, LimitWindow, windowBoundsOf,
7
+ } from '@owlmeans/payment'
8
+ import { entitlements, gateway } from '../src/utils.js'
9
+ import { makeFakeContext, PRO } from './fake-stripe.js'
10
+ import type { FakeContext } from './fake-stripe.js'
11
+
12
+ const request = (auth: Partial<Auth> | null = {}): AbstractRequest => ({
13
+ alias: 'test', headers: {}, params: {}, query: {}, body: {}, path: '/',
14
+ ...(auth != null ? {
15
+ auth: {
16
+ type: 'ed25519-basic-token', token: 't', userId: 'u', role: AuthRole.User,
17
+ scopes: ['*'], entitySlug: 'entity-1', isUser: true, createdAt: new Date(), ...auth,
18
+ } satisfies Auth,
19
+ } : {}),
20
+ }) as unknown as AbstractRequest
21
+
22
+ const res = {} as AbstractResponse<unknown>
23
+ const gateOf = (fake: FakeContext): GateService => fake.ctx.service<GateService>(LIMIT_GATE)
24
+
25
+ describe('@owlmeans/server-payment — the limit gate', () => {
26
+ test('passes with room and consumes nothing', async () => {
27
+ const fake = await makeFakeContext()
28
+ await gateOf(fake).assert(request(), res, [formatLimitParam('exports')])
29
+ await gateOf(fake).assert(request(), res, [formatLimitParam('exports')])
30
+
31
+ expect(fake.stores['payment-usage'].rows).toHaveLength(0)
32
+ expect(fake.stores['payment-usage-counter'].rows).toHaveLength(0)
33
+ })
34
+
35
+ test('refuses at the ceiling with the limit, the usage and the reset date', async () => {
36
+ const fake = await makeFakeContext()
37
+ await entitlements(fake.ctx).consume({ entityId: 'entity-1', limitKey: 'exports', eventKey: 'export:1' })
38
+
39
+ const refusal = await gateOf(fake).assert(request(), res, [formatLimitParam('exports')]).catch(error => error)
40
+ expect(refusal).toBeInstanceOf(LimitExhausted)
41
+ expect(refusal).toBeInstanceOf(AuthForbidden)
42
+ expect(refusal).toEqual(expect.objectContaining({
43
+ limitKey: 'exports', used: 1, limit: 1, resetsAt: windowBoundsOf(LimitWindow.Month).resetsAt,
44
+ }))
45
+ await expect(gateOf(fake).assert(request(), res, [formatLimitParam('reports', 2)])).rejects.toBeInstanceOf(LimitExhausted)
46
+ })
47
+
48
+ test('skips malformed and undeclared parameters, ORs the rest, and refuses when none is left', async () => {
49
+ const fake = await makeFakeContext()
50
+ await gateway(fake.ctx).grantInternalPlan(fake.ctx, 'entity-1', PRO, { force: true })
51
+ await gateOf(fake).assert(request(), res, ['feature:exports', 'limit:', formatLimitParam('nope'), formatLimitParam('seats')])
52
+
53
+ const seats = await gateOf(fake).assert(request(), res, [formatLimitParam('seats', 3), formatLimitParam('exports')])
54
+ expect(seats).toBeUndefined()
55
+
56
+ const malformed = await gateOf(fake).assert(request(), res, ['garbage', 'feature:x']).catch(error => error)
57
+ expect(malformed).toBeInstanceOf(LimitExhausted)
58
+ expect(malformed.limitKey).toBe('garbage')
59
+ })
60
+
61
+ test('refuses when authentication is missing or the store cannot be read', async () => {
62
+ const fake = await makeFakeContext()
63
+ await expect(gateOf(fake).assert(request(null), res, [formatLimitParam('exports')])).rejects.toBeInstanceOf(AuthForbidden)
64
+
65
+ fake.stores['payment-usage-counter'].failing.add('load')
66
+ await expect(gateOf(fake).assert(request(), res, [formatLimitParam('exports')])).rejects.toBeInstanceOf(LimitExhausted)
67
+ })
68
+ })
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { PortalFlow, PortalUnavailable, ProductType, PlanDuration, SubscriptionStatus, TaxBehavior } from '@owlmeans/payment'
3
+ import { declarePaymentPlan, declarePaymentPricing, declarePaymentProduct } from '../src/config.js'
4
+ import { createPortalLink, ensurePortalConfiguration } from '../src/plugins/portal.js'
5
+ import { fingerprints, paygateCustomers, subscriptions } from '../src/utils.js'
6
+ import { makeFakeContext, PLANS_PRODUCT, PRO, SERVICE, TEAM } from './fake-stripe.js'
7
+ import type { FakeContextOptions } from './fake-stripe.js'
8
+
9
+ const RETURN = 'https://app.example.com/billing'
10
+ const hookOf = (host: string): string => `https://${host}/payment-gate/webhook/stripe`
11
+ const tagOf = (host: string) => ({ owlmeans: 'payment', service: SERVICE, deployment: hookOf(host) })
12
+ const prices = [
13
+ { id: 'price_pro', product: PLANS_PRODUCT, lookup_key: PRO, active: true, recurring: { interval: 'month' } },
14
+ { id: 'price_team', product: PLANS_PRODUCT, lookup_key: TEAM, active: true, recurring: { interval: 'month' } },
15
+ { id: 'price_old', product: PLANS_PRODUCT, lookup_key: null, active: false, recurring: { interval: 'month' } },
16
+ { id: 'price_once', product: PLANS_PRODUCT, lookup_key: 'once', active: true, recurring: null },
17
+ ]
18
+
19
+ const withPortal = async (opts: FakeContextOptions = {}) => await makeFakeContext({
20
+ portal: { returnUrl: RETURN, headline: 'Plans', privacyPolicyUrl: 'https://example.com/privacy' },
21
+ ...opts, stripe: { prices: structuredClone(prices), ...opts.stripe },
22
+ })
23
+
24
+ describe('@owlmeans/server-payment — portal configuration', () => {
25
+ test('offers switching between active recurring prices and cancellation at period end; unchanged means no call', async () => {
26
+ const fake = await withPortal()
27
+ const id = await ensurePortalConfiguration(fake.ctx, fake.stripe)
28
+ const [configuration] = fake.state.portalConfigurations
29
+
30
+ expect(configuration.id).toBe(id)
31
+ expect(configuration.features.subscription_update).toEqual({
32
+ enabled: true, default_allowed_updates: ['price'], proration_behavior: 'create_prorations',
33
+ products: [{ product: PLANS_PRODUCT, prices: ['price_pro', 'price_team'] }],
34
+ })
35
+ expect(configuration.features.subscription_cancel).toEqual({ enabled: true, mode: 'at_period_end', proration_behavior: 'none' })
36
+ expect(configuration.features.customer_update).toEqual({ enabled: true, allowed_updates: ['email', 'address', 'tax_id'] })
37
+ expect(configuration).toEqual(expect.objectContaining({
38
+ default_return_url: RETURN, metadata: tagOf('api.example.com'),
39
+ business_profile: { headline: 'Plans', privacy_policy_url: 'https://example.com/privacy' },
40
+ }))
41
+
42
+ fake.state.calls.length = 0
43
+ expect(await ensurePortalConfiguration(fake.ctx, fake.stripe)).toBe(id)
44
+ expect(fake.state.calls).toEqual([])
45
+ })
46
+
47
+ test('re-adopts its own untracked configuration, and never an untagged one or one tagged for another deployment', async () => {
48
+ const fake = await withPortal({
49
+ stripe: {
50
+ prices: structuredClone(prices),
51
+ portalConfigurations: [
52
+ { id: 'bpc_service_only', active: true, metadata: { owlmeans: 'payment', service: SERVICE } },
53
+ { id: 'bpc_other', active: true, metadata: tagOf('other.example.com') },
54
+ ],
55
+ },
56
+ })
57
+ const id = await ensurePortalConfiguration(fake.ctx, fake.stripe)
58
+ expect(id).not.toBe('bpc_service_only')
59
+ expect(id).not.toBe('bpc_other')
60
+
61
+ await fingerprints(fake.ctx).clear()
62
+ fake.state.calls.length = 0
63
+ expect(await ensurePortalConfiguration(fake.ctx, fake.stripe, { force: true })).toBe(id)
64
+ expect(fake.state.calls).toEqual(['prices.list', 'billingPortal.configurations.list', 'billingPortal.configurations.update'])
65
+ expect(fake.stores['payment-fingerprint'].rows).toEqual([
66
+ expect.objectContaining({ sku: `portal:${SERVICE}`, externalId: id }),
67
+ ])
68
+ expect(fake.state.portalConfigurations.map(({ id, metadata, features }) => ({ id, metadata, features: features != null })))
69
+ .toEqual([
70
+ { id: 'bpc_service_only', metadata: { owlmeans: 'payment', service: SERVICE }, features: false },
71
+ { id: 'bpc_other', metadata: tagOf('other.example.com'), features: false },
72
+ { id, metadata: tagOf('api.example.com'), features: true },
73
+ ])
74
+ })
75
+
76
+ test('a declared tax behavior is hashed: changing it re-hashes and updates the configuration', async () => {
77
+ const fake = await withPortal({
78
+ pricing: { tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Exclusive }, currency: { estimate: false } },
79
+ })
80
+ const id = await ensurePortalConfiguration(fake.ctx, fake.stripe)
81
+ fake.state.calls.length = 0
82
+ expect(await ensurePortalConfiguration(fake.ctx, fake.stripe)).toBe(id)
83
+ expect(fake.state.calls).toEqual([])
84
+
85
+ declarePaymentPricing(fake.ctx.cfg as never, {
86
+ tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Inclusive },
87
+ currency: { estimate: false },
88
+ })
89
+ expect(await ensurePortalConfiguration(fake.ctx, fake.stripe)).toBe(id)
90
+ expect(fake.state.calls).toContain('billingPortal.configurations.update')
91
+ })
92
+
93
+ test('two deployments on one Stripe account keep separate configurations, even when both rows name one', async () => {
94
+ const shared = { owlmeans: 'payment', service: SERVICE }
95
+ const stage = await withPortal({
96
+ host: 'stage.example.com',
97
+ stripe: { prices: structuredClone(prices), portalConfigurations: [{ id: 'bpc_shared', active: true, metadata: shared }] },
98
+ })
99
+ const staging = await withPortal({ host: 'staging.example.com' })
100
+ for (const deployment of [stage, staging]) {
101
+ await fingerprints(deployment.ctx).create({ sku: `portal:${SERVICE}`, hash: 'legacy', externalId: 'bpc_shared', updatedAt: new Date() })
102
+ }
103
+
104
+ expect(await ensurePortalConfiguration(stage.ctx, stage.stripe)).toBe('bpc_shared')
105
+ const stagingId = await ensurePortalConfiguration(staging.ctx, stage.stripe)
106
+ expect(stagingId).not.toBe('bpc_shared')
107
+ expect(stage.state.portalConfigurations.map(({ id, metadata }) => ({ id, metadata }))).toEqual([
108
+ { id: 'bpc_shared', metadata: tagOf('stage.example.com') },
109
+ { id: stagingId, metadata: tagOf('staging.example.com') },
110
+ ])
111
+
112
+ for (const deployment of [stage, staging]) {
113
+ await fingerprints(deployment.ctx).clear()
114
+ }
115
+ expect(await ensurePortalConfiguration(staging.ctx, stage.stripe, { force: true })).toBe(stagingId)
116
+ expect(await ensurePortalConfiguration(stage.ctx, stage.stripe, { force: true })).toBe('bpc_shared')
117
+ expect(stage.state.portalConfigurations).toHaveLength(2)
118
+
119
+ const local = await withPortal({ host: 'localhost' })
120
+ const localId = await ensurePortalConfiguration(local.ctx, stage.stripe)
121
+ expect(stage.state.portalConfigurations.find(configuration => configuration.id === localId)?.metadata)
122
+ .toEqual(tagOf('localhost'))
123
+ expect(stage.state.portalConfigurations).toHaveLength(3)
124
+ })
125
+
126
+ test('disables both subscription features when nothing recurring is sold', async () => {
127
+ const fake = await makeFakeContext({
128
+ declare: cfg => {
129
+ declarePaymentProduct(cfg, { sku: 'kit', type: ProductType.Simple, services: [SERVICE], name: 'Kit' })
130
+ declarePaymentPlan(cfg, { productSku: 'kit', sku: 'kit-once', duration: PlanDuration.Lifetime, price: 5 })
131
+ },
132
+ })
133
+ await ensurePortalConfiguration(fake.ctx, fake.stripe)
134
+ expect(fake.state.portalConfigurations[0].features.subscription_update).toEqual({ enabled: false })
135
+ expect(fake.state.portalConfigurations[0].features.subscription_cancel).toEqual({ enabled: false })
136
+ })
137
+ })
138
+
139
+ describe('@owlmeans/server-payment — portal links', () => {
140
+ const subscribed = async () => {
141
+ const fake = await withPortal()
142
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_1', entityId: 'entity-1' })
143
+ await subscriptions(fake.ctx).create({
144
+ entityId: 'entity-1', planSku: PRO, productSku: PLANS_PRODUCT, service: SERVICE, paygate: 'stripe',
145
+ externalId: 'sub_1', itemId: 'si_1', status: SubscriptionStatus.Active, rank: 10, createdAt: new Date(),
146
+ })
147
+ return fake
148
+ }
149
+
150
+ test('needs a customer; Manage opens the portal home on the managed configuration', async () => {
151
+ const empty = await withPortal()
152
+ await expect(createPortalLink(empty.ctx, empty.stripe, 'entity-1', { flow: PortalFlow.Manage, returnUrl: RETURN }))
153
+ .rejects.toBeInstanceOf(PortalUnavailable)
154
+
155
+ const fake = await subscribed()
156
+ const url = await createPortalLink(fake.ctx, fake.stripe, 'entity-1', { flow: PortalFlow.Manage, returnUrl: RETURN })
157
+ expect(url).toStartWith('https://billing.example.test/')
158
+ expect(fake.state.portalSessions[0]).toEqual({
159
+ customer: 'cus_1', return_url: RETURN, configuration: fake.state.portalConfigurations[0].id,
160
+ })
161
+ })
162
+
163
+ test('PaymentMethod, Cancel and Update deep-link with a redirect back', async () => {
164
+ const fake = await subscribed()
165
+ const after = { type: 'redirect', redirect: { return_url: RETURN } }
166
+ for (const flow of [PortalFlow.PaymentMethod, PortalFlow.Cancel, PortalFlow.Update]) {
167
+ await createPortalLink(fake.ctx, fake.stripe, 'entity-1', { flow, returnUrl: RETURN })
168
+ }
169
+ expect(fake.state.portalSessions.map(session => session.flow_data)).toEqual([
170
+ { type: 'payment_method_update', after_completion: after },
171
+ { type: 'subscription_cancel', subscription_cancel: { subscription: 'sub_1' }, after_completion: after },
172
+ { type: 'subscription_update', subscription_update: { subscription: 'sub_1' }, after_completion: after },
173
+ ])
174
+ })
175
+
176
+ test('Change confirms the stored item switching to the target plan price, as one item', async () => {
177
+ const fake = await subscribed()
178
+ await createPortalLink(fake.ctx, fake.stripe, 'entity-1', { flow: PortalFlow.Change, planSku: TEAM, returnUrl: RETURN })
179
+ expect(fake.state.portalSessions[0].flow_data).toEqual({
180
+ type: 'subscription_update_confirm',
181
+ subscription_update_confirm: { subscription: 'sub_1', items: [{ id: 'si_1', price: 'price_team', quantity: 1 }] },
182
+ after_completion: { type: 'redirect', redirect: { return_url: RETURN } },
183
+ })
184
+ await expect(createPortalLink(fake.ctx, fake.stripe, 'entity-1', { flow: PortalFlow.Change, returnUrl: RETURN }))
185
+ .rejects.toBeInstanceOf(PortalUnavailable)
186
+ })
187
+
188
+ test('a subscription flow without an entitling paygate subscription is unavailable', async () => {
189
+ const fake = await withPortal()
190
+ await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_1', entityId: 'entity-1' })
191
+ await subscriptions(fake.ctx).create({
192
+ entityId: 'entity-1', planSku: PRO, productSku: PLANS_PRODUCT, service: SERVICE, paygate: 'stripe',
193
+ externalId: 'sub_1', itemId: 'si_1', status: SubscriptionStatus.Canceled, rank: 10, createdAt: new Date(),
194
+ })
195
+ const refusal = await createPortalLink(fake.ctx, fake.stripe, 'entity-1', { flow: PortalFlow.Cancel, returnUrl: RETURN })
196
+ .catch(error => error)
197
+ expect(refusal).toBeInstanceOf(PortalUnavailable)
198
+ expect(refusal.message).toContain('subscription')
199
+ })
200
+ })
@@ -1,20 +1,37 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
+ import { protocols } from '@owlmeans/entrypoint'
2
3
  import type { ParamsOf, ResponseOf } from '@owlmeans/entrypoint'
3
- import { paymentGate, paymentGateProtocols } from '../src/index.js'
4
+ import { GUARD_ED25519 } from '@owlmeans/server-app'
5
+ import * as payment from '../src/index.js'
6
+ import { paymentGate, paymentGateEntrypoints, WEBHOOK_EVENTS } from '../src/index.js'
4
7
 
5
8
  describe('server payment protocols', () => {
6
- test('bind webhook params and resync response to their declarations', () => {
9
+ test('bind webhook params and the resync responses to their declarations', () => {
7
10
  const params: ParamsOf<typeof paymentGate.webhook> = { paygate: 'stripe' }
8
11
  const response: ResponseOf<typeof paymentGate.resync> = { ok: true }
12
+ const resynced: ResponseOf<typeof paymentGate.resyncSubscriptions> = { scanned: 3, updated: 1 }
9
13
 
10
14
  expect(params.paygate).toBe('stripe')
11
15
  expect(response.ok).toBe(true)
16
+ expect(resynced.updated).toBe(1)
12
17
  expect(paymentGate.webhook.route.route.parent).toBe(paymentGate.base.alias)
18
+ expect(paymentGate.resyncSubscriptions.route.route.parent).toBe(paymentGate.base.alias)
13
19
  })
14
20
 
15
- test('exports a flattened list of immutable protocol objects', () => {
16
- expect(paymentGateProtocols).toContain(paymentGate.webhook)
17
- expect(paymentGateProtocols.every(item => item.kind === 'entrypoint-protocol')).toBe(true)
18
- expect(Object.isFrozen(paymentGate.resync)).toBe(true)
21
+ test('exports an immutable protocol tree without a flattened compatibility list', () => {
22
+ expect(protocols(paymentGate)).toContain(paymentGate.webhook)
23
+ expect(protocols(paymentGate)).toContain(paymentGate.resyncSubscriptions)
24
+ expect(protocols(paymentGate).every(item => item.kind === 'entrypoint-protocol')).toBe(true)
25
+ expect(payment).not.toHaveProperty('paymentGateProtocols')
26
+ expect(Object.isFrozen(paymentGate.resyncSubscriptions)).toBe(true)
27
+ expect(paymentGateEntrypoints).toHaveLength(4)
28
+ })
29
+
30
+ test('guards both resync routes with the service signature and leaves the webhook public', () => {
31
+ expect(JSON.stringify(paymentGate.resyncSubscriptions)).toContain(GUARD_ED25519)
32
+ expect(JSON.stringify(paymentGate.resync)).toContain(GUARD_ED25519)
33
+ expect(JSON.stringify(paymentGate.webhook)).not.toContain(GUARD_ED25519)
34
+ expect(Object.isFrozen(WEBHOOK_EVENTS)).toBe(true)
35
+ expect(new Set(WEBHOOK_EVENTS).size).toBe(WEBHOOK_EVENTS.length)
19
36
  })
20
37
  })
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { TaxBehavior } from '@owlmeans/payment'
3
+ import type { PricingDef } from '../src/types.js'
4
+ import { syncStripeProducts } from '../src/sync.js'
5
+ import { makeFakeContext, PLANS_PRODUCT, PRO } from './fake-stripe.js'
6
+ import type { FakeContextOptions } from './fake-stripe.js'
7
+
8
+ const exclusive: PricingDef = {
9
+ tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Exclusive },
10
+ currency: { estimate: false },
11
+ }
12
+
13
+ const inclusiveAccountDefault = {
14
+ defaults: { tax_behavior: 'inclusive' as const, tax_code: null }, head_office: null,
15
+ status: 'active' as const, status_details: {},
16
+ }
17
+
18
+ /** A price already in place, matching PRO's lookup key, amount, currency and interval. */
19
+ const existingProPrice = (taxBehavior: string, id = 'price_pro_existing') => ({
20
+ id, product: PLANS_PRODUCT, lookup_key: PRO, active: true, unit_amount: 2_000, currency: 'usd',
21
+ recurring: { interval: 'month' }, tax_behavior: taxBehavior,
22
+ })
23
+
24
+ const withProProduct = async (opts: FakeContextOptions = {}) => await makeFakeContext(opts)
25
+ const proPrice = (fake: Awaited<ReturnType<typeof withProProduct>>) =>
26
+ fake.state.prices.find(item => item.lookup_key === PRO && item.active)
27
+
28
+ describe('@owlmeans/server-payment — price tax_behavior sync', () => {
29
+ test('an undeclared policy creates a price with no forced tax_behavior (Stripe defaults it to unspecified)', async () => {
30
+ const fake = await withProProduct()
31
+ await syncStripeProducts(fake.ctx, fake.stripe)
32
+ expect(proPrice(fake)?.tax_behavior).toBe('unspecified')
33
+ })
34
+
35
+ test('re-syncing an unchanged, undeclared catalogue makes no paygate call', async () => {
36
+ const fake = await withProProduct()
37
+ await syncStripeProducts(fake.ctx, fake.stripe)
38
+ fake.state.calls.length = 0
39
+ await syncStripeProducts(fake.ctx, fake.stripe)
40
+ expect(fake.state.calls).toEqual([])
41
+ })
42
+
43
+ test('a declared behavior sets tax_behavior on a freshly created price', async () => {
44
+ const fake = await withProProduct({ pricing: exclusive })
45
+ await syncStripeProducts(fake.ctx, fake.stripe)
46
+ expect(proPrice(fake)?.tax_behavior).toBe('exclusive')
47
+ })
48
+
49
+ test("a declared behavior updates a pre-existing 'unspecified' price IN PLACE — the id is kept", async () => {
50
+ const fake = await withProProduct({ pricing: exclusive, stripe: { prices: [existingProPrice('unspecified')] } })
51
+ await syncStripeProducts(fake.ctx, fake.stripe)
52
+ expect(fake.state.calls).toContain('prices.update')
53
+ // PRO's own price keeps its id (an update, not a replacement) — TEAM's plan has no price
54
+ // fixture, so it legitimately goes through `prices.create` alongside it.
55
+ expect(proPrice(fake)).toEqual(expect.objectContaining({ id: 'price_pro_existing', tax_behavior: 'exclusive' }))
56
+ })
57
+
58
+ test("an inclusive account default skips the in-place update and leaves the price 'unspecified'", async () => {
59
+ const fake = await withProProduct({
60
+ pricing: exclusive, stripe: { prices: [existingProPrice('unspecified')], taxSettings: inclusiveAccountDefault },
61
+ })
62
+ await syncStripeProducts(fake.ctx, fake.stripe)
63
+ expect(fake.state.calls).toContain('tax.settings.retrieve')
64
+ expect(fake.state.calls).not.toContain('prices.update')
65
+ expect(proPrice(fake)?.tax_behavior).toBe('unspecified')
66
+ })
67
+
68
+ test("'stripe.migrateUnspecifiedPrices' overrides the account-default guard, without even checking it", async () => {
69
+ const fake = await withProProduct({
70
+ pricing: { ...exclusive, stripe: { migrateUnspecifiedPrices: true } },
71
+ stripe: { prices: [existingProPrice('unspecified')], taxSettings: inclusiveAccountDefault },
72
+ })
73
+ await syncStripeProducts(fake.ctx, fake.stripe)
74
+ expect(fake.state.calls).not.toContain('tax.settings.retrieve')
75
+ expect(proPrice(fake)?.tax_behavior).toBe('exclusive')
76
+ })
77
+
78
+ test('a price already carrying the OPPOSITE behavior is deactivated and replaced, never mutated', async () => {
79
+ const fake = await withProProduct({ pricing: exclusive, stripe: { prices: [existingProPrice('inclusive')] } })
80
+ await syncStripeProducts(fake.ctx, fake.stripe)
81
+ const old = fake.state.prices.find(item => item.id === 'price_pro_existing')
82
+ const fresh = proPrice(fake)
83
+ expect(old?.active).toBe(false)
84
+ expect(old?.tax_behavior).toBe('inclusive')
85
+ expect(fresh?.id).not.toBe('price_pro_existing')
86
+ expect(fresh).toEqual(expect.objectContaining({ tax_behavior: 'exclusive', lookup_key: PRO }))
87
+ })
88
+
89
+ test('recurring USD catalogue prices are synchronized as EUR settlement prices', async () => {
90
+ const fake = await withProProduct({
91
+ pricing: { ...exclusive, stripe: { settlementCurrency: 'eur' } },
92
+ stripe: { fxRates: { usd: { exchangeRate: 0.853568, referenceRate: 0.8726 } } },
93
+ })
94
+ await syncStripeProducts(fake.ctx, fake.stripe)
95
+ expect(proPrice(fake)).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 1_746 }))
96
+ expect(fake.state.rawRequests).toContainEqual(expect.objectContaining({
97
+ params: { to_currency: 'eur', 'from_currencies[]': 'usd', lock_duration: 'none' },
98
+ }))
99
+ })
100
+
101
+ test('a changed reference rate replaces a recurring settlement price on the next sync', async () => {
102
+ const fake = await withProProduct({
103
+ pricing: { ...exclusive, stripe: { settlementCurrency: 'eur' } },
104
+ stripe: { fxRates: { usd: { exchangeRate: 0.853568, referenceRate: 0.8726 } } },
105
+ })
106
+ await syncStripeProducts(fake.ctx, fake.stripe)
107
+ const first = proPrice(fake)
108
+ fake.state.fxRates.usd = { exchangeRate: 0.88, referenceRate: 0.9 }
109
+ await syncStripeProducts(fake.ctx, fake.stripe)
110
+ expect(first?.active).toBe(false)
111
+ expect(proPrice(fake)).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 1_800 }))
112
+ expect(proPrice(fake)?.id).not.toBe(first?.id)
113
+ })
114
+ })