@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
|
@@ -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 }
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import {
|
|
3
|
+
makeCheckoutReadProtocols, makeConsumerRightsProtocols, PerformanceConsentRequired, TaxBehavior,
|
|
4
|
+
WithdrawalStatus,
|
|
5
|
+
} from '@owlmeans/payment'
|
|
6
|
+
import { checkoutReadEntrypoints, consumerRightsEntrypoints } from '../src/consumer/handlers.js'
|
|
7
|
+
import { isReservedAddress } from '../src/consumer/format.js'
|
|
8
|
+
import { requestOriginOf } from '../src/consumer/origin.js'
|
|
9
|
+
import { estimateStripePrice, makeEstimateCache } from '../src/plugins/estimate.js'
|
|
10
|
+
import { ensurePortalConfiguration } from '../src/plugins/portal.js'
|
|
11
|
+
import { syncStripeProducts } from '../src/sync.js'
|
|
12
|
+
import {
|
|
13
|
+
billingProfiles, consumerEvents, consumerRights, fingerprints, fulfillments, gateway, paygateCustomers, purchases,
|
|
14
|
+
} from '../src/utils.js'
|
|
15
|
+
import {
|
|
16
|
+
ALL_ON, buyTopUp, CREDITS_PRODUCT, ENTITY, epoch, fixedMeter, makeRightsContext, paidSession, PLANS_PRODUCT, PRO,
|
|
17
|
+
rightsOf, TEXT_VERSION,
|
|
18
|
+
} from './consumer-fixtures.js'
|
|
19
|
+
import type { FakeContext } from './fake-stripe.js'
|
|
20
|
+
|
|
21
|
+
const service = (fake: FakeContext) => consumerRights(fake.ctx)
|
|
22
|
+
const subject = { entityId: ENTITY, email: 'owner@shop.eu', name: 'Anna' }
|
|
23
|
+
|
|
24
|
+
/** Run a server binding the way the transport does. */
|
|
25
|
+
const invoke = async (entrypoint: any, fake: FakeContext, req: Record<string, unknown>): Promise<any> => {
|
|
26
|
+
fake.ctx.registerEntrypoint(entrypoint)
|
|
27
|
+
const res: any = { resolve: (value: unknown) => { res.value = value }, reject: (error: Error) => { res.error = error } }
|
|
28
|
+
await entrypoint.handle({ headers: {}, query: {}, params: {}, path: '/', alias: entrypoint.alias, ...req }, res)
|
|
29
|
+
if (res.error != null) throw res.error
|
|
30
|
+
return res.value
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('reconcile', () => {
|
|
34
|
+
test('a failed mail is sent again; a sent one never twice', async () => {
|
|
35
|
+
const fake = await makeRightsContext()
|
|
36
|
+
const send = fake.mailer.send
|
|
37
|
+
fake.mailer.send = async () => { throw new Error('smtp down') }
|
|
38
|
+
const purchase = await buyTopUp(fake)
|
|
39
|
+
fake.mailer.send = send
|
|
40
|
+
const failed = await consumerEvents(fake.ctx).load({ recordId: purchase.purchaseId, action: 'mail' })
|
|
41
|
+
expect(failed).toEqual(expect.objectContaining({ ok: false, step: 'purchase', error: 'smtp down' }))
|
|
42
|
+
|
|
43
|
+
expect((await service(fake).reconcile()).mailed).toBe(1)
|
|
44
|
+
expect(fake.mails.filter(mail => mail.to === 'buyer@shop.eu')).toHaveLength(1)
|
|
45
|
+
expect((await service(fake).reconcile()).mailed).toBe(0)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('the application renderer may replace a mail or suppress it (recorded as skipped)', async () => {
|
|
49
|
+
const fake = await makeRightsContext()
|
|
50
|
+
const kinds: string[] = []
|
|
51
|
+
service(fake).useMailRenderer((kind, data, rendered) => {
|
|
52
|
+
kinds.push(kind)
|
|
53
|
+
if (kind === 'purchase') return { ...rendered, subject: `[Shop] ${rendered.subject}`, to: data.to }
|
|
54
|
+
return null
|
|
55
|
+
})
|
|
56
|
+
const purchase = await buyTopUp(fake)
|
|
57
|
+
expect(fake.mails.find(mail => mail.to === 'buyer@shop.eu')?.subject).toStartWith('[Shop] ')
|
|
58
|
+
await service(fake).recordConsent(subject, {
|
|
59
|
+
purchaseIds: [purchase.purchaseId], textVersion: TEXT_VERSION, language: 'en', acknowledged: true,
|
|
60
|
+
})
|
|
61
|
+
expect(kinds).toEqual(['purchase', 'consent'])
|
|
62
|
+
expect(fake.mails.some(mail => mail.to === 'owner@shop.eu')).toBe(false)
|
|
63
|
+
const skipped = (await consumerEvents(fake.ctx).list({ action: 'mail', step: 'consent' })).items[0]
|
|
64
|
+
expect(skipped).toEqual(expect.objectContaining({ ok: true, skipped: true, detail: '{"reason":"renderer"}' }))
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('a throwing withdrawal observer is recorded and told again by reconcile, once', async () => {
|
|
68
|
+
const fake = await makeRightsContext({ meter: fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }) })
|
|
69
|
+
const purchase = await buyTopUp(fake)
|
|
70
|
+
fake.observed.failWithdrawal = 1
|
|
71
|
+
const receipt = await service(fake).withdraw(subject, { purchaseId: purchase.purchaseId, name: 'Anna', email: 'owner@shop.eu' })
|
|
72
|
+
expect(receipt.status).toBe(WithdrawalStatus.Refunded)
|
|
73
|
+
expect(fake.observed.withdrawal).toHaveLength(0)
|
|
74
|
+
expect((await service(fake).reconcile()).observed).toBe(1)
|
|
75
|
+
expect(fake.observed.withdrawal).toEqual([expect.objectContaining({
|
|
76
|
+
withdrawalId: receipt.declarationId, status: WithdrawalStatus.Refunded, units: { granted: 1_000, used: 0, returned: 1_000 },
|
|
77
|
+
})])
|
|
78
|
+
await service(fake).reconcile()
|
|
79
|
+
expect(fake.observed.withdrawal).toHaveLength(1)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('completed checkouts of the last 16 days without a purchase are backfilled, without mail', async () => {
|
|
83
|
+
const fake = await makeRightsContext()
|
|
84
|
+
const created = epoch(new Date(Date.now() - 3 * 86_400_000))
|
|
85
|
+
fake.state.listedSessions = [
|
|
86
|
+
paidSession({ id: 'cs_missed', created, invoice: null }),
|
|
87
|
+
paidSession({ id: 'cs_old', created: epoch(new Date(Date.now() - 40 * 86_400_000)), invoice: null }),
|
|
88
|
+
]
|
|
89
|
+
const result = await service(fake).reconcile()
|
|
90
|
+
expect(result.backfilled).toBe(1)
|
|
91
|
+
const purchase = await purchases(fake.ctx).load({ sessionId: 'cs_missed' })
|
|
92
|
+
expect(purchase).toEqual(expect.objectContaining({ purchasedAt: new Date(created * 1000), inScope: true }))
|
|
93
|
+
expect(fake.mails).toHaveLength(0)
|
|
94
|
+
expect((await billingProfiles(fake.ctx).byEntity(ENTITY))?.country).toBe('PL')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test('an organization that paid before the lock is locked from its paygate customer', async () => {
|
|
98
|
+
const fake = await makeRightsContext()
|
|
99
|
+
await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_n', entityId: 'entity-n', country: 'NO' })
|
|
100
|
+
await fulfillments(fake.ctx).create({
|
|
101
|
+
entityId: 'entity-n', productSku: CREDITS_PRODUCT, service: 'app', paygate: 'stripe', externalId: 'cs_n',
|
|
102
|
+
mode: 'amount' as never, createdAt: new Date(), fulfilledAt: new Date(),
|
|
103
|
+
})
|
|
104
|
+
await paygateCustomers(fake.ctx).create({ paygate: 'stripe', externalId: 'cus_u', entityId: 'entity-u', country: 'US' })
|
|
105
|
+
expect((await service(fake).reconcile()).locked).toBe(1)
|
|
106
|
+
expect(await billingProfiles(fake.ctx).byEntity('entity-n')).toEqual(expect.objectContaining({
|
|
107
|
+
country: 'NO', source: 'customer', region: 'eu',
|
|
108
|
+
}))
|
|
109
|
+
expect(await billingProfiles(fake.ctx).byEntity('entity-u')).toBeNull()
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('handlers', () => {
|
|
114
|
+
const account = { base: { alias: 'app:account', path: '/account' } }
|
|
115
|
+
const protocols = makeConsumerRightsProtocols({ prefix: 'app:consumer', guards: ['guard:default'], public: {} })
|
|
116
|
+
const req = (extra: Record<string, unknown> = {}) => ({ entity: { id: ENTITY }, ...extra })
|
|
117
|
+
|
|
118
|
+
test('a public subtree needs a throttle; the account and public routes are all bound', () => {
|
|
119
|
+
expect(() => consumerRightsEntrypoints(protocols)).toThrow(SyntaxError)
|
|
120
|
+
expect(consumerRightsEntrypoints(protocols, { throttle: async () => undefined })).toHaveLength(14)
|
|
121
|
+
const accountOnly = makeConsumerRightsProtocols({ prefix: 'app:consumer2', guards: ['guard:default'] })
|
|
122
|
+
expect(consumerRightsEntrypoints(accountOnly)).toHaveLength(10)
|
|
123
|
+
expect(account.base.alias).toBe('app:account')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('the account routes act for the request organization; money-moving acts pass guardMoney first', async () => {
|
|
127
|
+
const fake = await makeRightsContext()
|
|
128
|
+
await buyTopUp(fake)
|
|
129
|
+
const guarded: string[] = []
|
|
130
|
+
const contexts = new Set<unknown>()
|
|
131
|
+
const bound = consumerRightsEntrypoints(protocols, {
|
|
132
|
+
throttle: async () => undefined, publicMinMs: 0,
|
|
133
|
+
resolveEntity: (request, ctx) => { contexts.add(ctx); return request.entity?.id },
|
|
134
|
+
guardMoney: async (_req, action, ctx) => {
|
|
135
|
+
contexts.add(ctx)
|
|
136
|
+
guarded.push(action)
|
|
137
|
+
if (action === 'withdraw') throw new Error('token refused')
|
|
138
|
+
},
|
|
139
|
+
metaOf: (request, ctx) => { contexts.add(ctx); return requestOriginOf(request) },
|
|
140
|
+
subjectOf: () => ({ email: 'owner@shop.eu', name: 'Anna', profileId: 'profile-1' }),
|
|
141
|
+
})
|
|
142
|
+
const byAlias = (alias: string) => bound.find(entry => entry.alias === alias)
|
|
143
|
+
const view = await invoke(byAlias('app:consumer:consent'), fake, req())
|
|
144
|
+
expect(view.required).toBe(true)
|
|
145
|
+
const recorded = await invoke(byAlias('app:consumer:consent:give'), fake, req({
|
|
146
|
+
body: { purchaseIds: view.purchases.map((item: { purchaseId: string }) => item.purchaseId), textVersion: TEXT_VERSION, language: 'pl', acknowledged: true },
|
|
147
|
+
headers: { 'cf-connecting-ip': '203.0.113.9', 'user-agent': 'browser' },
|
|
148
|
+
}))
|
|
149
|
+
expect(recorded.purchaseIds).toHaveLength(1)
|
|
150
|
+
expect(fake.stores['payment-consumer-consent'].rows[0]).toEqual(expect.objectContaining({
|
|
151
|
+
ip: '203.0.113.9', userAgent: 'browser', profileId: 'profile-1',
|
|
152
|
+
}))
|
|
153
|
+
await expect(invoke(byAlias('app:consumer:withdraw'), fake, req({ body: { purchaseId: 'x', name: 'A', email: 'a@shop.eu' } })))
|
|
154
|
+
.rejects.toThrow('token refused')
|
|
155
|
+
expect(guarded).toEqual(['consent', 'withdraw'])
|
|
156
|
+
const profile = await invoke(byAlias('app:consumer:profile'), fake, req())
|
|
157
|
+
expect(profile).toEqual(expect.objectContaining({ country: 'PL', locked: true }))
|
|
158
|
+
await expect(invoke(byAlias('app:consumer:profile'), fake, {})).rejects.toThrow()
|
|
159
|
+
// Every hook is handed the request's context.
|
|
160
|
+
expect([...contexts]).toEqual([fake.ctx])
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('a start request takes the plan name the application gives, with the context', async () => {
|
|
164
|
+
const fake = await makeRightsContext()
|
|
165
|
+
const named: unknown[] = []
|
|
166
|
+
const bound = consumerRightsEntrypoints(protocols, {
|
|
167
|
+
throttle: async () => undefined,
|
|
168
|
+
planNameOf: (planSku, language, _req, ctx) => { named.push([planSku, language, ctx]); return 'Pro' },
|
|
169
|
+
})
|
|
170
|
+
const started = await invoke(bound.find(entry => entry.alias === 'app:consumer:start:request'), fake, req({
|
|
171
|
+
body: { planSku: PRO, textVersion: TEXT_VERSION, language: 'en', acknowledged: true },
|
|
172
|
+
}))
|
|
173
|
+
expect(started.startRequestId).toBeString()
|
|
174
|
+
expect(named).toEqual([[PRO, 'en', fake.ctx]])
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('public declarations are throttled, a honeypot records nothing, and the answer takes at least publicMinMs', async () => {
|
|
178
|
+
const fake = await makeRightsContext()
|
|
179
|
+
const throttled: unknown[] = []
|
|
180
|
+
const bound = consumerRightsEntrypoints(protocols, {
|
|
181
|
+
throttle: async (_req, key, ctx) => { throttled.push(key); expect(ctx).toBe(fake.ctx) }, publicMinMs: 150,
|
|
182
|
+
})
|
|
183
|
+
const withdraw = bound.find(entry => entry.alias === 'app:consumer:public:withdraw')
|
|
184
|
+
const started = Date.now()
|
|
185
|
+
const receipt = await invoke(withdraw, fake, {
|
|
186
|
+
body: { contractRef: 'CR-000000-AAAAAA', name: 'Eve', email: 'eve@else.eu' }, headers: { 'x-forwarded-for': '10.0.0.1, 198.51.100.4' },
|
|
187
|
+
})
|
|
188
|
+
expect(Date.now() - started).toBeGreaterThanOrEqual(140)
|
|
189
|
+
expect(Object.keys(receipt).sort()).toEqual(['content', 'declarationId', 'mailed', 'receivedAt'])
|
|
190
|
+
expect(throttled).toEqual([{ action: 'withdrawal', email: 'eve@else.eu', ip: '198.51.100.4' }])
|
|
191
|
+
expect(fake.stores['payment-consumer-declaration'].rows).toHaveLength(1)
|
|
192
|
+
|
|
193
|
+
const decoy = await invoke(withdraw, fake, { body: { contractRef: 'CR-1', name: 'Bot', email: 'bot@else.eu', honeypot: 'x' } })
|
|
194
|
+
expect(Object.keys(decoy).sort()).toEqual(['content', 'declarationId', 'mailed', 'receivedAt'])
|
|
195
|
+
expect(fake.stores['payment-consumer-declaration'].rows).toHaveLength(1)
|
|
196
|
+
|
|
197
|
+
const policy = await invoke(bound.find(entry => entry.alias === 'app:consumer:public:policy'), fake, {})
|
|
198
|
+
expect(policy).toEqual(expect.objectContaining({ mechanisms: { withdrawal: true, cancellation: true }, textVersion: TEXT_VERSION }))
|
|
199
|
+
expect(policy.languages).toEqual(['en', 'de', 'pl'])
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
test('checkout read routes answer the narrowed amount policy and the synced prices', async () => {
|
|
203
|
+
const fake = await makeRightsContext()
|
|
204
|
+
gateway(fake.ctx).use({ alias: 'tier', narrow: async () => ({ maximumMinor: 2_500, reason: 'per-purchase' }) })
|
|
205
|
+
const read = checkoutReadEntrypoints(makeCheckoutReadProtocols({ prefix: 'app:checkout', guards: ['guard:default'] }))
|
|
206
|
+
const policy = await invoke(read.find(entry => entry.alias === 'app:checkout:amount-policy'), fake, req({ query: { productSku: CREDITS_PRODUCT } }))
|
|
207
|
+
expect(policy.limit).toEqual(expect.objectContaining({ maximumMinor: 2_500 }))
|
|
208
|
+
const prices = await invoke(read.find(entry => entry.alias === 'app:checkout:plan-prices'), fake, req({ query: { productSku: PLANS_PRODUCT } }))
|
|
209
|
+
expect(prices).toEqual({ prices: [] })
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('requestOriginOf: the Cloudflare address first, else the LAST forwarded entry, else x-real-ip, else the socket', () => {
|
|
213
|
+
expect(requestOriginOf({ headers: { 'cf-connecting-ip': '1.1.1.1', 'x-forwarded-for': '9.9.9.9, 2.2.2.2' } } as never).ip).toBe('1.1.1.1')
|
|
214
|
+
const origin = requestOriginOf({
|
|
215
|
+
headers: {
|
|
216
|
+
'x-forwarded-for': '9.9.9.9, 2.2.2.2', 'user-agent': 'x'.repeat(600), 'cf-ipcountry': 'de', 'accept-language': 'de-DE',
|
|
217
|
+
},
|
|
218
|
+
} as never)
|
|
219
|
+
expect(origin).toEqual({
|
|
220
|
+
ip: '2.2.2.2', forwardedFor: '9.9.9.9, 2.2.2.2', userAgent: 'x'.repeat(512), ipCountry: 'DE', acceptLanguage: 'de-DE',
|
|
221
|
+
})
|
|
222
|
+
expect(requestOriginOf({ headers: { 'x-real-ip': '3.3.3.3' } } as never).ip).toBe('3.3.3.3')
|
|
223
|
+
expect(requestOriginOf({ headers: {}, original: { socket: { remoteAddress: '4.4.4.4' } } } as never).ip).toBe('4.4.4.4')
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('sync, portal and estimate under the policy', () => {
|
|
228
|
+
const exclusive = { tax: { automatic: true, collectTaxId: true, estimate: true, behavior: TaxBehavior.Exclusive }, currency: { adaptive: true, estimate: false }, stripe: { settlementCurrency: 'eur' } }
|
|
229
|
+
|
|
230
|
+
test('a USD catalogue price syncs as EUR with an exact USD option, each with its tax behavior, persisted', async () => {
|
|
231
|
+
const fake = await makeRightsContext({ pricing: exclusive })
|
|
232
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
233
|
+
const pro = fake.state.prices.find(price => price.lookup_key === PRO && price.active)
|
|
234
|
+
expect(pro).toEqual(expect.objectContaining({ currency: 'eur', unit_amount: 1_800, tax_behavior: 'exclusive' }))
|
|
235
|
+
expect(pro?.currency_options).toEqual({ usd: { unit_amount: 2_000, tax_behavior: 'exclusive' } })
|
|
236
|
+
const row = await fingerprints(fake.ctx).bySku(PLANS_PRODUCT)
|
|
237
|
+
expect(row?.prices?.find(price => price.planSku === PRO)).toEqual(expect.objectContaining({
|
|
238
|
+
priceId: pro?.id, currency: 'eur', unitAmount: 1_800, options: [{ currency: 'usd', unitAmount: 2_000 }], interval: 'month',
|
|
239
|
+
sourceUnitAmount: 2_000, sourceCurrency: 'usd',
|
|
240
|
+
}))
|
|
241
|
+
expect(await gateway(fake.ctx).planPrices(fake.ctx, PLANS_PRODUCT)).toEqual(expect.arrayContaining([
|
|
242
|
+
{ planSku: PRO, currency: 'eur', unitAmountMinor: 1_800, default: true, taxBehavior: TaxBehavior.Exclusive, interval: 'month' },
|
|
243
|
+
{ planSku: PRO, currency: 'usd', unitAmountMinor: 2_000, default: false, taxBehavior: TaxBehavior.Exclusive, interval: 'month' },
|
|
244
|
+
]))
|
|
245
|
+
|
|
246
|
+
fake.state.calls.length = 0
|
|
247
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
248
|
+
expect(fake.state.calls.filter(call => call !== 'rawRequest')).toEqual([])
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('a changed option replaces the Price, never edits it', async () => {
|
|
252
|
+
const fake = await makeRightsContext({
|
|
253
|
+
pricing: exclusive,
|
|
254
|
+
catalogue: {
|
|
255
|
+
plans: [{
|
|
256
|
+
productSku: PLANS_PRODUCT, sku: 'pro-exact', duration: 'monthly' as never, rank: 15, price: 20, recurring: { interval: 'month' },
|
|
257
|
+
currencyPrices: { usd: 21 },
|
|
258
|
+
}],
|
|
259
|
+
},
|
|
260
|
+
})
|
|
261
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
262
|
+
const first = fake.state.prices.find(price => price.lookup_key === 'pro-exact' && price.active)
|
|
263
|
+
expect(first?.currency_options).toEqual({ usd: { unit_amount: 2_100, tax_behavior: 'exclusive' } })
|
|
264
|
+
first!.currency_options = { usd: { unit_amount: 1_900, tax_behavior: 'exclusive' } }
|
|
265
|
+
await fingerprints(fake.ctx).clear()
|
|
266
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
267
|
+
expect(first?.active).toBe(false)
|
|
268
|
+
expect(fake.state.prices.find(price => price.lookup_key === 'pro-exact' && price.active)?.currency_options)
|
|
269
|
+
.toEqual({ usd: { unit_amount: 2_100, tax_behavior: 'exclusive' } })
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
test('the portal drops address updates under the country lock', async () => {
|
|
273
|
+
const fake = await makeRightsContext({ portal: { returnUrl: 'https://app.example.com/billing' } })
|
|
274
|
+
await ensurePortalConfiguration(fake.ctx, fake.stripe)
|
|
275
|
+
expect(fake.state.portalConfigurations[0].features.customer_update).toEqual({ enabled: true, allowed_updates: ['email', 'tax_id'] })
|
|
276
|
+
const open = await makeRightsContext({
|
|
277
|
+
portal: { returnUrl: 'https://app.example.com/billing' }, consumerRights: rightsOf({ mechanisms: { ...ALL_ON, countryLock: false } }),
|
|
278
|
+
})
|
|
279
|
+
await ensurePortalConfiguration(open.ctx, open.stripe)
|
|
280
|
+
expect(open.state.portalConfigurations[0].features.customer_update.allowed_updates).toEqual(['email', 'address', 'tax_id'])
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('a locked profile overrides the requested estimate country; a recurring plan is estimated in the charge currency', async () => {
|
|
284
|
+
const fake = await makeRightsContext({ pricing: exclusive, stripe: { taxRates: { DE: [{ type: 'vat', percentage: '19' }] } } })
|
|
285
|
+
await syncStripeProducts(fake.ctx, fake.stripe)
|
|
286
|
+
await service(fake).lock(ENTITY, 'DE', 'manual')
|
|
287
|
+
const estimate = await estimateStripePrice(fake.ctx, fake.stripe, {
|
|
288
|
+
entityId: ENTITY, productSku: PLANS_PRODUCT, planSku: PRO, country: 'US',
|
|
289
|
+
}, makeEstimateCache())
|
|
290
|
+
expect(estimate).toEqual(expect.objectContaining({ country: 'DE', source: 'profile', locked: true, region: 'eu', currency: 'eur' }))
|
|
291
|
+
expect(estimate.tax).toEqual(expect.objectContaining({ subtotalMinor: 1_800, taxMinor: 342, totalMinor: 2_142 }))
|
|
292
|
+
|
|
293
|
+
const other = await makeRightsContext({ pricing: exclusive })
|
|
294
|
+
await syncStripeProducts(other.ctx, other.stripe)
|
|
295
|
+
const usd = await estimateStripePrice(other.ctx, other.stripe, {
|
|
296
|
+
entityId: 'entity-us', productSku: PLANS_PRODUCT, planSku: PRO, country: 'US',
|
|
297
|
+
}, makeEstimateCache())
|
|
298
|
+
expect(usd).toEqual(expect.objectContaining({ country: 'US', source: 'request', region: 'other', currency: 'usd' }))
|
|
299
|
+
expect(usd.tax.subtotalMinor).toBe(2_000)
|
|
300
|
+
expect(usd.locked).toBeUndefined()
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
describe('the declaration', () => {
|
|
305
|
+
test('a mechanism without a trader, or a malformed archive address, is refused', async () => {
|
|
306
|
+
const { declareConsumerRights } = await import('../src/config.js')
|
|
307
|
+
const cfg = { records: [] } as never
|
|
308
|
+
expect(() => declareConsumerRights(cfg, rightsOf({ trader: undefined }))).toThrow('policy:trader')
|
|
309
|
+
expect(() => declareConsumerRights(cfg, rightsOf({ mail: { bcc: ['not-an-address'] } }))).toThrow('policy:mail-bcc')
|
|
310
|
+
const policy = declareConsumerRights(cfg, rightsOf())
|
|
311
|
+
expect(policy).not.toHaveProperty('trader')
|
|
312
|
+
expect(policy).not.toHaveProperty('mail')
|
|
313
|
+
expect(policy.deadline).toEqual({ weekendRollover: true, marginDays: 5 })
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test('plan withdrawal components must add up to the price', async () => {
|
|
317
|
+
const { declarePaymentPlan } = await import('../src/config.js')
|
|
318
|
+
const plan = {
|
|
319
|
+
productSku: PLANS_PRODUCT, sku: 'split-plan', duration: 'monthly' as never, price: 20, recurring: { interval: 'month' as const },
|
|
320
|
+
}
|
|
321
|
+
expect(() => declarePaymentPlan({ records: [] } as never, {
|
|
322
|
+
...plan, withdrawal: { components: [{ key: 'services', basis: 'time', shareMinor: 1_000 }] },
|
|
323
|
+
})).toThrow('withdrawal:split-plan:shares')
|
|
324
|
+
expect(() => declarePaymentPlan({ records: [] } as never, { ...plan, currencyPrices: { usd: 0 } })).toThrow('currency-prices')
|
|
325
|
+
const cfg = { records: [] } as { records: Array<Record<string, unknown>> }
|
|
326
|
+
declarePaymentPlan(cfg as never, {
|
|
327
|
+
...plan, currencyPrices: { USD: 20 },
|
|
328
|
+
withdrawal: { components: [{ key: 'services', basis: 'time', shareMinor: 1_000 }, { key: 'credits', basis: 'units', shareMinor: 1_000 }] },
|
|
329
|
+
})
|
|
330
|
+
expect(cfg.records[0]).toEqual(expect.objectContaining({ currencyPrices: { usd: 20 } }))
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
test('reserved mail domains: .test, .example, .invalid, .localhost and every subdomain of them', () => {
|
|
334
|
+
for (const address of [
|
|
335
|
+
'a@shop.test', 'a@mail.shop.test', 'A@Shop.EXAMPLE', 'a@x.invalid', 'a@localhost', 'a@app.localhost',
|
|
336
|
+
' Jan <a@shop.test> ', 'a@shop.test.',
|
|
337
|
+
]) {
|
|
338
|
+
expect([address, isReservedAddress(address)]).toEqual([address, true])
|
|
339
|
+
}
|
|
340
|
+
for (const address of ['a@shop.eu', 'a@test.com', 'a@example.com', 'a@testing', 'a@localhost.com', 'a@test.example.org']) {
|
|
341
|
+
expect([address, isReservedAddress(address)]).toEqual([address, false])
|
|
342
|
+
}
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test('an unmet consent still refuses after a reconcile — reconcile never consents for anyone', async () => {
|
|
346
|
+
const fake = await makeRightsContext()
|
|
347
|
+
await buyTopUp(fake)
|
|
348
|
+
await service(fake).reconcile()
|
|
349
|
+
await expect(service(fake).assertConsent(ENTITY)).rejects.toBeInstanceOf(PerformanceConsentRequired)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { CancellationKind, PurchaseKind, WithdrawalStatus } from '@owlmeans/payment'
|
|
3
|
+
import { capturePaymentPurchase } from '../src/consumer/capture.js'
|
|
4
|
+
import { lockProfile, recordEvent } from '../src/consumer/records.js'
|
|
5
|
+
import { createEventHandler } from '../src/plugins/events.js'
|
|
6
|
+
import { syncStripeProducts } from '../src/sync.js'
|
|
7
|
+
import {
|
|
8
|
+
billingProfiles, consumerConsents, consumerDeclarations, consumerEvents, consumerRights, fingerprints,
|
|
9
|
+
fulfillments, isDuplicateKey, payment, purchases, subscriptions,
|
|
10
|
+
} from '../src/utils.js'
|
|
11
|
+
import { gate, makeSuite } from './context.js'
|
|
12
|
+
import type { Booted } from './context.js'
|
|
13
|
+
import {
|
|
14
|
+
EUR_SETTLEMENT, FX, fixedMeter, invoiceOf, paidSession, requestStart, rightsOf, TEXT_VERSION,
|
|
15
|
+
} from './consumer-fixtures.js'
|
|
16
|
+
import { eventOf, makeFakeStripe, PRO, subscriptionOf } from './fake-stripe.js'
|
|
17
|
+
|
|
18
|
+
const { stripe, state } = makeFakeStripe({ fxRates: FX })
|
|
19
|
+
const suite = makeSuite('payconsumer', {
|
|
20
|
+
consumerRights: rightsOf(), pricing: EUR_SETTLEMENT, stripe, meter: fixedMeter({ granted: 1_000, used: 0, usedAfter: 0 }),
|
|
21
|
+
})
|
|
22
|
+
const it = gate.skip ? test.skip : test
|
|
23
|
+
|
|
24
|
+
const complete = async (booted: Booted, id: string, entityId: string, extra: Record<string, unknown> = {}) => {
|
|
25
|
+
state.invoices[`in_${id}`] = invoiceOf(`in_${id}`, { payment_intent: `pi_${id}`, lines: { object: 'list', data: [{ id: `il_${id}` }] } })
|
|
26
|
+
await createEventHandler(booted.ctx, stripe).process(eventOf('checkout.session.completed', paidSession({
|
|
27
|
+
id: `cs_${id}`, invoice: `in_${id}`, payment_intent: `pi_${id}`, customer: `cus_${entityId}`,
|
|
28
|
+
metadata: { entityId }, ...extra,
|
|
29
|
+
})))
|
|
30
|
+
const purchase = await purchases(booted.ctx).load({ sessionId: `cs_${id}` })
|
|
31
|
+
if (purchase == null) throw new Error('not captured')
|
|
32
|
+
return purchase
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('@owlmeans/server-payment — consumer-rights records on Mongo', () => {
|
|
36
|
+
if (gate.skip) {
|
|
37
|
+
test.skip(gate.reason ?? 'mongo gate closed', () => {})
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let booted: Booted
|
|
42
|
+
beforeAll(async () => { booted = await suite.boot() }, 60_000)
|
|
43
|
+
afterAll(async () => { await suite.teardown() }, 60_000)
|
|
44
|
+
|
|
45
|
+
it('stores every consumer-rights record and the new evidence fields through their collection validators', async () => {
|
|
46
|
+
const { ctx } = booted
|
|
47
|
+
const purchase = await complete(booted, 'v1', 'org-v')
|
|
48
|
+
expect(purchase).toEqual(expect.objectContaining({ kind: PurchaseKind.TopUp, inScope: true, invoiceLineId: 'il_v1' }))
|
|
49
|
+
expect(purchase.deadline).toBeInstanceOf(Date)
|
|
50
|
+
expect(await billingProfiles(ctx).byEntity('org-v')).toEqual(expect.objectContaining({ country: 'PL', currency: 'eur' }))
|
|
51
|
+
expect(await fulfillments(ctx).byExternalId('cs_v1', 'stripe')).toEqual(expect.objectContaining({
|
|
52
|
+
country: 'PL', purchaseId: purchase.purchaseId, termsAccepted: true, amountTotalMinor: 1_130,
|
|
53
|
+
}))
|
|
54
|
+
|
|
55
|
+
const consent = await consumerRights(ctx).recordConsent({ entityId: 'org-v', email: 'owner@shop.eu' }, {
|
|
56
|
+
purchaseIds: [purchase.purchaseId], textVersion: TEXT_VERSION, language: 'pl', acknowledged: true,
|
|
57
|
+
}, { ip: '203.0.113.1', userAgent: 'agent', ipCountry: 'PL', acceptLanguage: 'pl' })
|
|
58
|
+
expect(await consumerConsents(ctx).load(consent.consentId)).toEqual(expect.objectContaining({ kind: 'performance', ip: '203.0.113.1' }))
|
|
59
|
+
expect((await purchases(ctx).byPurchaseId(purchase.purchaseId))?.consentedAt).toBeInstanceOf(Date)
|
|
60
|
+
|
|
61
|
+
const startRequestId = await requestStart({ ctx } as never, { planSku: PRO })
|
|
62
|
+
expect((await consumerConsents(ctx).load(startRequestId))?.expiresAt).toBeInstanceOf(Date)
|
|
63
|
+
|
|
64
|
+
const receipt = await consumerRights(ctx).withdraw({ entityId: 'org-v' }, {
|
|
65
|
+
purchaseId: purchase.purchaseId, name: 'Jan', email: 'owner@shop.eu',
|
|
66
|
+
})
|
|
67
|
+
expect(receipt.status).toBe(WithdrawalStatus.Refunded)
|
|
68
|
+
expect(await consumerDeclarations(ctx).load(receipt.declarationId)).toEqual(expect.objectContaining({ matched: true }))
|
|
69
|
+
expect(await consumerEvents(ctx).count({ recordId: receipt.declarationId, action: 'refund', ok: true })).toBe(1)
|
|
70
|
+
|
|
71
|
+
const subscription = subscriptionOf({ id: 'sub_v', customer: 'cus_org-v', entityId: 'org-v' }) as unknown as Record<string, any>
|
|
72
|
+
subscription.currency = 'eur'
|
|
73
|
+
state.subscriptions.sub_v = subscription as never
|
|
74
|
+
await createEventHandler(ctx, stripe).process(eventOf('customer.subscription.updated', subscription))
|
|
75
|
+
const cancelled = await consumerRights(ctx).cancel({ entityId: 'org-v' }, {
|
|
76
|
+
kind: CancellationKind.Ordinary, name: 'Jan', email: 'owner@shop.eu', effective: 'earliest',
|
|
77
|
+
})
|
|
78
|
+
expect(await consumerDeclarations(ctx).load(cancelled.declarationId)).toEqual(expect.objectContaining({ kind: 'cancellation' }))
|
|
79
|
+
expect((await subscriptions(ctx).byExternalId('sub_v', 'stripe'))?.currency).toBe('eur')
|
|
80
|
+
|
|
81
|
+
await syncStripeProducts(ctx, stripe)
|
|
82
|
+
const row = await fingerprints(ctx).bySku('app-plans')
|
|
83
|
+
expect(row?.prices?.find(price => price.planSku === PRO)?.options).toEqual([{ currency: 'usd', unitAmount: 2_000 }])
|
|
84
|
+
expect(await payment(ctx).consumerRightsPolicy()).not.toBeNull()
|
|
85
|
+
}, 60_000)
|
|
86
|
+
|
|
87
|
+
it('keeps one purchase per id and contract reference, one profile per organization', async () => {
|
|
88
|
+
const { ctx } = booted
|
|
89
|
+
const purchase = await complete(booted, 'u1', 'org-u')
|
|
90
|
+
const { id: _id, ...copy } = purchase
|
|
91
|
+
const again = await purchases(ctx).create({ ...copy, contractRef: 'CR-000000-UNIQUE' }).catch(error => error)
|
|
92
|
+
expect(isDuplicateKey(again)).toBe(true)
|
|
93
|
+
const sameRef = await purchases(ctx).create({ ...copy, purchaseId: 'stripe:other', sessionId: 'cs_other' }).catch(error => error)
|
|
94
|
+
expect(isDuplicateKey(sameRef)).toBe(true)
|
|
95
|
+
// Subscription purchases have no session yet: the sparse unique index lets them coexist.
|
|
96
|
+
for (const id of ['sub_a', 'sub_b']) {
|
|
97
|
+
const { sessionId: _session, invoiceId: _invoice, ...rest } = copy
|
|
98
|
+
await purchases(ctx).create({ ...rest, purchaseId: `stripe:${id}`, contractRef: `CR-000000-${id.toUpperCase().replace('_', '')}X`, subscriptionId: id })
|
|
99
|
+
}
|
|
100
|
+
const profile = await billingProfiles(ctx).byEntity('org-u')
|
|
101
|
+
const { id: _pid, ...profileCopy } = profile!
|
|
102
|
+
expect(isDuplicateKey(await billingProfiles(ctx).create(profileCopy).catch(error => error))).toBe(true)
|
|
103
|
+
}, 60_000)
|
|
104
|
+
|
|
105
|
+
it('the first of concurrent locks wins; the others are mismatch events, never relocks', async () => {
|
|
106
|
+
const { ctx } = booted
|
|
107
|
+
const policy = await payment(ctx).consumerRightsPolicy()
|
|
108
|
+
const countries = ['DE', 'FR', 'PL', 'ES', 'IT', 'NL', 'AT', 'BE']
|
|
109
|
+
const results = await Promise.all(countries.map(country => lockProfile(ctx, policy, { entityId: 'org-race', country, source: 'checkout' })))
|
|
110
|
+
expect(results.filter(result => result.created)).toHaveLength(1)
|
|
111
|
+
const winner = (await billingProfiles(ctx).byEntity('org-race'))!.country
|
|
112
|
+
expect(results.every(result => result.record.country === winner)).toBe(true)
|
|
113
|
+
expect(await consumerEvents(ctx).count({ recordId: 'org-race', action: 'lock-mismatch' })).toBe(countries.length - 1)
|
|
114
|
+
}, 60_000)
|
|
115
|
+
|
|
116
|
+
it('stores the confirmation claim, an unlock and a terms fallback through their validators', async () => {
|
|
117
|
+
const { ctx } = booted
|
|
118
|
+
const purchase = await complete(booted, 'l1', 'org-l')
|
|
119
|
+
expect(purchase.confirmationMailAt).toBeInstanceOf(Date)
|
|
120
|
+
|
|
121
|
+
// Two processes capturing one session: one claim, one mail attempt.
|
|
122
|
+
state.invoices.in_l2 = invoiceOf('in_l2', { payment_intent: 'pi_l2', lines: { object: 'list', data: [{ id: 'il_l2' }] } })
|
|
123
|
+
const session = paidSession({ id: 'cs_l2', invoice: 'in_l2', payment_intent: 'pi_l2', customer: 'cus_org-l', metadata: { entityId: 'org-l' } })
|
|
124
|
+
await Promise.all([capturePaymentPurchase(ctx, stripe, session as never), capturePaymentPurchase(ctx, stripe, session as never)])
|
|
125
|
+
expect(await consumerEvents(ctx).count({ recordId: 'stripe:cs_l2', action: 'mail', step: 'purchase' })).toBe(1)
|
|
126
|
+
|
|
127
|
+
expect(await consumerRights(ctx).unlock('org-l', { by: 'operator', reason: 'integration' }))
|
|
128
|
+
.toEqual(expect.objectContaining({ country: 'PL' }))
|
|
129
|
+
expect(await billingProfiles(ctx).byEntity('org-l')).toBeNull()
|
|
130
|
+
expect(await consumerEvents(ctx).count({ recordId: 'org-l', action: 'unlock', ok: true })).toBe(1)
|
|
131
|
+
|
|
132
|
+
await recordEvent(ctx, {
|
|
133
|
+
recordId: 'org-l', recordKind: 'checkout', entityId: 'org-l', action: 'checkout-terms-fallback', ok: false,
|
|
134
|
+
externalId: 'cs_l3', detail: '{"param":"consent_collection[terms_of_service]"}',
|
|
135
|
+
})
|
|
136
|
+
expect(await consumerEvents(ctx).count({ recordId: 'org-l', action: 'checkout-terms-fallback' })).toBe(1)
|
|
137
|
+
}, 60_000)
|
|
138
|
+
|
|
139
|
+
it('only one of concurrent withdrawals of a purchase closes its window and refunds', async () => {
|
|
140
|
+
const { ctx } = booted
|
|
141
|
+
const purchase = await complete(booted, 'r1', 'org-r')
|
|
142
|
+
const refundsBefore = state.refunds.length
|
|
143
|
+
const receipts = await Promise.all(Array.from({ length: 5 }, async () => await consumerRights(ctx).withdraw({ entityId: 'org-r' }, {
|
|
144
|
+
purchaseId: purchase.purchaseId, name: 'Jan', email: 'owner@shop.eu',
|
|
145
|
+
})))
|
|
146
|
+
expect(state.refunds.length - refundsBefore).toBe(1)
|
|
147
|
+
const closed = await purchases(ctx).byPurchaseId(purchase.purchaseId)
|
|
148
|
+
expect(receipts.every(receipt => receipt.declarationId === closed?.withdrawalId)).toBe(true)
|
|
149
|
+
}, 60_000)
|
|
150
|
+
})
|