@owlmeans/web-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.
Files changed (203) hide show
  1. package/README.md +30 -1
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/web-payment/SKILL.md +287 -20
  4. package/build/@/components/ui/card.d.ts +10 -0
  5. package/build/@/components/ui/card.d.ts.map +1 -0
  6. package/build/@/components/ui/card.js +25 -0
  7. package/build/@/components/ui/card.js.map +1 -0
  8. package/build/@/components/ui/checkbox.d.ts +5 -0
  9. package/build/@/components/ui/checkbox.d.ts.map +1 -0
  10. package/build/@/components/ui/checkbox.js +6 -0
  11. package/build/@/components/ui/checkbox.js.map +1 -0
  12. package/build/@/components/ui/progress.d.ts +5 -0
  13. package/build/@/components/ui/progress.d.ts.map +1 -0
  14. package/build/@/components/ui/progress.js +11 -0
  15. package/build/@/components/ui/progress.js.map +1 -0
  16. package/build/@/components/ui/select.d.ts +8 -0
  17. package/build/@/components/ui/select.d.ts.map +1 -0
  18. package/build/@/components/ui/select.js +9 -0
  19. package/build/@/components/ui/select.js.map +1 -0
  20. package/build/capability-list.d.ts +7 -0
  21. package/build/capability-list.d.ts.map +1 -0
  22. package/build/capability-list.js +24 -0
  23. package/build/capability-list.js.map +1 -0
  24. package/build/checkout-limit.d.ts +11 -0
  25. package/build/checkout-limit.d.ts.map +1 -0
  26. package/build/checkout-limit.js +34 -0
  27. package/build/checkout-limit.js.map +1 -0
  28. package/build/consumer/cancellation.d.ts +20 -0
  29. package/build/consumer/cancellation.d.ts.map +1 -0
  30. package/build/consumer/cancellation.js +115 -0
  31. package/build/consumer/cancellation.js.map +1 -0
  32. package/build/consumer/copy.d.ts +23 -0
  33. package/build/consumer/copy.d.ts.map +1 -0
  34. package/build/consumer/copy.js +75 -0
  35. package/build/consumer/copy.js.map +1 -0
  36. package/build/consumer/ensure.d.ts +56 -0
  37. package/build/consumer/ensure.d.ts.map +1 -0
  38. package/build/consumer/ensure.js +71 -0
  39. package/build/consumer/ensure.js.map +1 -0
  40. package/build/consumer/fields.d.ts +43 -0
  41. package/build/consumer/fields.d.ts.map +1 -0
  42. package/build/consumer/fields.js +19 -0
  43. package/build/consumer/fields.js.map +1 -0
  44. package/build/consumer/hooks.d.ts +113 -0
  45. package/build/consumer/hooks.d.ts.map +1 -0
  46. package/build/consumer/hooks.js +225 -0
  47. package/build/consumer/hooks.js.map +1 -0
  48. package/build/consumer/index.d.ts +14 -0
  49. package/build/consumer/index.d.ts.map +1 -0
  50. package/build/consumer/index.js +12 -0
  51. package/build/consumer/index.js.map +1 -0
  52. package/build/consumer/legal.d.ts +53 -0
  53. package/build/consumer/legal.d.ts.map +1 -0
  54. package/build/consumer/legal.js +77 -0
  55. package/build/consumer/legal.js.map +1 -0
  56. package/build/consumer/performance-consent-dialog.d.ts +18 -0
  57. package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
  58. package/build/consumer/performance-consent-dialog.js +71 -0
  59. package/build/consumer/performance-consent-dialog.js.map +1 -0
  60. package/build/consumer/provider.d.ts +33 -0
  61. package/build/consumer/provider.d.ts.map +1 -0
  62. package/build/consumer/provider.js +26 -0
  63. package/build/consumer/provider.js.map +1 -0
  64. package/build/consumer/refusal.d.ts +35 -0
  65. package/build/consumer/refusal.d.ts.map +1 -0
  66. package/build/consumer/refusal.js +98 -0
  67. package/build/consumer/refusal.js.map +1 -0
  68. package/build/consumer/subscription-start-dialog.d.ts +16 -0
  69. package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
  70. package/build/consumer/subscription-start-dialog.js +43 -0
  71. package/build/consumer/subscription-start-dialog.js.map +1 -0
  72. package/build/consumer/types.d.ts +114 -0
  73. package/build/consumer/types.d.ts.map +1 -0
  74. package/build/consumer/types.js +2 -0
  75. package/build/consumer/types.js.map +1 -0
  76. package/build/consumer/withdrawal.d.ts +29 -0
  77. package/build/consumer/withdrawal.d.ts.map +1 -0
  78. package/build/consumer/withdrawal.js +140 -0
  79. package/build/consumer/withdrawal.js.map +1 -0
  80. package/build/copy.d.ts +14 -0
  81. package/build/copy.d.ts.map +1 -0
  82. package/build/copy.js +51 -0
  83. package/build/copy.js.map +1 -0
  84. package/build/dialog.d.ts +1 -1
  85. package/build/dialog.d.ts.map +1 -1
  86. package/build/dialog.js +46 -17
  87. package/build/dialog.js.map +1 -1
  88. package/build/entitlement.d.ts +26 -0
  89. package/build/entitlement.d.ts.map +1 -0
  90. package/build/entitlement.js +42 -0
  91. package/build/entitlement.js.map +1 -0
  92. package/build/estimate-cache.d.ts +14 -0
  93. package/build/estimate-cache.d.ts.map +1 -0
  94. package/build/estimate-cache.js +39 -0
  95. package/build/estimate-cache.js.map +1 -0
  96. package/build/estimate-summary.d.ts +64 -0
  97. package/build/estimate-summary.d.ts.map +1 -0
  98. package/build/estimate-summary.js +94 -0
  99. package/build/estimate-summary.js.map +1 -0
  100. package/build/estimate.d.ts +34 -0
  101. package/build/estimate.d.ts.map +1 -0
  102. package/build/estimate.js +92 -0
  103. package/build/estimate.js.map +1 -0
  104. package/build/format.d.ts +15 -0
  105. package/build/format.d.ts.map +1 -0
  106. package/build/format.js +68 -0
  107. package/build/format.js.map +1 -0
  108. package/build/hooks.d.ts +0 -4
  109. package/build/hooks.d.ts.map +1 -1
  110. package/build/hooks.js +3 -30
  111. package/build/hooks.js.map +1 -1
  112. package/build/i18n/be.json +1 -1
  113. package/build/i18n/de.json +1 -1
  114. package/build/i18n/en.json +1 -1
  115. package/build/i18n/es.json +1 -1
  116. package/build/i18n/fr.json +145 -0
  117. package/build/i18n/pl.json +1 -1
  118. package/build/i18n/ru.json +1 -1
  119. package/build/i18n/uk.json +1 -1
  120. package/build/i18n.d.ts +2 -1
  121. package/build/i18n.d.ts.map +1 -1
  122. package/build/i18n.js +11 -7
  123. package/build/i18n.js.map +1 -1
  124. package/build/index.d.ts +9 -0
  125. package/build/index.d.ts.map +1 -1
  126. package/build/index.js +9 -0
  127. package/build/index.js.map +1 -1
  128. package/build/limit-meter.d.ts +7 -0
  129. package/build/limit-meter.d.ts.map +1 -0
  130. package/build/limit-meter.js +26 -0
  131. package/build/limit-meter.js.map +1 -0
  132. package/build/plan-card.d.ts +7 -0
  133. package/build/plan-card.d.ts.map +1 -0
  134. package/build/plan-card.js +29 -0
  135. package/build/plan-card.js.map +1 -0
  136. package/build/poll.d.ts +7 -0
  137. package/build/poll.d.ts.map +1 -0
  138. package/build/poll.js +28 -0
  139. package/build/poll.js.map +1 -0
  140. package/build/selectors.d.ts +18 -0
  141. package/build/selectors.d.ts.map +1 -0
  142. package/build/selectors.js +78 -0
  143. package/build/selectors.js.map +1 -0
  144. package/build/types.d.ts +120 -2
  145. package/build/types.d.ts.map +1 -1
  146. package/package.json +22 -7
  147. package/src/@/components/ui/card.tsx +93 -0
  148. package/src/@/components/ui/checkbox.tsx +23 -0
  149. package/src/@/components/ui/progress.tsx +44 -0
  150. package/src/@/components/ui/select.tsx +62 -0
  151. package/src/@/globals.css +10 -0
  152. package/src/capability-list.tsx +50 -0
  153. package/src/checkout-limit.tsx +48 -0
  154. package/src/consumer/cancellation.tsx +214 -0
  155. package/src/consumer/copy.ts +93 -0
  156. package/src/consumer/ensure.ts +124 -0
  157. package/src/consumer/fields.tsx +89 -0
  158. package/src/consumer/hooks.ts +358 -0
  159. package/src/consumer/index.ts +13 -0
  160. package/src/consumer/legal.tsx +129 -0
  161. package/src/consumer/performance-consent-dialog.tsx +132 -0
  162. package/src/consumer/provider.tsx +49 -0
  163. package/src/consumer/refusal.ts +110 -0
  164. package/src/consumer/subscription-start-dialog.tsx +87 -0
  165. package/src/consumer/types.ts +121 -0
  166. package/src/consumer/withdrawal.tsx +279 -0
  167. package/src/copy.tsx +62 -0
  168. package/src/dialog.tsx +58 -25
  169. package/src/entitlement.ts +56 -0
  170. package/src/estimate-cache.ts +40 -0
  171. package/src/estimate-summary.tsx +172 -0
  172. package/src/estimate.ts +119 -0
  173. package/src/format.ts +71 -0
  174. package/src/hooks.ts +3 -35
  175. package/src/i18n/be.json +1 -1
  176. package/src/i18n/de.json +1 -1
  177. package/src/i18n/en.json +1 -1
  178. package/src/i18n/es.json +1 -1
  179. package/src/i18n/fr.json +145 -0
  180. package/src/i18n/pl.json +1 -1
  181. package/src/i18n/ru.json +1 -1
  182. package/src/i18n/uk.json +1 -1
  183. package/src/i18n.ts +12 -7
  184. package/src/index.ts +9 -0
  185. package/src/limit-meter.tsx +55 -0
  186. package/src/plan-card.tsx +67 -0
  187. package/src/poll.ts +27 -0
  188. package/src/selectors.ts +93 -0
  189. package/src/types.ts +142 -2
  190. package/tests/consent-refusal.spec.ts +83 -0
  191. package/tests/consumer-hooks.spec.ts +205 -0
  192. package/tests/consumer.spec.ts +343 -0
  193. package/tests/context.ts +9 -0
  194. package/tests/dialog.spec.ts +49 -0
  195. package/tests/entitlement.spec.ts +117 -0
  196. package/tests/estimate-cache.spec.ts +46 -0
  197. package/tests/harness/consumer-fixtures.ts +105 -0
  198. package/tests/harness/consumer.tsx +197 -0
  199. package/tests/harness/entitlement.tsx +159 -0
  200. package/tests/harness/estimate-fixtures.ts +48 -0
  201. package/tests/harness/mount.tsx +19 -3
  202. package/tests/i18n.spec.ts +78 -8
  203. package/tests/pieces.spec.ts +134 -0
@@ -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
+ })
@@ -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
+ }
@@ -0,0 +1,159 @@
1
+ import { useState } from 'react'
2
+ import type { RegisteredEntrypoint } from '@owlmeans/entrypoint'
3
+ import {
4
+ LimitKind, LimitWindow, SubscriptionStatus, TaxBehavior, TaxEstimateStatus, TaxType,
5
+ capabilityViewsOf, entitlementViewOf, limitViewsOf,
6
+ type EntitlementPlanView, type EntitlementView, type PlanCapability, type LimitDeclaration,
7
+ type PriceEstimate, type ProductPlan,
8
+ } from '@owlmeans/payment'
9
+ import {
10
+ CapabilityList, CountrySelect, LimitMeter, PlanCard, PriceEstimateAmount, useCapability,
11
+ useEntitlementView, useLimit, usePriceEstimate,
12
+ } from '../../src/index.js'
13
+
14
+ const AT = new Date('2026-09-16T12:00:00Z')
15
+ const SUBSCRIBED = new Date('2026-01-01T00:00:00Z')
16
+ const PROMO_UNTIL = new Date('2026-12-31T00:00:00Z')
17
+
18
+ const pro: EntitlementPlanView = {
19
+ sku: 'pro-monthly', productSku: 'pro', title: 'Pro', rank: 10, free: false,
20
+ status: SubscriptionStatus.Active, paygate: 'stripe', subscribedAt: SUBSCRIBED,
21
+ }
22
+ const free: EntitlementPlanView = {
23
+ sku: 'free-plan', productSku: 'pro', title: 'Free', rank: 0, free: true,
24
+ status: SubscriptionStatus.Active, paygate: 'internal',
25
+ }
26
+
27
+ const PLANS: Record<string, EntitlementPlanView> = {
28
+ renews: { ...pro, periodEnd: new Date('2026-10-16T00:00:00Z') },
29
+ trial: { ...pro, status: SubscriptionStatus.Trial, trialEnd: new Date('2026-09-30T00:00:00Z') },
30
+ 'past-due': { ...pro, status: SubscriptionStatus.PastDue, pastDue: true, periodEnd: new Date('2026-10-16T00:00:00Z') },
31
+ 'cancel-scheduled': { ...pro, cancelAtPeriodEnd: true, periodEnd: new Date('2026-10-16T00:00:00Z') },
32
+ paused: { ...pro, status: SubscriptionStatus.Suspended, pausedAt: new Date('2026-09-01T00:00:00Z') },
33
+ free,
34
+ active: pro,
35
+ }
36
+
37
+ const capabilities: PlanCapability[] = [
38
+ { scope: 'feature', permissions: { whitelabel: true, 'custom-domain': true } },
39
+ { scope: 'feature', permissions: { 'local-llm': true }, promo: { until: PROMO_UNTIL } },
40
+ { scope: 'feature', permissions: { conversions: true }, promo: { until: PROMO_UNTIL, grandfather: true } },
41
+ { scope: 'feature', permissions: { beta: true }, promo: { until: new Date('2026-09-01T00:00:00Z') } },
42
+ ]
43
+
44
+ const limits: Record<string, LimitDeclaration> = {
45
+ seats: { kind: LimitKind.Window, window: LimitWindow.Month, limit: 5 },
46
+ exports: { kind: LimitKind.Window, window: LimitWindow.Day, limit: 2 },
47
+ projects: { kind: LimitKind.Lifetime, limit: 0 },
48
+ sites: { kind: LimitKind.Occupancy, limit: 3, promo: { until: PROMO_UNTIL } },
49
+ }
50
+
51
+ const usage = [
52
+ { key: 'seats', window: '2026-09', used: 3 },
53
+ { key: 'exports', window: '2026-09-16', used: 2 },
54
+ { key: 'sites', window: 'occupancy', used: 1 },
55
+ ]
56
+
57
+ const LABELS: Record<string, string> = {
58
+ 'feature:whitelabel': 'White label',
59
+ 'feature:local-llm': 'Local models',
60
+ 'feature:conversions': 'Conversions',
61
+ 'feature:beta': 'Beta programme',
62
+ }
63
+
64
+ const Pieces = () => {
65
+ const [acted, setActed] = useState('')
66
+ const capabilityRows = capabilityViewsOf({ capabilities }, SUBSCRIBED, AT)
67
+ const limitRows = limitViewsOf({ limits }, usage, SUBSCRIBED, AT)
68
+ const label = (key: string) => `${key[0].toUpperCase()}${key.slice(1)}`
69
+
70
+ return <main className="grid gap-6 p-6">
71
+ <section data-case="plans" className="grid gap-4">
72
+ {Object.entries(PLANS).map(([name, plan]) => <div key={name} data-case={name}><PlanCard plan={plan} /></div>)}
73
+ </section>
74
+ <section data-case="offers" className="grid gap-4">
75
+ <div data-case="offer-current">
76
+ <PlanCard plan={free} offer={{ sku: 'free-plan', title: 'Free', priceLabel: '$0' }} actionLabel="Manage" onAction={setActed} />
77
+ </div>
78
+ <div data-case="offer-upgrade">
79
+ <PlanCard plan={free} offer={{ sku: 'pro-monthly', title: 'Pro', priceLabel: '$20 / month', highlight: true }}
80
+ actionLabel="Upgrade" onAction={setActed}>
81
+ <p>Everything in Free, and more.</p>
82
+ </PlanCard>
83
+ </div>
84
+ <div data-case="offer-pending">
85
+ <PlanCard plan={free} offer={{ sku: 'pro-monthly', title: 'Pro', priceLabel: '$20 / month' }}
86
+ actionLabel="Upgrade" onAction={setActed} pending />
87
+ </div>
88
+ <output id="acted">{acted}</output>
89
+ </section>
90
+ <section data-case="limits" className="grid gap-4">
91
+ {limitRows.map(limit => <LimitMeter key={limit.key} limit={limit} label={label(limit.key)} />)}
92
+ <div data-case="no-reset"><LimitMeter limit={limitRows[0]} label="Seats" showReset={false} compact /></div>
93
+ </section>
94
+ <section data-case="capabilities"><CapabilityList capabilities={capabilityRows} labels={LABELS} /></section>
95
+ <section data-case="only-granted"><CapabilityList capabilities={capabilityRows} labels={LABELS} onlyGranted /></section>
96
+ </main>
97
+ }
98
+
99
+ /** What a server answers: the view through JSON, every date an ISO string. */
100
+ const wireView = JSON.parse(JSON.stringify(entitlementViewOf(
101
+ { capabilities, limits } as unknown as ProductPlan, PLANS.renews, usage, AT,
102
+ ))) as EntitlementView
103
+
104
+ const HookView = () => {
105
+ const [entry] = useState(() => ({
106
+ call: async () => {
107
+ await new Promise(resolve => setTimeout(resolve, 800))
108
+ return wireView
109
+ },
110
+ }) as unknown as RegisteredEntrypoint<{}, EntitlementView>)
111
+ const view = useEntitlementView(entry)
112
+ const whitelabel = useCapability(view, 'feature:whitelabel')
113
+ const seats = useLimit(view, 'seats')
114
+
115
+ return <main className="grid gap-4 p-6">
116
+ <output id="capability">{String(whitelabel)}</output>
117
+ <output id="seats">{seats == null ? 'null' : `${seats.used}/${seats.limit}:${seats.ratio}`}</output>
118
+ {view != null && <PlanCard plan={view.plan} />}
119
+ {view != null && view.limits.map(limit => <LimitMeter key={limit.key} limit={limit} label={limit.key} />)}
120
+ </main>
121
+ }
122
+
123
+ const PLAN_SUBTOTAL: Record<string, number> = { 'pro-monthly': 2_000, 'team-monthly': 5_000 }
124
+
125
+ /** A fake `account.priceEstimate` that actually varies with the body it's called with. */
126
+ const fakeEstimateEntry = () => ({
127
+ call: async ({ body }: { body?: { planSku?: string, country?: string } }) => {
128
+ await new Promise(resolve => setTimeout(resolve, 50))
129
+ const subtotalMinor = PLAN_SUBTOTAL[body?.planSku ?? ''] ?? 1_000
130
+ const country = body?.country
131
+ const tax: PriceEstimate['tax'] = country == null
132
+ ? { status: TaxEstimateStatus.LocationRequired, subtotalMinor, taxMinor: 0, totalMinor: subtotalMinor, scalable: false, rates: [] }
133
+ : country === 'PL'
134
+ ? {
135
+ status: TaxEstimateStatus.Taxed, subtotalMinor, taxMinor: Math.round(subtotalMinor * 0.23),
136
+ totalMinor: subtotalMinor + Math.round(subtotalMinor * 0.23), scalable: true,
137
+ rates: [{ type: TaxType.Vat, percentage: '23', ratePpm: 230_000, country: 'PL' }],
138
+ }
139
+ : { status: TaxEstimateStatus.None, subtotalMinor, taxMinor: 0, totalMinor: subtotalMinor, scalable: true, rates: [] }
140
+ return { country, source: 'request', currency: 'usd', behavior: TaxBehavior.Exclusive, tax } satisfies PriceEstimate
141
+ },
142
+ }) as unknown as RegisteredEntrypoint<{ body?: { planSku?: string, country?: string } }, PriceEstimate>
143
+
144
+ /** One `CountrySelect` driving two independent, controlled `usePriceEstimate` calls. */
145
+ const SharedEstimateCase = () => {
146
+ const [entry] = useState(fakeEstimateEntry)
147
+ const [country, setCountry] = useState('')
148
+ const pro = usePriceEstimate(entry, { enabled: true, country, onCountryChange: setCountry }, { body: { planSku: 'pro-monthly' } })
149
+ const team = usePriceEstimate(entry, { enabled: true, country, onCountryChange: setCountry }, { body: { planSku: 'team-monthly' } })
150
+
151
+ return <main className="grid gap-4 p-6">
152
+ <CountrySelect value={country} onChange={setCountry} />
153
+ <div data-case="pro"><PriceEstimateAmount control={pro} subtotalMinor={2_000} currency="usd" /></div>
154
+ <div data-case="team"><PriceEstimateAmount control={team} subtotalMinor={5_000} currency="usd" /></div>
155
+ </main>
156
+ }
157
+
158
+ export const EntitlementCase = ({ name }: { name: string }) =>
159
+ name === 'hook' ? <HookView /> : name === 'shared-estimate' ? <SharedEstimateCase /> : <Pieces />
@@ -0,0 +1,48 @@
1
+ import { TaxBehavior, TaxEstimateStatus, TaxType } from '@owlmeans/payment'
2
+ import type { PriceEstimate } from '@owlmeans/payment'
3
+ import type { PriceEstimateControl } from '../../src/index.js'
4
+
5
+ /** Canned `PriceEstimateControl`s for the Playwright harness — no server, no Stripe. */
6
+ export const ESTIMATE_FIXTURES: Record<string, PriceEstimateControl> = {
7
+ pl: {
8
+ country: 'PL', loading: false, failed: false, onCountryChange: () => {},
9
+ estimate: {
10
+ country: 'PL', source: 'request', currency: 'usd', behavior: TaxBehavior.Exclusive,
11
+ tax: {
12
+ status: TaxEstimateStatus.Taxed, subtotalMinor: 1_021, taxMinor: 235, totalMinor: 1_256,
13
+ scalable: true, rates: [{ type: TaxType.Vat, percentage: '23', ratePpm: 230_000, country: 'PL' }],
14
+ },
15
+ local: { currency: 'pln', exchangeRate: 0.25, fxFeeRate: 0.02 },
16
+ } satisfies PriceEstimate,
17
+ },
18
+ reverse: {
19
+ country: 'DE', loading: false, failed: false, onCountryChange: () => {},
20
+ estimate: {
21
+ country: 'DE', source: 'request', currency: 'usd', behavior: TaxBehavior.Exclusive,
22
+ tax: {
23
+ status: TaxEstimateStatus.ReverseCharge, subtotalMinor: 1_021, taxMinor: 0, totalMinor: 1_021,
24
+ scalable: true, rates: [],
25
+ },
26
+ } satisfies PriceEstimate,
27
+ },
28
+ 'at-checkout': {
29
+ country: 'US', loading: false, failed: false, onCountryChange: () => {},
30
+ estimate: {
31
+ country: 'US', source: 'request', currency: 'usd', behavior: TaxBehavior.Exclusive,
32
+ tax: {
33
+ status: TaxEstimateStatus.AtCheckout, subtotalMinor: 1_021, taxMinor: 0, totalMinor: 1_021,
34
+ scalable: false, rates: [],
35
+ },
36
+ } satisfies PriceEstimate,
37
+ },
38
+ 'location-required': {
39
+ country: '', loading: false, failed: false, onCountryChange: () => {},
40
+ estimate: {
41
+ currency: 'usd', behavior: TaxBehavior.Exclusive,
42
+ tax: {
43
+ status: TaxEstimateStatus.LocationRequired, subtotalMinor: 1_021, taxMinor: 0, totalMinor: 1_021,
44
+ scalable: false, rates: [],
45
+ },
46
+ } satisfies PriceEstimate,
47
+ },
48
+ }
@@ -2,18 +2,23 @@ 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'
6
+ import { EntitlementCase } from './entitlement.js'
7
+ import { ESTIMATE_FIXTURES } from './estimate-fixtures.js'
5
8
 
6
9
  const params = new URLSearchParams(window.location.search)
7
10
  const pending = params.get('pending') === 'true'
11
+ const scenario = params.get('case')
12
+ const estimateCase = params.get('estimate')
8
13
  const policy = {
9
14
  currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
10
15
  presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
11
16
  }
12
17
 
13
- const App = () => {
18
+ const Dialog = () => {
14
19
  const [open, setOpen] = useState(true)
15
20
  const [confirmed, setConfirmed] = useState<number | null>(null)
16
- return <I18nContext config={{ service: 'web-payment-test', i18n: { defaultLng: 'en' } } as never}>
21
+ return <>
17
22
  <output id="dialog-state">{open ? 'open' : 'closed'}</output>
18
23
  <output id="confirmed">{confirmed ?? ''}</output>
19
24
  <AmountCheckoutDialog
@@ -22,8 +27,19 @@ const App = () => {
22
27
  policy={policy}
23
28
  pending={pending}
24
29
  onConfirm={setConfirmed}
30
+ estimate={estimateCase != null ? ESTIMATE_FIXTURES[estimateCase] : undefined}
25
31
  />
26
- </I18nContext>
32
+ </>
27
33
  }
28
34
 
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} />}
43
+ </I18nContext>
44
+
29
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,16 +7,85 @@ 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'
11
+
12
+ type Messages = { [key: string]: string | Messages }
13
+
14
+ /** Every leaf as `branch.key` → message, however deep the resource nests. */
15
+ const leaves = (messages: Messages, prefix = ''): Map<string, unknown> => {
16
+ const found = new Map<string, unknown>()
17
+ for (const [key, value] of Object.entries(messages)) {
18
+ const path = prefix === '' ? key : `${prefix}.${key}`
19
+ if (value != null && typeof value === 'object') {
20
+ for (const [leaf, message] of leaves(value, path)) found.set(leaf, message)
21
+ } else {
22
+ found.set(path, value)
23
+ }
24
+ }
25
+ return found
26
+ }
27
+
28
+ const placeholders = (message: string): string[] =>
29
+ [...message.matchAll(/\{\{\s*([\w-]+)\s*\}\}/g)].map(match => match[1]).sort()
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
9
36
 
10
37
  describe('web-payment localization', () => {
11
- const translations = { en, pl, ru, be, uk, es, de }
12
-
13
- test('ships all seven library languages with exact key parity', () => {
14
- const expected = Object.keys(en['amount-checkout']).sort()
15
- expect(Object.keys(translations).sort()).toEqual(['be', 'de', 'en', 'es', 'pl', 'ru', 'uk'])
16
- for (const messages of Object.values(translations)) {
17
- expect(Object.keys(messages['amount-checkout']).sort()).toEqual(expected)
18
- expect(Object.values(messages['amount-checkout']).every(value => value.trim().length > 0)).toBe(true)
38
+ const translations: Record<string, Messages> = { en, pl, ru, be, uk, es, de, fr }
39
+ const reference = leaves(en)
40
+
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
+ }
50
+ for (const [lng, messages] of Object.entries(translations)) {
51
+ const found = leaves(messages)
52
+ expect({ lng, keys: [...found.keys()].sort() }).toEqual({ lng, keys: [...reference.keys()].sort() })
53
+ for (const [key, message] of found) {
54
+ expect({ lng, key, filled: typeof message === 'string' && message.trim().length > 0 })
55
+ .toEqual({ lng, key, filled: true })
56
+ }
57
+ }
58
+ })
59
+
60
+ test('keeps the interpolation placeholders of every message', () => {
61
+ for (const [lng, messages] of Object.entries(translations)) {
62
+ for (const [key, message] of leaves(messages)) {
63
+ expect({ lng, key, placeholders: placeholders(String(message)) })
64
+ .toEqual({ lng, key, placeholders: placeholders(String(reference.get(key))) })
65
+ }
66
+ }
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
+ }
19
89
  }
20
90
  })
21
91
  })