@owlmeans/server-payment 0.1.18-rc.20 → 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 +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +402 -260
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +99 -3
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +27 -0
- 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/index.d.ts +4 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -2
- package/build/index.js.map +1 -1
- package/build/model.d.ts +6 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +134 -4
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +13 -0
- package/build/observer.js.map +1 -1
- 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.map +1 -1
- package/build/plugins/estimate.js +41 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts +2 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +126 -9
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +5 -0
- package/build/plugins/fx.d.ts.map +1 -1
- package/build/plugins/fx.js +25 -0
- package/build/plugins/fx.js.map +1 -1
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +11 -1
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +21 -2
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +397 -61
- package/build/plugins/stripe.js.map +1 -1
- package/build/resource.d.ts +8 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +59 -2
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +2 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +28 -5
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +6 -0
- package/build/subscription.d.ts.map +1 -1
- package/build/subscription.js +1 -0
- package/build/subscription.js.map +1 -1
- package/build/sync.d.ts +7 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +108 -15
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +707 -4
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +22 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +27 -1
- package/build/utils.js.map +1 -1
- package/package.json +14 -13
- package/src/config.ts +111 -7
- package/src/consts.ts +34 -0
- 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/index.ts +6 -4
- package/src/model.ts +148 -6
- package/src/observer.ts +15 -2
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +49 -9
- package/src/plugins/events.ts +135 -11
- package/src/plugins/fx.ts +29 -0
- package/src/plugins/portal.ts +11 -1
- package/src/plugins/stripe.ts +476 -60
- package/src/resource.ts +87 -4
- package/src/service.ts +28 -6
- package/src/subscription.ts +7 -0
- package/src/sync.ts +124 -17
- package/src/types.ts +756 -6
- package/src/utils.ts +56 -7
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- 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 +20 -2
- package/tests/fake-stripe.ts +188 -18
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { ConsentKind } from '@owlmeans/payment'
|
|
2
|
+
import { createEventHandler } from '../src/plugins/events.js'
|
|
3
|
+
import { consumerRights, purchases } from '../src/utils.js'
|
|
4
|
+
import type { ConsumerRightsDef, PricingDef, PurchaseRecord, UsageMeter, UsageQuery, UsageReading } from '../src/types.js'
|
|
5
|
+
import { CREDIT_UNIT, CREDITS_PRODUCT, makeFakeContext, PLANS_PRODUCT, PRO } from './fake-stripe.js'
|
|
6
|
+
import type { FakeContext, FakeContextOptions } from './fake-stripe.js'
|
|
7
|
+
|
|
8
|
+
type Rec = Record<string, any>
|
|
9
|
+
|
|
10
|
+
export const ENTITY = 'entity-1'
|
|
11
|
+
export const TEXT_VERSION = 'terms-v1'
|
|
12
|
+
export const epoch = (date: Date): number => Math.floor(date.getTime() / 1000)
|
|
13
|
+
|
|
14
|
+
export const LINKS = {
|
|
15
|
+
en: {
|
|
16
|
+
billingTerms: 'https://legal.example.com/en/billing',
|
|
17
|
+
withdrawalInformation: 'https://legal.example.com/en/withdrawal',
|
|
18
|
+
withdrawalForm: 'https://legal.example.com/en/withdrawal-form',
|
|
19
|
+
withdrawalFunction: 'https://app.example.com/legal/withdraw',
|
|
20
|
+
cancellation: 'https://app.example.com/legal/cancel',
|
|
21
|
+
},
|
|
22
|
+
de: { billingTerms: 'https://legal.example.com/de/billing', withdrawalInformation: 'https://legal.example.com/de/withdrawal' },
|
|
23
|
+
pl: { billingTerms: 'https://legal.example.com/pl/billing', withdrawalInformation: 'https://legal.example.com/pl/withdrawal' },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const ALL_ON = {
|
|
27
|
+
countryLock: true, checkoutTerms: false, performanceConsent: true, subscriptionStart: true, withdrawal: true,
|
|
28
|
+
automaticRefunds: true, cancellation: true, purchaseConfirmation: true,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const TRADER = {
|
|
32
|
+
name: 'Example', legalName: 'Example Trading Ltd', address: '1 Example Street, 00-001 Example',
|
|
33
|
+
email: 'support@example.com',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const rightsOf = (patch: Partial<ConsumerRightsDef> = {}): ConsumerRightsDef => ({
|
|
37
|
+
textVersion: TEXT_VERSION,
|
|
38
|
+
links: LINKS,
|
|
39
|
+
currencies: { eu: 'eur', other: 'usd' },
|
|
40
|
+
mechanisms: ALL_ON,
|
|
41
|
+
trader: TRADER,
|
|
42
|
+
mail: { from: 'billing@example.com', bcc: ['archive@example.com'] },
|
|
43
|
+
...patch,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/** The pricing a EUR-settling account with USD catalogue prices declares. */
|
|
47
|
+
export const EUR_SETTLEMENT: PricingDef = {
|
|
48
|
+
tax: { automatic: true, collectTaxId: true, estimate: true },
|
|
49
|
+
currency: { adaptive: true, estimate: false },
|
|
50
|
+
stripe: { settlementCurrency: 'eur' },
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 1 USD = 0.9 EUR at the reference rate. */
|
|
54
|
+
export const FX = { usd: { exchangeRate: 0.88, referenceRate: 0.9 } }
|
|
55
|
+
|
|
56
|
+
export const makeRightsContext = async (opts: FakeContextOptions = {}): Promise<FakeContext> =>
|
|
57
|
+
await makeFakeContext({
|
|
58
|
+
pricing: EUR_SETTLEMENT,
|
|
59
|
+
consumerRights: rightsOf(),
|
|
60
|
+
...opts,
|
|
61
|
+
stripe: { fxRates: FX, ...opts.stripe },
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
export interface RecordingMeter extends UsageMeter {
|
|
65
|
+
queries: UsageQuery[]
|
|
66
|
+
reading: Partial<UsageReading> & { granted: number }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A usage meter answering one fixed reading (mutable through `reading`). */
|
|
70
|
+
export const fixedMeter = (reading: Partial<UsageReading> & { granted: number }): RecordingMeter => {
|
|
71
|
+
const meter: RecordingMeter = {
|
|
72
|
+
queries: [],
|
|
73
|
+
reading,
|
|
74
|
+
used: async (_ctx, query) => {
|
|
75
|
+
meter.queries.push(query)
|
|
76
|
+
return { used: 0, usedAfter: 0, ...meter.reading }
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return meter
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const send = async (fake: FakeContext, type: string, object: unknown): Promise<void> => {
|
|
84
|
+
await createEventHandler(fake.ctx, fake.stripe).process({ id: `evt_${type}_${Math.random()}`, type, created: epoch(new Date()), data: { object } } as never)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A completed, paid amount checkout: 10.00 USD of credit charged as 9.19 EUR + 23 % VAT, bought from Poland. */
|
|
88
|
+
export const paidSession = (overrides: Rec = {}): Rec => ({
|
|
89
|
+
id: 'cs_pl', object: 'checkout.session', mode: 'payment', payment_status: 'paid', status: 'complete',
|
|
90
|
+
customer: 'cus_1', currency: 'eur', amount_subtotal: 919, amount_total: 1130, total_details: { amount_tax: 211 },
|
|
91
|
+
payment_intent: 'pi_pl', invoice: 'in_pl', created: epoch(new Date()),
|
|
92
|
+
customer_details: {
|
|
93
|
+
address: { country: 'PL' }, email: 'buyer@shop.eu', name: 'Jan Kowalski', tax_ids: [], tax_exempt: 'none',
|
|
94
|
+
},
|
|
95
|
+
consent: { terms_of_service: 'accepted' },
|
|
96
|
+
...overrides,
|
|
97
|
+
metadata: {
|
|
98
|
+
pricingMode: 'amount', amountMinor: '1000', sourceChargeAmountMinor: '1021', amountCurrency: 'usd',
|
|
99
|
+
chargeAmountMinor: '919', currency: 'eur', entityId: ENTITY, service: 'app', productSku: CREDITS_PRODUCT,
|
|
100
|
+
planSku: CREDIT_UNIT, country: 'PL', language: 'pl', termsVersion: TEXT_VERSION, ipCountry: 'PL',
|
|
101
|
+
...overrides.metadata,
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
/** The post-payment invoice of `paidSession`. */
|
|
106
|
+
export const invoiceOf = (id = 'in_pl', overrides: Rec = {}): Rec => ({
|
|
107
|
+
id, object: 'invoice', number: 'INV-0001', subtotal: 919, tax: 211, total: 1130, currency: 'eur',
|
|
108
|
+
payment_intent: 'pi_pl', lines: { object: 'list', data: [{ id: 'il_pl', amount: 919 }] },
|
|
109
|
+
customer_address: { country: 'PL' }, ...overrides,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
/** Complete a top-up: the webhook captures the purchase (and locks the country). */
|
|
113
|
+
export const buyTopUp = async (fake: FakeContext, overrides: Rec = {}): Promise<PurchaseRecord> => {
|
|
114
|
+
const session = paidSession(overrides)
|
|
115
|
+
fake.state.invoices[session.invoice as string] = fake.state.invoices[session.invoice as string] ?? invoiceOf(session.invoice as string)
|
|
116
|
+
await send(fake, 'checkout.session.completed', session)
|
|
117
|
+
const purchase = await purchases(fake.ctx).load({ sessionId: session.id as string })
|
|
118
|
+
if (purchase == null) throw new Error('no purchase captured')
|
|
119
|
+
|
|
120
|
+
return purchase
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A start request of `ENTITY` for the pro plan. */
|
|
124
|
+
export const requestStart = async (fake: FakeContext, patch: Rec = {}): Promise<string> =>
|
|
125
|
+
(await consumerRights(fake.ctx).recordStartRequest(
|
|
126
|
+
{ entityId: ENTITY, email: 'owner@shop.eu', name: 'Anna' },
|
|
127
|
+
{ planSku: PRO, textVersion: TEXT_VERSION, language: 'en', acknowledged: true, ...patch },
|
|
128
|
+
{ ip: '203.0.113.7' },
|
|
129
|
+
{ plan: 'Pro' },
|
|
130
|
+
)).startRequestId
|
|
131
|
+
|
|
132
|
+
export { ConsentKind, CREDITS_PRODUCT, CREDIT_UNIT, PLANS_PRODUCT, PRO }
|