@owlmeans/server-payment 0.1.18-rc.2 → 0.1.18-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -25
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +475 -52
- package/build/actions/index.d.ts +1 -0
- package/build/actions/index.d.ts.map +1 -1
- package/build/actions/index.js +1 -0
- package/build/actions/index.js.map +1 -1
- package/build/actions/resync-subscriptions.d.ts +3 -0
- package/build/actions/resync-subscriptions.d.ts.map +1 -0
- package/build/actions/resync-subscriptions.js +7 -0
- package/build/actions/resync-subscriptions.js.map +1 -0
- package/build/actions/resync.d.ts +1 -0
- package/build/actions/resync.d.ts.map +1 -1
- package/build/actions/resync.js +7 -3
- package/build/actions/resync.js.map +1 -1
- package/build/actions/webhook.d.ts.map +1 -1
- package/build/actions/webhook.js +6 -3
- package/build/actions/webhook.js.map +1 -1
- package/build/config.d.ts +60 -5
- package/build/config.d.ts.map +1 -1
- package/build/config.js +237 -5
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +75 -3
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +94 -5
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/entitlement.d.ts +10 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +69 -0
- package/build/entitlement.js.map +1 -0
- package/build/entrypoints.d.ts +1 -1
- package/build/entrypoints.d.ts.map +1 -1
- package/build/entrypoints.js +2 -1
- package/build/entrypoints.js.map +1 -1
- package/build/gate.d.ts +32 -4
- package/build/gate.d.ts.map +1 -1
- package/build/gate.js +59 -19
- package/build/gate.js.map +1 -1
- package/build/index.d.ts +18 -3
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -3
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +13 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +47 -0
- package/build/limit.js.map +1 -0
- package/build/model.d.ts +10 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +215 -17
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +9 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +38 -12
- package/build/observer.js.map +1 -1
- package/build/plan.d.ts +22 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +72 -0
- package/build/plan.js.map +1 -0
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts +43 -0
- package/build/plugins/estimate.d.ts.map +1 -0
- package/build/plugins/estimate.js +268 -0
- package/build/plugins/estimate.js.map +1 -0
- package/build/plugins/events.d.ts +45 -10
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +605 -136
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +31 -0
- package/build/plugins/fx.d.ts.map +1 -0
- package/build/plugins/fx.js +81 -0
- package/build/plugins/fx.js.map +1 -0
- package/build/plugins/portal.d.ts +37 -0
- package/build/plugins/portal.d.ts.map +1 -0
- package/build/plugins/portal.js +265 -0
- package/build/plugins/portal.js.map +1 -0
- package/build/plugins/refunds.d.ts +33 -0
- package/build/plugins/refunds.d.ts.map +1 -0
- package/build/plugins/refunds.js +80 -0
- package/build/plugins/refunds.js.map +1 -0
- package/build/plugins/stripe.d.ts +36 -4
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +492 -97
- package/build/plugins/stripe.js.map +1 -1
- package/build/plugins/webhook-manager.d.ts +46 -0
- package/build/plugins/webhook-manager.d.ts.map +1 -0
- package/build/plugins/webhook-manager.js +231 -0
- package/build/plugins/webhook-manager.js.map +1 -0
- package/build/reconcile.d.ts +15 -0
- package/build/reconcile.d.ts.map +1 -0
- package/build/reconcile.js +88 -0
- package/build/reconcile.js.map +1 -0
- package/build/resource.d.ts +12 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +97 -6
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +28 -3
- package/build/service.d.ts.map +1 -1
- package/build/service.js +146 -19
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +48 -0
- package/build/subscription.d.ts.map +1 -0
- package/build/subscription.js +176 -0
- package/build/subscription.js.map +1 -0
- package/build/sync.d.ts +28 -1
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +208 -31
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +1115 -36
- package/build/types.d.ts.map +1 -1
- package/build/usage.d.ts +63 -0
- package/build/usage.d.ts.map +1 -0
- package/build/usage.js +363 -0
- package/build/usage.js.map +1 -0
- package/build/utils.d.ts +52 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +69 -7
- package/build/utils.js.map +1 -1
- package/package.json +17 -13
- package/src/actions/index.ts +1 -0
- package/src/actions/resync-subscriptions.ts +10 -0
- package/src/actions/resync.ts +6 -3
- package/src/actions/webhook.ts +5 -3
- package/src/config.ts +264 -8
- package/src/consts.ts +114 -6
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/entitlement.ts +84 -0
- package/src/entrypoints.ts +2 -1
- package/src/gate.ts +88 -21
- package/src/index.ts +24 -3
- package/src/limit.ts +57 -0
- package/src/model.ts +237 -18
- package/src/observer.ts +44 -11
- package/src/plan.ts +89 -0
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +339 -0
- package/src/plugins/events.ts +677 -121
- package/src/plugins/fx.ts +122 -0
- package/src/plugins/portal.ts +306 -0
- package/src/plugins/refunds.ts +108 -0
- package/src/plugins/stripe.ts +581 -96
- package/src/plugins/webhook-manager.ts +270 -0
- package/src/reconcile.ts +103 -0
- package/src/resource.ts +152 -7
- package/src/service.ts +174 -18
- package/src/subscription.ts +231 -0
- package/src/sync.ts +249 -29
- package/src/types.ts +1227 -32
- package/src/usage.ts +453 -0
- package/src/utils.ts +127 -10
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/checkout.spec.ts +184 -83
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +109 -0
- package/tests/entitlement.spec.ts +103 -0
- package/tests/estimate.spec.ts +240 -0
- package/tests/events.spec.ts +356 -0
- package/tests/fake-stripe.ts +972 -0
- package/tests/gate.spec.ts +62 -72
- package/tests/limit-gate.spec.ts +68 -0
- package/tests/portal.spec.ts +200 -0
- package/tests/protocol.spec.ts +23 -6
- package/tests/sync.spec.ts +114 -0
- package/tests/usage.integration.spec.ts +101 -0
- package/tests/usage.spec.ts +171 -0
- package/tests/webhook-manager.spec.ts +152 -0
package/tests/checkout.spec.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { CheckoutPricingMode, PlanDuration, PlanStatus, ProductType } from '@owlmeans/payment'
|
|
3
|
-
import { PAYMENT_OBSERVER, RES_PAYMENT_SUBSCRIPTION } from '../src/consts.js'
|
|
2
|
+
import { CheckoutPricingMode, PlanDuration, PlanStatus, ProductError, ProductType, TaxBehavior } from '@owlmeans/payment'
|
|
4
3
|
import { createEventHandler } from '../src/plugins/events.js'
|
|
5
|
-
import { amountCheckoutLineItem, quantityCheckoutLineItem } from '../src/plugins/stripe.js'
|
|
6
|
-
import type { PaymentPlan, PaymentProduct
|
|
4
|
+
import { amountCheckoutLineItem, createCheckoutLink, quantityCheckoutLineItem } from '../src/plugins/stripe.js'
|
|
5
|
+
import type { PaymentPlan, PaymentProduct } from '../src/types.js'
|
|
6
|
+
import { CREDITS_PRODUCT, FREE, makeFakeContext, PLANS_PRODUCT, PRO, TEAM } from './fake-stripe.js'
|
|
7
|
+
import type { FakeContext } from './fake-stripe.js'
|
|
8
|
+
|
|
9
|
+
const proPrice = { id: 'price_pro', product: PLANS_PRODUCT, lookup_key: PRO, active: true, recurring: { interval: 'month' } }
|
|
10
|
+
const successUrl = 'https://app.example.com/ok'
|
|
7
11
|
|
|
8
12
|
const product: PaymentProduct = {
|
|
9
13
|
type: ProductType.Consumable, sku: 'credits', title: 'Credits', services: ['app'],
|
|
@@ -20,41 +24,20 @@ const plan: PaymentPlan = {
|
|
|
20
24
|
|
|
21
25
|
const paid = (overrides: Record<string, unknown> = {}) => ({
|
|
22
26
|
id: 'cs_amount', mode: 'payment', payment_status: 'paid', customer: 'cus_1',
|
|
23
|
-
currency: 'usd', amount_subtotal: 1021,
|
|
27
|
+
currency: 'usd', amount_subtotal: 1021, payment_intent: 'pi_1', invoice: 'in_1',
|
|
24
28
|
metadata: {
|
|
25
29
|
pricingMode: 'amount', amountMinor: '1000', chargeAmountMinor: '1021', currency: 'usd',
|
|
26
|
-
entityId: 'entity-1', service: 'app', productSku:
|
|
30
|
+
entityId: 'entity-1', service: 'app', productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit',
|
|
27
31
|
},
|
|
28
32
|
...overrides,
|
|
29
33
|
})
|
|
30
34
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const subscriptions = {
|
|
35
|
-
byExternalId: async (id: string) => records.find(record => record.externalId === id) ?? null,
|
|
36
|
-
create: async (record: PaymentSubscriptionRecord) => {
|
|
37
|
-
const created = { ...record, id: String(records.length + 1) }
|
|
38
|
-
records.push(created)
|
|
39
|
-
return created
|
|
40
|
-
},
|
|
41
|
-
save: async (record: PaymentSubscriptionRecord) => {
|
|
42
|
-
const index = records.findIndex(item => item.id === record.id)
|
|
43
|
-
records[index] = { ...record }
|
|
44
|
-
return records[index]
|
|
45
|
-
},
|
|
46
|
-
}
|
|
47
|
-
const observer = { propagateTopUp: async (completion: TopUpCompletion) => { completions.push(completion) } }
|
|
48
|
-
const context = {
|
|
49
|
-
resource: (alias: string) => alias === RES_PAYMENT_SUBSCRIPTION ? subscriptions : undefined,
|
|
50
|
-
service: (alias: string) => alias === PAYMENT_OBSERVER ? observer : undefined,
|
|
51
|
-
}
|
|
52
|
-
const stripe = { checkout: { sessions: { listLineItems: async () => ({ data: [{ quantity: 7 }] }) } } }
|
|
53
|
-
return { records, completions, context, stripe }
|
|
54
|
-
}
|
|
35
|
+
const process = async (fake: FakeContext, type: string, object: unknown) =>
|
|
36
|
+
await createEventHandler(fake.ctx, fake.stripe).process({ type, data: { object } } as never)
|
|
37
|
+
const fulfillment = (fake: FakeContext) => fake.stores['payment-fulfillment'].rows
|
|
55
38
|
|
|
56
|
-
describe('Stripe
|
|
57
|
-
test('uses one inline, tax-exclusive item and no reusable Price', () => {
|
|
39
|
+
describe('Stripe checkout', () => {
|
|
40
|
+
test('amount checkout uses one inline, tax-exclusive item and no reusable Price', () => {
|
|
58
41
|
const result = amountCheckoutLineItem(product, plan, 1_000)
|
|
59
42
|
expect(result.chargeMinor).toBe(1_021)
|
|
60
43
|
expect(result.lineItem).toEqual({
|
|
@@ -64,7 +47,7 @@ describe('Stripe amount checkout', () => {
|
|
|
64
47
|
expect(result.lineItem.adjustable_quantity).toBeUndefined()
|
|
65
48
|
})
|
|
66
49
|
|
|
67
|
-
test('keeps reusable Price and adjustable quantity
|
|
50
|
+
test('quantity checkout keeps a reusable Price and an adjustable quantity', () => {
|
|
68
51
|
expect(quantityCheckoutLineItem({ id: 'price_1' } as never, {
|
|
69
52
|
minimum: 5, maximum: 500, default: 10,
|
|
70
53
|
})).toEqual({
|
|
@@ -73,63 +56,181 @@ describe('Stripe amount checkout', () => {
|
|
|
73
56
|
})
|
|
74
57
|
})
|
|
75
58
|
|
|
76
|
-
test('
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
59
|
+
test('a subscription checkout takes the asked plan, locale and disclosure; a free plan is never checked out', async () => {
|
|
60
|
+
const fake = await makeFakeContext({
|
|
61
|
+
stripe: { prices: [{ id: 'price_team', product: PLANS_PRODUCT, lookup_key: TEAM, active: true, recurring: { interval: 'month' } }] },
|
|
62
|
+
})
|
|
63
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
64
|
+
productSku: PLANS_PRODUCT, planSku: TEAM, entityId: 'entity-1', service: 'app', successUrl: 'https://app.example.com/ok',
|
|
65
|
+
locale: 'fr', submitText: 'Comprend des crédits prépayés obligatoires.',
|
|
66
|
+
})
|
|
67
|
+
expect(fake.state.checkoutSessions[0]).toEqual(expect.objectContaining({
|
|
68
|
+
mode: 'subscription', line_items: [{ price: 'price_team', quantity: 1 }],
|
|
69
|
+
locale: 'fr', custom_text: { submit: { message: 'Comprend des crédits prépayés obligatoires.' } },
|
|
70
|
+
}))
|
|
71
|
+
expect(fake.state.checkoutSessions[0].subscription_data.metadata).toEqual(expect.objectContaining({ entityId: 'entity-1', planSku: TEAM }))
|
|
72
|
+
expect(fake.state.customers.cus_1.preferred_locales).toEqual(['fr'])
|
|
73
|
+
|
|
74
|
+
await expect(createCheckoutLink(fake.ctx, fake.stripe, {
|
|
75
|
+
productSku: PLANS_PRODUCT, planSku: FREE, entityId: 'entity-1', service: 'app',
|
|
76
|
+
})).rejects.toBeInstanceOf(ProductError)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('Stripe checkout — pricing policy', () => {
|
|
81
|
+
test('an undeclared policy reproduces exactly the session hard-coded before this policy existed', async () => {
|
|
82
|
+
const fake = await makeFakeContext({ stripe: { prices: [proPrice] } })
|
|
83
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
84
|
+
productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
|
|
85
|
+
})
|
|
86
|
+
const session = fake.state.checkoutSessions[0]
|
|
87
|
+
expect(session).toEqual(expect.objectContaining({
|
|
88
|
+
automatic_tax: { enabled: true }, billing_address_collection: 'required',
|
|
89
|
+
tax_id_collection: { enabled: true }, customer_update: { address: 'auto', name: 'auto' },
|
|
90
|
+
}))
|
|
91
|
+
expect(session.adaptive_pricing).toBeUndefined()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('tax.automatic and tax.collectTaxId are independent switches', async () => {
|
|
95
|
+
const fake = await makeFakeContext({
|
|
96
|
+
pricing: { tax: { automatic: false, collectTaxId: true, estimate: false }, currency: { estimate: false } },
|
|
97
|
+
stripe: { prices: [proPrice] },
|
|
98
|
+
})
|
|
99
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
100
|
+
productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
|
|
101
|
+
})
|
|
102
|
+
const session = fake.state.checkoutSessions[0]
|
|
103
|
+
expect(session.automatic_tax).toBeUndefined()
|
|
104
|
+
expect(session.billing_address_collection).toBeUndefined()
|
|
105
|
+
expect(session.tax_id_collection).toEqual({ enabled: true })
|
|
106
|
+
// only the concern that needs it contributes a `customer_update` field
|
|
107
|
+
expect(session.customer_update).toEqual({ name: 'auto' })
|
|
83
108
|
})
|
|
84
109
|
|
|
85
|
-
test('
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
expect(
|
|
110
|
+
test('adaptive_pricing appears on the session only when currency.adaptive is declared', async () => {
|
|
111
|
+
const fake = await makeFakeContext({
|
|
112
|
+
pricing: { tax: { automatic: true, collectTaxId: true, estimate: false }, currency: { adaptive: true, estimate: false } },
|
|
113
|
+
stripe: { prices: [proPrice] },
|
|
114
|
+
})
|
|
115
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
116
|
+
productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
|
|
117
|
+
})
|
|
118
|
+
expect(fake.state.checkoutSessions[0].adaptive_pricing).toEqual({ enabled: true })
|
|
94
119
|
})
|
|
95
120
|
|
|
96
|
-
test('
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
test('subscription checkout can explicitly offer recurring-capable payment methods', async () => {
|
|
122
|
+
const fake = await makeFakeContext({
|
|
123
|
+
pricing: {
|
|
124
|
+
tax: { automatic: true, collectTaxId: true, estimate: false }, currency: { estimate: false },
|
|
125
|
+
stripe: { subscriptionPaymentMethodTypes: ['CARD', 'link', 'klarna'] },
|
|
126
|
+
},
|
|
127
|
+
stripe: { prices: [proPrice] },
|
|
128
|
+
})
|
|
129
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
130
|
+
productSku: PLANS_PRODUCT, planSku: PRO, entityId: 'entity-1', service: 'app', successUrl,
|
|
131
|
+
})
|
|
132
|
+
expect(fake.state.checkoutSessions[0].payment_method_types).toEqual(['card', 'link', 'klarna'])
|
|
105
133
|
})
|
|
106
134
|
|
|
107
|
-
test('
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
await
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
test('a declared behavior is set on the inline amount line item', async () => {
|
|
136
|
+
const fake = await makeFakeContext({
|
|
137
|
+
pricing: { tax: { automatic: true, collectTaxId: true, estimate: false, behavior: TaxBehavior.Inclusive }, currency: { estimate: false } },
|
|
138
|
+
})
|
|
139
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
140
|
+
productSku: CREDITS_PRODUCT, entityId: 'entity-1', service: 'app', amountMinor: 1_000, successUrl,
|
|
141
|
+
})
|
|
142
|
+
expect(fake.state.checkoutSessions[0].line_items[0].price_data.tax_behavior).toBe('inclusive')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('an amount checkout converts the USD catalogue charge to the settlement currency', async () => {
|
|
146
|
+
const fake = await makeFakeContext({
|
|
147
|
+
pricing: {
|
|
148
|
+
tax: { automatic: true, collectTaxId: true, estimate: false },
|
|
149
|
+
currency: { adaptive: true, estimate: false }, stripe: { settlementCurrency: 'EUR' },
|
|
150
|
+
},
|
|
151
|
+
stripe: { fxRates: { usd: { exchangeRate: 0.853568, referenceRate: 0.8726 } } },
|
|
152
|
+
})
|
|
153
|
+
await createCheckoutLink(fake.ctx, fake.stripe, {
|
|
154
|
+
productSku: CREDITS_PRODUCT, entityId: 'entity-1', service: 'app', amountMinor: 1_000, successUrl,
|
|
155
|
+
})
|
|
156
|
+
const session = fake.state.checkoutSessions[0]
|
|
157
|
+
expect(session.line_items[0].price_data).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 891 }))
|
|
158
|
+
expect(session.metadata).toEqual(expect.objectContaining({
|
|
159
|
+
amountMinor: '1000', sourceChargeAmountMinor: '1021', amountCurrency: 'usd',
|
|
160
|
+
chargeAmountMinor: '891', currency: 'eur',
|
|
161
|
+
}))
|
|
162
|
+
expect(fake.state.rawRequests[0]).toEqual(expect.objectContaining({
|
|
163
|
+
params: { to_currency: 'eur', 'from_currencies[]': 'usd', lock_duration: 'none' },
|
|
164
|
+
}))
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
describe('Stripe checkout fulfillment', () => {
|
|
169
|
+
test('fulfills a paid amount once even when the session carries Adaptive Pricing presentment details', async () => {
|
|
170
|
+
const fake = await makeFakeContext()
|
|
171
|
+
await process(fake, 'checkout.session.completed', paid({
|
|
172
|
+
presentment_details: { presentment_amount: 4_613, presentment_currency: 'eur' },
|
|
173
|
+
}))
|
|
174
|
+
expect(fake.observed.topUp).toEqual([expect.objectContaining({ amountMinor: 1_000, chargeAmountMinor: 1_021 })])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('fulfills a paid amount once, without crediting the adjustment, and records the payment intent', async () => {
|
|
178
|
+
const fake = await makeFakeContext()
|
|
179
|
+
await process(fake, 'checkout.session.completed', paid())
|
|
180
|
+
await process(fake, 'checkout.session.completed', paid())
|
|
181
|
+
expect(fake.observed.topUp).toEqual([expect.objectContaining({
|
|
182
|
+
mode: 'amount', amountMinor: 1_000, chargeAmountMinor: 1_021, externalId: 'cs_amount', entityId: 'entity-1',
|
|
183
|
+
})])
|
|
184
|
+
expect(fulfillment(fake)[0]).toEqual(expect.objectContaining({
|
|
185
|
+
externalId: 'cs_amount', paymentIntentId: 'pi_1', invoiceId: 'in_1', mode: 'amount',
|
|
186
|
+
}))
|
|
187
|
+
expect(fulfillment(fake)[0].fulfilledAt).toBeInstanceOf(Date)
|
|
188
|
+
expect(fake.stores['payment-subscription'].rows).toHaveLength(0)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('fulfills the USD credit value from an EUR settlement charge', async () => {
|
|
192
|
+
const fake = await makeFakeContext()
|
|
193
|
+
await process(fake, 'checkout.session.completed', paid({
|
|
194
|
+
currency: 'eur', amount_subtotal: 891,
|
|
195
|
+
metadata: {
|
|
196
|
+
pricingMode: 'amount', amountMinor: '1000', sourceChargeAmountMinor: '1021', amountCurrency: 'usd',
|
|
197
|
+
chargeAmountMinor: '891', currency: 'eur', entityId: 'entity-1', service: 'app',
|
|
198
|
+
productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit',
|
|
199
|
+
},
|
|
200
|
+
}))
|
|
201
|
+
expect(fake.observed.topUp[0]).toEqual(expect.objectContaining({
|
|
202
|
+
amountMinor: 1_000, sourceChargeAmountMinor: 1_021, amountCurrency: 'usd',
|
|
203
|
+
chargeAmountMinor: 891, currency: 'eur',
|
|
204
|
+
}))
|
|
205
|
+
expect(fulfillment(fake)[0]).toEqual(expect.objectContaining({ amountCurrency: 'usd', currency: 'eur' }))
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('grants nothing for an unpaid completion; the asynchronous success does', async () => {
|
|
209
|
+
const fake = await makeFakeContext()
|
|
210
|
+
await process(fake, 'checkout.session.completed', paid({ payment_status: 'unpaid' }))
|
|
211
|
+
expect(fake.observed.topUp).toHaveLength(0)
|
|
212
|
+
await process(fake, 'checkout.session.async_payment_succeeded', paid())
|
|
213
|
+
expect(fake.observed.topUp).toHaveLength(1)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('rejects a currency or subtotal mismatch; retries after a failed observer', async () => {
|
|
217
|
+
const fake = await makeFakeContext()
|
|
218
|
+
await expect(process(fake, 'checkout.session.completed', paid({ amount_subtotal: 1020 }))).rejects.toThrow()
|
|
219
|
+
|
|
220
|
+
fake.observed.failTopUp = 1
|
|
221
|
+
await expect(process(fake, 'checkout.session.completed', paid())).rejects.toThrow('ledger unavailable')
|
|
222
|
+
expect(fulfillment(fake)[0]?.fulfilledAt).toBeUndefined()
|
|
223
|
+
await process(fake, 'checkout.session.completed', paid())
|
|
224
|
+
expect(fake.observed.topUp).toHaveLength(1)
|
|
225
|
+
expect(fulfillment(fake)[0].fulfilledAt).toBeInstanceOf(Date)
|
|
123
226
|
})
|
|
124
227
|
|
|
125
|
-
test('legacy
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
entityId: 'entity-1', service: 'app', productSku:
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
} as never)
|
|
133
|
-
expect(h.completions[0]).toEqual(expect.objectContaining({ mode: 'quantity', units: 7 }))
|
|
228
|
+
test('a legacy session without pricing metadata fulfills by quantity', async () => {
|
|
229
|
+
const fake = await makeFakeContext()
|
|
230
|
+
await process(fake, 'checkout.session.completed', paid({
|
|
231
|
+
id: 'cs_legacy', metadata: { entityId: 'entity-1', service: 'app', productSku: CREDITS_PRODUCT, planSku: 'app-credit-unit' },
|
|
232
|
+
}))
|
|
233
|
+
expect(fake.observed.topUp[0]).toEqual(expect.objectContaining({ mode: 'quantity', units: 7 }))
|
|
234
|
+
expect(fulfillment(fake)[0]).toEqual(expect.objectContaining({ mode: 'quantity', units: 7 }))
|
|
134
235
|
})
|
|
135
236
|
})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { PurchaseKind, withdrawalDeadlineOf } from '@owlmeans/payment'
|
|
3
|
+
import {
|
|
4
|
+
billingProfiles, consumerEvents, fulfillments, paygateCustomers, purchases, subscriptions,
|
|
5
|
+
} from '../src/utils.js'
|
|
6
|
+
import {
|
|
7
|
+
buyTopUp, ENTITY, invoiceOf, makeRightsContext, paidSession, PRO, requestStart, send,
|
|
8
|
+
} from './consumer-fixtures.js'
|
|
9
|
+
import { capturePaymentPurchase } from '../src/consumer/capture.js'
|
|
10
|
+
import { subscriptionOf } from './fake-stripe.js'
|
|
11
|
+
import type { FakeContext } from './fake-stripe.js'
|
|
12
|
+
|
|
13
|
+
const proSubscription = (startRequestId?: string, overrides: Record<string, unknown> = {}) => {
|
|
14
|
+
const subscription = subscriptionOf({ id: 'sub_pro', customer: 'cus_1', latestInvoice: 'in_sub' }) as unknown as Record<string, any>
|
|
15
|
+
subscription.currency = 'eur'
|
|
16
|
+
subscription.items.data[0].price = { id: 'price_pro', lookup_key: PRO, unit_amount: 1_800, currency: 'eur', tax_behavior: 'exclusive' }
|
|
17
|
+
subscription.items.data[0].quantity = 1
|
|
18
|
+
subscription.metadata = {
|
|
19
|
+
entityId: ENTITY, planSku: PRO, productSku: 'app-plans', service: 'app', country: 'DE', language: 'de',
|
|
20
|
+
...(startRequestId != null ? { startRequestId } : {}),
|
|
21
|
+
}
|
|
22
|
+
return { ...subscription, ...overrides }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const subscriptionSession = (overrides: Record<string, unknown> = {}) => paidSession({
|
|
26
|
+
id: 'cs_sub', mode: 'subscription', subscription: 'sub_pro', invoice: 'in_sub', payment_intent: null,
|
|
27
|
+
amount_subtotal: 1_800, amount_total: 2_142, total_details: { amount_tax: 342 },
|
|
28
|
+
customer_details: { address: { country: 'DE' }, email: 'owner@shop.eu', name: 'Anna', tax_ids: [] },
|
|
29
|
+
metadata: { pricingMode: 'quantity', planSku: PRO, productSku: 'app-plans', country: 'DE', language: 'de' },
|
|
30
|
+
...overrides,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const withSubInvoice = (fake: FakeContext) => {
|
|
34
|
+
fake.state.invoices.in_sub = invoiceOf('in_sub', {
|
|
35
|
+
number: 'INV-SUB-1', subtotal: 1_800, tax: 342, total: 2_142, payment_intent: 'pi_sub',
|
|
36
|
+
lines: { object: 'list', data: [{ id: 'il_sub', amount: 1_800 }] }, customer_address: { country: 'DE' },
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('webhook capture — one-time purchases', () => {
|
|
41
|
+
test('the purchase row exists before the credits are granted, with its window and evidence', async () => {
|
|
42
|
+
const fake = await makeRightsContext()
|
|
43
|
+
const seen: unknown[] = []
|
|
44
|
+
fake.observed.onTopUp = async () => { seen.push(await purchases(fake.ctx).load({ sessionId: 'cs_pl' })) }
|
|
45
|
+
const purchase = await buyTopUp(fake)
|
|
46
|
+
|
|
47
|
+
expect(seen[0]).toEqual(expect.objectContaining({ purchaseId: 'stripe:cs_pl' }))
|
|
48
|
+
expect(purchase).toEqual(expect.objectContaining({
|
|
49
|
+
purchaseId: 'stripe:cs_pl', kind: PurchaseKind.TopUp, entityId: ENTITY, country: 'PL', region: 'eu', inScope: true,
|
|
50
|
+
language: 'pl', currency: 'eur', amountSubtotalMinor: 919, amountTaxMinor: 211, amountTotalMinor: 1_130,
|
|
51
|
+
netAmountMinor: 1_000, amountCurrency: 'usd', paymentIntentId: 'pi_pl', invoiceId: 'in_pl',
|
|
52
|
+
invoiceNumber: 'INV-0001', invoiceLineId: 'il_pl', termsAccepted: true, ipCountry: 'PL', email: 'buyer@shop.eu',
|
|
53
|
+
textVersion: 'terms-v1',
|
|
54
|
+
}))
|
|
55
|
+
expect(purchase.contractRef).toMatch(/^CR-\d{6}-[2-9A-HJKMNP-Z]{6}$/)
|
|
56
|
+
expect(purchase.deadline).toEqual(withdrawalDeadlineOf(new Date(purchase.purchasedAt), { marginDays: 5 }))
|
|
57
|
+
expect((await fulfillments(fake.ctx).byExternalId('cs_pl', 'stripe'))).toEqual(expect.objectContaining({
|
|
58
|
+
country: 'PL', email: 'buyer@shop.eu', amountTotalMinor: 1_130, amountTaxMinor: 211, termsAccepted: true,
|
|
59
|
+
purchaseId: 'stripe:cs_pl',
|
|
60
|
+
}))
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('the first purchase locks the country; a later one elsewhere is only a lock-mismatch event', async () => {
|
|
64
|
+
const fake = await makeRightsContext()
|
|
65
|
+
await buyTopUp(fake)
|
|
66
|
+
expect(await billingProfiles(fake.ctx).byEntity(ENTITY)).toEqual(expect.objectContaining({
|
|
67
|
+
country: 'PL', region: 'eu', currency: 'eur', language: 'pl', source: 'checkout', ipCountry: 'PL',
|
|
68
|
+
email: 'buyer@shop.eu', sessionId: 'cs_pl',
|
|
69
|
+
}))
|
|
70
|
+
const second = await buyTopUp(fake, {
|
|
71
|
+
id: 'cs_de', invoice: 'in_de', customer_details: { address: { country: 'DE' }, email: 'buyer@shop.eu' },
|
|
72
|
+
metadata: { ipCountry: 'AT' },
|
|
73
|
+
})
|
|
74
|
+
expect(second.country).toBe('DE')
|
|
75
|
+
expect((await billingProfiles(fake.ctx).byEntity(ENTITY))?.country).toBe('PL')
|
|
76
|
+
const mismatch = (await consumerEvents(fake.ctx).list({ action: 'lock-mismatch' })).items
|
|
77
|
+
expect(mismatch).toHaveLength(1)
|
|
78
|
+
expect(JSON.parse(mismatch[0].detail ?? '{}')).toEqual(expect.objectContaining({ locked: 'PL', observed: 'DE', sessionId: 'cs_de' }))
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('the confirmation is mailed once, in the billing language, with the withdrawal information and an archive copy', async () => {
|
|
82
|
+
const fake = await makeRightsContext()
|
|
83
|
+
await buyTopUp(fake, { customer_details: { address: { country: 'PL' }, email: 'buyer@shop.eu', name: 'Jan <b>K</b>' } })
|
|
84
|
+
await send(fake, 'checkout.session.completed', paidSession())
|
|
85
|
+
const mails = fake.mails.filter(mail => mail.subject.includes('CR-'))
|
|
86
|
+
expect(mails.map(mail => mail.to)).toEqual(['buyer@shop.eu', 'archive@example.com'])
|
|
87
|
+
const [mail] = mails
|
|
88
|
+
expect(mail.from).toBe('billing@example.com')
|
|
89
|
+
expect(mail.text).toContain('Example Trading Ltd, 1 Example Street, 00-001 Example, support@example.com')
|
|
90
|
+
expect(mail.text).toContain('https://app.example.com/legal/withdraw')
|
|
91
|
+
expect(mail.text).toContain('Odstąp od umowy tutaj')
|
|
92
|
+
expect(mail.html).toContain('Jan <b>K</b>')
|
|
93
|
+
expect(mail.html).not.toContain('<b>K</b>')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('concurrent deliveries in several processes mail the confirmation once', async () => {
|
|
97
|
+
const fake = await makeRightsContext()
|
|
98
|
+
fake.state.invoices.in_pl = invoiceOf()
|
|
99
|
+
const session = paidSession() as never
|
|
100
|
+
// Not through the webhook handler, whose per-customer lock serializes one process: two processes race here.
|
|
101
|
+
await Promise.all([
|
|
102
|
+
capturePaymentPurchase(fake.ctx, fake.stripe, session), capturePaymentPurchase(fake.ctx, fake.stripe, session),
|
|
103
|
+
])
|
|
104
|
+
await send(fake, 'checkout.session.completed', paidSession())
|
|
105
|
+
expect(fake.mails.filter(mail => mail.subject.includes('CR-')).map(mail => mail.to)).toEqual(['buyer@shop.eu', 'archive@example.com'])
|
|
106
|
+
expect(await consumerEvents(fake.ctx).count({ action: 'mail', step: 'purchase' })).toBe(1)
|
|
107
|
+
expect((await purchases(fake.ctx).load({ sessionId: 'cs_pl' }))?.confirmationMailAt).toBeInstanceOf(Date)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a checkout that collected no terms records no acceptance', async () => {
|
|
111
|
+
const fake = await makeRightsContext()
|
|
112
|
+
const purchase = await buyTopUp(fake, { consent: null, metadata: { termsCollected: 'false' } })
|
|
113
|
+
expect(purchase.termsAccepted).toBeUndefined()
|
|
114
|
+
expect((await fulfillments(fake.ctx).byExternalId('cs_pl', 'stripe'))?.termsAccepted).toBeUndefined()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('a purchase outside the territories has no window and no confirmation', async () => {
|
|
118
|
+
const fake = await makeRightsContext()
|
|
119
|
+
const purchase = await buyTopUp(fake, {
|
|
120
|
+
id: 'cs_us', invoice: 'in_us', currency: 'usd', amount_subtotal: 1_021, amount_total: 1_021, total_details: { amount_tax: 0 },
|
|
121
|
+
customer_details: { address: { country: 'US' }, email: 'buyer@shop.us' },
|
|
122
|
+
metadata: { country: 'US', currency: 'usd', chargeAmountMinor: '1021', language: 'en' },
|
|
123
|
+
})
|
|
124
|
+
expect(purchase).toEqual(expect.objectContaining({ inScope: false, region: 'other', country: 'US' }))
|
|
125
|
+
expect(purchase.deadline).toBeUndefined()
|
|
126
|
+
expect(fake.mails).toHaveLength(0)
|
|
127
|
+
expect((await billingProfiles(fake.ctx).byEntity(ENTITY))?.currency).toBe('usd')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('customer webhooks store the paygate country and currency', async () => {
|
|
131
|
+
const fake = await makeRightsContext()
|
|
132
|
+
await send(fake, 'customer.updated', { id: 'cus_9', email: 'a@shop.eu', address: { country: 'fr' }, currency: 'EUR', metadata: { entityId: ENTITY } })
|
|
133
|
+
expect(await paygateCustomers(fake.ctx).loadByPgId('cus_9', 'stripe')).toEqual(expect.objectContaining({ country: 'FR', currency: 'eur' }))
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('webhook capture — subscriptions', () => {
|
|
138
|
+
test('the first commit writes the purchase before the created observers; the checkout refines it', async () => {
|
|
139
|
+
const fake = await makeRightsContext()
|
|
140
|
+
withSubInvoice(fake)
|
|
141
|
+
const startRequestId = await requestStart(fake)
|
|
142
|
+
const seen: unknown[] = []
|
|
143
|
+
fake.observed.onSubscription = async event => {
|
|
144
|
+
if (event.change === 'created') seen.push(await purchases(fake.ctx).byPurchaseId('stripe:sub_pro'))
|
|
145
|
+
}
|
|
146
|
+
const subscription = proSubscription(startRequestId)
|
|
147
|
+
fake.state.subscriptions.sub_pro = subscription as never
|
|
148
|
+
await send(fake, 'customer.subscription.created', subscription)
|
|
149
|
+
|
|
150
|
+
expect(seen[0]).toEqual(expect.objectContaining({
|
|
151
|
+
purchaseId: 'stripe:sub_pro', kind: PurchaseKind.Subscription, subscriptionId: 'sub_pro', invoiceId: 'in_sub',
|
|
152
|
+
invoiceNumber: 'INV-SUB-1', invoiceLineId: 'il_sub', paymentIntentId: 'pi_sub', currency: 'eur',
|
|
153
|
+
amountSubtotalMinor: 1_800, amountTaxMinor: 342, amountTotalMinor: 2_142, startRequestId, consentId: startRequestId,
|
|
154
|
+
country: 'DE', inScope: true,
|
|
155
|
+
}))
|
|
156
|
+
const stored = seen[0] as { servicesStartedAt: Date, consentedAt: Date }
|
|
157
|
+
expect(stored.servicesStartedAt).toBeInstanceOf(Date)
|
|
158
|
+
expect(stored.consentedAt).toEqual(stored.servicesStartedAt)
|
|
159
|
+
|
|
160
|
+
await send(fake, 'checkout.session.completed', subscriptionSession({ customer_details: { address: { country: 'AT' }, email: 'owner@shop.eu', name: 'Anna' } }))
|
|
161
|
+
expect(await purchases(fake.ctx).byPurchaseId('stripe:sub_pro')).toEqual(expect.objectContaining({
|
|
162
|
+
sessionId: 'cs_sub', country: 'AT', email: 'owner@shop.eu', termsAccepted: true, amountTotalMinor: 2_142,
|
|
163
|
+
}))
|
|
164
|
+
expect(await subscriptions(fake.ctx).byExternalId('sub_pro', 'stripe')).toEqual(expect.objectContaining({
|
|
165
|
+
checkoutSessionId: 'cs_sub', purchaseId: 'stripe:sub_pro', firstInvoiceId: 'in_sub', currency: 'eur', country: 'AT',
|
|
166
|
+
email: 'owner@shop.eu', amountTotalMinor: 2_142, amountTaxMinor: 342, termsAccepted: true, startRequestId,
|
|
167
|
+
}))
|
|
168
|
+
expect((await billingProfiles(fake.ctx).byEntity(ENTITY))?.country).toBe('AT')
|
|
169
|
+
const confirmation = fake.mails.find(mail => mail.to === 'owner@shop.eu' && mail.subject.includes('CR-'))
|
|
170
|
+
// The order confirmation is in the billing language; the start request is repeated verbatim, as shown (en).
|
|
171
|
+
expect(confirmation?.subject).toStartWith('Bestellbestätigung')
|
|
172
|
+
expect(confirmation?.text).toContain('I expressly request and agree that Example starts the Pro platform services')
|
|
173
|
+
expect(confirmation?.text).toContain('https://app.example.com/legal/cancel')
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('a completed subscription checkout that arrives first applies the subscription itself', async () => {
|
|
177
|
+
const fake = await makeRightsContext()
|
|
178
|
+
withSubInvoice(fake)
|
|
179
|
+
fake.state.subscriptions.sub_pro = proSubscription() as never
|
|
180
|
+
await send(fake, 'checkout.session.completed', subscriptionSession())
|
|
181
|
+
expect(fake.observed.subscription.map(event => event.change)).toEqual(['created'])
|
|
182
|
+
expect(await purchases(fake.ctx).byPurchaseId('stripe:sub_pro')).toEqual(expect.objectContaining({ sessionId: 'cs_sub', country: 'DE' }))
|
|
183
|
+
// Renewals are not purchases.
|
|
184
|
+
await send(fake, 'invoice.paid', { id: 'in_renew', object: 'invoice', subscription: 'sub_pro', customer: 'cus_1', billing_reason: 'subscription_cycle' })
|
|
185
|
+
expect(fake.stores['payment-purchase'].rows).toHaveLength(1)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe('webhook capture — refunds', () => {
|
|
190
|
+
const charge = { id: 'ch_pl', amount: 1_130, amount_refunded: 1_130, payment_intent: 'pi_pl', invoice: null }
|
|
191
|
+
|
|
192
|
+
test('a withdrawal refund carries its id to the refund observers; a whole refund closes the window', async () => {
|
|
193
|
+
const fake = await makeRightsContext({ stripe: { charges: { ch_pl: charge } } })
|
|
194
|
+
await buyTopUp(fake)
|
|
195
|
+
await send(fake, 'refund.created', {
|
|
196
|
+
id: 're_w', object: 'refund', amount: 1_130, currency: 'eur', status: 'succeeded', payment_intent: 'pi_pl',
|
|
197
|
+
charge: 'ch_pl', metadata: { owlmeans: 'payment', withdrawalId: 'decl_1', purchaseId: 'stripe:cs_pl' },
|
|
198
|
+
})
|
|
199
|
+
expect(fake.observed.refund[0]).toEqual(expect.objectContaining({
|
|
200
|
+
withdrawalId: 'decl_1', metadata: expect.objectContaining({ withdrawalId: 'decl_1' }), partial: false,
|
|
201
|
+
}))
|
|
202
|
+
const purchase = await purchases(fake.ctx).byPurchaseId('stripe:cs_pl')
|
|
203
|
+
expect(purchase?.refundedMinor).toBe(1_130)
|
|
204
|
+
expect(purchase?.refundedAt).toBeInstanceOf(Date)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('an ordinary partial refund carries no withdrawal id and keeps the window open', async () => {
|
|
208
|
+
const fake = await makeRightsContext({ stripe: { charges: { ch_pl: { ...charge, amount_refunded: 300 } } } })
|
|
209
|
+
await buyTopUp(fake)
|
|
210
|
+
await send(fake, 'refund.created', {
|
|
211
|
+
id: 're_p', object: 'refund', amount: 300, currency: 'eur', status: 'succeeded', payment_intent: 'pi_pl', charge: 'ch_pl',
|
|
212
|
+
})
|
|
213
|
+
expect(fake.observed.refund[0].withdrawalId).toBeUndefined()
|
|
214
|
+
const purchase = await purchases(fake.ctx).byPurchaseId('stripe:cs_pl')
|
|
215
|
+
expect(purchase?.refundedMinor).toBe(300)
|
|
216
|
+
expect(purchase?.refundedAt).toBeUndefined()
|
|
217
|
+
})
|
|
218
|
+
})
|