@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/types.ts CHANGED
@@ -1,10 +1,150 @@
1
- import type { AmountCheckoutPolicy, CreateCheckoutResponse } from '@owlmeans/payment'
1
+ import type { ReactNode } from 'react'
2
+ import type { CallOptions, RequestShape } from '@owlmeans/entrypoint'
3
+ import type {
4
+ AmountCheckoutPolicy, CapabilityView, CheckoutLimitView, CreateCheckoutResponse, EntitlementPlanView, LimitView,
5
+ PortalLinkBody, PriceEstimate, PriceEstimateBody,
6
+ } from '@owlmeans/payment'
2
7
 
3
8
  export type CheckoutResult = CreateCheckoutResponse
4
9
  export interface AmountCheckoutDialogProps {
5
10
  open: boolean
6
11
  onOpenChange: (open: boolean) => void
12
+ /** The plan's policy — or one already narrowed for the entity (`AmountPolicyView.policy`). */
7
13
  policy: AmountCheckoutPolicy
8
14
  pending?: boolean
9
- onConfirm: (amountMinor: number) => Promise<void> | void
15
+ /** Chosen amount and, where tax estimation collected it, the billing country. */
16
+ onConfirm: (amountMinor: number, country?: string) => Promise<void> | void
17
+ /** A live tax/currency estimate for the credit line — absent: the plain "tax at checkout" note. */
18
+ estimate?: PriceEstimateControl
19
+ /**
20
+ * What the entity may buy now (`AmountPolicyView.limit`). The dialog narrows `policy` with it
21
+ * through `narrowAmountPolicy` — presets above the maximum disappear, the default is clamped —
22
+ * shows a `CheckoutLimitNote`, and on `blocked` disables the input and the confirm button.
23
+ */
24
+ limit?: CheckoutLimitView | null
25
+ /** Legal text the application places right above the buttons (a withdrawal note, terms links). */
26
+ legalNote?: ReactNode
27
+ }
28
+
29
+ /** What `usePriceEstimate` returns: the latest answer, the chosen country, and its lifecycle. */
30
+ export interface PriceEstimateControl {
31
+ estimate: PriceEstimate | null
32
+ /** ISO 3166-1 alpha-2, or `''` before a country is known. */
33
+ country: string
34
+ /** A request is in flight — including the first, silent one. */
35
+ loading: boolean
36
+ /** The last request failed; `estimate` is the previous good answer, if any. */
37
+ failed: boolean
38
+ /**
39
+ * The country is the entity's locked billing country (an answer with `locked` or `source:
40
+ * 'profile'`): a picker shows it disabled, and `onCountryChange` does nothing.
41
+ */
42
+ locked?: boolean
43
+ onCountryChange: (country: string) => void
44
+ }
45
+
46
+ export interface CheckoutLimitNoteProps {
47
+ /** `null`, or a limit that narrowed nothing, renders nothing. */
48
+ limit: CheckoutLimitView | null | undefined
49
+ /** Replaces the package's sentence for the limit's `reason` (`per-purchase`, `window`, `hold`). */
50
+ reasonLabel?: string | ((reason: string) => string | null | undefined)
51
+ className?: string
52
+ }
53
+
54
+ /** The request of a price-estimate protocol: its body is a `PriceEstimateBody`. */
55
+ export type PriceEstimateRequest = RequestShape & { body: PriceEstimateBody }
56
+
57
+ type PriceEstimateCall<Request extends PriceEstimateRequest> = Omit<Request, 'body'> & {
58
+ body?: Omit<Request['body'], 'country'>
59
+ }
60
+
61
+ /** A price-estimate protocol's call arguments with `body.country` left to `usePriceEstimate`. */
62
+ export type PriceEstimateArguments<Request extends PriceEstimateRequest> = {} extends PriceEstimateCall<Request>
63
+ ? [request?: PriceEstimateCall<Request> & CallOptions]
64
+ : [request: PriceEstimateCall<Request> & CallOptions]
65
+
66
+ /** A limit row plus what a UI derives from it. */
67
+ export interface LimitStatus extends LimitView {
68
+ /** No room for one more unit — including a limit that is not included (`limit: 0`). */
69
+ exhausted: boolean
70
+ /**
71
+ * How full the limit is, clamped to `[0, 1]`. A `0` ceiling reads `1` once anything is used and
72
+ * `0` otherwise: the ratio says how full, `exhausted` says whether anything is left.
73
+ */
74
+ ratio: number
75
+ }
76
+
77
+ /**
78
+ * What a plan's status line says, in precedence order: an inactive or blocked status, paused,
79
+ * suspended, trial, past due, cancellation scheduled, free, renews, active.
80
+ */
81
+ export type PlanStatusLineKind =
82
+ | 'created' | 'canceled' | 'expired' | 'ended' | 'blocked'
83
+ | 'paused' | 'suspended' | 'trial' | 'past-due' | 'cancel-scheduled'
84
+ | 'free' | 'renews' | 'active'
85
+
86
+ /** How a status line should be coloured: entitled and fine, needs attention, revoked, or over. */
87
+ export type PlanStatusTone = 'ok' | 'warning' | 'critical' | 'inactive'
88
+
89
+ export interface PlanStatusLine {
90
+ kind: PlanStatusLineKind
91
+ tone: PlanStatusTone
92
+ /** The date the line names — trial end, cancellation, pause start or renewal. */
93
+ date?: Date
94
+ }
95
+
96
+ /** How a promo is inscribed: free until its end, kept by a grandfathered plan, or over. */
97
+ export type PromoInscription = 'free-until' | 'grandfathered' | 'ended'
98
+
99
+ /** The request of a portal protocol: its body is a `PortalLinkBody`. */
100
+ export type PortalRequest = RequestShape & { body: PortalLinkBody }
101
+
102
+ type PortalCall<Request extends PortalRequest> = Omit<Request, 'body'> & {
103
+ body?: Omit<Request['body'], 'flow'>
104
+ }
105
+
106
+ /** A portal protocol's call arguments with `body.flow` left to `usePortal`. */
107
+ export type PortalArguments<Request extends PortalRequest> = {} extends PortalCall<Request>
108
+ ? [request?: PortalCall<Request> & CallOptions]
109
+ : [request: PortalCall<Request> & CallOptions]
110
+
111
+ /** A plan offered next to the entity's own — an upgrade, a downgrade, a plan comparison column. */
112
+ export interface PlanOffer {
113
+ sku: string
114
+ title: string
115
+ /** The price as the application formats it (`$20 / month`); the package never formats prices. */
116
+ priceLabel: string
117
+ highlight?: boolean
118
+ }
119
+
120
+ export interface PlanCardProps {
121
+ /** The entity's effective plan. */
122
+ plan: EntitlementPlanView
123
+ /** Render an offered plan instead; the status line then shows only when the offer is current. */
124
+ offer?: PlanOffer
125
+ /** Defaults to `offer.sku === plan.sku` when an offer is given. */
126
+ current?: boolean
127
+ pending?: boolean
128
+ actionLabel?: string
129
+ /** Called with the rendered sku (`offer.sku`, else `plan.sku`). */
130
+ onAction?: (sku: string) => Promise<void> | void
131
+ className?: string
132
+ children?: ReactNode
133
+ }
134
+
135
+ export interface LimitMeterProps {
136
+ limit: LimitView
137
+ label: string
138
+ /** Show when a window limit resets. Default `true`. */
139
+ showReset?: boolean
140
+ compact?: boolean
141
+ className?: string
142
+ }
143
+
144
+ export interface CapabilityListProps {
145
+ capabilities: CapabilityView[]
146
+ /** Product copy by capability `param`; a capability without a label is not rendered. */
147
+ labels: Record<string, string>
148
+ onlyGranted?: boolean
149
+ className?: string
10
150
  }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ResilientError } from '@owlmeans/error'
3
+ import { ApiStatusError } from '@owlmeans/api'
4
+ import { ConsentKind, PerformanceConsentRequired, SubscriptionStartRequired } from '@owlmeans/payment'
5
+ import {
6
+ ConsentDeclined, consentRefusalKindOf, isConsentDeclined, isConsentRefusal, isPerformanceConsentRefusal,
7
+ } from '../src/consumer/refusal.js'
8
+
9
+ const INCIDENT = '0b6f8a3e-8f0e-4b9f-9c55-2f1f0f6f2a11'
10
+ const MARKER = 'payment:consumer-rights:performance-consent-required:1:2026-10-09T00:00:00.000Z'
11
+
12
+ describe('isConsentRefusal', () => {
13
+ test('the class, fresh and rebuilt from its marshalled form', () => {
14
+ const fresh = new PerformanceConsentRequired({ pending: 1, deadline: new Date('2026-10-09T00:00:00Z') })
15
+ expect(isConsentRefusal(fresh)).toBe(true)
16
+ expect(consentRefusalKindOf(fresh)).toBe(ConsentKind.Performance)
17
+
18
+ const rebuilt = ResilientError.ensure(fresh.marshal())
19
+ expect(isConsentRefusal(rebuilt)).toBe(true)
20
+ expect(isPerformanceConsentRefusal(rebuilt)).toBe(true)
21
+ })
22
+
23
+ test('the marker in a message, in a type, and as a stored string', () => {
24
+ expect(isConsentRefusal(new Error(MARKER))).toBe(true)
25
+ expect(isConsentRefusal({ type: PerformanceConsentRequired.typeName, message: 'rebuilt elsewhere' })).toBe(true)
26
+ expect(isConsentRefusal(MARKER)).toBe(true)
27
+ })
28
+
29
+ test('a bare 428 — a production incident body — by its status alone', () => {
30
+ const bare = new ApiStatusError(428, INCIDENT)
31
+ expect(isConsentRefusal(bare)).toBe(true)
32
+ expect(consentRefusalKindOf(bare)).toBe('unknown')
33
+ expect(isPerformanceConsentRefusal(bare)).toBe(true)
34
+
35
+ // A development body keeps its class and gets the status stamped on it.
36
+ const stamped = Object.assign(new Error('something else'), { responseStatus: 428 })
37
+ expect(isConsentRefusal(stamped)).toBe(true)
38
+ expect(isConsentRefusal(`api:client:status:428:${INCIDENT}`)).toBe(true)
39
+ })
40
+
41
+ test('wrapped: in a cause, in an error field, and in a planning commit failure', () => {
42
+ expect(isConsentRefusal(new Error('the action failed', { cause: new ApiStatusError(428) }))).toBe(true)
43
+ expect(isConsentRefusal({ message: 'job failed', error: { message: MARKER } })).toBe(true)
44
+ // `CommitFailed` packs the refusal's text after its own marker.
45
+ expect(consentRefusalKindOf(new Error(`planning:commit-failed:t1:${MARKER}`))).toBe(ConsentKind.Performance)
46
+ // … or carries it as a string cause.
47
+ expect(isConsentRefusal({ message: 'planning:commit-failed:t1', cause: `api:client:status:428:${INCIDENT}` })).toBe(true)
48
+ expect(isConsentRefusal(new AggregateError([new Error('other'), new PerformanceConsentRequired({ pending: 2 })]))).toBe(true)
49
+ })
50
+
51
+ test('the subscription start is a consent refusal, but not the one the spend dialog answers', () => {
52
+ const start = new SubscriptionStartRequired('pro-monthly')
53
+ expect(isConsentRefusal(start)).toBe(true)
54
+ expect(consentRefusalKindOf(start)).toBe(ConsentKind.SubscriptionStart)
55
+ expect(isPerformanceConsentRefusal(start)).toBe(false)
56
+ })
57
+
58
+ test('anything else is not one — other statuses, plain failures, a decline, nothing at all', () => {
59
+ expect(isConsentRefusal(new ApiStatusError(409, INCIDENT))).toBe(false)
60
+ expect(isConsentRefusal(new ApiStatusError(402))).toBe(false)
61
+ expect(isConsentRefusal(new Error('network down'))).toBe(false)
62
+ expect(isConsentRefusal(new ConsentDeclined())).toBe(false)
63
+ expect(isConsentRefusal(null)).toBe(false)
64
+ expect(isConsentRefusal(undefined)).toBe(false)
65
+ expect(isConsentRefusal('just text')).toBe(false)
66
+
67
+ const loop: { message: string, cause?: unknown } = { message: 'loop' }
68
+ loop.cause = loop
69
+ expect(isConsentRefusal(loop)).toBe(false)
70
+ })
71
+ })
72
+
73
+ describe('ConsentDeclined', () => {
74
+ test('keeps its kind across a marshal and is told apart from a refusal', () => {
75
+ const declined = new ConsentDeclined(ConsentKind.Performance)
76
+ expect(isConsentDeclined(declined)).toBe(true)
77
+ expect(declined.kind).toBe(ConsentKind.Performance)
78
+
79
+ const rebuilt = ResilientError.ensure(declined.marshal())
80
+ expect(rebuilt).toBeInstanceOf(ConsentDeclined)
81
+ expect((rebuilt as ConsentDeclined).kind).toBe(ConsentKind.Performance)
82
+ })
83
+ })
@@ -0,0 +1,205 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { createElement } from 'react'
3
+ import { renderToStaticMarkup } from 'react-dom/server'
4
+ import type { RegisteredEntrypoint, RequestShape } from '@owlmeans/entrypoint'
5
+ import { ApiStatusError } from '@owlmeans/api'
6
+ import {
7
+ ConsumerRegion, PerformanceConsentRequired,
8
+ type CancellationBody, type CancellationReceipt, type PerformanceConsentBody, type PerformanceConsentResponse,
9
+ type PerformanceConsentView, type WithdrawalBody, type WithdrawalCandidateList, type WithdrawalReceipt,
10
+ } from '@owlmeans/payment'
11
+ import { makeAsker, makeConsentGate, passThroughGate } from '../src/consumer/ensure.js'
12
+ import { useCancellation, usePerformanceConsent, useWithdrawal } from '../src/consumer/hooks.js'
13
+ import { useConsentGate } from '../src/consumer/provider.js'
14
+ import { ConsentDeclined } from '../src/consumer/refusal.js'
15
+
16
+ const tick = async () => await new Promise(resolve => setTimeout(resolve, 0))
17
+
18
+ interface View { required: boolean }
19
+
20
+ const asker = (views: Array<View | Error>) => {
21
+ const shown: View[] = []
22
+ let loads = 0
23
+ const control = makeAsker<[], View, boolean>({
24
+ load: async () => {
25
+ const next = views[Math.min(loads, views.length - 1)]
26
+ loads++
27
+ if (next instanceof Error) throw next
28
+ return next
29
+ },
30
+ required: view => view.required,
31
+ show: view => { shown.push(view) },
32
+ skip: true,
33
+ })
34
+
35
+ return { control, shown, loads: () => loads }
36
+ }
37
+
38
+ describe('makeAsker — the ensure() state machine', () => {
39
+ test('answers true at once when nothing is required, without showing anything', async () => {
40
+ const { control, shown } = asker([{ required: false }])
41
+ expect(await control.ask()).toBe(true)
42
+ expect(shown).toHaveLength(0)
43
+ })
44
+
45
+ test('shows the view and answers what the person decides', async () => {
46
+ const accept = asker([{ required: true }])
47
+ const accepted = accept.control.ask()
48
+ await tick()
49
+ expect(accept.shown).toHaveLength(1)
50
+ expect(accept.control.waiting()).toBe(true)
51
+ accept.control.settle(true)
52
+ expect(await accepted).toBe(true)
53
+ expect(accept.control.waiting()).toBe(false)
54
+
55
+ const decline = asker([{ required: true }])
56
+ const declined = decline.control.ask()
57
+ await tick()
58
+ decline.control.settle(false)
59
+ expect(await declined).toBe(false)
60
+ })
61
+
62
+ test('a burst of calls shares ONE pending answer and one dialog', async () => {
63
+ const { control, shown, loads } = asker([{ required: true }])
64
+ const answers = [control.ask(), control.ask(), control.ask()]
65
+ await tick()
66
+ expect(shown).toHaveLength(1)
67
+ expect(loads()).toBe(1)
68
+ control.settle(true)
69
+ expect(await Promise.all(answers)).toEqual([true, true, true])
70
+
71
+ // Settled: the next call reads the view afresh.
72
+ const next = control.ask()
73
+ await tick()
74
+ expect(loads()).toBe(2)
75
+ control.settle(false)
76
+ expect(await next).toBe(false)
77
+ })
78
+
79
+ test('fails open: a view that cannot be read answers the skip value', async () => {
80
+ const { control, shown } = asker([new Error('network down')])
81
+ expect(await control.ask()).toBe(true)
82
+ expect(shown).toHaveLength(0)
83
+ })
84
+
85
+ test('settling with nothing waiting does nothing', () => {
86
+ const { control } = asker([{ required: true }])
87
+ expect(() => control.settle(true)).not.toThrow()
88
+ expect(control.waiting()).toBe(false)
89
+ })
90
+ })
91
+
92
+ describe('makeConsentGate — withConsent retries exactly once', () => {
93
+ const counted = (failures: unknown[]) => {
94
+ let calls = 0
95
+ const action = async () => {
96
+ const failure = failures[calls]
97
+ calls++
98
+ if (failure != null) throw failure
99
+ return 'done'
100
+ }
101
+
102
+ return { action, calls: () => calls }
103
+ }
104
+
105
+ test('no refusal: runs once and never asks', async () => {
106
+ let asked = 0
107
+ const gate = makeConsentGate(async () => { asked++; return true })
108
+ const { action, calls } = counted([])
109
+ expect(await gate.withConsent(action)).toBe('done')
110
+ expect([calls(), asked]).toEqual([1, 0])
111
+ })
112
+
113
+ test('a refusal (class or bare 428) asks with force, then retries once', async () => {
114
+ for (const refusal of [new PerformanceConsentRequired({ pending: 1 }), new ApiStatusError(428)]) {
115
+ const forced: unknown[] = []
116
+ const gate = makeConsentGate(async opts => { forced.push(opts?.force); return true })
117
+ const { action, calls } = counted([refusal])
118
+ expect(await gate.withConsent(action)).toBe('done')
119
+ expect([calls(), forced]).toEqual([2, [true]])
120
+ }
121
+ })
122
+
123
+ test('a decline throws ConsentDeclined and never retries', async () => {
124
+ const gate = makeConsentGate(async () => false)
125
+ const { action, calls } = counted([new ApiStatusError(428)])
126
+ await expect(gate.withConsent(action)).rejects.toBeInstanceOf(ConsentDeclined)
127
+ expect(calls()).toBe(1)
128
+ })
129
+
130
+ test('a second refusal propagates — never a loop', async () => {
131
+ let asked = 0
132
+ const gate = makeConsentGate(async () => { asked++; return true })
133
+ const second = new ApiStatusError(428, 'second')
134
+ const { action, calls } = counted([new ApiStatusError(428, 'first'), second])
135
+ await expect(gate.withConsent(action)).rejects.toBe(second)
136
+ expect([calls(), asked]).toEqual([2, 1])
137
+ })
138
+
139
+ test('any other failure propagates without asking', async () => {
140
+ let asked = 0
141
+ const gate = makeConsentGate(async () => { asked++; return true })
142
+ const failure = new ApiStatusError(402)
143
+ await expect(gate.withConsent(async () => { throw failure })).rejects.toBe(failure)
144
+ expect(asked).toBe(0)
145
+ })
146
+
147
+ test('the pass-through gate asks nothing and lets a refusal through', async () => {
148
+ expect(await passThroughGate.ensure()).toBe(true)
149
+ const refusal = new ApiStatusError(428)
150
+ await expect(passThroughGate.withConsent(async () => { throw refusal })).rejects.toBe(refusal)
151
+ })
152
+ })
153
+
154
+ /** Render hooks once on the server and hand back what they answered. */
155
+ const answer = <T>(hook: () => T): T => {
156
+ let value: T | undefined
157
+ const Probe = () => { value = hook(); return null }
158
+ renderToStaticMarkup(createElement(Probe))
159
+ return value as T
160
+ }
161
+
162
+ const entry = <Request extends RequestShape, Response>(call: () => Promise<Response>) =>
163
+ ({ call }) as unknown as RegisteredEntrypoint<Request, Response>
164
+
165
+ const consentView: PerformanceConsentView = {
166
+ required: true, region: ConsumerRegion.Eu, country: 'DE', language: 'de', trader: 'Trader', textVersion: 'v1',
167
+ copyVersion: 'c1', links: { billingTerms: 'https://example.test/terms' }, purchases: [], at: new Date(),
168
+ }
169
+
170
+ describe('consumer hooks — first render', () => {
171
+ test('usePerformanceConsent: unknown until read, the dialog closed and wired', () => {
172
+ const control = answer(() => usePerformanceConsent(
173
+ entry<{}, PerformanceConsentView>(async () => consentView),
174
+ entry<{ body: PerformanceConsentBody }, PerformanceConsentResponse>(async () => ({
175
+ consentId: 'c', consentedAt: new Date(), purchaseIds: [], mailed: true,
176
+ })),
177
+ ))
178
+ expect(control.required).toBeNull()
179
+ expect(control.view).toBeNull()
180
+ expect(control.dialog.open).toBe(false)
181
+ expect(control.dialog.pending).toBe(false)
182
+ expect(typeof control.ensure).toBe('function')
183
+ expect(typeof control.dialog.onConfirm).toBe('function')
184
+ expect(control.dialog.onWithdraw).toBeUndefined()
185
+ })
186
+
187
+ test('useConsentGate outside a provider is the pass-through gate', async () => {
188
+ const gate = answer(() => useConsentGate())
189
+ expect(gate.required).toBeNull()
190
+ expect(await gate.ensure()).toBe(true)
191
+ })
192
+
193
+ test('useWithdrawal and useCancellation start empty and expose their form props', () => {
194
+ const withdrawal = answer(() => useWithdrawal(
195
+ entry<{}, WithdrawalCandidateList>(async () => ({ candidates: [], language: 'en', links: { billingTerms: 'https://x.test' } })),
196
+ entry<{ body: WithdrawalBody }, WithdrawalReceipt>(async () => { throw new Error('not called') }),
197
+ ))
198
+ expect([withdrawal.list, withdrawal.receipt, withdrawal.pending, withdrawal.form.error]).toEqual([null, null, false, false])
199
+
200
+ const cancellation = answer(() => useCancellation(
201
+ entry<{ body: CancellationBody }, CancellationReceipt>(async () => { throw new Error('not called') }),
202
+ ))
203
+ expect([cancellation.receipt, cancellation.pending, cancellation.form.error]).toEqual([null, false, false])
204
+ })
205
+ })