@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,356 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ProductError, SubscriptionStatus } from '@owlmeans/payment'
3
+ import { createEventHandler, mapStatus } from '../src/plugins/events.js'
4
+ import { resyncStripeSubscription, resyncStripeSubscriptions } from '../src/plugins/events.js'
5
+ import { classifySubscriptionChange } from '../src/subscription.js'
6
+ import { gateway } from '../src/utils.js'
7
+ import type { PaymentSubscriptionRecord, PropagatedState } from '../src/types.js'
8
+ import {
9
+ CAP_WHITELABEL, CREDITS_PRODUCT, eventOf, FREE, future, makeFakeContext, PRO, subscriptionOf, TEAM,
10
+ } from './fake-stripe.js'
11
+ import type { FakeContext, SubscriptionShape } from './fake-stripe.js'
12
+
13
+ const send = async (fake: FakeContext, type: string, object: unknown, overrides: Record<string, unknown> = {}) => {
14
+ const event = eventOf(type, object, overrides)
15
+ await createEventHandler(fake.ctx, fake.stripe).process(event)
16
+ return event
17
+ }
18
+ const rows = (fake: FakeContext) => fake.stores['payment-subscription'].rows
19
+ const changes = (fake: FakeContext) => fake.observed.subscription.map(event => event.change)
20
+ const epoch = (date: Date): number => Math.floor(date.getTime() / 1000)
21
+
22
+ const subscribe = async (fake: FakeContext, shape: SubscriptionShape = {}) => {
23
+ const subscription = subscriptionOf(shape)
24
+ fake.state.subscriptions[subscription.id] = subscription as never
25
+ await send(fake, 'customer.subscription.updated', subscription)
26
+ return subscription
27
+ }
28
+
29
+ const record = (status: SubscriptionStatus, overrides: Partial<PaymentSubscriptionRecord> = {}): PaymentSubscriptionRecord => ({
30
+ entityId: 'e', planSku: PRO, productSku: 'p', service: 's', paygate: 'stripe', externalId: 'sub_1', status,
31
+ rank: 10, createdAt: new Date(), ...overrides,
32
+ })
33
+ const state = (overrides: Partial<PropagatedState> = {}): PropagatedState => ({
34
+ planSku: PRO, rank: 10, status: SubscriptionStatus.Active, cancelAtPeriodEnd: false, ...overrides,
35
+ })
36
+
37
+ describe('@owlmeans/server-payment — subscription change classification', () => {
38
+ test('created once entitling; canceled; paused; resumed from a pause or a suspension', () => {
39
+ expect(classifySubscriptionChange(null, record(SubscriptionStatus.Active))).toBe('created')
40
+ expect(classifySubscriptionChange(null, record(SubscriptionStatus.Trial))).toBe('created')
41
+ expect(classifySubscriptionChange(null, record(SubscriptionStatus.Created))).toBeNull()
42
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Canceled))).toBe('canceled')
43
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Ended))).toBe('canceled')
44
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Suspended, { pausedAt: new Date() }))).toBe('paused')
45
+ expect(classifySubscriptionChange(state({ status: SubscriptionStatus.Suspended, pausedAt: new Date() }), record(SubscriptionStatus.Active))).toBe('resumed')
46
+ expect(classifySubscriptionChange(state({ status: SubscriptionStatus.Suspended }), record(SubscriptionStatus.Active))).toBe('resumed')
47
+ })
48
+
49
+ test('plan changes by rank, cancel scheduling, renewal once per invoice, dunning, trial end, else nothing', () => {
50
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Active, { planSku: TEAM, rank: 20 }))).toBe('upgraded')
51
+ expect(classifySubscriptionChange(state({ planSku: TEAM, rank: 20 }), record(SubscriptionStatus.Active))).toBe('downgraded')
52
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Active, { planSku: 'pro-yearly', rank: 10 }))).toBe('upgraded')
53
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Active, { cancelAtPeriodEnd: true }))).toBe('cancel-scheduled')
54
+ expect(classifySubscriptionChange(state({ cancelAtPeriodEnd: true }), record(SubscriptionStatus.Active))).toBe('cancel-undone')
55
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Active), { renewal: true, invoiceId: 'in_2' })).toBe('renewed')
56
+ expect(classifySubscriptionChange(state({ renewedInvoiceId: 'in_2' }), record(SubscriptionStatus.Active), { renewal: true, invoiceId: 'in_2' })).toBeNull()
57
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.PastDue))).toBe('past-due')
58
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Suspended))).toBe('suspended')
59
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Trial), { trialEnding: true })).toBe('trial-ending')
60
+ expect(classifySubscriptionChange(state(), record(SubscriptionStatus.Active))).toBeNull()
61
+ })
62
+
63
+ test('maps paygate statuses: past_due entitles, unpaid, paused and paused collection revoke', () => {
64
+ const map = (status: string, pause: unknown = null) => mapStatus({ status, pause_collection: pause } as never)
65
+ expect(map('active')).toBe(SubscriptionStatus.Active)
66
+ expect(map('trialing')).toBe(SubscriptionStatus.Trial)
67
+ expect(map('past_due')).toBe(SubscriptionStatus.PastDue)
68
+ expect(map('unpaid')).toBe(SubscriptionStatus.Suspended)
69
+ expect(map('paused')).toBe(SubscriptionStatus.Suspended)
70
+ expect(map('active', { behavior: 'void' })).toBe(SubscriptionStatus.Suspended)
71
+ expect(map('incomplete')).toBe(SubscriptionStatus.Created)
72
+ expect(map('incomplete_expired')).toBe(SubscriptionStatus.Ended)
73
+ expect(map('canceled')).toBe(SubscriptionStatus.Canceled)
74
+ })
75
+ })
76
+
77
+ describe('@owlmeans/server-payment — customer and checkout events', () => {
78
+ test('customer created and updated upsert the customer; deleted marks it', async () => {
79
+ const fake = await makeFakeContext()
80
+ await send(fake, 'customer.created', { id: 'cus_1', email: 'a@example.com', metadata: { entityId: 'entity-1' } })
81
+ await send(fake, 'customer.updated', { id: 'cus_1', email: 'b@example.com', metadata: {} })
82
+ expect(fake.stores['payment-paygate-customer'].rows).toEqual([
83
+ expect.objectContaining({ externalId: 'cus_1', entityId: 'entity-1', email: 'b@example.com' }),
84
+ ])
85
+ await send(fake, 'customer.deleted', { id: 'cus_1', deleted: true })
86
+ expect(fake.stores['payment-paygate-customer'].rows[0].deletedAt).toBeInstanceOf(Date)
87
+ })
88
+
89
+ test('an asynchronous payment failure is recorded and observed; an expired session leaves nothing', async () => {
90
+ const fake = await makeFakeContext()
91
+ const session = {
92
+ id: 'cs_1', mode: 'payment', customer: 'cus_1', payment_intent: 'pi_1',
93
+ metadata: { entityId: 'entity-1', service: 'app', productSku: CREDITS_PRODUCT, pricingMode: 'amount' },
94
+ }
95
+ await send(fake, 'checkout.session.async_payment_failed', session)
96
+ expect(fake.stores['payment-fulfillment'].rows[0]).toEqual(expect.objectContaining({ externalId: 'cs_1', paymentIntentId: 'pi_1' }))
97
+ expect(fake.stores['payment-fulfillment'].rows[0].failedAt).toBeInstanceOf(Date)
98
+ expect(fake.observed.paymentFailed).toEqual([
99
+ expect.objectContaining({ entityId: 'entity-1', kind: 'checkout', externalId: 'cs_1', eventKey: 'payment-failed:cs_1:0' }),
100
+ ])
101
+
102
+ await send(fake, 'checkout.session.expired', session)
103
+ expect(fake.stores['payment-fulfillment'].rows).toHaveLength(0)
104
+ })
105
+ })
106
+
107
+ describe('@owlmeans/server-payment — subscription events', () => {
108
+ test('observers hear `created` once the subscription entitles; a repeated or payment-method-only event says nothing', async () => {
109
+ const fake = await makeFakeContext()
110
+ await send(fake, 'customer.subscription.created', subscriptionOf({ status: 'incomplete' }))
111
+ expect(rows(fake)[0].status).toBe(SubscriptionStatus.Created)
112
+ expect(fake.observed.subscription).toHaveLength(0)
113
+
114
+ const active = subscriptionOf()
115
+ const event = await send(fake, 'customer.subscription.updated', active)
116
+ const [created] = fake.observed.subscription
117
+ expect(created).toEqual(expect.objectContaining({
118
+ change: 'created', active: true, previous: null, externalEventId: event.id,
119
+ eventKey: `subscription:sub_1:created:${new Date(active.created * 1000).toISOString()}`,
120
+ }))
121
+ expect(created.current).toEqual(expect.objectContaining({ planSku: PRO, rank: 10, entityId: 'entity-1', subscriptionId: 'sub_1' }))
122
+ expect(created.current.capabilities?.[0].permissions.whitelabel).toBe(true)
123
+ expect(rows(fake)[0].initialPropagatedAt).toBeInstanceOf(Date)
124
+
125
+ await createEventHandler(fake.ctx, fake.stripe).process(event)
126
+ await send(fake, 'customer.subscription.updated', subscriptionOf({ defaultPaymentMethod: 'pm_2' }))
127
+ expect(changes(fake)).toEqual(['created'])
128
+ })
129
+
130
+ test("resolves the plan from the price's `metadata.sku` when a replaced price carries no lookup key", async () => {
131
+ const fake = await makeFakeContext()
132
+ const replaced = {
133
+ ...subscriptionOf(),
134
+ items: {
135
+ object: 'list', has_more: false, url: '',
136
+ data: [{ id: 'si_1', price: { id: 'price_new', lookup_key: null, metadata: { sku: PRO } } }],
137
+ },
138
+ } as never
139
+ await send(fake, 'customer.subscription.updated', replaced)
140
+ expect(rows(fake)[0]).toEqual(expect.objectContaining({ planSku: PRO, priceId: 'price_new' }))
141
+ })
142
+
143
+ test('plan changes and cancel scheduling are classified with stable keys', async () => {
144
+ const fake = await makeFakeContext()
145
+ await subscribe(fake)
146
+ await send(fake, 'customer.subscription.updated', subscriptionOf({ planSku: TEAM }))
147
+ await send(fake, 'customer.subscription.pending_update_applied', subscriptionOf({ planSku: PRO }))
148
+ await send(fake, 'customer.subscription.updated', subscriptionOf({ cancelAtPeriodEnd: true }))
149
+ const undo = await send(fake, 'customer.subscription.updated', subscriptionOf({ cancelAtPeriodEnd: false }))
150
+
151
+ expect(changes(fake)).toEqual(['created', 'upgraded', 'downgraded', 'cancel-scheduled', 'cancel-undone'])
152
+ expect(fake.observed.subscription[1].eventKey).toBe(`subscription:sub_1:upgraded:${TEAM}`)
153
+ expect(fake.observed.subscription[1].previous?.rank).toBe(10)
154
+ expect(fake.observed.subscription[1].current.rank).toBe(20)
155
+ expect(fake.observed.subscription[2].eventKey).toBe(`subscription:sub_1:downgraded:${PRO}`)
156
+ expect(fake.observed.subscription[4].eventKey).toBe(`subscription:sub_1:cancel-undone:${undo.id}`)
157
+ })
158
+
159
+ test('paused collection suspends, resume restores, trial end is announced, deletion cancels', async () => {
160
+ const fake = await makeFakeContext()
161
+ await subscribe(fake)
162
+ await send(fake, 'customer.subscription.updated', subscriptionOf({ pauseCollection: { behavior: 'void' } }))
163
+ expect(rows(fake)[0]).toEqual(expect.objectContaining({ status: SubscriptionStatus.Suspended, externalStatus: 'active' }))
164
+ expect(rows(fake)[0].pausedAt).toBeInstanceOf(Date)
165
+ await send(fake, 'customer.subscription.resumed', subscriptionOf())
166
+ expect(rows(fake)[0].pausedAt).toBeUndefined()
167
+
168
+ const trialEnd = future(3)
169
+ await send(fake, 'customer.subscription.trial_will_end', subscriptionOf({ status: 'trialing', trialEnd }))
170
+ await send(fake, 'customer.subscription.deleted', subscriptionOf({ status: 'canceled' }))
171
+
172
+ expect(changes(fake)).toEqual(['created', 'paused', 'resumed', 'trial-ending', 'canceled'])
173
+ expect(fake.observed.subscription[3].eventKey)
174
+ .toBe(`subscription:sub_1:trial-ending:${new Date(epoch(trialEnd) * 1000).toISOString()}`)
175
+ expect(rows(fake)[0].status).toBe(SubscriptionStatus.Canceled)
176
+ expect(rows(fake)[0].endedAt).toBeInstanceOf(Date)
177
+ })
178
+
179
+ test('a throwing observer leaves the change to be told again; an older payload is not applied', async () => {
180
+ const fake = await makeFakeContext()
181
+ await subscribe(fake)
182
+ fake.observed.failSubscription = 1
183
+ const upgrade = eventOf('customer.subscription.updated', subscriptionOf({ planSku: TEAM }))
184
+ await expect(createEventHandler(fake.ctx, fake.stripe).process(upgrade)).rejects.toThrow('observer unavailable')
185
+ expect(rows(fake)[0].planSku).toBe(TEAM)
186
+ await createEventHandler(fake.ctx, fake.stripe).process(upgrade)
187
+ expect(changes(fake)).toEqual(['created', 'upgraded'])
188
+
189
+ await send(fake, 'customer.subscription.updated', subscriptionOf({ planSku: PRO }), { created: epoch(new Date()) - 3600 })
190
+ expect(rows(fake)[0].planSku).toBe(TEAM)
191
+ })
192
+ })
193
+
194
+ describe('@owlmeans/server-payment — invoice events', () => {
195
+ const invoice = (overrides: Record<string, unknown> = {}) => ({
196
+ id: 'in_2', object: 'invoice', subscription: 'sub_1', customer: 'cus_1', billing_reason: 'subscription_cycle',
197
+ attempt_count: 1, next_payment_attempt: epoch(future(2)), ...overrides,
198
+ })
199
+
200
+ test('a paid cycle invoice renews once per invoice; any other paid invoice only moves the latest invoice', async () => {
201
+ const fake = await makeFakeContext()
202
+ await subscribe(fake)
203
+ await send(fake, 'invoice.paid', invoice())
204
+ await send(fake, 'invoice.paid', invoice())
205
+ expect(changes(fake)).toEqual(['created', 'renewed'])
206
+ expect(fake.observed.subscription[1]).toEqual(expect.objectContaining({
207
+ eventKey: 'subscription:sub_1:renewed:in_2', invoiceId: 'in_2',
208
+ }))
209
+
210
+ await send(fake, 'invoice.paid', invoice({ id: 'in_3', billing_reason: 'manual' }))
211
+ expect(rows(fake)[0].latestInvoiceId).toBe('in_3')
212
+ expect(changes(fake)).toEqual(['created', 'renewed'])
213
+ })
214
+
215
+ test('a failed payment re-reads the subscription (past-due) and reports the attempt', async () => {
216
+ const fake = await makeFakeContext()
217
+ await subscribe(fake)
218
+ fake.state.subscriptions.sub_1 = subscriptionOf({ status: 'past_due' }) as never
219
+ await send(fake, 'invoice.payment_failed', invoice({ attempt_count: 2 }))
220
+ await send(fake, 'invoice.payment_action_required', invoice({ attempt_count: 2, next_payment_attempt: null }))
221
+
222
+ expect(changes(fake)).toEqual(['created', 'past-due'])
223
+ expect(fake.observed.paymentFailed).toEqual([
224
+ expect.objectContaining({
225
+ entityId: 'entity-1', kind: 'invoice', invoiceId: 'in_2', subscriptionId: 'sub_1', attempt: 2,
226
+ actionRequired: false, eventKey: 'payment-failed:in_2:2',
227
+ }),
228
+ expect.objectContaining({ actionRequired: true, eventKey: 'payment-failed:in_2:2' }),
229
+ ])
230
+ expect(fake.observed.paymentFailed[0].nextAttemptAt).toBeInstanceOf(Date)
231
+ })
232
+
233
+ test('uncollectible suspends, voided refreshes the latest invoice, upcoming does nothing', async () => {
234
+ const fake = await makeFakeContext()
235
+ await subscribe(fake, { latestInvoice: 'in_1' })
236
+ fake.state.calls.length = 0
237
+ await send(fake, 'invoice.upcoming', { object: 'invoice', subscription: 'sub_1' })
238
+ expect(fake.state.calls).toEqual([])
239
+
240
+ fake.state.subscriptions.sub_1 = subscriptionOf({ status: 'unpaid', latestInvoice: 'in_4' }) as never
241
+ await send(fake, 'invoice.marked_uncollectible', invoice())
242
+ expect(changes(fake)).toEqual(['created', 'suspended'])
243
+
244
+ fake.state.subscriptions.sub_1 = subscriptionOf({ status: 'unpaid', latestInvoice: 'in_5' }) as never
245
+ await send(fake, 'invoice.voided', invoice({ id: 'in_4' }))
246
+ expect(rows(fake)[0].latestInvoiceId).toBe('in_5')
247
+ expect(changes(fake)).toEqual(['created', 'suspended'])
248
+ })
249
+ })
250
+
251
+ describe('@owlmeans/server-payment — refund and dispute events', () => {
252
+ const fulfilled = async () => {
253
+ const fake = await makeFakeContext({
254
+ stripe: {
255
+ charges: { ch_1: { id: 'ch_1', amount: 1225, amount_refunded: 500, payment_intent: 'pi_1', invoice: null } },
256
+ },
257
+ })
258
+ await send(fake, 'checkout.session.completed', {
259
+ id: 'cs_1', mode: 'payment', payment_status: 'paid', customer: 'cus_1', currency: 'usd', amount_subtotal: 1021,
260
+ payment_intent: 'pi_1', invoice: 'in_9',
261
+ metadata: {
262
+ pricingMode: 'amount', amountMinor: '1000', chargeAmountMinor: '1021', currency: 'usd',
263
+ entityId: 'entity-1', service: 'app', productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit',
264
+ },
265
+ })
266
+ return fake
267
+ }
268
+ const refund = (overrides: Record<string, unknown> = {}) => ({
269
+ id: 're_1', object: 'refund', amount: 500, currency: 'usd', status: 'succeeded', payment_intent: 'pi_1', charge: 'ch_1',
270
+ ...overrides,
271
+ })
272
+
273
+ test('a succeeded refund of a fulfillment is recorded and observed with the checkout amounts', async () => {
274
+ const fake = await fulfilled()
275
+ await send(fake, 'refund.updated', refund({ status: 'pending' }))
276
+ await send(fake, 'refund.failed', refund({ status: 'failed' }))
277
+ expect(fake.observed.refund).toHaveLength(0)
278
+
279
+ await send(fake, 'refund.created', refund())
280
+ expect(fake.stores['payment-fulfillment'].rows[0]).toEqual(expect.objectContaining({ refundedMinor: 500, paymentIntentId: 'pi_1' }))
281
+ expect(fake.observed.refund).toEqual([expect.objectContaining({
282
+ entityId: 'entity-1', target: 'fulfillment', externalId: 'cs_1', refundId: 're_1', amountMinor: 500,
283
+ refundedTotalMinor: 500, paidMinor: 1225, partial: true, currency: 'usd', eventKey: 'refund:re_1',
284
+ netAmountMinor: 1000, chargeAmountMinor: 1021, productSku: CREDITS_PRODUCT,
285
+ })])
286
+ })
287
+
288
+ test('charge.refunded resolves a subscription invoice through the charge; an unknown payment is ignored', async () => {
289
+ const fake = await makeFakeContext({
290
+ stripe: {
291
+ charges: { ch_2: { id: 'ch_2', amount: 2000, amount_refunded: 2000, payment_intent: 'pi_2', invoice: 'in_2' } },
292
+ refunds: [{ id: 're_2', amount: 2000, currency: 'usd', status: 'succeeded', charge: 'ch_2', payment_intent: 'pi_2' }],
293
+ },
294
+ })
295
+ await subscribe(fake, { latestInvoice: 'in_2' })
296
+ await send(fake, 'charge.refunded', fake.state.charges.ch_2)
297
+ expect(fake.observed.refund).toEqual([expect.objectContaining({
298
+ target: 'subscription', externalId: 'sub_1', invoiceId: 'in_2', planSku: PRO, partial: false, eventKey: 'refund:re_2',
299
+ })])
300
+
301
+ await send(fake, 'refund.created', refund({ id: 're_x', payment_intent: 'pi_x', charge: null }))
302
+ expect(fake.observed.refund).toHaveLength(1)
303
+ })
304
+
305
+ test('every dispute phase marks the target and is observed once per phase', async () => {
306
+ const fake = await fulfilled()
307
+ const dispute = { id: 'dp_1', object: 'dispute', amount: 1225, currency: 'usd', charge: 'ch_1', payment_intent: null }
308
+ await send(fake, 'charge.dispute.created', { ...dispute, status: 'needs_response' })
309
+ await send(fake, 'charge.dispute.funds_withdrawn', { ...dispute, status: 'under_review' })
310
+ await send(fake, 'charge.dispute.funds_reinstated', { ...dispute, status: 'won' })
311
+ await send(fake, 'charge.dispute.closed', { ...dispute, status: 'won' })
312
+
313
+ expect(fake.observed.dispute.map(event => event.eventKey)).toEqual([
314
+ 'dispute:dp_1:opened', 'dispute:dp_1:funds-withdrawn', 'dispute:dp_1:funds-reinstated', 'dispute:dp_1:closed',
315
+ ])
316
+ expect(fake.observed.dispute[0]).toEqual(expect.objectContaining({
317
+ target: 'fulfillment', externalId: 'cs_1', amountMinor: 1225, netAmountMinor: 1000,
318
+ }))
319
+ expect(fake.stores['payment-fulfillment'].rows[0]).toEqual(expect.objectContaining({ disputeStatus: 'won', chargeId: 'ch_1' }))
320
+ expect(fake.stores['payment-fulfillment'].rows[0].disputedAt).toBeInstanceOf(Date)
321
+ })
322
+ })
323
+
324
+ describe('@owlmeans/server-payment — resync and internal grants', () => {
325
+ test('resync applies the paygate state, cancels a vanished subscription and skips terminal rows', async () => {
326
+ const fake = await makeFakeContext()
327
+ await subscribe(fake)
328
+ await subscribe(fake, { id: 'sub_2', planSku: TEAM, entityId: 'entity-2' })
329
+ fake.state.subscriptions.sub_1 = subscriptionOf({ cancelAtPeriodEnd: true }) as never
330
+ delete fake.state.subscriptions.sub_2
331
+
332
+ expect(await resyncStripeSubscriptions(fake.ctx, fake.stripe)).toEqual({ scanned: 2, updated: 2 })
333
+ expect(changes(fake)).toEqual(['created', 'created', 'cancel-scheduled', 'canceled'])
334
+ expect(rows(fake).find(row => row.externalId === 'sub_2')?.status).toBe(SubscriptionStatus.Canceled)
335
+
336
+ expect(await resyncStripeSubscriptions(fake.ctx, fake.stripe)).toEqual({ scanned: 1, updated: 0 })
337
+ expect(await resyncStripeSubscription(fake.ctx, fake.stripe, { entityId: 'entity-2' })).toBe(0)
338
+ })
339
+
340
+ test('an internal grant is created once, keeps its creation date and needs force for a paid plan', async () => {
341
+ const fake = await makeFakeContext()
342
+ const first = await gateway(fake.ctx).grantInternalPlan(fake.ctx, 'entity-1', FREE)
343
+ const again = await gateway(fake.ctx).grantInternalPlan(fake.ctx, 'entity-1', FREE)
344
+ expect(first.externalId).toBe('free:entity-1')
345
+ expect(again.createdAt.getTime()).toBe(first.createdAt.getTime())
346
+ expect(fake.observed.subscription).toEqual([expect.objectContaining({
347
+ change: 'created', eventKey: `subscription:free:entity-1:created:${first.createdAt.toISOString()}`,
348
+ })])
349
+
350
+ await expect(gateway(fake.ctx).grantInternalPlan(fake.ctx, 'entity-1', PRO)).rejects.toBeInstanceOf(ProductError)
351
+ const comp = await gateway(fake.ctx).grantInternalPlan(fake.ctx, 'entity-1', PRO, { force: true, periodEnd: future(365) })
352
+ expect(comp).toEqual(expect.objectContaining({ externalId: `internal:${PRO}:entity-1`, paygate: 'internal', rank: 10 }))
353
+ expect(fake.observed.subscription[1].current.capabilities?.some(set => set.permissions.whitelabel === true)).toBe(true)
354
+ expect(CAP_WHITELABEL).toBe('feature:whitelabel')
355
+ })
356
+ })