@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,197 @@
1
+ import { useState, type ReactElement } from 'react'
2
+ import type { RegisteredEntrypoint } from '@owlmeans/entrypoint'
3
+ import type {
4
+ CancellationBody, DeclarationReceipt, PerformanceConsentBody, PerformanceConsentResponse, PerformanceConsentView,
5
+ PriceEstimate, WithdrawalBody, WithdrawalCandidateList,
6
+ } from '@owlmeans/payment'
7
+ import { AmountCheckoutDialog, PriceEstimateSummary, usePriceEstimate } from '../../src/index.js'
8
+ import {
9
+ CancellationForm, PerformanceConsentDialog, PerformanceConsentProvider, SubscriptionStartDialog, WithdrawalDialog,
10
+ WithdrawalForm, WithdrawalFunctionButton, isConsentDeclined, useCancellation, useConsentGate, useWithdrawal,
11
+ } from '../../src/consumer/index.js'
12
+ import {
13
+ BLOCKED_LIMIT, LINKS, PER_PURCHASE_LIMIT, POLICY, cancellationReceipt, consentView, lockedEstimate, publicReceipt,
14
+ startView, withdrawalList, withdrawalReceipt,
15
+ } from './consumer-fixtures.js'
16
+
17
+ const params = new URLSearchParams(window.location.search)
18
+ const lng = params.get('lng') ?? 'en'
19
+
20
+ /** A fake bound protocol: records the request and answers after a short, real delay. */
21
+ const fake = <Response,>(answer: (request: { body?: unknown }) => Response, record?: (request: unknown) => void) => ({
22
+ call: async (request: { body?: unknown } = {}) => {
23
+ record?.(request)
24
+ await new Promise(resolve => setTimeout(resolve, 50))
25
+ return answer(request)
26
+ },
27
+ }) as unknown as RegisteredEntrypoint<any, Response>
28
+
29
+ const Output = ({ id, value }: { id: string, value: unknown }) =>
30
+ <output id={id}>{value == null ? '' : typeof value === 'string' ? value : JSON.stringify(value)}</output>
31
+
32
+ const ConsentCase = () => {
33
+ const [open, setOpen] = useState(true)
34
+ const [result, setResult] = useState<unknown>(null)
35
+ return <main className="p-6">
36
+ <Output id="consent-result" value={result} />
37
+ <Output id="dialog-state" value={open ? 'open' : 'closed'} />
38
+ <PerformanceConsentDialog
39
+ open={open} onOpenChange={setOpen} view={consentView(lng)} links={LINKS}
40
+ onConfirm={body => { setResult(body); setOpen(false) }}
41
+ onDecline={() => { setResult('declined'); setOpen(false) }}
42
+ />
43
+ </main>
44
+ }
45
+
46
+ const StartCase = () => {
47
+ const [open, setOpen] = useState(true)
48
+ const [result, setResult] = useState<unknown>(null)
49
+ return <main className="p-6">
50
+ <Output id="start-result" value={result} />
51
+ <SubscriptionStartDialog
52
+ open={open} onOpenChange={setOpen} view={startView(lng)} planTitle="Pro" links={LINKS}
53
+ price={<span>€20.00 / month</span>}
54
+ onConfirm={body => { setResult(body); setOpen(false) }}
55
+ onDecline={() => { setResult('declined'); setOpen(false) }}
56
+ />
57
+ </main>
58
+ }
59
+
60
+ const WithdrawalCase = () => {
61
+ const [open, setOpen] = useState(false)
62
+ const [sent, setSent] = useState<unknown>(null)
63
+ const [entries] = useState(() => ({
64
+ list: fake<WithdrawalCandidateList>(() => withdrawalList(lng)),
65
+ withdraw: fake<DeclarationReceipt>(() => withdrawalReceipt, request => setSent((request as { body: WithdrawalBody }).body)),
66
+ }))
67
+ const withdrawal = useWithdrawal(entries.list, entries.withdraw, { enabled: true })
68
+ return <main className="grid gap-4 p-6">
69
+ <Output id="withdrawal-body" value={sent} />
70
+ <div><WithdrawalFunctionButton language={lng} onClick={() => setOpen(true)} /></div>
71
+ <WithdrawalDialog
72
+ open={open} onOpenChange={next => { setOpen(next); if (!next) withdrawal.reset() }}
73
+ mode="in-app" language={lng} {...withdrawal.form}
74
+ />
75
+ </main>
76
+ }
77
+
78
+ const WithdrawalPublicCase = () => {
79
+ const [sent, setSent] = useState<unknown>(null)
80
+ const [entry] = useState(() => fake<DeclarationReceipt>(() => publicReceipt, request => setSent((request as { body: WithdrawalBody }).body)))
81
+ const withdrawal = useWithdrawal(null, entry)
82
+ return <main className="p-6">
83
+ <Output id="withdrawal-body" value={sent} />
84
+ <WithdrawalForm mode="public" language={lng} {...withdrawal.form} />
85
+ </main>
86
+ }
87
+
88
+ const CancellationCase = () => {
89
+ const [sent, setSent] = useState<unknown>(null)
90
+ const [printed, setPrinted] = useState(false)
91
+ const [entry] = useState(() => fake<DeclarationReceipt>(() => cancellationReceipt, request => setSent((request as { body: CancellationBody }).body)))
92
+ const cancellation = useCancellation(entry)
93
+ return <main className="p-6">
94
+ <Output id="cancellation-body" value={sent} />
95
+ <Output id="printed" value={printed ? 'printed' : ''} />
96
+ <CancellationForm mode="public" language={lng} onPrint={() => setPrinted(true)} {...cancellation.form} />
97
+ </main>
98
+ }
99
+
100
+ const LimitCase = () => {
101
+ const [open, setOpen] = useState(true)
102
+ const [confirmed, setConfirmed] = useState<number | null>(null)
103
+ const blocked = params.get('blocked') === 'true'
104
+ return <main className="p-6">
105
+ <Output id="confirmed" value={confirmed == null ? '' : String(confirmed)} />
106
+ <AmountCheckoutDialog
107
+ open={open} onOpenChange={setOpen} policy={POLICY} onConfirm={setConfirmed}
108
+ limit={blocked ? BLOCKED_LIMIT : PER_PURCHASE_LIMIT}
109
+ legalNote={<span data-harness-legal-note="">Only unused credits are reimbursed on withdrawal.</span>}
110
+ />
111
+ </main>
112
+ }
113
+
114
+ const CountryLockedCase = () => {
115
+ const [requested, setRequested] = useState<string[]>([])
116
+ const [entry] = useState(() => fake<PriceEstimate>(
117
+ () => lockedEstimate(2_000),
118
+ request => setRequested(held => [...held, (request as { body?: { country?: string } }).body?.country ?? '']),
119
+ ))
120
+ const estimate = usePriceEstimate(entry, { enabled: true })
121
+ return <main className="p-6">
122
+ <Output id="requested" value={requested} />
123
+ <Output id="locked" value={String(estimate.locked === true)} />
124
+ <PriceEstimateSummary control={estimate} subtotalMinor={2_000} currency="usd" />
125
+ </main>
126
+ }
127
+
128
+ /** `withConsent` against a protocol refused ONCE with a bare 428 — a production incident body. */
129
+ const GateButtons = () => {
130
+ const gate = useConsentGate()
131
+ const [result, setResult] = useState('')
132
+ const [calls, setCalls] = useState(0)
133
+ const [refusals] = useState(() => ({ left: 1 }))
134
+ const run = async () => {
135
+ let made = 0
136
+ try {
137
+ await gate.withConsent(async () => {
138
+ made++
139
+ setCalls(made)
140
+ if (refusals.left > 0) {
141
+ refusals.left--
142
+ throw new Error('api:client:status:428:0b6f8a3e-8f0e-4b9f-9c55-2f1f0f6f2a11')
143
+ }
144
+ return 'done'
145
+ })
146
+ setResult(`ok:${made}`)
147
+ } catch (e) {
148
+ setResult(isConsentDeclined(e) ? `declined:${made}` : `error:${made}`)
149
+ }
150
+ }
151
+ const ensure = async () => { setResult(`ensure:${String(await gate.ensure())}`) }
152
+ return <>
153
+ <Output id="gate-result" value={result} />
154
+ <Output id="gate-calls" value={String(calls)} />
155
+ <button type="button" id="gate-run" onClick={() => void run()}>Run</button>
156
+ <button type="button" id="gate-ensure" onClick={() => void ensure()}>Ensure</button>
157
+ </>
158
+ }
159
+
160
+ const ConsentGateCase = () => {
161
+ const [state] = useState(() => ({ consented: false }))
162
+ const [recorded, setRecorded] = useState<unknown>(null)
163
+ const [entries] = useState(() => ({
164
+ view: fake<PerformanceConsentView>(() => JSON.parse(JSON.stringify(
165
+ state.consented ? { ...consentView(lng), required: false, purchases: [] } : consentView(lng),
166
+ ))),
167
+ record: fake<PerformanceConsentResponse>(request => {
168
+ state.consented = true
169
+ const body = (request as { body: PerformanceConsentBody }).body
170
+ return { consentId: 'cons_1', consentedAt: new Date().toISOString(), purchaseIds: body.purchaseIds, mailed: true } as never
171
+ }, request => setRecorded((request as { body: unknown }).body)),
172
+ }))
173
+ return <main className="grid gap-2 p-6">
174
+ <Output id="recorded" value={recorded} />
175
+ <PerformanceConsentProvider view={entries.view} record={entries.record} options={{ links: LINKS }}>
176
+ <GateButtons />
177
+ </PerformanceConsentProvider>
178
+ </main>
179
+ }
180
+
181
+ const CASES: Record<string, () => ReactElement> = {
182
+ consent: ConsentCase,
183
+ start: StartCase,
184
+ withdrawal: WithdrawalCase,
185
+ 'withdrawal-public': WithdrawalPublicCase,
186
+ cancellation: CancellationCase,
187
+ limit: LimitCase,
188
+ 'country-locked': CountryLockedCase,
189
+ 'consent-gate': ConsentGateCase,
190
+ }
191
+
192
+ export const isConsumerCase = (name: string | null): name is string => name != null && name in CASES
193
+
194
+ export const ConsumerCase = ({ name }: { name: string }) => {
195
+ const Case = CASES[name]
196
+ return <Case />
197
+ }
@@ -2,6 +2,7 @@ import { useState } from 'react'
2
2
  import { createRoot } from 'react-dom/client'
3
3
  import { I18nContext } from '@owlmeans/client-i18n'
4
4
  import { AmountCheckoutDialog } from '../../src/index.js'
5
+ import { ConsumerCase, isConsumerCase } from './consumer.js'
5
6
  import { EntitlementCase } from './entitlement.js'
6
7
  import { ESTIMATE_FIXTURES } from './estimate-fixtures.js'
7
8
 
@@ -31,8 +32,14 @@ const Dialog = () => {
31
32
  </>
32
33
  }
33
34
 
34
- const App = () => <I18nContext config={{ service: 'web-payment-test', i18n: { defaultLng: 'en' } } as never}>
35
- {scenario == null ? <Dialog /> : <EntitlementCase name={scenario} />}
35
+ /** `?ui=` is the interface language; `?lng=` (read by the consumer cases) the contract language. */
36
+ const ui = params.get('ui') ?? 'en'
37
+ const LANGUAGES = ['en', 'pl', 'ru', 'be', 'uk', 'es', 'de', 'fr']
38
+
39
+ const App = () => <I18nContext config={{ service: 'web-payment-test', i18n: { defaultLng: ui, supportedLngs: LANGUAGES } } as never}>
40
+ {scenario == null
41
+ ? <Dialog />
42
+ : isConsumerCase(scenario) ? <ConsumerCase name={scenario} /> : <EntitlementCase name={scenario} />}
36
43
  </I18nContext>
37
44
 
38
45
  createRoot(document.getElementById('root')!).render(<App />)
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test'
2
+ import { CancellationStatus, PurchaseKind, WithdrawalStatus } from '@owlmeans/payment'
2
3
  import en from '../src/i18n/en.json'
3
4
  import pl from '../src/i18n/pl.json'
4
5
  import ru from '../src/i18n/ru.json'
@@ -6,6 +7,7 @@ import be from '../src/i18n/be.json'
6
7
  import uk from '../src/i18n/uk.json'
7
8
  import es from '../src/i18n/es.json'
8
9
  import de from '../src/i18n/de.json'
10
+ import fr from '../src/i18n/fr.json'
9
11
 
10
12
  type Messages = { [key: string]: string | Messages }
11
13
 
@@ -26,14 +28,25 @@ const leaves = (messages: Messages, prefix = ''): Map<string, unknown> => {
26
28
  const placeholders = (message: string): string[] =>
27
29
  [...message.matchAll(/\{\{\s*([\w-]+)\s*\}\}/g)].map(match => match[1]).sort()
28
30
 
31
+ /**
32
+ * What no payment text may say in any language (UCPD art. 6(1)(g)): the right of withdrawal
33
+ * EXPIRES for what was used, and unused credits ARE reimbursed — nothing is "non-refundable".
34
+ */
35
+ const FORBIDDEN = /non-?refundable|nicht erstattungsf|non rembours|bezzwrotn|no reembolsable|невозвратн|неповоротн|незваротн/i
36
+
29
37
  describe('web-payment localization', () => {
30
- const translations: Record<string, Messages> = { en, pl, ru, be, uk, es, de }
38
+ const translations: Record<string, Messages> = { en, pl, ru, be, uk, es, de, fr }
31
39
  const reference = leaves(en)
32
40
 
33
- test('ships all seven library languages with exact key parity at every depth', () => {
34
- expect(Object.keys(translations).sort()).toEqual(['be', 'de', 'en', 'es', 'pl', 'ru', 'uk'])
35
- expect(reference.has('amount-checkout.title')).toBe(true)
36
- expect(reference.has('entitlement.status.renews-on')).toBe(true)
41
+ test('ships all eight library languages with exact key parity at every depth', () => {
42
+ expect(Object.keys(translations).sort()).toEqual(['be', 'de', 'en', 'es', 'fr', 'pl', 'ru', 'uk'])
43
+ for (const key of [
44
+ 'amount-checkout.title', 'amount-checkout.above-limit', 'entitlement.status.renews-on', 'estimate.country-locked',
45
+ 'checkout-limit.note', 'checkout-limit.reason.per-purchase', 'checkout-limit.reason.window', 'checkout-limit.reason.hold',
46
+ 'consumer.show-in', 'consumer.review', 'consumer.receipt', 'withdrawal.status.refunded', 'cancellation.status.scheduled',
47
+ ]) {
48
+ expect({ key, present: reference.has(key) }).toEqual({ key, present: true })
49
+ }
37
50
  for (const [lng, messages] of Object.entries(translations)) {
38
51
  const found = leaves(messages)
39
52
  expect({ lng, keys: [...found.keys()].sort() }).toEqual({ lng, keys: [...reference.keys()].sort() })
@@ -52,4 +65,27 @@ describe('web-payment localization', () => {
52
65
  }
53
66
  }
54
67
  })
68
+
69
+ test('phrases every status and kind a receipt or a candidate can carry, in every language', () => {
70
+ const required = [
71
+ ...Object.values(WithdrawalStatus).map(status => `withdrawal.status.${status}`),
72
+ ...Object.values(CancellationStatus).map(status => `cancellation.status.${status}`),
73
+ ...Object.values(PurchaseKind).map(kind => `withdrawal.kind.${kind}`),
74
+ ]
75
+ expect(required).toContain('cancellation.status.review')
76
+ for (const [lng, messages] of Object.entries(translations)) {
77
+ const found = leaves(messages)
78
+ for (const key of required) {
79
+ expect({ lng, key, present: typeof found.get(key) === 'string' }).toEqual({ lng, key, present: true })
80
+ }
81
+ }
82
+ })
83
+
84
+ test('never calls anything non-refundable, in any language', () => {
85
+ for (const [lng, messages] of Object.entries(translations)) {
86
+ for (const [key, message] of leaves(messages)) {
87
+ expect({ lng, key, forbidden: FORBIDDEN.test(String(message)) }).toEqual({ lng, key, forbidden: false })
88
+ }
89
+ }
90
+ })
55
91
  })