@owlmeans/web-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.
Files changed (136) hide show
  1. package/README.md +1 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-payment/SKILL.md +171 -31
  4. package/build/@/components/ui/checkbox.d.ts +5 -0
  5. package/build/@/components/ui/checkbox.d.ts.map +1 -0
  6. package/build/@/components/ui/checkbox.js +6 -0
  7. package/build/@/components/ui/checkbox.js.map +1 -0
  8. package/build/checkout-limit.d.ts +11 -0
  9. package/build/checkout-limit.d.ts.map +1 -0
  10. package/build/checkout-limit.js +34 -0
  11. package/build/checkout-limit.js.map +1 -0
  12. package/build/consumer/cancellation.d.ts +20 -0
  13. package/build/consumer/cancellation.d.ts.map +1 -0
  14. package/build/consumer/cancellation.js +115 -0
  15. package/build/consumer/cancellation.js.map +1 -0
  16. package/build/consumer/copy.d.ts +23 -0
  17. package/build/consumer/copy.d.ts.map +1 -0
  18. package/build/consumer/copy.js +75 -0
  19. package/build/consumer/copy.js.map +1 -0
  20. package/build/consumer/ensure.d.ts +56 -0
  21. package/build/consumer/ensure.d.ts.map +1 -0
  22. package/build/consumer/ensure.js +71 -0
  23. package/build/consumer/ensure.js.map +1 -0
  24. package/build/consumer/fields.d.ts +43 -0
  25. package/build/consumer/fields.d.ts.map +1 -0
  26. package/build/consumer/fields.js +19 -0
  27. package/build/consumer/fields.js.map +1 -0
  28. package/build/consumer/hooks.d.ts +113 -0
  29. package/build/consumer/hooks.d.ts.map +1 -0
  30. package/build/consumer/hooks.js +225 -0
  31. package/build/consumer/hooks.js.map +1 -0
  32. package/build/consumer/index.d.ts +14 -0
  33. package/build/consumer/index.d.ts.map +1 -0
  34. package/build/consumer/index.js +12 -0
  35. package/build/consumer/index.js.map +1 -0
  36. package/build/consumer/legal.d.ts +53 -0
  37. package/build/consumer/legal.d.ts.map +1 -0
  38. package/build/consumer/legal.js +77 -0
  39. package/build/consumer/legal.js.map +1 -0
  40. package/build/consumer/performance-consent-dialog.d.ts +18 -0
  41. package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
  42. package/build/consumer/performance-consent-dialog.js +71 -0
  43. package/build/consumer/performance-consent-dialog.js.map +1 -0
  44. package/build/consumer/provider.d.ts +33 -0
  45. package/build/consumer/provider.d.ts.map +1 -0
  46. package/build/consumer/provider.js +26 -0
  47. package/build/consumer/provider.js.map +1 -0
  48. package/build/consumer/refusal.d.ts +35 -0
  49. package/build/consumer/refusal.d.ts.map +1 -0
  50. package/build/consumer/refusal.js +98 -0
  51. package/build/consumer/refusal.js.map +1 -0
  52. package/build/consumer/subscription-start-dialog.d.ts +16 -0
  53. package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
  54. package/build/consumer/subscription-start-dialog.js +43 -0
  55. package/build/consumer/subscription-start-dialog.js.map +1 -0
  56. package/build/consumer/types.d.ts +114 -0
  57. package/build/consumer/types.d.ts.map +1 -0
  58. package/build/consumer/types.js +2 -0
  59. package/build/consumer/types.js.map +1 -0
  60. package/build/consumer/withdrawal.d.ts +29 -0
  61. package/build/consumer/withdrawal.d.ts.map +1 -0
  62. package/build/consumer/withdrawal.js +140 -0
  63. package/build/consumer/withdrawal.js.map +1 -0
  64. package/build/dialog.d.ts +1 -1
  65. package/build/dialog.d.ts.map +1 -1
  66. package/build/dialog.js +42 -16
  67. package/build/dialog.js.map +1 -1
  68. package/build/estimate-summary.d.ts +9 -1
  69. package/build/estimate-summary.d.ts.map +1 -1
  70. package/build/estimate-summary.js +10 -7
  71. package/build/estimate-summary.js.map +1 -1
  72. package/build/estimate.d.ts +6 -0
  73. package/build/estimate.d.ts.map +1 -1
  74. package/build/estimate.js +26 -1
  75. package/build/estimate.js.map +1 -1
  76. package/build/format.d.ts +15 -0
  77. package/build/format.d.ts.map +1 -0
  78. package/build/format.js +68 -0
  79. package/build/format.js.map +1 -0
  80. package/build/i18n/be.json +1 -1
  81. package/build/i18n/de.json +1 -1
  82. package/build/i18n/en.json +1 -1
  83. package/build/i18n/es.json +1 -1
  84. package/build/i18n/fr.json +110 -15
  85. package/build/i18n/pl.json +1 -1
  86. package/build/i18n/ru.json +1 -1
  87. package/build/i18n/uk.json +1 -1
  88. package/build/i18n.d.ts +2 -1
  89. package/build/i18n.d.ts.map +1 -1
  90. package/build/i18n.js +10 -8
  91. package/build/i18n.js.map +1 -1
  92. package/build/index.d.ts +2 -0
  93. package/build/index.d.ts.map +1 -1
  94. package/build/index.js +2 -0
  95. package/build/index.js.map +1 -1
  96. package/build/types.d.ts +22 -1
  97. package/build/types.d.ts.map +1 -1
  98. package/package.json +18 -7
  99. package/src/@/components/ui/checkbox.tsx +23 -0
  100. package/src/checkout-limit.tsx +48 -0
  101. package/src/consumer/cancellation.tsx +214 -0
  102. package/src/consumer/copy.ts +93 -0
  103. package/src/consumer/ensure.ts +124 -0
  104. package/src/consumer/fields.tsx +89 -0
  105. package/src/consumer/hooks.ts +358 -0
  106. package/src/consumer/index.ts +13 -0
  107. package/src/consumer/legal.tsx +129 -0
  108. package/src/consumer/performance-consent-dialog.tsx +132 -0
  109. package/src/consumer/provider.tsx +49 -0
  110. package/src/consumer/refusal.ts +110 -0
  111. package/src/consumer/subscription-start-dialog.tsx +87 -0
  112. package/src/consumer/types.ts +121 -0
  113. package/src/consumer/withdrawal.tsx +279 -0
  114. package/src/dialog.tsx +53 -23
  115. package/src/estimate-summary.tsx +21 -11
  116. package/src/estimate.ts +27 -1
  117. package/src/format.ts +71 -0
  118. package/src/i18n/be.json +1 -1
  119. package/src/i18n/de.json +1 -1
  120. package/src/i18n/en.json +1 -1
  121. package/src/i18n/es.json +1 -1
  122. package/src/i18n/fr.json +110 -15
  123. package/src/i18n/pl.json +1 -1
  124. package/src/i18n/ru.json +1 -1
  125. package/src/i18n/uk.json +1 -1
  126. package/src/i18n.ts +11 -8
  127. package/src/index.ts +2 -0
  128. package/src/types.ts +23 -1
  129. package/tests/consent-refusal.spec.ts +83 -0
  130. package/tests/consumer-hooks.spec.ts +205 -0
  131. package/tests/consumer.spec.ts +343 -0
  132. package/tests/context.ts +1 -1
  133. package/tests/harness/consumer-fixtures.ts +105 -0
  134. package/tests/harness/consumer.tsx +197 -0
  135. package/tests/harness/mount.tsx +9 -2
  136. package/tests/i18n.spec.ts +41 -5
@@ -0,0 +1,205 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createElement } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import type { RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
5
+ import { ApiStatusError } from '@owlmeans/api'
6
+ import {
7
+ ConsumerRegion, PerformanceConsentRequired,
8
+ type CancellationBody, type CancellationReceipt, type PerformanceConsentBody, type PerformanceConsentResponse,
9
+ type PerformanceConsentView, type WithdrawalBody, type WithdrawalCandidateList, type WithdrawalReceipt,
10
+ } from '@owlmeans/payment'
11
+ import { makeAsker, makeConsentGate, passThroughGate } from '../src/consumer/ensure.js'
12
+ import { useCancellation, usePerformanceConsent, useWithdrawal } from '../src/consumer/hooks.js'
13
+ import { useConsentGate } from '../src/consumer/provider.js'
14
+ import { ConsentDeclined } from '../src/consumer/refusal.js'
15
+
16
+ const tick = async () => await new Promise(resolve => setTimeout(resolve, 0))
17
+
18
+ interface View { required: boolean }
19
+
20
+ const asker = (views: Array<View | Error>) => {
21
+ const shown: View[] = []
22
+ let loads = 0
23
+ const control = makeAsker<[], View, boolean>({
24
+ load: async () => {
25
+ const next = views[Math.min(loads, views.length - 1)]
26
+ loads++
27
+ if (next instanceof Error) throw next
28
+ return next
29
+ },
30
+ required: view => view.required,
31
+ show: view => { shown.push(view) },
32
+ skip: true,
33
+ })
34
+
35
+ return { control, shown, loads: () => loads }
36
+ }
37
+
38
+ describe('makeAsker — the ensure() state machine', () => {
39
+ test('answers true at once when nothing is required, without showing anything', async () => {
40
+ const { control, shown } = asker([{ required: false }])
41
+ expect(await control.ask()).toBe(true)
42
+ expect(shown).toHaveLength(0)
43
+ })
44
+
45
+ test('shows the view and answers what the person decides', async () => {
46
+ const accept = asker([{ required: true }])
47
+ const accepted = accept.control.ask()
48
+ await tick()
49
+ expect(accept.shown).toHaveLength(1)
50
+ expect(accept.control.waiting()).toBe(true)
51
+ accept.control.settle(true)
52
+ expect(await accepted).toBe(true)
53
+ expect(accept.control.waiting()).toBe(false)
54
+
55
+ const decline = asker([{ required: true }])
56
+ const declined = decline.control.ask()
57
+ await tick()
58
+ decline.control.settle(false)
59
+ expect(await declined).toBe(false)
60
+ })
61
+
62
+ test('a burst of calls shares ONE pending answer and one dialog', async () => {
63
+ const { control, shown, loads } = asker([{ required: true }])
64
+ const answers = [control.ask(), control.ask(), control.ask()]
65
+ await tick()
66
+ expect(shown).toHaveLength(1)
67
+ expect(loads()).toBe(1)
68
+ control.settle(true)
69
+ expect(await Promise.all(answers)).toEqual([true, true, true])
70
+
71
+ // Settled: the next call reads the view afresh.
72
+ const next = control.ask()
73
+ await tick()
74
+ expect(loads()).toBe(2)
75
+ control.settle(false)
76
+ expect(await next).toBe(false)
77
+ })
78
+
79
+ test('fails open: a view that cannot be read answers the skip value', async () => {
80
+ const { control, shown } = asker([new Error('network down')])
81
+ expect(await control.ask()).toBe(true)
82
+ expect(shown).toHaveLength(0)
83
+ })
84
+
85
+ test('settling with nothing waiting does nothing', () => {
86
+ const { control } = asker([{ required: true }])
87
+ expect(() => control.settle(true)).not.toThrow()
88
+ expect(control.waiting()).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('makeConsentGate — withConsent retries exactly once', () => {
93
+ const counted = (failures: unknown[]) => {
94
+ let calls = 0
95
+ const action = async () => {
96
+ const failure = failures[calls]
97
+ calls++
98
+ if (failure != null) throw failure
99
+ return 'done'
100
+ }
101
+
102
+ return { action, calls: () => calls }
103
+ }
104
+
105
+ test('no refusal: runs once and never asks', async () => {
106
+ let asked = 0
107
+ const gate = makeConsentGate(async () => { asked++; return true })
108
+ const { action, calls } = counted([])
109
+ expect(await gate.withConsent(action)).toBe('done')
110
+ expect([calls(), asked]).toEqual([1, 0])
111
+ })
112
+
113
+ test('a refusal (class or bare 428) asks with force, then retries once', async () => {
114
+ for (const refusal of [new PerformanceConsentRequired({ pending: 1 }), new ApiStatusError(428)]) {
115
+ const forced: unknown[] = []
116
+ const gate = makeConsentGate(async opts => { forced.push(opts?.force); return true })
117
+ const { action, calls } = counted([refusal])
118
+ expect(await gate.withConsent(action)).toBe('done')
119
+ expect([calls(), forced]).toEqual([2, [true]])
120
+ }
121
+ })
122
+
123
+ test('a decline throws ConsentDeclined and never retries', async () => {
124
+ const gate = makeConsentGate(async () => false)
125
+ const { action, calls } = counted([new ApiStatusError(428)])
126
+ await expect(gate.withConsent(action)).rejects.toBeInstanceOf(ConsentDeclined)
127
+ expect(calls()).toBe(1)
128
+ })
129
+
130
+ test('a second refusal propagates — never a loop', async () => {
131
+ let asked = 0
132
+ const gate = makeConsentGate(async () => { asked++; return true })
133
+ const second = new ApiStatusError(428, 'second')
134
+ const { action, calls } = counted([new ApiStatusError(428, 'first'), second])
135
+ await expect(gate.withConsent(action)).rejects.toBe(second)
136
+ expect([calls(), asked]).toEqual([2, 1])
137
+ })
138
+
139
+ test('any other failure propagates without asking', async () => {
140
+ let asked = 0
141
+ const gate = makeConsentGate(async () => { asked++; return true })
142
+ const failure = new ApiStatusError(402)
143
+ await expect(gate.withConsent(async () => { throw failure })).rejects.toBe(failure)
144
+ expect(asked).toBe(0)
145
+ })
146
+
147
+ test('the pass-through gate asks nothing and lets a refusal through', async () => {
148
+ expect(await passThroughGate.ensure()).toBe(true)
149
+ const refusal = new ApiStatusError(428)
150
+ await expect(passThroughGate.withConsent(async () => { throw refusal })).rejects.toBe(refusal)
151
+ })
152
+ })
153
+
154
+ /** Render hooks once on the server and hand back what they answered. */
155
+ const answer = <T>(hook: () => T): T => {
156
+ let value: T | undefined
157
+ const Probe = () => { value = hook(); return null }
158
+ renderToStaticMarkup(createElement(Probe))
159
+ return value as T
160
+ }
161
+
162
+ const entry = <Request extends RequestShape, Response>(call: () => Promise<Response>) =>
163
+ ({ call }) as unknown as RegisteredEntrypoint<Request, Response>
164
+
165
+ const consentView: PerformanceConsentView = {
166
+ required: true, region: ConsumerRegion.Eu, country: 'DE', language: 'de', trader: 'Trader', textVersion: 'v1',
167
+ copyVersion: 'c1', links: { billingTerms: 'https://example.test/terms' }, purchases: [], at: new Date(),
168
+ }
169
+
170
+ describe('consumer hooks — first render', () => {
171
+ test('usePerformanceConsent: unknown until read, the dialog closed and wired', () => {
172
+ const control = answer(() => usePerformanceConsent(
173
+ entry<{}, PerformanceConsentView>(async () => consentView),
174
+ entry<{ body: PerformanceConsentBody }, PerformanceConsentResponse>(async () => ({
175
+ consentId: 'c', consentedAt: new Date(), purchaseIds: [], mailed: true,
176
+ })),
177
+ ))
178
+ expect(control.required).toBeNull()
179
+ expect(control.view).toBeNull()
180
+ expect(control.dialog.open).toBe(false)
181
+ expect(control.dialog.pending).toBe(false)
182
+ expect(typeof control.ensure).toBe('function')
183
+ expect(typeof control.dialog.onConfirm).toBe('function')
184
+ expect(control.dialog.onWithdraw).toBeUndefined()
185
+ })
186
+
187
+ test('useConsentGate outside a provider is the pass-through gate', async () => {
188
+ const gate = answer(() => useConsentGate())
189
+ expect(gate.required).toBeNull()
190
+ expect(await gate.ensure()).toBe(true)
191
+ })
192
+
193
+ test('useWithdrawal and useCancellation start empty and expose their form props', () => {
194
+ const withdrawal = answer(() => useWithdrawal(
195
+ entry<{}, WithdrawalCandidateList>(async () => ({ candidates: [], language: 'en', links: { billingTerms: 'https://x.test' } })),
196
+ entry<{ body: WithdrawalBody }, WithdrawalReceipt>(async () => { throw new Error('not called') }),
197
+ ))
198
+ expect([withdrawal.list, withdrawal.receipt, withdrawal.pending, withdrawal.form.error]).toEqual([null, null, false, false])
199
+
200
+ const cancellation = answer(() => useCancellation(
201
+ entry<{ body: CancellationBody }, CancellationReceipt>(async () => { throw new Error('not called') }),
202
+ ))
203
+ expect([cancellation.receipt, cancellation.pending, cancellation.form.error]).toEqual([null, false, false])
204
+ })
205
+ })
@@ -0,0 +1,343 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { closeBrowser, mountComponent, type Page } from '@owlmeans/test-ui'
3
+ import { closeHarness, harnessUrl } from './context.js'
4
+
5
+ const TIMEOUT = 60_000
6
+
7
+ afterAll(async () => {
8
+ await closeBrowser()
9
+ await closeHarness()
10
+ })
11
+
12
+ const open = async (query: string) => {
13
+ const mounted = await mountComponent({ url: `${await harnessUrl()}?${query}` })
14
+ // A case whose only content is a dialog (portalled) leaves an empty, zero-size <main>.
15
+ await mounted.page.locator('main').waitFor({ state: 'attached' })
16
+ return mounted
17
+ }
18
+
19
+ const text = async (page: Page, selector: string): Promise<string> =>
20
+ (await page.locator(selector).first().innerText()).replace(/\s+/g, ' ').trim()
21
+
22
+ const attr = async (page: Page, selector: string, name: string) => await page.locator(selector).first().getAttribute(name)
23
+
24
+ const output = async (page: Page, id: string): Promise<string> => (await page.locator(`#${id}`).textContent()) ?? ''
25
+
26
+ /** The honeypot sits off screen and out of the tab order — present for a bot, never for a person. */
27
+ const honeypotHidden = async (page: Page): Promise<boolean> => await page.locator('[data-honeypot] input').evaluate(input => {
28
+ const rect = input.getBoundingClientRect()
29
+ return rect.right <= 0 && input.getAttribute('tabindex') === '-1' && input.closest('[aria-hidden="true"]') != null
30
+ })
31
+
32
+ const FORBIDDEN = /non-?refundable|nicht erstattungsf|non rembours|bezzwrotn|no reembolsable|невозвратн|неповоротн|незваротн/i
33
+
34
+ describe('PerformanceConsentDialog — contract language, one unchecked checkbox', () => {
35
+ test('shows the German statement with the purchases, gates confirm on the checkbox, and toggles to English', async () => {
36
+ const { page, close } = await open('case=consent&lng=de&ui=en')
37
+ try {
38
+ const dialog = page.locator('[data-consent-dialog]')
39
+ await dialog.waitFor()
40
+ expect(await dialog.getAttribute('data-language')).toBe('de')
41
+ expect(await dialog.getAttribute('data-contract-language')).toBe('de')
42
+ expect(await text(page, '[data-consent-statement]')).toStartWith('Ich verlange ausdrücklich und stimme ausdrücklich zu')
43
+ expect(await text(page, '[data-consent-statement]')).toContain('Example Trader')
44
+ expect(await page.locator('[data-consent-purchase]').count()).toBe(2)
45
+ expect(await attr(page, '[data-consent-purchase][data-purchase-id="pur_1"]', 'data-deadline')).toBe('2026-10-09T00:00:00.000Z')
46
+ expect(await text(page, '[data-consent-purchase][data-purchase-id="pur_1"]')).toContain('8. Oktober 2026')
47
+
48
+ // Unchecked by default; confirm disabled until it is ticked.
49
+ expect(await attr(page, '[data-consent-checkbox]', 'data-state')).toBe('unchecked')
50
+ expect(await page.locator('[data-consent-confirm]').isDisabled()).toBe(true)
51
+ expect(await text(page, '[data-consent-confirm]')).toBe('Jetzt beginnen')
52
+ expect(await text(page, '[data-consent-decline]')).toBe('Nicht jetzt')
53
+ expect(await attr(page, '[data-legal-link="billing-terms"]', 'href')).toBe('https://example.test/de/legal/billing-terms')
54
+ expect(await text(page, '[data-legal-link="withdrawal-function"]')).toBe('Vertrag widerrufen')
55
+ expect(FORBIDDEN.test(await dialog.innerText())).toBe(false)
56
+
57
+ await page.locator('[data-consent-checkbox]').click()
58
+ expect(await page.locator('[data-consent-confirm]').isDisabled()).toBe(false)
59
+
60
+ // The toggle is phrased in the interface language, and un-ticks: the text agreed to is the one shown.
61
+ expect(await text(page, '[data-consent-language-toggle]')).toBe('Show in English')
62
+ await page.locator('[data-consent-language-toggle]').click()
63
+ expect(await dialog.getAttribute('data-language')).toBe('en')
64
+ expect(await text(page, '[data-consent-statement]')).toStartWith('I expressly request and agree')
65
+ expect(await attr(page, '[data-legal-link="billing-terms"]', 'href')).toBe('https://example.test/legal/billing-terms')
66
+ expect(await text(page, '[data-legal-link="withdrawal-function"]')).toBe('Withdraw from contract here')
67
+ expect(await text(page, '[data-consent-language-toggle]')).toBe('Show in German')
68
+ expect(await attr(page, '[data-consent-checkbox]', 'data-state')).toBe('unchecked')
69
+ expect(await page.locator('[data-consent-confirm]').isDisabled()).toBe(true)
70
+
71
+ await page.locator('[data-consent-checkbox]').click()
72
+ await page.locator('[data-consent-confirm]').click()
73
+ const body = JSON.parse(await output(page, 'consent-result'))
74
+ expect(body).toEqual({
75
+ purchaseIds: ['pur_1', 'pur_2'], textVersion: 'terms-2026-09', language: 'en', acknowledged: true, uiLanguage: 'en',
76
+ })
77
+ } finally { await close() }
78
+ }, TIMEOUT)
79
+
80
+ test('records the contract language when confirmed untoggled, and declines on "Nicht jetzt"', async () => {
81
+ const confirmed = await open('case=consent&lng=de&ui=en')
82
+ try {
83
+ await confirmed.page.locator('[data-consent-checkbox]').click()
84
+ await confirmed.page.locator('[data-consent-confirm]').click()
85
+ expect(JSON.parse(await output(confirmed.page, 'consent-result')).language).toBe('de')
86
+ } finally { await confirmed.close() }
87
+
88
+ const declined = await open('case=consent&lng=de&ui=en')
89
+ try {
90
+ await declined.page.locator('[data-consent-decline]').click()
91
+ expect(await output(declined.page, 'consent-result')).toBe('declined')
92
+ expect(await declined.page.locator('[data-consent-dialog]').count()).toBe(0)
93
+ } finally { await declined.close() }
94
+ }, TIMEOUT)
95
+ })
96
+
97
+ describe('SubscriptionStartDialog', () => {
98
+ test('French statement, "Continuer vers le paiement" gated on the checkbox', async () => {
99
+ const { page, close } = await open('case=start&lng=fr')
100
+ try {
101
+ const dialog = page.locator('[data-start-dialog]')
102
+ await dialog.waitFor()
103
+ expect(await dialog.getAttribute('data-language')).toBe('fr')
104
+ expect(await dialog.getAttribute('data-plan-sku')).toBe('pro-monthly')
105
+ expect(await dialog.innerText()).toContain('Commencer les services Pro maintenant')
106
+ expect(await text(page, '[data-start-statement]')).toStartWith('Je demande expressément et j’accepte expressément')
107
+ expect(await text(page, '[data-start-price]')).toBe('€20.00 / month')
108
+ expect(await text(page, '[data-start-confirm]')).toBe('Continuer vers le paiement')
109
+ expect(await attr(page, '[data-start-checkbox]', 'data-state')).toBe('unchecked')
110
+ expect(await page.locator('[data-start-confirm]').isDisabled()).toBe(true)
111
+ expect(await text(page, '[data-start-language-toggle]')).toBe('Show in English')
112
+
113
+ await page.locator('[data-start-checkbox]').click()
114
+ await page.locator('[data-start-confirm]').click()
115
+ expect(JSON.parse(await output(page, 'start-result'))).toEqual({
116
+ planSku: 'pro-monthly', textVersion: 'terms-2026-09', language: 'fr', acknowledged: true,
117
+ })
118
+ } finally { await close() }
119
+ }, TIMEOUT)
120
+ })
121
+
122
+ describe('Withdrawal function', () => {
123
+ test('in-app, French: statutory entry and confirm, three steps, receipt with the time of receipt in UTC', async () => {
124
+ const { page, close } = await open('case=withdrawal&lng=fr&ui=en')
125
+ try {
126
+ const entry = page.locator('[data-withdrawal-function]')
127
+ await entry.waitFor()
128
+ expect((await entry.innerText()).trim()).toBe('Renoncer au contrat ici')
129
+ expect(await entry.getAttribute('title')).toBe('Withdraw from contract here')
130
+ expect(await entry.getAttribute('data-language')).toBe('fr')
131
+
132
+ await entry.click()
133
+ const dialog = page.locator('[data-withdrawal-dialog]')
134
+ await dialog.waitFor()
135
+ expect(await dialog.getAttribute('data-language')).toBe('fr')
136
+ expect(await dialog.getAttribute('data-step')).toBe('form')
137
+ await page.locator('[data-withdrawal-step="form"]').waitFor()
138
+ await page.locator('[data-withdrawal-candidate]').first().waitFor()
139
+ expect(await page.locator('[data-withdrawal-candidate]').count()).toBe(2)
140
+ expect(await attr(page, '[data-withdrawal-candidate][data-purchase-id="pur_1"] [data-withdrawal-estimate]', 'data-refund-minor')).toBe('942')
141
+ // Only name, contract and e-mail are asked — prefilled from the list.
142
+ expect(await page.locator('[data-withdrawal-field="name"]').inputValue()).toBe('Marie Curie')
143
+ expect(await page.locator('[data-withdrawal-field="email"]').inputValue()).toBe('marie@example.test')
144
+
145
+ // No contract chosen yet: the form stays.
146
+ await page.locator('[data-withdrawal-continue]').click()
147
+ expect(await attr(page, '[data-withdrawal-form]', 'data-withdrawal-step')).toBe('form')
148
+
149
+ await page.locator('[data-withdrawal-candidate-input="pur_1"]').check()
150
+ await page.locator('[data-withdrawal-continue]').click()
151
+ await page.locator('[data-withdrawal-step="review"]').waitFor()
152
+ expect(await dialog.getAttribute('data-step')).toBe('review')
153
+ expect(await text(page, '[data-withdrawal-confirm]')).toBe('Confirmer la rétractation')
154
+
155
+ await page.locator('[data-withdrawal-language-toggle]').click()
156
+ expect(await text(page, '[data-withdrawal-confirm]')).toBe('Confirm withdrawal')
157
+ await page.locator('[data-withdrawal-language-toggle]').click()
158
+ expect(await text(page, '[data-withdrawal-confirm]')).toBe('Confirmer la rétractation')
159
+
160
+ await page.locator('[data-withdrawal-confirm]').click()
161
+ const receipt = page.locator('[data-withdrawal-receipt]')
162
+ await receipt.waitFor()
163
+ expect(await dialog.getAttribute('data-step')).toBe('receipt')
164
+ expect(await receipt.getAttribute('data-status')).toBe('refunded')
165
+ expect(await receipt.getAttribute('data-declaration-id')).toBe('wd_7Kq2')
166
+ expect(await receipt.getAttribute('data-received-at')).toBe('2026-09-27T14:05:09.000Z')
167
+ expect(await text(page, '[data-withdrawal-received-at]')).toContain('14:05:09')
168
+ expect(await text(page, '[data-withdrawal-received-at]')).toContain('UTC')
169
+ expect(await receipt.innerText()).toContain('Votre rétractation a été reçue le')
170
+
171
+ expect(JSON.parse(await output(page, 'withdrawal-body'))).toEqual({
172
+ purchaseId: 'pur_1', contractRef: 'CR-260923-AB12CD', name: 'Marie Curie', email: 'marie@example.test', language: 'fr',
173
+ })
174
+ } finally { await close() }
175
+ }, TIMEOUT)
176
+
177
+ test('public, German: contract reference, name and e-mail only, a honeypot no one reaches', async () => {
178
+ const { page, close } = await open('case=withdrawal-public&lng=de&ui=de')
179
+ try {
180
+ await page.locator('[data-withdrawal-form]').waitFor()
181
+ // Contract, name, e-mail — and the honeypot, which no person sees.
182
+ expect(await page.locator('[data-withdrawal-form] input').count()).toBe(4)
183
+ expect(await honeypotHidden(page)).toBe(true)
184
+ await page.locator('[data-withdrawal-field="contract"]').fill('CR-260923-AB12CD')
185
+ await page.locator('[data-withdrawal-field="name"]').fill('Max Mustermann')
186
+ await page.locator('[data-withdrawal-field="email"]').fill('not-an-address')
187
+ await page.locator('[data-withdrawal-continue]').click()
188
+ expect(await attr(page, '[data-withdrawal-form]', 'data-step')).toBe('form')
189
+ await page.locator('[data-withdrawal-field="email"]').fill('max@example.test')
190
+ await page.locator('[data-withdrawal-continue]').click()
191
+ expect(await text(page, '[data-withdrawal-confirm]')).toBe('Widerruf bestätigen')
192
+ await page.locator('[data-withdrawal-confirm]').click()
193
+ await page.locator('[data-withdrawal-receipt]').waitFor()
194
+ expect(await attr(page, '[data-withdrawal-receipt]', 'data-status')).toBe('received')
195
+ expect(JSON.parse(await output(page, 'withdrawal-body'))).toEqual({
196
+ contractRef: 'CR-260923-AB12CD', name: 'Max Mustermann', email: 'max@example.test', language: 'de',
197
+ })
198
+ } finally { await close() }
199
+ }, TIMEOUT)
200
+ })
201
+
202
+ describe('CancellationForm', () => {
203
+ test('German: kind, reason for cause, summary with "Jetzt kündigen", receipt with the effective date and print', async () => {
204
+ const { page, close } = await open('case=cancellation&lng=de&ui=de')
205
+ try {
206
+ const form = page.locator('[data-cancellation-form]')
207
+ await form.waitFor()
208
+ expect(await form.getAttribute('data-cancellation-step')).toBe('form')
209
+ expect(await form.getAttribute('data-language')).toBe('de')
210
+ expect(await honeypotHidden(page)).toBe(true)
211
+
212
+ await page.locator('[data-cancellation-field="kind"][data-kind="extraordinary"]').check()
213
+ await page.locator('[data-cancellation-field="name"]').fill('Max Mustermann')
214
+ await page.locator('[data-cancellation-field="email"]').fill('max@example.test')
215
+ await page.locator('[data-cancellation-continue]').click()
216
+ // A cancellation for cause needs its reason.
217
+ expect(await form.getAttribute('data-cancellation-step')).toBe('form')
218
+ await page.locator('[data-cancellation-field="kind"][data-kind="ordinary"]').check()
219
+ await page.locator('[data-cancellation-field="contract"]').fill('CR-260925-ZZ99YY')
220
+ await page.locator('[data-cancellation-continue]').click()
221
+
222
+ await page.locator('[data-cancellation-step="review"]').waitFor()
223
+ expect(await text(page, '[data-cancellation-summary] [data-summary="contract"]')).toBe('CR-260925-ZZ99YY')
224
+ expect(await text(page, '[data-cancellation-confirm]')).toBe('Jetzt kündigen')
225
+ await page.locator('[data-cancellation-confirm]').click()
226
+
227
+ const receipt = page.locator('[data-cancellation-receipt]')
228
+ await receipt.waitFor()
229
+ expect(await receipt.getAttribute('data-status')).toBe('scheduled')
230
+ expect(await receipt.getAttribute('data-effective-at')).toBe('2026-10-31T00:00:00.000Z')
231
+ expect(await receipt.getAttribute('data-received-at')).toBe('2026-09-27T16:30:00.000Z')
232
+ expect(await text(page, '[data-cancellation-effective]')).toContain('31. Oktober 2026')
233
+ await page.locator('[data-cancellation-print]').click()
234
+ expect(await output(page, 'printed')).toBe('printed')
235
+ expect(JSON.parse(await output(page, 'cancellation-body'))).toEqual({
236
+ kind: 'ordinary', name: 'Max Mustermann', contractRef: 'CR-260925-ZZ99YY', effective: 'earliest',
237
+ email: 'max@example.test', language: 'de',
238
+ })
239
+ } finally { await close() }
240
+ }, TIMEOUT)
241
+
242
+ test('French and English statutory confirm labels', async () => {
243
+ for (const [lng, label] of [['fr', 'Notification de la résiliation'], ['en', 'Cancel now']] as const) {
244
+ const { page, close } = await open(`case=cancellation&lng=${lng}&ui=${lng}`)
245
+ try {
246
+ await page.locator('[data-cancellation-field="name"]').fill('A Person')
247
+ await page.locator('[data-cancellation-field="email"]').fill('a@example.test')
248
+ await page.locator('[data-cancellation-continue]').click()
249
+ expect(await text(page, '[data-cancellation-confirm]')).toBe(label)
250
+ } finally { await close() }
251
+ }
252
+ }, TIMEOUT)
253
+ })
254
+
255
+ describe('Checkout limit', () => {
256
+ test('a per-purchase limit hides presets above it, names its maximum, and refuses more', async () => {
257
+ const { page, close } = await open('case=limit')
258
+ try {
259
+ const note = page.locator('[data-checkout-limit]')
260
+ await note.waitFor()
261
+ expect(await note.getAttribute('data-max-minor')).toBe('2500')
262
+ expect(await note.getAttribute('data-blocked')).toBe('false')
263
+ expect(await note.getAttribute('data-reason')).toBe('per-purchase')
264
+ expect(await note.innerText()).toContain('You can add up to $25.00 right now.')
265
+ expect(await page.locator('[data-amount-preset]').evaluateAll(items => items.map(item => item.getAttribute('data-amount-preset'))))
266
+ .toEqual(['1000', '2000'])
267
+ expect(await page.locator('[data-harness-legal-note]').count()).toBe(1)
268
+
269
+ const input = page.getByLabel('Custom amount')
270
+ await input.fill('30')
271
+ expect(await page.getByText('The most you can add right now is $25.00.').count()).toBe(1)
272
+ expect(await page.locator('[data-amount-confirm]').isDisabled()).toBe(true)
273
+ await input.fill('25')
274
+ expect(await page.locator('[data-amount-confirm]').isDisabled()).toBe(false)
275
+ await page.locator('[data-amount-confirm]').click()
276
+ expect(await output(page, 'confirmed')).toBe('2500')
277
+ } finally { await close() }
278
+ }, TIMEOUT)
279
+
280
+ test('a blocked limit disables the amount and the confirm button', async () => {
281
+ const { page, close } = await open('case=limit&blocked=true')
282
+ try {
283
+ const note = page.locator('[data-checkout-limit]')
284
+ await note.waitFor()
285
+ expect(await note.getAttribute('data-blocked')).toBe('true')
286
+ expect(await note.getAttribute('data-reason')).toBe('window')
287
+ expect(await note.innerText()).toContain('You cannot add credits right now.')
288
+ expect(await page.getByLabel('Custom amount').isDisabled()).toBe(true)
289
+ expect(await page.locator('[data-amount-confirm]').isDisabled()).toBe(true)
290
+ expect(await page.locator('[data-amount-preset]').count()).toBe(0)
291
+ } finally { await close() }
292
+ }, TIMEOUT)
293
+ })
294
+
295
+ describe('Locked billing country', () => {
296
+ test('the estimate adopts the locked country and the picker is disabled with its note', async () => {
297
+ const { page, close } = await open('case=country-locked')
298
+ try {
299
+ await page.locator('[data-country-select][data-locked="true"]').waitFor()
300
+ await page.locator('#locked:text("true")').waitFor()
301
+ expect(await attr(page, '[data-country-select]', 'data-country')).toBe('DE')
302
+ expect(await page.getByLabel('Billing country').isDisabled()).toBe(true)
303
+ expect(await text(page, '[data-country-note]')).toBe('Your billing country was set by your first purchase and cannot be changed here.')
304
+ // The first request named no country; the locked answer's country is the one asked next.
305
+ expect(JSON.parse(await output(page, 'requested'))).toEqual(['', 'DE'])
306
+ } finally { await close() }
307
+ }, TIMEOUT)
308
+ })
309
+
310
+ describe('PerformanceConsentProvider + useConsentGate', () => {
311
+ test('a bare 428 opens the one dialog, and after consent the action is retried exactly once', async () => {
312
+ const { page, close } = await open('case=consent-gate&lng=pl&ui=en')
313
+ try {
314
+ await page.locator('#gate-run').click()
315
+ const dialog = page.locator('[data-consent-dialog]')
316
+ await dialog.waitFor()
317
+ expect(await dialog.getAttribute('data-language')).toBe('pl')
318
+ await page.locator('[data-consent-checkbox]').click()
319
+ await page.locator('[data-consent-confirm]').click()
320
+ await page.locator('#gate-result:text("ok:2")').waitFor()
321
+ expect(JSON.parse(await output(page, 'recorded'))).toEqual(expect.objectContaining({
322
+ purchaseIds: ['pur_1', 'pur_2'], language: 'pl', acknowledged: true,
323
+ }))
324
+ await dialog.waitFor({ state: 'detached' })
325
+
326
+ // Recorded: ensure() now answers true with no dialog.
327
+ await page.locator('#gate-ensure').click()
328
+ await page.locator('#gate-result:text("ensure:true")').waitFor()
329
+ expect(await page.locator('[data-consent-dialog]').count()).toBe(0)
330
+ } finally { await close() }
331
+ }, TIMEOUT)
332
+
333
+ test('a decline throws ConsentDeclined and the action is not retried', async () => {
334
+ const { page, close } = await open('case=consent-gate&lng=pl&ui=en')
335
+ try {
336
+ await page.locator('#gate-run').click()
337
+ await page.locator('[data-consent-dialog]').waitFor()
338
+ await page.locator('[data-consent-decline]').click()
339
+ await page.locator('#gate-result:text("declined:1")').waitFor()
340
+ expect(await output(page, 'gate-calls')).toBe('1')
341
+ } finally { await close() }
342
+ }, TIMEOUT)
343
+ })
package/tests/context.ts CHANGED
@@ -21,7 +21,7 @@ export const harnessUrl = async (): Promise<string> => {
21
21
  // makes Vite re-optimize under a new hash, and the page briefly runs two copies of React.
22
22
  optimizeDeps: {
23
23
  include: [
24
- 'react', 'react-dom/client', '@radix-ui/react-dialog', '@radix-ui/react-label',
24
+ 'react', 'react-dom/client', '@radix-ui/react-checkbox', '@radix-ui/react-dialog', '@radix-ui/react-label',
25
25
  '@radix-ui/react-progress', '@radix-ui/react-select', '@radix-ui/react-slot',
26
26
  'class-variance-authority', 'clsx', 'tailwind-merge',
27
27
  ],
@@ -0,0 +1,105 @@
1
+ import {
2
+ CancellationStatus, ConsumerRegion, PurchaseKind, TaxBehavior, TaxEstimateStatus, TaxType, WithdrawalStatus,
3
+ } from '@owlmeans/payment'
4
+ import type {
5
+ AmountCheckoutPolicy, CheckoutLimitView, ConsumerRightsLinks, PerformanceConsentView, PriceEstimate,
6
+ SubscriptionStartView, WithdrawalCandidateList,
7
+ } from '@owlmeans/payment'
8
+
9
+ /** Canned consumer-rights data for the Playwright harness — no server, no Stripe. */
10
+
11
+ export const TRADER = 'Example Trader'
12
+
13
+ /** Links per language — a toggle switches them with the text. */
14
+ export const LINKS: Record<string, ConsumerRightsLinks> = Object.fromEntries(['en', 'de', 'fr', 'pl', 'es'].map(lng => {
15
+ const prefix = lng === 'en' ? '' : `/${lng}`
16
+ return [lng, {
17
+ billingTerms: `https://example.test${prefix}/legal/billing-terms`,
18
+ withdrawalInformation: `https://example.test${prefix}/legal/withdrawal`,
19
+ withdrawalFunction: `https://example.test${prefix}/legal/withdraw`,
20
+ }]
21
+ }))
22
+
23
+ export const linksOf = (lng: string): ConsumerRightsLinks => LINKS[lng] ?? LINKS.en
24
+
25
+ export const consentView = (lng: string): PerformanceConsentView => ({
26
+ required: true, region: ConsumerRegion.Eu, country: lng.toUpperCase(), language: lng, trader: TRADER,
27
+ textVersion: 'terms-2026-09', copyVersion: '2026-09-23', links: linksOf(lng),
28
+ purchases: [
29
+ {
30
+ purchaseId: 'pur_1', contractRef: 'CR-260923-AB12CD', kind: PurchaseKind.TopUp,
31
+ purchasedAt: new Date('2026-09-23T10:00:00Z'), deadline: new Date('2026-10-09T00:00:00Z'),
32
+ productSku: 'credits', amountTotalMinor: 1_256, currency: 'eur', withdrawable: true,
33
+ },
34
+ {
35
+ purchaseId: 'pur_2', contractRef: 'CR-260926-EF34GH', kind: PurchaseKind.TopUp,
36
+ purchasedAt: new Date('2026-09-26T09:30:00Z'), deadline: new Date('2026-10-14T00:00:00Z'),
37
+ productSku: 'credits', amountTotalMinor: 2_460, currency: 'eur', withdrawable: true,
38
+ },
39
+ ],
40
+ deadline: new Date('2026-10-14T00:00:00Z'),
41
+ at: new Date('2026-09-27T12:00:00Z'),
42
+ })
43
+
44
+ export const startView = (lng: string): SubscriptionStartView => ({
45
+ required: true, planSku: 'pro-monthly', language: lng, trader: TRADER, textVersion: 'terms-2026-09',
46
+ copyVersion: '2026-09-23', links: linksOf(lng), region: ConsumerRegion.Eu,
47
+ })
48
+
49
+ /** What the account withdrawal list answers on the wire — dates as ISO strings. */
50
+ export const withdrawalList = (lng: string): WithdrawalCandidateList => JSON.parse(JSON.stringify({
51
+ language: lng, links: linksOf(lng), name: 'Marie Curie', email: 'marie@example.test',
52
+ candidates: [
53
+ {
54
+ purchaseId: 'pur_1', contractRef: 'CR-260923-AB12CD', kind: PurchaseKind.TopUp,
55
+ purchasedAt: new Date('2026-09-23T10:00:00Z'), deadline: new Date('2026-10-09T00:00:00Z'),
56
+ amountTotalMinor: 1_256, currency: 'eur', automatic: true,
57
+ estimate: { refundMinor: 942, currency: 'eur', timeDeductionMinor: 0, unitsDeductionMinor: 314, unitsUsed: 125_000, unitsGranted: 500_000 },
58
+ },
59
+ {
60
+ purchaseId: 'pur_3', contractRef: 'CR-260925-ZZ99YY', kind: PurchaseKind.Subscription,
61
+ purchasedAt: new Date('2026-09-25T08:00:00Z'), deadline: new Date('2026-10-10T00:00:00Z'),
62
+ amountTotalMinor: 2_460, currency: 'eur', automatic: true, estimate: null,
63
+ },
64
+ ],
65
+ })) as WithdrawalCandidateList
66
+
67
+ export const withdrawalReceipt = {
68
+ declarationId: 'wd_7Kq2', receivedAt: '2026-09-27T14:05:09.000Z', content: { name: 'Marie Curie' }, mailed: true,
69
+ status: WithdrawalStatus.Refunded, refundMinor: 942, currency: 'eur',
70
+ }
71
+
72
+ export const publicReceipt = {
73
+ declarationId: 'wd_pub1', receivedAt: '2026-09-27T14:05:09.000Z', content: {}, mailed: true,
74
+ }
75
+
76
+ export const cancellationReceipt = {
77
+ declarationId: 'cn_4Rt8', receivedAt: '2026-09-27T16:30:00.000Z', content: {}, mailed: true,
78
+ status: CancellationStatus.Scheduled, effectiveAt: '2026-10-31T00:00:00.000Z',
79
+ }
80
+
81
+ export const POLICY: AmountCheckoutPolicy = {
82
+ currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
83
+ presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
84
+ }
85
+
86
+ export const PER_PURCHASE_LIMIT: CheckoutLimitView = {
87
+ productSku: 'credits', currency: 'usd', minimumMinor: 500, maximumMinor: 2_500, narrowed: true, blocked: false,
88
+ reason: 'per-purchase', resetsAt: new Date('2026-10-01T09:00:00Z'),
89
+ }
90
+
91
+ export const BLOCKED_LIMIT: CheckoutLimitView = {
92
+ productSku: 'credits', currency: 'usd', minimumMinor: 500, maximumMinor: 0, narrowed: true, blocked: true,
93
+ reason: 'window', resetsAt: new Date('2026-09-30T12:00:00Z'), remainingMinor: 0,
94
+ }
95
+
96
+ /** An estimate for a LOCKED billing country: the requested country is ignored. */
97
+ export const lockedEstimate = (subtotalMinor: number): PriceEstimate => ({
98
+ country: 'DE', source: 'profile', locked: true, region: ConsumerRegion.Eu, currency: 'usd',
99
+ behavior: TaxBehavior.Exclusive,
100
+ tax: {
101
+ status: TaxEstimateStatus.Taxed, subtotalMinor, taxMinor: Math.round(subtotalMinor * 0.19),
102
+ totalMinor: subtotalMinor + Math.round(subtotalMinor * 0.19), scalable: true,
103
+ rates: [{ type: TaxType.Vat, percentage: '19', ratePpm: 190_000, country: 'DE' }],
104
+ },
105
+ })