@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
package/src/dialog.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useMemo, useState } from 'react'
2
2
  import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
3
- import { assertCheckoutAmount, chargeAmountMinor } from '@owlmeans/payment'
3
+ import { assertCheckoutAmount, chargeAmountMinor, narrowAmountPolicy } from '@owlmeans/payment'
4
+ import type { AmountCheckoutPolicy, CheckoutLimitView } from '@owlmeans/payment'
4
5
  import { Button } from '@/components/ui/button'
5
6
  import {
6
7
  Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
@@ -8,76 +9,108 @@ import {
8
9
  import { Input } from '@/components/ui/input'
9
10
  import { Label } from '@/components/ui/label'
10
11
  import { inputAmount, parseAmountMinor } from './amount.js'
12
+ import { CheckoutLimitNote } from './checkout-limit.js'
13
+ import { PriceEstimateSummary } from './estimate-summary.js'
14
+ import { money } from './format.js'
11
15
  import type { AmountCheckoutDialogProps } from './types.js'
12
16
 
13
- const money = (minor: number, currency: string, locale: string): string => new Intl.NumberFormat(locale, {
14
- style: 'currency', currency: currency.toUpperCase(), minimumFractionDigits: 2,
15
- }).format(minor / 100)
17
+ /**
18
+ * The policy the dialog offers: `policy` narrowed by `limit` — the same `narrowAmountPolicy` the
19
+ * server refuses with. A policy the package cannot narrow (an invalid one) is offered as given.
20
+ */
21
+ const effectivePolicyOf = (policy: AmountCheckoutPolicy, limit?: CheckoutLimitView | null): AmountCheckoutPolicy => {
22
+ if (limit == null || (!limit.narrowed && !limit.blocked)) {
23
+ return policy
24
+ }
25
+ try {
26
+ return narrowAmountPolicy(policy, [{
27
+ maximumMinor: limit.maximumMinor,
28
+ reason: limit.reason ?? 'limit',
29
+ ...(limit.resetsAt != null ? { resetsAt: limit.resetsAt } : {}),
30
+ ...(limit.remainingMinor != null ? { remainingMinor: limit.remainingMinor } : {}),
31
+ }]).policy
32
+ } catch {
33
+ return policy
34
+ }
35
+ }
16
36
 
17
37
  export const AmountCheckoutDialog = ({
18
- open, onOpenChange, policy, pending = false, onConfirm,
38
+ open, onOpenChange, policy: basePolicy, pending = false, onConfirm, estimate, limit, legalNote,
19
39
  }: AmountCheckoutDialogProps) => {
20
40
  const t = useI18nLib('web-payment', 'amount-checkout')
21
41
  const [locale] = useLanguage()
42
+ const policy = useMemo(() => effectivePolicyOf(basePolicy, limit), [basePolicy, limit])
43
+ const blocked = limit?.blocked === true
44
+ // The limit, not the plan, set the maximum — also when `policy` arrives already narrowed.
45
+ const limited = limit != null && limit.narrowed && limit.maximumMinor <= policy.maximumMinor
22
46
  const [value, setValue] = useState(() => inputAmount(policy.defaultMinor, locale))
23
47
  useEffect(() => {
24
48
  if (open) setValue(inputAmount(policy.defaultMinor, locale))
25
49
  }, [open, policy.defaultMinor, locale])
26
50
 
27
51
  const amountMinor = useMemo(() => parseAmountMinor(value, locale), [value, locale])
28
- const error = amountMinor == null
29
- ? t('invalid')
30
- : amountMinor < policy.minimumMinor
31
- ? t('below', { amount: money(policy.minimumMinor, policy.currency, locale) })
32
- : amountMinor > policy.maximumMinor
33
- ? t('above', { amount: money(policy.maximumMinor, policy.currency, locale) })
34
- : null
35
- const valid = error == null && amountMinor != null
52
+ const error = blocked
53
+ ? null
54
+ : amountMinor == null
55
+ ? t('invalid')
56
+ : amountMinor < policy.minimumMinor
57
+ ? t('below', { amount: money(policy.minimumMinor, policy.currency, locale) })
58
+ : amountMinor > policy.maximumMinor
59
+ ? t(limited ? 'above-limit' : 'above', { amount: money(policy.maximumMinor, policy.currency, locale) })
60
+ : null
61
+ const valid = !blocked && error == null && amountMinor != null
36
62
  const chargeMinor = valid ? chargeAmountMinor(amountMinor, policy) : 0
37
63
  const adjustmentMinor = valid && amountMinor != null ? chargeMinor - amountMinor : 0
64
+ const locked = pending || blocked
38
65
  const submit = async () => {
39
- if (amountMinor == null || !valid || pending) return
66
+ if (amountMinor == null || !valid || locked) return
40
67
  assertCheckoutAmount(policy, amountMinor)
41
- await onConfirm(amountMinor)
68
+ await onConfirm(amountMinor, estimate?.country)
42
69
  }
43
70
 
44
71
  return <Dialog open={open} onOpenChange={onOpenChange}>
45
- <DialogContent closeLabel={t('close')}>
72
+ <DialogContent closeLabel={t('close')} data-amount-checkout="" data-blocked={blocked ? 'true' : 'false'}>
46
73
  <DialogHeader>
47
74
  <DialogTitle>{t('title')}</DialogTitle>
48
75
  <DialogDescription>{t('description')}</DialogDescription>
49
76
  </DialogHeader>
50
77
  <div className="grid gap-5">
51
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label={t('presets')}>
78
+ {limit != null && <CheckoutLimitNote limit={limit} />}
79
+ {policy.presetsMinor.length > 0 && <div className="grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label={t('presets')}>
52
80
  {policy.presetsMinor.map(preset => <Button
53
- key={preset} type="button" variant={preset === amountMinor ? 'default' : 'outline'}
54
- disabled={pending} onClick={() => setValue(inputAmount(preset, locale))}
81
+ key={preset} type="button" variant={preset === amountMinor ? 'default' : 'outline'} data-amount-preset={preset}
82
+ disabled={locked} onClick={() => setValue(inputAmount(preset, locale))}
55
83
  >{money(preset, policy.currency, locale)}</Button>)}
56
- </div>
84
+ </div>}
57
85
  <div className="grid gap-2">
58
86
  <Label htmlFor="payment-amount">{t('custom')}</Label>
59
87
  <Input
60
88
  id="payment-amount" inputMode="decimal" autoComplete="off" value={value}
61
- disabled={pending} aria-invalid={error != null} aria-describedby="payment-amount-help"
89
+ disabled={locked} aria-invalid={error != null} aria-describedby="payment-amount-help"
62
90
  onChange={event => setValue(event.target.value)}
63
91
  />
64
- <p id="payment-amount-help" className={error == null ? 'text-muted-foreground text-xs' : 'text-destructive text-xs'}>
92
+ {!blocked && <p id="payment-amount-help" className={error == null ? 'text-muted-foreground text-xs' : 'text-destructive text-xs'}>
65
93
  {error ?? t('bounds', {
66
94
  minimum: money(policy.minimumMinor, policy.currency, locale),
67
95
  maximum: money(policy.maximumMinor, policy.currency, locale),
68
96
  })}
69
- </p>
97
+ </p>}
70
98
  </div>
71
99
  <dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-2 rounded-lg border bg-muted/30 p-4 text-sm">
72
100
  <dt className="text-muted-foreground">{t('credits')}</dt><dd>{valid && amountMinor != null ? money(amountMinor, policy.currency, locale) : '—'}</dd>
73
101
  <dt className="text-muted-foreground">{t('adjustment')}</dt><dd>{valid ? money(adjustmentMinor, policy.currency, locale) : '—'}</dd>
74
102
  <dt className="font-medium">{t('subtotal')}</dt><dd className="font-medium">{valid ? money(chargeMinor, policy.currency, locale) : '—'}</dd>
75
103
  </dl>
76
- <p className="text-muted-foreground text-xs">{t('tax-note')}</p>
104
+ {estimate != null
105
+ ? valid && <PriceEstimateSummary control={estimate} subtotalMinor={chargeMinor} currency={policy.currency} />
106
+ : <p className="text-muted-foreground text-xs">{t('tax-note')}</p>}
107
+ {legalNote != null && <div className="text-muted-foreground text-xs" data-legal-note="">{legalNote}</div>}
77
108
  </div>
78
109
  <DialogFooter>
79
110
  <Button type="button" variant="outline" disabled={pending} onClick={() => onOpenChange(false)}>{t('cancel')}</Button>
80
- <Button type="button" disabled={!valid || pending} onClick={() => void submit()}>{pending ? t('pending') : t('continue')}</Button>
111
+ <Button type="button" data-amount-confirm="" disabled={!valid || locked} onClick={() => void submit()}>
112
+ {pending ? t('pending') : t('continue')}
113
+ </Button>
81
114
  </DialogFooter>
82
115
  </DialogContent>
83
116
  </Dialog>
@@ -0,0 +1,56 @@
1
+ import { useCallback, useMemo, useState } from 'react'
2
+ import type { CallArguments, RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
3
+ import { limitOf, reviveEntitlementView } from '@owlmeans/payment'
4
+ import type { EntitlementView, PortalFlow, PortalLinkResponse } from '@owlmeans/payment'
5
+ import { usePolled } from './poll.js'
6
+ import { capabilityStateOf, limitStatusOf } from './selectors.js'
7
+ import { openCheckout, type CheckoutTarget } from './service.js'
8
+ import type { LimitStatus, PortalArguments, PortalRequest } from './types.js'
9
+
10
+ /**
11
+ * Poll an entitlement-view protocol — `null` until the first answer. The view is revived on the way
12
+ * in (the wire carries ISO strings), so every date in it is a `Date`.
13
+ *
14
+ * An application that already keeps the view in its own store reads it through the pure selectors
15
+ * (`capabilityStateOf`, `limitStatusOf`, `planStatusLineOf`) instead of polling a second time.
16
+ */
17
+ export const useEntitlementView = <Request extends RequestShape>(
18
+ entry: RegisteredEntrypoint<Request, EntitlementView>,
19
+ intervalMs: number = 60_000,
20
+ deps: unknown[] = [],
21
+ ...request: CallArguments<Request>
22
+ ): EntitlementView | null => usePolled(
23
+ async () => reviveEntitlementView(await entry.call(...request)), intervalMs, deps,
24
+ )
25
+
26
+ /** Whether the view grants a capability parameter; `null` while the view is unknown. */
27
+ export const useCapability = (view: EntitlementView | null | undefined, param: string): boolean | null =>
28
+ useMemo(() => capabilityStateOf(view, param), [view, param])
29
+
30
+ /** One limit of the view with `exhausted` and `ratio`; `null` while unknown or undeclared. */
31
+ export const useLimit = (view: EntitlementView | null | undefined, key: string): LimitStatus | null =>
32
+ useMemo(() => limitStatusOf(limitOf(view, key)), [view, key])
33
+
34
+ /**
35
+ * Open a billing-portal flow: `portal(entry, request?)` calls the portal protocol with `body.flow`
36
+ * set to `flow`, then sends the browser to the returned URL (same window by default). Without a DOM
37
+ * the navigation is inert and the response is still returned.
38
+ */
39
+ export const usePortal = (flow: PortalFlow, target: CheckoutTarget = '_self') => {
40
+ const [pending, setPending] = useState(false)
41
+ const portal = useCallback(async <Request extends PortalRequest>(
42
+ entry: RegisteredEntrypoint<Request, PortalLinkResponse>, ...request: PortalArguments<Request>
43
+ ): Promise<PortalLinkResponse | null> => {
44
+ setPending(true)
45
+ try {
46
+ const [options] = request as [{ body?: object }?]
47
+ // The flow completes the body the caller's arguments deliberately leave out.
48
+ const args = [{ ...options, body: { ...options?.body, flow } }] as unknown as CallArguments<Request>
49
+ const result: PortalLinkResponse | undefined = await entry.call(...args)
50
+ if (result?.url != null) openCheckout(result.url, target)
51
+ return result ?? null
52
+ } finally { setPending(false) }
53
+ }, [flow, target])
54
+
55
+ return { portal, pending }
56
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A small keyed TTL cache with in-flight de-duplication — the client-side twin of
3
+ * `@owlmeans/server-payment`'s `EstimateCache`, so opening the credit dialog twice in a row (or two
4
+ * mounts asking for the same plan/country) never issues a second request while the first is still
5
+ * on the wire, and a settled answer is reused for its TTL. A rejected fetch is never cached: the
6
+ * next call simply tries again.
7
+ */
8
+ export class EstimateCache<T> {
9
+ private readonly store = new Map<string, { value: T, expiresAt: number }>()
10
+ private readonly inflight = new Map<string, Promise<T>>()
11
+
12
+ get(key: string): T | undefined {
13
+ const hit = this.store.get(key)
14
+ if (hit == null || hit.expiresAt <= Date.now()) {
15
+ return undefined
16
+ }
17
+ return hit.value
18
+ }
19
+
20
+ async load(key: string, ttlMs: number, factory: () => Promise<T>): Promise<T> {
21
+ const hit = this.get(key)
22
+ if (hit !== undefined) {
23
+ return hit
24
+ }
25
+ const pending = this.inflight.get(key)
26
+ if (pending != null) {
27
+ return pending
28
+ }
29
+ const promise = factory().then(value => {
30
+ this.store.set(key, { value, expiresAt: Date.now() + ttlMs })
31
+ this.inflight.delete(key)
32
+ return value
33
+ }).catch(error => {
34
+ this.inflight.delete(key)
35
+ throw error
36
+ })
37
+ this.inflight.set(key, promise)
38
+ return promise
39
+ }
40
+ }
@@ -0,0 +1,172 @@
1
+ import { Fragment, useMemo, type ReactNode } from 'react'
2
+ import { useI18nLib, useLanguage } from '@owlmeans/client-i18n'
3
+ import { COUNTRY_CODES, TaxEstimateStatus, estimateOf } from '@owlmeans/payment'
4
+ import { cn } from '@/lib/utils'
5
+ import { Label } from '@/components/ui/label'
6
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
7
+ import { money } from './format.js'
8
+ import type { PriceEstimateControl } from './types.js'
9
+
10
+ const localMoney = (amount: number, currency: string, locale: string): string => {
11
+ try {
12
+ return new Intl.NumberFormat(locale, { style: 'currency', currency: currency.toUpperCase() }).format(amount)
13
+ } catch {
14
+ return `${amount.toFixed(2)} ${currency.toUpperCase()}`
15
+ }
16
+ }
17
+
18
+ /** The country names a `<Select>` offers, in the viewer's own language and alphabetical order. */
19
+ const useCountryOptions = (locale: string): Array<{ code: string, name: string }> => useMemo(() => {
20
+ let names: Intl.DisplayNames | null = null
21
+ try { names = new Intl.DisplayNames([locale], { type: 'region' }) } catch { names = null }
22
+ const collator = new Intl.Collator(locale)
23
+ return COUNTRY_CODES
24
+ .map(code => ({ code, name: names?.of(code) ?? code }))
25
+ .sort((a, b) => collator.compare(a.name, b.name))
26
+ }, [locale])
27
+
28
+ export interface CountrySelectProps {
29
+ /** ISO 3166-1 alpha-2, or `''` for the placeholder. */
30
+ value: string
31
+ onChange: (country: string) => void
32
+ /** Defaults to the `estimate.country` string (also the field's own label). */
33
+ label?: string
34
+ id?: string
35
+ className?: string
36
+ /** The country cannot be changed here — a billing country locked by the first purchase. */
37
+ disabled?: boolean
38
+ /** A line under the picker — why it is disabled, typically. */
39
+ note?: ReactNode
40
+ }
41
+
42
+ /**
43
+ * The billing-country picker alone — the piece `PriceEstimateSummary` composes for a single,
44
+ * self-contained estimate, and what a shared picker (several estimates, one country — a plan
45
+ * comparison table) renders once and drives every `usePriceEstimate({ country, onCountryChange })`
46
+ * with. Country names are the viewer's own language, alphabetically ordered.
47
+ *
48
+ * `[data-country-select]` carries `data-locked` (`"true"` while disabled) and the chosen
49
+ * `data-country`; the note is `[data-country-note]`.
50
+ */
51
+ export const CountrySelect = ({
52
+ value, onChange, label, id = 'price-estimate-country', className, disabled = false, note,
53
+ }: CountrySelectProps) => {
54
+ const t = useI18nLib('web-payment', 'estimate')
55
+ const [locale] = useLanguage()
56
+ const countries = useCountryOptions(locale)
57
+ const text = label ?? t('country')
58
+
59
+ return <div className={cn('grid gap-2', className)} data-country-select="" data-locked={disabled ? 'true' : 'false'}
60
+ data-country={value}>
61
+ <Label htmlFor={id}>{text}</Label>
62
+ {/* Always controlled, `''` included (no `SelectItem` ever has that value) — switching to
63
+ `undefined` once a country is chosen is what trips React's controlled/uncontrolled warning. */}
64
+ <Select value={value} onValueChange={next => { if (!disabled) onChange(next) }} disabled={disabled}>
65
+ <SelectTrigger id={id} aria-label={text} aria-describedby={note != null ? `${id}-note` : undefined}>
66
+ <SelectValue placeholder={t('country-placeholder')} />
67
+ </SelectTrigger>
68
+ <SelectContent>
69
+ {countries.map(({ code, name }) => <SelectItem key={code} value={code}>{name}</SelectItem>)}
70
+ </SelectContent>
71
+ </Select>
72
+ {note != null && <p id={`${id}-note`} className="text-muted-foreground text-xs" data-country-note="">{note}</p>}
73
+ </div>
74
+ }
75
+
76
+ export interface PriceEstimateAmountProps {
77
+ control: Pick<PriceEstimateControl, 'estimate' | 'loading' | 'failed'>
78
+ /** The amount to show tax and a total for — may differ from the estimate's own reference amount. */
79
+ subtotalMinor: number
80
+ currency: string
81
+ /** Appended after the total row, e.g. `"/ month"`. */
82
+ suffix?: string
83
+ className?: string
84
+ }
85
+
86
+ /**
87
+ * What Stripe Tax says at the estimate's country, with NO country picker of its own: the rate(s),
88
+ * an estimated total, and one sentence for whichever status leaves no number to trust (reverse
89
+ * charge, no tax, "at checkout", "choose a country"). Several of these may share one
90
+ * `CountrySelect` (a plan comparison table).
91
+ *
92
+ * A country whose currency differs from `currency` (Adaptive Pricing found a rate) shows the tax
93
+ * and the total in THAT currency instead, marked `≈` throughout — never both currencies at once,
94
+ * which would read as two different prices for the same line. The rate percentage itself is
95
+ * currency-agnostic and always shows as Stripe stated it.
96
+ *
97
+ * `data-price-estimate` / `data-status` are stable test hooks.
98
+ */
99
+ export const PriceEstimateAmount = ({ control, subtotalMinor, currency, suffix, className }: PriceEstimateAmountProps) => {
100
+ const t = useI18nLib('web-payment', 'estimate')
101
+ const [locale] = useLanguage()
102
+ const { estimate, loading, failed } = control
103
+
104
+ const derived = estimate != null ? estimateOf(subtotalMinor, estimate) : null
105
+ const status = estimate?.tax.status
106
+ const numeric = status === TaxEstimateStatus.Taxed || status === TaxEstimateStatus.ReverseCharge
107
+ || status === TaxEstimateStatus.None
108
+ const rateLabel = estimate != null && estimate.tax.rates.length > 0
109
+ ? estimate.tax.rates
110
+ .map(rate => t('rate', { type: t(`tax-type.${rate.type}`), percentage: rate.percentage }))
111
+ .join(' + ')
112
+ : null
113
+
114
+ const local = derived?.local
115
+ const taxText = local != null
116
+ ? (derived?.taxMinor != null ? localMoney(local.taxAmount, local.currency, locale) : null)
117
+ : (derived?.taxMinor != null ? money(derived.taxMinor, currency, locale) : null)
118
+ const totalText = local != null
119
+ ? (derived?.totalMinor != null ? localMoney(local.totalAmount, local.currency, locale) : null)
120
+ : (derived?.totalMinor != null ? money(derived.totalMinor, currency, locale) : null)
121
+ const approx = local != null ? '≈ ' : ''
122
+
123
+ return <div data-price-estimate="" data-status={status ?? 'pending'} className={cn('grid gap-1.5', className)}>
124
+ {estimate == null && loading && <p className="text-muted-foreground text-xs">{t('loading')}</p>}
125
+ {estimate == null && !loading && failed && <p className="text-destructive text-xs">{t('failed')}</p>}
126
+
127
+ {estimate != null && <>
128
+ {numeric && taxText != null && totalText != null && <dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-sm" data-estimate-rows="">
129
+ {status === TaxEstimateStatus.Taxed && rateLabel != null && <Fragment>
130
+ <dt className="text-muted-foreground" data-estimate-rate="">{rateLabel}</dt>
131
+ <dd data-estimate-tax="">{approx}{taxText}</dd>
132
+ </Fragment>}
133
+ <dt className="font-medium">{t('total')}</dt>
134
+ <dd className="font-medium" data-estimate-total="">
135
+ {approx}{totalText}{suffix != null ? ` ${suffix}` : ''}
136
+ </dd>
137
+ </dl>}
138
+ {numeric && local != null && <p className="text-muted-foreground text-xs" data-estimate-converted="">
139
+ {t('converted-note')}
140
+ </p>}
141
+ <p className="text-muted-foreground text-xs" data-estimate-status="">
142
+ {t(`status.${status}`)}
143
+ </p>
144
+ </>}
145
+ </div>
146
+ }
147
+
148
+ export interface PriceEstimateSummaryProps {
149
+ control: PriceEstimateControl
150
+ /** The amount the summary shows tax and a total for — may differ from the estimate's own reference amount. */
151
+ subtotalMinor: number
152
+ currency: string
153
+ /** Appended after the total row, e.g. `"/ month"`. */
154
+ suffix?: string
155
+ className?: string
156
+ }
157
+
158
+ /**
159
+ * A self-contained estimate: `CountrySelect` bound to the control's own `country`/`onCountryChange`,
160
+ * plus `PriceEstimateAmount` beneath it. What a single estimate (the credit dialog) wants; several
161
+ * estimates sharing one picker (a plan comparison table) compose the two pieces separately instead.
162
+ */
163
+ export const PriceEstimateSummary = ({ control, subtotalMinor, currency, suffix, className }: PriceEstimateSummaryProps) => {
164
+ const t = useI18nLib('web-payment', 'estimate')
165
+ const { country, onCountryChange, locked = false } = control
166
+
167
+ return <div className={cn('grid gap-3', className)}>
168
+ <CountrySelect value={country} onChange={onCountryChange} disabled={locked}
169
+ note={locked ? t('country-locked') : undefined} />
170
+ <PriceEstimateAmount control={control} subtotalMinor={subtotalMinor} currency={currency} suffix={suffix} />
171
+ </div>
172
+ }
@@ -0,0 +1,119 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { RegisteredEntrypoint } from '@owlmeans/entrypoint'
3
+ import type { PriceEstimate } from '@owlmeans/payment'
4
+ import { EstimateCache } from './estimate-cache.js'
5
+ import type { PriceEstimateArguments, PriceEstimateControl, PriceEstimateRequest } from './types.js'
6
+
7
+ const DEFAULT_TTL_MS = 5 * 60_000
8
+
9
+ /** Shared across every mounted dialog/estimate card in the tab — the server already caches per TTL too. */
10
+ const cache = new EstimateCache<PriceEstimate>()
11
+
12
+ export interface UsePriceEstimateOptions {
13
+ /** Fetch (and refetch on a country change) only while `true` — a closed dialog costs nothing. */
14
+ enabled: boolean
15
+ ttlMs?: number
16
+ /**
17
+ * Controls the country from outside (several estimates sharing one picker — a plan comparison
18
+ * table). Given: the hook never owns `useState` for it, never preselects from `source:
19
+ * 'customer'` (the owner does, once, for whichever estimate answers first), and the returned
20
+ * control's `onCountryChange` calls this back instead of an internal setter. Absent (the
21
+ * default): the hook is self-contained, exactly as a standalone dialog needs.
22
+ */
23
+ country?: string
24
+ onCountryChange?: (country: string) => void
25
+ }
26
+
27
+ /**
28
+ * A live `PriceEstimate` for one protocol/request, with a country the caller may change.
29
+ *
30
+ * Fetches once `enabled` and again on every `onCountryChange`, from a client-side cache keyed by
31
+ * the entry's alias, the request body and the country — a TTL match answers instantly, an identical
32
+ * in-flight request is shared. Uncontrolled (no `opts.country`): the first answer whose `source` is
33
+ * `'customer'` preselects its `country`, once, so a returning buyer sees their own country without
34
+ * picking it. Controlled (`opts.country` given): the country and its preselection are the caller's.
35
+ *
36
+ * An answer for a LOCKED billing country (`locked`, or `source: 'profile'`) wins over any pick: the
37
+ * hook adopts its country (controlled: through `opts.onCountryChange`, once), returns `locked:
38
+ * true`, and ignores `onCountryChange` from then on — the picker renders disabled.
39
+ */
40
+ export const usePriceEstimate = <Request extends PriceEstimateRequest>(
41
+ entry: RegisteredEntrypoint<Request, PriceEstimate>, opts: UsePriceEstimateOptions,
42
+ ...request: PriceEstimateArguments<Request>
43
+ ): PriceEstimateControl => {
44
+ const entryRef = useRef(entry)
45
+ entryRef.current = entry
46
+ const base = (request[0] ?? {}) as { body?: Omit<Request['body'], 'country'> } & Record<string, unknown>
47
+ const bodyKey = JSON.stringify(base.body ?? {})
48
+ const controlled = opts.country !== undefined
49
+
50
+ const [internalCountry, setInternalCountry] = useState('')
51
+ const country = controlled ? opts.country! : internalCountry
52
+ const [estimate, setEstimate] = useState<PriceEstimate | null>(null)
53
+ const [loading, setLoading] = useState(false)
54
+ const [failed, setFailed] = useState(false)
55
+ const preselectedRef = useRef(false)
56
+ const lockedRef = useRef(false)
57
+ const locked = estimate != null && isLockedAnswer(estimate)
58
+ lockedRef.current = locked
59
+ const onCountryChangeRef = useRef(opts.onCountryChange)
60
+ onCountryChangeRef.current = opts.onCountryChange
61
+
62
+ useEffect(() => {
63
+ if (!opts.enabled) {
64
+ return
65
+ }
66
+ let active = true
67
+ const key = `${entryRef.current.alias}:${bodyKey}:${country}`
68
+ const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS
69
+ setLoading(true)
70
+ setFailed(false)
71
+ cache.load(key, ttlMs, async () => await entryRef.current.call({
72
+ ...base, body: { ...(base.body ?? {}), ...(country !== '' ? { country } : {}) },
73
+ } as never)).then(result => {
74
+ if (!active) return
75
+ setEstimate(result)
76
+ setLoading(false)
77
+ if (isLockedAnswer(result) && result.country != null && result.country !== country) {
78
+ // A locked billing country overrides whatever was picked: adopt it, once per country.
79
+ preselectedRef.current = true
80
+ if (controlled) {
81
+ onCountryChangeRef.current?.(result.country)
82
+ } else {
83
+ setInternalCountry(result.country)
84
+ }
85
+ return
86
+ }
87
+ if (!controlled && !preselectedRef.current && country === '' && result.source === 'customer' && result.country != null) {
88
+ preselectedRef.current = true
89
+ setInternalCountry(result.country)
90
+ }
91
+ }).catch(() => {
92
+ if (active) {
93
+ setFailed(true)
94
+ setLoading(false)
95
+ }
96
+ })
97
+ return () => { active = false }
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [opts.enabled, opts.ttlMs, bodyKey, country])
100
+
101
+ const onCountryChange = useCallback((next: string) => {
102
+ if (lockedRef.current) {
103
+ return
104
+ }
105
+ if (controlled) {
106
+ opts.onCountryChange?.(next)
107
+ return
108
+ }
109
+ preselectedRef.current = true
110
+ setInternalCountry(next)
111
+ // eslint-disable-next-line react-hooks/exhaustive-deps
112
+ }, [controlled, opts.onCountryChange])
113
+
114
+ return { estimate, country, loading, failed, locked, onCountryChange }
115
+ }
116
+
117
+ /** An answer computed for the entity's locked billing country, which no picker may change. */
118
+ export const isLockedAnswer = (estimate: PriceEstimate): boolean =>
119
+ estimate.locked === true || estimate.source === 'profile'
package/src/format.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { lastWithdrawalDayOf } from '@owlmeans/payment'
2
+
3
+ /** Minor units as a currency amount in a locale; an unknown locale or currency still reads. */
4
+ export const money = (minor: number, currency: string, locale: string): string => {
5
+ try {
6
+ return new Intl.NumberFormat(locale, {
7
+ style: 'currency', currency: currency.toUpperCase(), minimumFractionDigits: 2,
8
+ }).format(minor / 100)
9
+ } catch {
10
+ return `${(minor / 100).toFixed(2)} ${currency.toUpperCase()}`
11
+ }
12
+ }
13
+
14
+ const dateOf = (date: Date | string | null | undefined): Date | null => {
15
+ if (date == null) {
16
+ return null
17
+ }
18
+ const value = date instanceof Date ? date : new Date(date)
19
+
20
+ return Number.isNaN(value.getTime()) ? null : value
21
+ }
22
+
23
+ /** A calendar day in UTC (`dateStyle: 'long'`); `''` for no or an unparseable date. */
24
+ export const dayUtc = (date: Date | string | null | undefined, locale: string): string => {
25
+ const value = dateOf(date)
26
+ if (value == null) {
27
+ return ''
28
+ }
29
+ try {
30
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeZone: 'UTC' }).format(value)
31
+ } catch {
32
+ return value.toISOString().slice(0, 10)
33
+ }
34
+ }
35
+
36
+ /**
37
+ * The last day of a window whose deadline is EXCLUSIVE (the start of the next UTC day) — the day a
38
+ * consumer reads as "until". The rule is `@owlmeans/payment`'s `lastWithdrawalDayOf`, the one the
39
+ * server's e-mails and texts use, so the dialog and the mail never name different days.
40
+ */
41
+ export const lastDayUtc = (deadline: Date | string | null | undefined, locale: string): string => {
42
+ const value = dateOf(deadline)
43
+
44
+ return value == null ? '' : dayUtc(lastWithdrawalDayOf(value), locale)
45
+ }
46
+
47
+ /** A date and time in UTC, to the second — the moment a declaration was received. */
48
+ export const momentUtc = (date: Date | string | null | undefined, locale: string): string => {
49
+ const value = dateOf(date)
50
+ if (value == null) {
51
+ return ''
52
+ }
53
+ try {
54
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', timeStyle: 'medium', timeZone: 'UTC' }).format(value)
55
+ } catch {
56
+ return value.toISOString().replace('T', ' ').slice(0, 19)
57
+ }
58
+ }
59
+
60
+ /** A date and time in UTC to the minute, with the zone named — when a limit rises again. */
61
+ export const shortMomentUtc = (date: Date | string | null | undefined, locale: string): string => {
62
+ const value = dateOf(date)
63
+ if (value == null) {
64
+ return ''
65
+ }
66
+ try {
67
+ return `${new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short', timeZone: 'UTC' }).format(value)} UTC`
68
+ } catch {
69
+ return `${value.toISOString().replace('T', ' ').slice(0, 16)} UTC`
70
+ }
71
+ }
package/src/hooks.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { useCallback, useEffect, useRef, useState } from 'react'
1
+ import { useCallback, useState } from 'react'
2
2
  import type { CallArguments, RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
3
- import { hasEntitlement } from '@owlmeans/payment'
3
+ import { usePolled } from './poll.js'
4
4
  import { openCheckout, type CheckoutTarget } from './service.js'
5
5
  import type { CheckoutResult } from './types.js'
6
6
 
@@ -21,36 +21,4 @@ export const useCheckout = (target: CheckoutTarget = '_self') => {
21
21
 
22
22
  export const usePaymentBalance = <T>(
23
23
  entry: RegisteredEntrypoint<{}, T>, intervalMs: number = 60_000, deps: unknown[] = [],
24
- ): T | null => {
25
- const [balance, setBalance] = useState<T | null>(null)
26
- const entryRef = useRef(entry)
27
- entryRef.current = entry
28
- useEffect(() => {
29
- let active = true
30
- const load = async () => {
31
- try {
32
- const value = await entryRef.current.call()
33
- if (active) setBalance(value)
34
- } catch { /* explicit actions own their errors */ }
35
- }
36
- void load()
37
- const ticker = setInterval(load, intervalMs)
38
- return () => { active = false; clearInterval(ticker) }
39
- // eslint-disable-next-line react-hooks/exhaustive-deps
40
- }, [intervalMs, ...deps])
41
- return balance
42
- }
43
-
44
- export const useEntitlements = <T extends { entitlements?: string[] }>(
45
- entry: RegisteredEntrypoint<{}, T>, intervalMs: number = 60_000, deps: unknown[] = [],
46
- ): string[] | null => usePaymentBalance(entry, intervalMs, deps)?.entitlements ?? null
47
-
48
- export const useEntitlement = (list: string[] | null, param: string): boolean => list != null && hasEntitlement(
49
- list.map(item => {
50
- const colon = item.indexOf(':')
51
- return colon >= 0
52
- ? { scope: item.slice(0, colon), permissions: { [item.slice(colon + 1)]: true } }
53
- : { scope: '', permissions: { [item]: true } }
54
- }),
55
- param,
56
- )
24
+ ): T | null => usePolled(() => entry.call(), intervalMs, deps)