@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.
- package/README.md +30 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-payment/SKILL.md +287 -20
- package/build/@/components/ui/card.d.ts +10 -0
- package/build/@/components/ui/card.d.ts.map +1 -0
- package/build/@/components/ui/card.js +25 -0
- package/build/@/components/ui/card.js.map +1 -0
- package/build/@/components/ui/checkbox.d.ts +5 -0
- package/build/@/components/ui/checkbox.d.ts.map +1 -0
- package/build/@/components/ui/checkbox.js +6 -0
- package/build/@/components/ui/checkbox.js.map +1 -0
- package/build/@/components/ui/progress.d.ts +5 -0
- package/build/@/components/ui/progress.d.ts.map +1 -0
- package/build/@/components/ui/progress.js +11 -0
- package/build/@/components/ui/progress.js.map +1 -0
- package/build/@/components/ui/select.d.ts +8 -0
- package/build/@/components/ui/select.d.ts.map +1 -0
- package/build/@/components/ui/select.js +9 -0
- package/build/@/components/ui/select.js.map +1 -0
- package/build/capability-list.d.ts +7 -0
- package/build/capability-list.d.ts.map +1 -0
- package/build/capability-list.js +24 -0
- package/build/capability-list.js.map +1 -0
- package/build/checkout-limit.d.ts +11 -0
- package/build/checkout-limit.d.ts.map +1 -0
- package/build/checkout-limit.js +34 -0
- package/build/checkout-limit.js.map +1 -0
- package/build/consumer/cancellation.d.ts +20 -0
- package/build/consumer/cancellation.d.ts.map +1 -0
- package/build/consumer/cancellation.js +115 -0
- package/build/consumer/cancellation.js.map +1 -0
- package/build/consumer/copy.d.ts +23 -0
- package/build/consumer/copy.d.ts.map +1 -0
- package/build/consumer/copy.js +75 -0
- package/build/consumer/copy.js.map +1 -0
- package/build/consumer/ensure.d.ts +56 -0
- package/build/consumer/ensure.d.ts.map +1 -0
- package/build/consumer/ensure.js +71 -0
- package/build/consumer/ensure.js.map +1 -0
- package/build/consumer/fields.d.ts +43 -0
- package/build/consumer/fields.d.ts.map +1 -0
- package/build/consumer/fields.js +19 -0
- package/build/consumer/fields.js.map +1 -0
- package/build/consumer/hooks.d.ts +113 -0
- package/build/consumer/hooks.d.ts.map +1 -0
- package/build/consumer/hooks.js +225 -0
- package/build/consumer/hooks.js.map +1 -0
- package/build/consumer/index.d.ts +14 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +12 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/legal.d.ts +53 -0
- package/build/consumer/legal.d.ts.map +1 -0
- package/build/consumer/legal.js +77 -0
- package/build/consumer/legal.js.map +1 -0
- package/build/consumer/performance-consent-dialog.d.ts +18 -0
- package/build/consumer/performance-consent-dialog.d.ts.map +1 -0
- package/build/consumer/performance-consent-dialog.js +71 -0
- package/build/consumer/performance-consent-dialog.js.map +1 -0
- package/build/consumer/provider.d.ts +33 -0
- package/build/consumer/provider.d.ts.map +1 -0
- package/build/consumer/provider.js +26 -0
- package/build/consumer/provider.js.map +1 -0
- package/build/consumer/refusal.d.ts +35 -0
- package/build/consumer/refusal.d.ts.map +1 -0
- package/build/consumer/refusal.js +98 -0
- package/build/consumer/refusal.js.map +1 -0
- package/build/consumer/subscription-start-dialog.d.ts +16 -0
- package/build/consumer/subscription-start-dialog.d.ts.map +1 -0
- package/build/consumer/subscription-start-dialog.js +43 -0
- package/build/consumer/subscription-start-dialog.js.map +1 -0
- package/build/consumer/types.d.ts +114 -0
- package/build/consumer/types.d.ts.map +1 -0
- package/build/consumer/types.js +2 -0
- package/build/consumer/types.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +29 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +140 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/copy.d.ts +14 -0
- package/build/copy.d.ts.map +1 -0
- package/build/copy.js +51 -0
- package/build/copy.js.map +1 -0
- package/build/dialog.d.ts +1 -1
- package/build/dialog.d.ts.map +1 -1
- package/build/dialog.js +46 -17
- package/build/dialog.js.map +1 -1
- package/build/entitlement.d.ts +26 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +42 -0
- package/build/entitlement.js.map +1 -0
- package/build/estimate-cache.d.ts +14 -0
- package/build/estimate-cache.d.ts.map +1 -0
- package/build/estimate-cache.js +39 -0
- package/build/estimate-cache.js.map +1 -0
- package/build/estimate-summary.d.ts +64 -0
- package/build/estimate-summary.d.ts.map +1 -0
- package/build/estimate-summary.js +94 -0
- package/build/estimate-summary.js.map +1 -0
- package/build/estimate.d.ts +34 -0
- package/build/estimate.d.ts.map +1 -0
- package/build/estimate.js +92 -0
- package/build/estimate.js.map +1 -0
- package/build/format.d.ts +15 -0
- package/build/format.d.ts.map +1 -0
- package/build/format.js +68 -0
- package/build/format.js.map +1 -0
- package/build/hooks.d.ts +0 -4
- package/build/hooks.d.ts.map +1 -1
- package/build/hooks.js +3 -30
- package/build/hooks.js.map +1 -1
- package/build/i18n/be.json +1 -1
- package/build/i18n/de.json +1 -1
- package/build/i18n/en.json +1 -1
- package/build/i18n/es.json +1 -1
- package/build/i18n/fr.json +145 -0
- package/build/i18n/pl.json +1 -1
- package/build/i18n/ru.json +1 -1
- package/build/i18n/uk.json +1 -1
- package/build/i18n.d.ts +2 -1
- package/build/i18n.d.ts.map +1 -1
- package/build/i18n.js +11 -7
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +9 -0
- package/build/index.js.map +1 -1
- package/build/limit-meter.d.ts +7 -0
- package/build/limit-meter.d.ts.map +1 -0
- package/build/limit-meter.js +26 -0
- package/build/limit-meter.js.map +1 -0
- package/build/plan-card.d.ts +7 -0
- package/build/plan-card.d.ts.map +1 -0
- package/build/plan-card.js +29 -0
- package/build/plan-card.js.map +1 -0
- package/build/poll.d.ts +7 -0
- package/build/poll.d.ts.map +1 -0
- package/build/poll.js +28 -0
- package/build/poll.js.map +1 -0
- package/build/selectors.d.ts +18 -0
- package/build/selectors.d.ts.map +1 -0
- package/build/selectors.js +78 -0
- package/build/selectors.js.map +1 -0
- package/build/types.d.ts +120 -2
- package/build/types.d.ts.map +1 -1
- package/package.json +22 -7
- package/src/@/components/ui/card.tsx +93 -0
- package/src/@/components/ui/checkbox.tsx +23 -0
- package/src/@/components/ui/progress.tsx +44 -0
- package/src/@/components/ui/select.tsx +62 -0
- package/src/@/globals.css +10 -0
- package/src/capability-list.tsx +50 -0
- package/src/checkout-limit.tsx +48 -0
- package/src/consumer/cancellation.tsx +214 -0
- package/src/consumer/copy.ts +93 -0
- package/src/consumer/ensure.ts +124 -0
- package/src/consumer/fields.tsx +89 -0
- package/src/consumer/hooks.ts +358 -0
- package/src/consumer/index.ts +13 -0
- package/src/consumer/legal.tsx +129 -0
- package/src/consumer/performance-consent-dialog.tsx +132 -0
- package/src/consumer/provider.tsx +49 -0
- package/src/consumer/refusal.ts +110 -0
- package/src/consumer/subscription-start-dialog.tsx +87 -0
- package/src/consumer/types.ts +121 -0
- package/src/consumer/withdrawal.tsx +279 -0
- package/src/copy.tsx +62 -0
- package/src/dialog.tsx +58 -25
- package/src/entitlement.ts +56 -0
- package/src/estimate-cache.ts +40 -0
- package/src/estimate-summary.tsx +172 -0
- package/src/estimate.ts +119 -0
- package/src/format.ts +71 -0
- package/src/hooks.ts +3 -35
- package/src/i18n/be.json +1 -1
- package/src/i18n/de.json +1 -1
- package/src/i18n/en.json +1 -1
- package/src/i18n/es.json +1 -1
- package/src/i18n/fr.json +145 -0
- package/src/i18n/pl.json +1 -1
- package/src/i18n/ru.json +1 -1
- package/src/i18n/uk.json +1 -1
- package/src/i18n.ts +12 -7
- package/src/index.ts +9 -0
- package/src/limit-meter.tsx +55 -0
- package/src/plan-card.tsx +67 -0
- package/src/poll.ts +27 -0
- package/src/selectors.ts +93 -0
- package/src/types.ts +142 -2
- package/tests/consent-refusal.spec.ts +83 -0
- package/tests/consumer-hooks.spec.ts +205 -0
- package/tests/consumer.spec.ts +343 -0
- package/tests/context.ts +9 -0
- package/tests/dialog.spec.ts +49 -0
- package/tests/entitlement.spec.ts +117 -0
- package/tests/estimate-cache.spec.ts +46 -0
- package/tests/harness/consumer-fixtures.ts +105 -0
- package/tests/harness/consumer.tsx +197 -0
- package/tests/harness/entitlement.tsx +159 -0
- package/tests/harness/estimate-fixtures.ts +48 -0
- package/tests/harness/mount.tsx +19 -3
- package/tests/i18n.spec.ts +78 -8
- package/tests/pieces.spec.ts +134 -0
package/src/types.ts
CHANGED
|
@@ -1,10 +1,150 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
|
|
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
|
+
})
|