@umituz/web-payment 0.0.0-stage → 1.0.0
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/LICENSE +21 -0
- package/README.md +349 -2
- package/dist/billing-adapter.interface-CDkeeowZ.d.mts +94 -0
- package/dist/billing-adapter.interface-m7yE50I0.d.ts +94 -0
- package/dist/billing-cycle.enum--hJRYlgP.d.mts +10 -0
- package/dist/billing-cycle.enum--hJRYlgP.d.ts +10 -0
- package/dist/billing-env-D4YjvA3M.d.mts +8 -0
- package/dist/billing-env-D4YjvA3M.d.ts +8 -0
- package/dist/checkout-metadata-D7Wp_DAD.d.mts +27 -0
- package/dist/checkout-metadata-D7Wp_DAD.d.ts +27 -0
- package/dist/chunk-2KV7JFYF.mjs +61 -0
- package/dist/chunk-2NH5DOLT.mjs +101 -0
- package/dist/chunk-3AXPZOCV.mjs +106 -0
- package/dist/chunk-4N3BASZ6.mjs +51 -0
- package/dist/chunk-NHKK3ENU.mjs +1128 -0
- package/dist/chunk-PIWNUAKW.mjs +30 -0
- package/dist/chunk-QFB7AOSQ.mjs +33 -0
- package/dist/chunk-RQTMBR74.mjs +30 -0
- package/dist/chunk-U45FS4FF.mjs +110 -0
- package/dist/chunk-X6R76FZT.mjs +0 -0
- package/dist/chunk-XQ2HJTTA.mjs +252 -0
- package/dist/core.d.mts +17 -0
- package/dist/core.d.ts +17 -0
- package/dist/core.js +425 -0
- package/dist/core.mjs +66 -0
- package/dist/http.d.mts +12 -0
- package/dist/http.d.ts +12 -0
- package/dist/http.js +115 -0
- package/dist/http.mjs +88 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +1929 -0
- package/dist/index.mjs +153 -0
- package/dist/memory.d.mts +14 -0
- package/dist/memory.d.ts +14 -0
- package/dist/memory.js +130 -0
- package/dist/memory.mjs +6 -0
- package/dist/portal.d.mts +445 -0
- package/dist/portal.d.ts +445 -0
- package/dist/portal.js +2927 -0
- package/dist/portal.mjs +2737 -0
- package/dist/product.mapper-CnY1ISOp.d.ts +97 -0
- package/dist/product.mapper-noVl-sel.d.mts +97 -0
- package/dist/products.d.mts +64 -0
- package/dist/products.d.ts +64 -0
- package/dist/products.js +370 -0
- package/dist/products.mjs +21 -0
- package/dist/react.d.mts +182 -0
- package/dist/react.d.ts +182 -0
- package/dist/react.js +1874 -0
- package/dist/react.mjs +105 -0
- package/dist/server.d.mts +177 -0
- package/dist/server.d.ts +177 -0
- package/dist/server.js +622 -0
- package/dist/server.mjs +417 -0
- package/dist/visual-tokens-BCLVR85G.d.mts +94 -0
- package/dist/visual-tokens-BCLVR85G.d.ts +94 -0
- package/package.json +122 -4
- package/src/core.ts +81 -0
- package/src/domain/entities/cancellation.entity.ts +28 -0
- package/src/domain/entities/checkout.entity.ts +21 -0
- package/src/domain/entities/index.ts +10 -0
- package/src/domain/entities/order.entity.ts +15 -0
- package/src/domain/entities/product.entity.ts +16 -0
- package/src/domain/entities/subscription.entity.ts +24 -0
- package/src/domain/entities/sync.entity.ts +4 -0
- package/src/domain/enums/amount-type.enum.ts +10 -0
- package/src/domain/enums/billing-cycle.enum.ts +30 -0
- package/src/domain/enums/index.ts +5 -0
- package/src/domain/enums/payment-provider.enum.ts +14 -0
- package/src/domain/enums/subscription-status.enum.ts +32 -0
- package/src/domain/index.ts +5 -0
- package/src/domain/interfaces/billing-adapter.interface.ts +18 -0
- package/src/domain/interfaces/index.ts +1 -0
- package/src/domain/mappers/product.mapper.ts +176 -0
- package/src/domain/utils/minor-units.ts +20 -0
- package/src/domain/utils/percent-discount.ts +17 -0
- package/src/http.ts +3 -0
- package/src/index.ts +12 -0
- package/src/infrastructure/browser/hosted-checkout.ts +26 -0
- package/src/infrastructure/config/billing-env.ts +31 -0
- package/src/infrastructure/constants/billing.constants.ts +10 -0
- package/src/infrastructure/http/http-client.ts +83 -0
- package/src/infrastructure/pagination/page-iterator.ts +43 -0
- package/src/infrastructure/query/query-builder.ts +26 -0
- package/src/infrastructure/services/billing.client.ts +300 -0
- package/src/infrastructure/services/create-billing-client.ts +23 -0
- package/src/infrastructure/services/http/create-http-adapter.ts +123 -0
- package/src/infrastructure/services/memory/create-memory-adapter.ts +126 -0
- package/src/infrastructure/services/product.repository.ts +198 -0
- package/src/infrastructure/utils/checkout-metadata.ts +55 -0
- package/src/infrastructure/utils/date-renderer.ts +53 -0
- package/src/infrastructure/utils/money-formatter.ts +39 -0
- package/src/infrastructure/utils/normalization.util.ts +22 -0
- package/src/infrastructure/utils/pagination-clamp.ts +11 -0
- package/src/infrastructure/utils/receipt.util.ts +6 -0
- package/src/infrastructure/utils/security.util.ts +23 -0
- package/src/infrastructure/utils/type-guards.util.ts +23 -0
- package/src/infrastructure/utils/validation.util.ts +10 -0
- package/src/infrastructure/webhook/parse-event.ts +135 -0
- package/src/infrastructure/webhook/verify-webhook.ts +124 -0
- package/src/memory.ts +3 -0
- package/src/portal.ts +2 -0
- package/src/presentation/components/BillingHistoryList.tsx +172 -0
- package/src/presentation/components/BillingProvider.tsx +179 -0
- package/src/presentation/components/CancelSubscriptionButton.tsx +155 -0
- package/src/presentation/components/CheckoutButton.tsx +76 -0
- package/src/presentation/components/CustomerPortalLink.tsx +71 -0
- package/src/presentation/components/PricingTable.tsx +225 -0
- package/src/presentation/components/SubscriptionStatusBadge.tsx +102 -0
- package/src/presentation/context/billing-context.ts +30 -0
- package/src/presentation/hooks/useBilling.ts +3 -0
- package/src/presentation/hooks/useCancelSubscription.ts +47 -0
- package/src/presentation/hooks/useCheckoutButton.ts +49 -0
- package/src/presentation/hooks/useProducts.ts +76 -0
- package/src/presentation/navigation/navigation.strategy.ts +16 -0
- package/src/presentation/portal/TurnkeyBillingPage.tsx +144 -0
- package/src/presentation/portal/components/BillingLayout.tsx +163 -0
- package/src/presentation/portal/components/BillingPage.tsx +22 -0
- package/src/presentation/portal/components/BillingPortal.tsx +248 -0
- package/src/presentation/portal/components/InvoiceCard.tsx +275 -0
- package/src/presentation/portal/components/OverviewTab.tsx +137 -0
- package/src/presentation/portal/components/PaymentMethodsList.tsx +268 -0
- package/src/presentation/portal/components/PlanComparison.tsx +405 -0
- package/src/presentation/portal/components/PlanTab.tsx +197 -0
- package/src/presentation/portal/components/PortalButton.tsx +89 -0
- package/src/presentation/portal/components/PortalSpinner.tsx +30 -0
- package/src/presentation/portal/components/UsageCard.tsx +158 -0
- package/src/presentation/portal/components/index.ts +11 -0
- package/src/presentation/portal/constants.ts +44 -0
- package/src/presentation/portal/create-portal-client.ts +202 -0
- package/src/presentation/portal/hooks/index.ts +3 -0
- package/src/presentation/portal/hooks/useBilling.ts +240 -0
- package/src/presentation/portal/index.ts +97 -0
- package/src/presentation/portal/labels.tsx +218 -0
- package/src/presentation/portal/styles.ts +70 -0
- package/src/presentation/portal/types.ts +213 -0
- package/src/presentation/portal/utils/billing.ts +193 -0
- package/src/presentation/portal/utils/index.ts +27 -0
- package/src/presentation/portal/utils/plans.ts +71 -0
- package/src/presentation/theme/visual-tokens.ts +103 -0
- package/src/presentation/utils/cancellation-reason-catalog.ts +18 -0
- package/src/presentation/utils/checkout-button-visual.ts +50 -0
- package/src/presentation/utils/inline-error-visual.ts +21 -0
- package/src/presentation/utils/product-pricing.ts +33 -0
- package/src/presentation/utils/status-label.ts +23 -0
- package/src/presentation/utils/status-tone.ts +25 -0
- package/src/products.ts +20 -0
- package/src/react.tsx +63 -0
- package/src/server.ts +79 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { toMajorUnits } from '../../domain/utils/minor-units';
|
|
2
|
+
export interface WebhookEvent {
|
|
3
|
+
type: string;
|
|
4
|
+
data: Record<string, unknown>;
|
|
5
|
+
}
|
|
6
|
+
export interface CheckoutCompleted {
|
|
7
|
+
checkoutId: string | null;
|
|
8
|
+
productId: string | null;
|
|
9
|
+
customerEmail: string | null;
|
|
10
|
+
customerName: string | null;
|
|
11
|
+
amount: number | null;
|
|
12
|
+
currency: string | null;
|
|
13
|
+
metadata: Record<string, string>;
|
|
14
|
+
createdAt: string | null;
|
|
15
|
+
}
|
|
16
|
+
export interface SubscriptionChanged {
|
|
17
|
+
subscriptionId: string | null;
|
|
18
|
+
status: string | null;
|
|
19
|
+
productId: string | null;
|
|
20
|
+
amount: number | null;
|
|
21
|
+
currency: string | null;
|
|
22
|
+
currentPeriodEnd: string | null;
|
|
23
|
+
cancelAtPeriodEnd: boolean;
|
|
24
|
+
canceledAt: string | null;
|
|
25
|
+
endsAt: string | null;
|
|
26
|
+
customerId: string | null;
|
|
27
|
+
metadata: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export interface OrderPaid {
|
|
30
|
+
orderId: string | null;
|
|
31
|
+
productId: string | null;
|
|
32
|
+
productName: string | null;
|
|
33
|
+
amount: number | null;
|
|
34
|
+
currency: string | null;
|
|
35
|
+
customerEmail: string | null;
|
|
36
|
+
metadata: Record<string, string>;
|
|
37
|
+
createdAt: string | null;
|
|
38
|
+
}
|
|
39
|
+
const SUBSCRIPTION_EVENT_TYPES = new Set([
|
|
40
|
+
'subscription.active',
|
|
41
|
+
'subscription.canceled',
|
|
42
|
+
'subscription.updated',
|
|
43
|
+
'subscription.uncanceled',
|
|
44
|
+
'subscription.revoked',
|
|
45
|
+
]);
|
|
46
|
+
function readString(value: unknown): string | null {
|
|
47
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
48
|
+
}
|
|
49
|
+
function readNumber(value: unknown): number | null {
|
|
50
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
51
|
+
}
|
|
52
|
+
function readObject(value: unknown): Record<string, unknown> | null {
|
|
53
|
+
return value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
|
54
|
+
}
|
|
55
|
+
function readMetadata(value: unknown): Record<string, string> {
|
|
56
|
+
const metadata = readObject(value);
|
|
57
|
+
if (!metadata)
|
|
58
|
+
return {};
|
|
59
|
+
return Object.fromEntries(Object.entries(metadata).filter((entry): entry is [string, string] => typeof entry[1] === 'string'));
|
|
60
|
+
}
|
|
61
|
+
export function parseWebhookEvent(rawBody: string): WebhookEvent | null {
|
|
62
|
+
let parsed: unknown;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(rawBody);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const envelope = readObject(parsed);
|
|
70
|
+
const type = envelope ? readString(envelope.type) : null;
|
|
71
|
+
const data = envelope ? readObject(envelope.data) : null;
|
|
72
|
+
if (!envelope || !type || !data)
|
|
73
|
+
return null;
|
|
74
|
+
return { type, data };
|
|
75
|
+
}
|
|
76
|
+
export function asCheckoutCompleted(event: WebhookEvent): CheckoutCompleted | null {
|
|
77
|
+
if (event.type !== 'checkout.completed')
|
|
78
|
+
return null;
|
|
79
|
+
const data = event.data;
|
|
80
|
+
const product = readObject(data.product);
|
|
81
|
+
const customer = readObject(data.customer);
|
|
82
|
+
const minorUnits = readNumber(data.total_amount ?? data.amount);
|
|
83
|
+
const currency = readString(data.currency);
|
|
84
|
+
return {
|
|
85
|
+
checkoutId: readString(data.id),
|
|
86
|
+
productId: readString(data.product_id) ?? (product ? readString(product.id) : null),
|
|
87
|
+
customerEmail: readString(data.customer_email) ?? (customer ? readString(customer.email) : null),
|
|
88
|
+
customerName: readString(data.customer_name) ?? (customer ? readString(customer.name) : null),
|
|
89
|
+
amount: minorUnits !== null && currency ? toMajorUnits(minorUnits, currency) : null,
|
|
90
|
+
currency,
|
|
91
|
+
metadata: readMetadata(data.metadata),
|
|
92
|
+
createdAt: readString(data.created_at) ?? readString(data.createdAt),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function asSubscriptionChanged(event: WebhookEvent): SubscriptionChanged | null {
|
|
96
|
+
if (!SUBSCRIPTION_EVENT_TYPES.has(event.type))
|
|
97
|
+
return null;
|
|
98
|
+
const data = event.data;
|
|
99
|
+
const minorUnits = readNumber(data.amount);
|
|
100
|
+
const currency = readString(data.currency);
|
|
101
|
+
return {
|
|
102
|
+
subscriptionId: readString(data.id),
|
|
103
|
+
status: readString(data.status),
|
|
104
|
+
productId: readString(data.product_id),
|
|
105
|
+
amount: minorUnits !== null && currency ? toMajorUnits(minorUnits, currency) : null,
|
|
106
|
+
currency,
|
|
107
|
+
currentPeriodEnd: readString(data.current_period_end),
|
|
108
|
+
cancelAtPeriodEnd: data.cancel_at_period_end === true,
|
|
109
|
+
canceledAt: readString(data.canceled_at),
|
|
110
|
+
endsAt: readString(data.ends_at),
|
|
111
|
+
customerId: readString(data.customer_id),
|
|
112
|
+
metadata: readMetadata(data.metadata),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export function asOrderPaid(event: WebhookEvent): OrderPaid | null {
|
|
116
|
+
if (event.type !== 'order.paid')
|
|
117
|
+
return null;
|
|
118
|
+
const data = event.data;
|
|
119
|
+
const product = readObject(data.product);
|
|
120
|
+
const customer = readObject(data.customer);
|
|
121
|
+
const minorUnits = readNumber(data.amount);
|
|
122
|
+
const currency = readString(data.currency);
|
|
123
|
+
return {
|
|
124
|
+
orderId: readString(data.id),
|
|
125
|
+
productId: readString(data.product_id) ?? (product ? readString(product.id) : null),
|
|
126
|
+
productName: readString(data.product_name) ?? (product ? readString(product.name) : null),
|
|
127
|
+
amount: minorUnits !== null && currency ? toMajorUnits(minorUnits, currency) : null,
|
|
128
|
+
currency,
|
|
129
|
+
customerEmail: readString(data.customer_email) ?? (customer ? readString(customer.email) : null),
|
|
130
|
+
metadata: readMetadata(data.metadata),
|
|
131
|
+
createdAt: readString(data.created_at) ?? readString(data.paid_at),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export const parsePolarWebhookEvent = parseWebhookEvent;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
|
|
2
|
+
export interface WebhookHeadersLike {
|
|
3
|
+
get(name: string): string | null | undefined;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const KNOWN_SECRET_PREFIXES = ['whsec_'] as const;
|
|
7
|
+
|
|
8
|
+
function headerValue(
|
|
9
|
+
headers: WebhookHeadersLike | Record<string, string>,
|
|
10
|
+
name: string,
|
|
11
|
+
): string | undefined {
|
|
12
|
+
if (typeof headers.get === 'function') {
|
|
13
|
+
const value = headers.get(name);
|
|
14
|
+
return value ?? undefined;
|
|
15
|
+
}
|
|
16
|
+
const record = headers as Record<string, string>;
|
|
17
|
+
return record[name] ?? record[name.toLowerCase()];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function stripSecretPrefix(secret: string): string {
|
|
21
|
+
for (const prefix of KNOWN_SECRET_PREFIXES) {
|
|
22
|
+
if (secret.startsWith(prefix)) return secret.slice(prefix.length);
|
|
23
|
+
}
|
|
24
|
+
return secret;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function base64ToBytes(value: string): Uint8Array | null {
|
|
28
|
+
try {
|
|
29
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
30
|
+
const binary = atob(normalized);
|
|
31
|
+
const bytes = new Uint8Array(binary.length);
|
|
32
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
33
|
+
return bytes;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function encodeUtf8(value: string): Uint8Array {
|
|
40
|
+
return new TextEncoder().encode(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function hmacSha256(keyBytes: Uint8Array, payload: string): Promise<string> {
|
|
44
|
+
const cryptoRef = globalThis.crypto;
|
|
45
|
+
if (!cryptoRef?.subtle) {
|
|
46
|
+
throw new Error('WebCrypto (crypto.subtle) is not available in this runtime');
|
|
47
|
+
}
|
|
48
|
+
const key = await cryptoRef.subtle.importKey(
|
|
49
|
+
'raw',
|
|
50
|
+
keyBytes as BufferSource,
|
|
51
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
52
|
+
false,
|
|
53
|
+
['sign'],
|
|
54
|
+
);
|
|
55
|
+
const signature = await cryptoRef.subtle.sign('HMAC', key, encodeUtf8(payload) as BufferSource);
|
|
56
|
+
const bytes = new Uint8Array(signature);
|
|
57
|
+
let binary = '';
|
|
58
|
+
for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]);
|
|
59
|
+
return btoa(binary);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function keyCandidates(secret: string): Uint8Array[] {
|
|
63
|
+
const stripped = stripSecretPrefix(secret);
|
|
64
|
+
const decoded = base64ToBytes(stripped);
|
|
65
|
+
const candidates: Uint8Array[] = [];
|
|
66
|
+
if (decoded && decoded.length > 0) candidates.push(decoded);
|
|
67
|
+
const raw = encodeUtf8(secret);
|
|
68
|
+
if (raw.length > 0) candidates.push(raw);
|
|
69
|
+
const rawStripped = encodeUtf8(stripped);
|
|
70
|
+
if (rawStripped.length > 0 && rawStripped.length !== raw.length) candidates.push(rawStripped);
|
|
71
|
+
return candidates;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function signaturesFromHeader(header: string): string[] {
|
|
75
|
+
return header
|
|
76
|
+
.split(',')
|
|
77
|
+
.map((part) => part.trim())
|
|
78
|
+
.filter((part) => part.length > 0)
|
|
79
|
+
.map((part) => (part.startsWith('v1,') ? part.slice(3) : part));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const DEFAULT_WEBHOOK_TOLERANCE_MS = 5 * 60 * 1000;
|
|
83
|
+
|
|
84
|
+
export interface VerifyWebhookOptions {
|
|
85
|
+
toleranceMs?: number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function verifyWebhook(
|
|
89
|
+
rawBody: string,
|
|
90
|
+
headers: WebhookHeadersLike | Record<string, string>,
|
|
91
|
+
secret: string,
|
|
92
|
+
options: VerifyWebhookOptions = {},
|
|
93
|
+
): Promise<boolean> {
|
|
94
|
+
const id = headerValue(headers, 'webhook-id');
|
|
95
|
+
const timestamp = headerValue(headers, 'webhook-timestamp');
|
|
96
|
+
const signatureHeader = headerValue(headers, 'webhook-signature');
|
|
97
|
+
if (!id || !timestamp || !signatureHeader || !secret) return false;
|
|
98
|
+
|
|
99
|
+
if (options.toleranceMs !== undefined) {
|
|
100
|
+
const age = Math.abs(Date.now() - Number(timestamp) * 1000);
|
|
101
|
+
if (!Number.isFinite(age) || age > options.toleranceMs) return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const expectedSignatures = signaturesFromHeader(signatureHeader);
|
|
105
|
+
if (expectedSignatures.length === 0) return false;
|
|
106
|
+
|
|
107
|
+
const payload = `${id}.${timestamp}.${rawBody}`;
|
|
108
|
+
for (const key of keyCandidates(secret)) {
|
|
109
|
+
const computed = await hmacSha256(key, payload);
|
|
110
|
+
for (const expected of expectedSignatures) {
|
|
111
|
+
if (timingSafeEqual(computed, expected)) return true;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function timingSafeEqual(a: string, b: string): boolean {
|
|
118
|
+
if (a.length !== b.length) return false;
|
|
119
|
+
let mismatch = 0;
|
|
120
|
+
for (let i = 0; i < a.length; i += 1) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
121
|
+
return mismatch === 0;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const verifyPolarWebhook = verifyWebhook;
|
package/src/memory.ts
ADDED
package/src/portal.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
|
|
2
|
+
import { useEffect, useState, useCallback, useMemo } from 'react';
|
|
3
|
+
import type { OrderItem } from '../../domain/entities/order.entity';
|
|
4
|
+
import { useBilling } from '../context/billing-context';
|
|
5
|
+
import { MoneyFormatter } from '../../infrastructure/utils/money-formatter';
|
|
6
|
+
import { DateRenderer } from '../../infrastructure/utils/date-renderer';
|
|
7
|
+
import { VisualTokens } from '../theme/visual-tokens';
|
|
8
|
+
|
|
9
|
+
export interface BillingHistoryListProps {
|
|
10
|
+
autoLoad?: boolean;
|
|
11
|
+
orders?: OrderItem[];
|
|
12
|
+
loading?: boolean;
|
|
13
|
+
locale?: string;
|
|
14
|
+
labels?: BillingHistoryListLabels;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface BillingHistoryListLabels {
|
|
18
|
+
loading?: string;
|
|
19
|
+
empty?: string;
|
|
20
|
+
refresh?: string;
|
|
21
|
+
retry?: string;
|
|
22
|
+
paid?: string;
|
|
23
|
+
invoiceLink?: string;
|
|
24
|
+
invoiceFallback?: string;
|
|
25
|
+
colDate?: string;
|
|
26
|
+
colProduct?: string;
|
|
27
|
+
colStatus?: string;
|
|
28
|
+
colAmount?: string;
|
|
29
|
+
colInvoice?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_LABELS: Required<BillingHistoryListLabels> = Object.freeze({
|
|
33
|
+
loading: 'Loading billing history…',
|
|
34
|
+
empty: 'No invoices yet.',
|
|
35
|
+
refresh: 'Refresh',
|
|
36
|
+
retry: 'Retry',
|
|
37
|
+
paid: 'Paid',
|
|
38
|
+
invoiceLink: 'View',
|
|
39
|
+
invoiceFallback: '—',
|
|
40
|
+
colDate: 'Date',
|
|
41
|
+
colProduct: 'Product',
|
|
42
|
+
colStatus: 'Status',
|
|
43
|
+
colAmount: 'Amount',
|
|
44
|
+
colInvoice: 'Invoice',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export function BillingHistoryList({
|
|
48
|
+
autoLoad = true,
|
|
49
|
+
orders: ordersOverride,
|
|
50
|
+
loading: loadingOverride,
|
|
51
|
+
locale,
|
|
52
|
+
labels,
|
|
53
|
+
}: BillingHistoryListProps) {
|
|
54
|
+
const { getBillingHistory } = useBilling();
|
|
55
|
+
const [orders, setOrders] = useState<OrderItem[]>(ordersOverride ?? []);
|
|
56
|
+
const [loading, setLoading] = useState(autoLoad && ordersOverride === undefined);
|
|
57
|
+
const [error, setError] = useState<string | null>(null);
|
|
58
|
+
|
|
59
|
+
const moneyFormatter = useMemo(() => new MoneyFormatter(locale), [locale]);
|
|
60
|
+
const dateRenderer = useMemo(() => new DateRenderer(locale), [locale]);
|
|
61
|
+
const text = useMemo(
|
|
62
|
+
() => ({ ...DEFAULT_LABELS, ...labels }),
|
|
63
|
+
[labels],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const refresh = useCallback(async (): Promise<void> => {
|
|
67
|
+
setLoading(true);
|
|
68
|
+
setError(null);
|
|
69
|
+
try {
|
|
70
|
+
const next = await getBillingHistory();
|
|
71
|
+
setOrders(next);
|
|
72
|
+
} catch (err) {
|
|
73
|
+
setError(err instanceof Error ? err.message : 'Failed to load billing history');
|
|
74
|
+
} finally {
|
|
75
|
+
setLoading(false);
|
|
76
|
+
}
|
|
77
|
+
}, [getBillingHistory]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (autoLoad && ordersOverride === undefined) {
|
|
81
|
+
void refresh();
|
|
82
|
+
}
|
|
83
|
+
}, [autoLoad, ordersOverride, refresh]);
|
|
84
|
+
|
|
85
|
+
if (ordersOverride !== undefined && ordersOverride.length > 0) {
|
|
86
|
+
return renderList(ordersOverride, moneyFormatter, dateRenderer, text);
|
|
87
|
+
}
|
|
88
|
+
if (loadingOverride !== undefined && loadingOverride) {
|
|
89
|
+
return <div role="status">{text.loading}</div>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (loading) {
|
|
93
|
+
return <div role="status">{text.loading}</div>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (error) {
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
role="alert"
|
|
100
|
+
style={{
|
|
101
|
+
background: VisualTokens.color.surfaceDanger,
|
|
102
|
+
padding: VisualTokens.spacing.lg,
|
|
103
|
+
borderRadius: VisualTokens.radius.md,
|
|
104
|
+
}}
|
|
105
|
+
>
|
|
106
|
+
{error}{' '}
|
|
107
|
+
<button type="button" onClick={() => void refresh()}>
|
|
108
|
+
{text.retry}
|
|
109
|
+
</button>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (orders.length === 0) {
|
|
115
|
+
return <div style={{ color: VisualTokens.color.textMuted }}>{text.empty}</div>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div>
|
|
120
|
+
<div style={{ marginBottom: VisualTokens.spacing.md }}>
|
|
121
|
+
<button type="button" onClick={() => void refresh()}>
|
|
122
|
+
{text.refresh}
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
{renderList(orders, moneyFormatter, dateRenderer, text)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderList(
|
|
131
|
+
orders: OrderItem[],
|
|
132
|
+
moneyFormatter: MoneyFormatter,
|
|
133
|
+
dateRenderer: DateRenderer,
|
|
134
|
+
text: Required<BillingHistoryListLabels>,
|
|
135
|
+
) {
|
|
136
|
+
return (
|
|
137
|
+
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
138
|
+
<thead>
|
|
139
|
+
<tr style={{ textAlign: 'left', borderBottom: `1px solid ${VisualTokens.color.borderSubtle}` }}>
|
|
140
|
+
<th style={{ padding: VisualTokens.spacing.md }}>{text.colDate}</th>
|
|
141
|
+
<th style={{ padding: VisualTokens.spacing.md }}>{text.colProduct}</th>
|
|
142
|
+
<th style={{ padding: VisualTokens.spacing.md }}>{text.colStatus}</th>
|
|
143
|
+
<th style={{ padding: VisualTokens.spacing.md }}>{text.colAmount}</th>
|
|
144
|
+
<th style={{ padding: VisualTokens.spacing.md }}>{text.colInvoice}</th>
|
|
145
|
+
</tr>
|
|
146
|
+
</thead>
|
|
147
|
+
<tbody>
|
|
148
|
+
{orders.map((order) => (
|
|
149
|
+
<tr key={order.id} style={{ borderBottom: `1px solid ${VisualTokens.color.surfaceSubtle}` }}>
|
|
150
|
+
<td style={{ padding: VisualTokens.spacing.md }}>{dateRenderer.short(order.createdAt)}</td>
|
|
151
|
+
<td style={{ padding: VisualTokens.spacing.md }}>{order.productName}</td>
|
|
152
|
+
<td style={{ padding: VisualTokens.spacing.md }}>
|
|
153
|
+
{order.paid ? text.paid : order.status}
|
|
154
|
+
</td>
|
|
155
|
+
<td style={{ padding: VisualTokens.spacing.md }}>
|
|
156
|
+
{moneyFormatter.format(order.amount, order.currency)}
|
|
157
|
+
</td>
|
|
158
|
+
<td style={{ padding: VisualTokens.spacing.md }}>
|
|
159
|
+
{order.invoiceUrl ? (
|
|
160
|
+
<a href={order.invoiceUrl} target="_blank" rel="noreferrer">
|
|
161
|
+
{text.invoiceLink}
|
|
162
|
+
</a>
|
|
163
|
+
) : (
|
|
164
|
+
text.invoiceFallback
|
|
165
|
+
)}
|
|
166
|
+
</td>
|
|
167
|
+
</tr>
|
|
168
|
+
))}
|
|
169
|
+
</tbody>
|
|
170
|
+
</table>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
|
|
2
|
+
import {
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import type { BillingAdapter } from '../../domain/interfaces';
|
|
11
|
+
import type {
|
|
12
|
+
CancellationReason,
|
|
13
|
+
CancelResult,
|
|
14
|
+
CheckoutParams,
|
|
15
|
+
OrderItem,
|
|
16
|
+
SubscriptionStatus,
|
|
17
|
+
SyncResult,
|
|
18
|
+
} from '../../domain/entities';
|
|
19
|
+
import { FREE_SUBSCRIPTION_STATUS } from '../../domain/entities/subscription.entity';
|
|
20
|
+
import {
|
|
21
|
+
isProductionInsecureUrl,
|
|
22
|
+
isValidCheckoutUrl,
|
|
23
|
+
} from '../../infrastructure/utils/security.util';
|
|
24
|
+
import { isValidProductId } from '../../infrastructure/utils/validation.util';
|
|
25
|
+
import { normalizeUserId } from '../../infrastructure/utils/validation.util';
|
|
26
|
+
import {
|
|
27
|
+
NoopNavigationStrategy,
|
|
28
|
+
type NavigationStrategy,
|
|
29
|
+
} from '../navigation/navigation.strategy';
|
|
30
|
+
import { BillingContext, type BillingContextValue } from '../context/billing-context';
|
|
31
|
+
|
|
32
|
+
export interface BillingProviderProps {
|
|
33
|
+
adapter: BillingAdapter;
|
|
34
|
+
userId?: string;
|
|
35
|
+
navigation?: NavigationStrategy;
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultNavigation(): NavigationStrategy {
|
|
40
|
+
if (typeof window === 'undefined') return new NoopNavigationStrategy();
|
|
41
|
+
return {
|
|
42
|
+
redirect(url: string): void {
|
|
43
|
+
window.location.href = url;
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function BillingProvider({
|
|
49
|
+
adapter,
|
|
50
|
+
userId,
|
|
51
|
+
navigation,
|
|
52
|
+
children,
|
|
53
|
+
}: BillingProviderProps) {
|
|
54
|
+
const [status, setStatus] = useState<SubscriptionStatus>(FREE_SUBSCRIPTION_STATUS);
|
|
55
|
+
const [loading, setLoading] = useState(true);
|
|
56
|
+
|
|
57
|
+
const adapterRef = useRef(adapter);
|
|
58
|
+
const userIdRef = useRef(normalizeUserId(userId));
|
|
59
|
+
const navigationRef = useRef<NavigationStrategy>(navigation ?? defaultNavigation());
|
|
60
|
+
const refreshGenerationRef = useRef(0);
|
|
61
|
+
|
|
62
|
+
adapterRef.current = adapter;
|
|
63
|
+
userIdRef.current = normalizeUserId(userId);
|
|
64
|
+
navigationRef.current = navigation ?? navigationRef.current;
|
|
65
|
+
|
|
66
|
+
const refresh = useCallback(async (): Promise<void> => {
|
|
67
|
+
const generation = ++refreshGenerationRef.current;
|
|
68
|
+
const uid = userIdRef.current;
|
|
69
|
+
if (!uid) {
|
|
70
|
+
setStatus(FREE_SUBSCRIPTION_STATUS);
|
|
71
|
+
setLoading(false);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setLoading(true);
|
|
76
|
+
try {
|
|
77
|
+
const next = await adapterRef.current.getStatus(uid);
|
|
78
|
+
if (generation !== refreshGenerationRef.current) return;
|
|
79
|
+
setStatus(next);
|
|
80
|
+
} catch {
|
|
81
|
+
if (generation !== refreshGenerationRef.current) return;
|
|
82
|
+
setStatus(FREE_SUBSCRIPTION_STATUS);
|
|
83
|
+
} finally {
|
|
84
|
+
if (generation === refreshGenerationRef.current) {
|
|
85
|
+
setLoading(false);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
void refresh();
|
|
92
|
+
}, [userId, refresh]);
|
|
93
|
+
|
|
94
|
+
const startCheckout = useCallback(async (params: CheckoutParams): Promise<void> => {
|
|
95
|
+
if (!isValidProductId(params.productId)) {
|
|
96
|
+
throw new Error('[billing] Invalid productId: must be a non-empty string');
|
|
97
|
+
}
|
|
98
|
+
const uid = userIdRef.current;
|
|
99
|
+
if (!uid) {
|
|
100
|
+
throw new Error('[billing] Cannot start checkout: no authenticated user');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const result = await adapterRef.current.createCheckout({ ...params, userId: uid });
|
|
104
|
+
if (!isValidCheckoutUrl(result.url)) {
|
|
105
|
+
throw new Error('[billing] Invalid checkout URL: must start with https:// or http://');
|
|
106
|
+
}
|
|
107
|
+
if (isProductionInsecureUrl(result.url)) {
|
|
108
|
+
throw new Error('[billing] Insecure http:// checkout URL in production');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
navigationRef.current.redirect(result.url);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const syncSubscription = useCallback(async (): Promise<SyncResult> => {
|
|
115
|
+
const uid = userIdRef.current;
|
|
116
|
+
if (!uid) return { synced: false };
|
|
117
|
+
|
|
118
|
+
const checkoutId =
|
|
119
|
+
typeof window !== 'undefined'
|
|
120
|
+
? new URLSearchParams(window.location.search).get('checkout_id') ?? undefined
|
|
121
|
+
: undefined;
|
|
122
|
+
|
|
123
|
+
const result = await adapterRef.current.syncSubscription(uid, checkoutId);
|
|
124
|
+
if (result.synced) {
|
|
125
|
+
await refresh();
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}, [refresh]);
|
|
129
|
+
|
|
130
|
+
const getBillingHistory = useCallback(async (): Promise<OrderItem[]> => {
|
|
131
|
+
const uid = userIdRef.current;
|
|
132
|
+
if (!uid) return [];
|
|
133
|
+
return adapterRef.current.getBillingHistory(uid);
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
const cancelSubscription = useCallback(
|
|
137
|
+
async (reason?: CancellationReason): Promise<CancelResult> => {
|
|
138
|
+
const result = await adapterRef.current.cancelSubscription(reason);
|
|
139
|
+
if (result.success) {
|
|
140
|
+
await refresh();
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
},
|
|
144
|
+
[refresh],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const getPortalUrl = useCallback(async (): Promise<string> => {
|
|
148
|
+
const uid = userIdRef.current;
|
|
149
|
+
if (!uid) {
|
|
150
|
+
throw new Error('[billing] Cannot get portal URL: no authenticated user');
|
|
151
|
+
}
|
|
152
|
+
return adapterRef.current.getPortalUrl(uid);
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const value = useMemo<BillingContextValue>(
|
|
156
|
+
() => ({
|
|
157
|
+
status,
|
|
158
|
+
loading,
|
|
159
|
+
refresh,
|
|
160
|
+
startCheckout,
|
|
161
|
+
syncSubscription,
|
|
162
|
+
getBillingHistory,
|
|
163
|
+
cancelSubscription,
|
|
164
|
+
getPortalUrl,
|
|
165
|
+
}),
|
|
166
|
+
[
|
|
167
|
+
status,
|
|
168
|
+
loading,
|
|
169
|
+
refresh,
|
|
170
|
+
startCheckout,
|
|
171
|
+
syncSubscription,
|
|
172
|
+
getBillingHistory,
|
|
173
|
+
cancelSubscription,
|
|
174
|
+
getPortalUrl,
|
|
175
|
+
],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return <BillingContext.Provider value={value}>{children}</BillingContext.Provider>;
|
|
179
|
+
}
|