@umituz/web-payment 0.0.0-stage → 1.0.1
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
package/src/http.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
|
|
2
|
+
export * from './core';
|
|
3
|
+
export * from './products';
|
|
4
|
+
export * from './react';
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
checkoutIdFromLocationSearch,
|
|
8
|
+
isSafeCheckoutUrl,
|
|
9
|
+
redirectToHostedCheckout,
|
|
10
|
+
} from './infrastructure/browser/hosted-checkout';
|
|
11
|
+
|
|
12
|
+
export { CHECKOUT_ID_PARAM } from './infrastructure/config/billing-env';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CHECKOUT_ID_PARAM } from '../config/billing-env';
|
|
2
|
+
import { isInsecureUrl, isLoopbackHost } from '../utils/security.util';
|
|
3
|
+
|
|
4
|
+
export function isSafeCheckoutUrl(url: string): boolean {
|
|
5
|
+
if (typeof url !== 'string' || url.length === 0) return false;
|
|
6
|
+
if (url.startsWith('https://')) return true;
|
|
7
|
+
return isInsecureUrl(url) && isLoopbackHost(url);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function redirectToHostedCheckout(url: string): void {
|
|
11
|
+
if (!isSafeCheckoutUrl(url)) throw new Error('invalid_checkout_url');
|
|
12
|
+
window.location.assign(url);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function checkoutIdFromLocationSearch(search: string): string | null {
|
|
16
|
+
const withoutLeading = search.startsWith('?') ? search.slice(1) : search;
|
|
17
|
+
for (const pair of withoutLeading.split('&')) {
|
|
18
|
+
const separator = pair.indexOf('=');
|
|
19
|
+
if (separator === -1) continue;
|
|
20
|
+
if (pair.slice(0, separator) !== CHECKOUT_ID_PARAM) continue;
|
|
21
|
+
const value = pair.slice(separator + 1);
|
|
22
|
+
if (value.length === 0) return null;
|
|
23
|
+
return decodeURIComponent(value);
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
|
|
2
|
+
export const CHECKOUT_ID_PARAM = 'checkout_id';
|
|
3
|
+
|
|
4
|
+
export const CHECKOUT_ID_PLACEHOLDER = '{CHECKOUT_ID}';
|
|
5
|
+
|
|
6
|
+
export type ProductIdMap = Map<string, string>;
|
|
7
|
+
|
|
8
|
+
export function parseProductIds(raw: string | undefined): ProductIdMap {
|
|
9
|
+
const map: ProductIdMap = new Map();
|
|
10
|
+
if (typeof raw !== 'string' || raw.length === 0) return map;
|
|
11
|
+
let parsed: unknown;
|
|
12
|
+
try {
|
|
13
|
+
parsed = JSON.parse(raw);
|
|
14
|
+
} catch {
|
|
15
|
+
return map;
|
|
16
|
+
}
|
|
17
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return map;
|
|
18
|
+
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
|
19
|
+
if (typeof value === 'string' && value.length > 0) map.set(key, value);
|
|
20
|
+
}
|
|
21
|
+
return map;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function productIdOf(ids: ProductIdMap, productKey: string): string | null {
|
|
25
|
+
return ids.get(productKey) ?? null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function successUrlWithCheckoutId(origin: string, returnPath: string): string {
|
|
29
|
+
const separator = returnPath.includes('?') ? '&' : '?';
|
|
30
|
+
return `${origin}${returnPath}${separator}${CHECKOUT_ID_PARAM}=${CHECKOUT_ID_PLACEHOLDER}`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
|
|
2
|
+
export {
|
|
3
|
+
SUBSCRIPTION_STATUS,
|
|
4
|
+
SUBSCRIPTION_STATUS_VALUES,
|
|
5
|
+
isKnownSubscriptionStatus,
|
|
6
|
+
} from '../../domain/enums/subscription-status.enum';
|
|
7
|
+
export type { SubscriptionStatusValue } from '../../domain/enums/subscription-status.enum';
|
|
8
|
+
|
|
9
|
+
export const API_BASE_URL = 'https://api.polar.sh/v1';
|
|
10
|
+
export const API_MAX_PAGE_SIZE = 100;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
|
|
2
|
+
export interface HttpRequest {
|
|
3
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
4
|
+
url: string;
|
|
5
|
+
headers?: Record<string, string>;
|
|
6
|
+
body?: unknown;
|
|
7
|
+
signal?: AbortSignal;
|
|
8
|
+
timeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface HttpResponse<T> {
|
|
12
|
+
status: number;
|
|
13
|
+
ok: boolean;
|
|
14
|
+
data: T | null;
|
|
15
|
+
text(): Promise<string>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface HttpClient {
|
|
19
|
+
request<T>(request: HttpRequest): Promise<HttpResponse<T>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
23
|
+
|
|
24
|
+
export class FetchHttpClient implements HttpClient {
|
|
25
|
+
async request<T>(request: HttpRequest): Promise<HttpResponse<T>> {
|
|
26
|
+
const init: RequestInit = {
|
|
27
|
+
method: request.method,
|
|
28
|
+
headers: {
|
|
29
|
+
Accept: 'application/json',
|
|
30
|
+
...(request.headers ?? {}),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
if (request.body !== undefined) {
|
|
34
|
+
(init as RequestInit & { body: string }).body = JSON.stringify(request.body);
|
|
35
|
+
(init.headers as Record<string, string>)['Content-Type'] = 'application/json';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const timeoutMs = request.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
let timedOut = false;
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
timedOut = true;
|
|
43
|
+
controller.abort();
|
|
44
|
+
}, timeoutMs);
|
|
45
|
+
const forwardAbort = () => controller.abort();
|
|
46
|
+
if (request.signal) {
|
|
47
|
+
if (request.signal.aborted) controller.abort();
|
|
48
|
+
else request.signal.addEventListener('abort', forwardAbort);
|
|
49
|
+
}
|
|
50
|
+
init.signal = controller.signal;
|
|
51
|
+
|
|
52
|
+
let response: Response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(request.url, init);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (timedOut && error instanceof Error && error.name === 'AbortError') {
|
|
57
|
+
throw new DOMException(`Request timed out after ${timeoutMs}ms`, 'TimeoutError');
|
|
58
|
+
}
|
|
59
|
+
throw error;
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer);
|
|
62
|
+
request.signal?.removeEventListener('abort', forwardAbort);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const bodyText = await response.text();
|
|
66
|
+
|
|
67
|
+
let data: T | null = null;
|
|
68
|
+
if (bodyText.length > 0) {
|
|
69
|
+
try {
|
|
70
|
+
data = JSON.parse(bodyText) as T;
|
|
71
|
+
} catch {
|
|
72
|
+
data = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
status: response.status,
|
|
78
|
+
ok: response.ok,
|
|
79
|
+
data,
|
|
80
|
+
text: async () => bodyText,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
|
|
2
|
+
export interface Page<T> {
|
|
3
|
+
items: T[];
|
|
4
|
+
page: number;
|
|
5
|
+
totalCount: number;
|
|
6
|
+
maxPage: number;
|
|
7
|
+
isLast: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PageFetcher<T> {
|
|
11
|
+
fetchPage(page: number, limit: number): Promise<Page<T>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PageIteratorOptions {
|
|
15
|
+
limit?: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class PageIterator<T> implements AsyncIterable<T> {
|
|
20
|
+
private readonly fetcher: PageFetcher<T>;
|
|
21
|
+
private readonly limit: number;
|
|
22
|
+
private readonly signal?: AbortSignal;
|
|
23
|
+
|
|
24
|
+
constructor(fetcher: PageFetcher<T>, options: PageIteratorOptions = {}) {
|
|
25
|
+
this.fetcher = fetcher;
|
|
26
|
+
this.limit = options.limit ?? 100;
|
|
27
|
+
this.signal = options.signal;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async *[Symbol.asyncIterator](): AsyncIterator<T> {
|
|
31
|
+
let page = 1;
|
|
32
|
+
while (true) {
|
|
33
|
+
if (this.signal?.aborted) return;
|
|
34
|
+
const current = await this.fetcher.fetchPage(page, this.limit);
|
|
35
|
+
for (const item of current.items) {
|
|
36
|
+
if (this.signal?.aborted) return;
|
|
37
|
+
yield item;
|
|
38
|
+
}
|
|
39
|
+
if (current.isLast || page >= current.maxPage) return;
|
|
40
|
+
page++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
export class QueryBuilder {
|
|
3
|
+
private readonly params: URLSearchParams;
|
|
4
|
+
|
|
5
|
+
constructor(initial?: Record<string, string | number | boolean | undefined | null>) {
|
|
6
|
+
this.params = new URLSearchParams();
|
|
7
|
+
if (initial) this.setMany(initial);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
set(key: string, value: string | number | boolean | undefined | null): this {
|
|
11
|
+
if (value === undefined || value === null) return this;
|
|
12
|
+
this.params.set(key, String(value));
|
|
13
|
+
return this;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
setMany(values: Record<string, string | number | boolean | undefined | null>): this {
|
|
17
|
+
for (const [key, value] of Object.entries(values)) {
|
|
18
|
+
this.set(key, value);
|
|
19
|
+
}
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
toString(): string {
|
|
24
|
+
return this.params.toString();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import type { PaymentProvider } from '../../domain/enums/payment-provider.enum';
|
|
2
|
+
import { API_BASE_URL } from '../constants/billing.constants';
|
|
3
|
+
import { HttpClient, FetchHttpClient } from '../http/http-client';
|
|
4
|
+
import { BillingApiError } from './product.repository';
|
|
5
|
+
|
|
6
|
+
export const SANDBOX_API_BASE_URL = 'https://sandbox-api.polar.sh/v1';
|
|
7
|
+
|
|
8
|
+
export interface PaymentServerClient {
|
|
9
|
+
readonly provider: PaymentProvider;
|
|
10
|
+
createProduct?(params: CreateProductParams): Promise<CreatedProduct>;
|
|
11
|
+
createCheckout(params: CheckoutSessionParams): Promise<CheckoutSession>;
|
|
12
|
+
getCheckout(id: string): Promise<CheckoutSession>;
|
|
13
|
+
createPortalSession?(customerId: string, returnUrl?: string): Promise<PortalSession>;
|
|
14
|
+
getSubscription?(id: string): Promise<SubscriptionRecord>;
|
|
15
|
+
listSubscriptions?(filter?: { customerId?: string; productId?: string }): Promise<SubscriptionRecord[]>;
|
|
16
|
+
updateSubscription?(id: string, patch: { cancelAtPeriodEnd?: boolean }): Promise<SubscriptionRecord>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BillingClientConfig {
|
|
20
|
+
apiToken: string;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
sandbox?: boolean;
|
|
23
|
+
http?: HttpClient;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CheckoutMetadata = Record<string, string | number | boolean>;
|
|
27
|
+
|
|
28
|
+
export interface CheckoutSessionParams {
|
|
29
|
+
productId: string;
|
|
30
|
+
successUrl?: string;
|
|
31
|
+
customerId?: string;
|
|
32
|
+
customerEmail?: string;
|
|
33
|
+
externalCustomerId?: string;
|
|
34
|
+
amountCents?: number;
|
|
35
|
+
currency?: string;
|
|
36
|
+
allowDiscountCodes?: boolean;
|
|
37
|
+
metadata?: CheckoutMetadata;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CheckoutSession {
|
|
41
|
+
id: string;
|
|
42
|
+
url: string;
|
|
43
|
+
status?: string;
|
|
44
|
+
customerId?: string;
|
|
45
|
+
customerEmail?: string;
|
|
46
|
+
subscriptionId?: string | null;
|
|
47
|
+
metadata?: CheckoutMetadata;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface SubscriptionRecord {
|
|
51
|
+
id: string;
|
|
52
|
+
status: string;
|
|
53
|
+
productId: string;
|
|
54
|
+
amountCents: number;
|
|
55
|
+
currency: string;
|
|
56
|
+
currentPeriodStart?: string;
|
|
57
|
+
currentPeriodEnd?: string;
|
|
58
|
+
cancelAtPeriodEnd: boolean;
|
|
59
|
+
canceledAt?: string | null;
|
|
60
|
+
endsAt?: string | null;
|
|
61
|
+
startedAt?: string | null;
|
|
62
|
+
customerId: string;
|
|
63
|
+
metadata: Record<string, string>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PortalSession {
|
|
67
|
+
url: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface CreateProductParams {
|
|
71
|
+
name: string;
|
|
72
|
+
description?: string;
|
|
73
|
+
amountCents: number;
|
|
74
|
+
currency?: string;
|
|
75
|
+
metadata?: CheckoutMetadata;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CreatedProduct {
|
|
79
|
+
id: string;
|
|
80
|
+
name: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ProductDto {
|
|
84
|
+
id: string;
|
|
85
|
+
name?: string | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface CheckoutDto {
|
|
89
|
+
id: string;
|
|
90
|
+
url?: string;
|
|
91
|
+
status?: string;
|
|
92
|
+
customer_id?: string | null;
|
|
93
|
+
customer_email?: string | null;
|
|
94
|
+
subscription_id?: string | null;
|
|
95
|
+
metadata?: CheckoutMetadata | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface SubscriptionDto {
|
|
99
|
+
id: string;
|
|
100
|
+
status: string;
|
|
101
|
+
product_id: string;
|
|
102
|
+
amount: number;
|
|
103
|
+
currency: string;
|
|
104
|
+
current_period_start?: string | null;
|
|
105
|
+
current_period_end?: string | null;
|
|
106
|
+
cancel_at_period_end: boolean;
|
|
107
|
+
canceled_at?: string | null;
|
|
108
|
+
ends_at?: string | null;
|
|
109
|
+
started_at?: string | null;
|
|
110
|
+
customer_id?: string | null;
|
|
111
|
+
metadata?: Record<string, string> | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface SubscriptionListDto {
|
|
115
|
+
items?: SubscriptionDto[];
|
|
116
|
+
pagination?: { max_page?: number; current_page?: number; total_count?: number };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveBaseUrl(config: BillingClientConfig): string {
|
|
120
|
+
if (config.baseUrl) return config.baseUrl;
|
|
121
|
+
return config.sandbox ? SANDBOX_API_BASE_URL : API_BASE_URL;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mapCheckout(dto: CheckoutDto): CheckoutSession {
|
|
125
|
+
return {
|
|
126
|
+
id: dto.id,
|
|
127
|
+
url: dto.url ?? '',
|
|
128
|
+
status: dto.status,
|
|
129
|
+
customerId: dto.customer_id ?? undefined,
|
|
130
|
+
customerEmail: dto.customer_email ?? undefined,
|
|
131
|
+
subscriptionId: dto.subscription_id ?? null,
|
|
132
|
+
metadata: dto.metadata ?? undefined,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function mapSubscription(dto: SubscriptionDto): SubscriptionRecord {
|
|
137
|
+
return {
|
|
138
|
+
id: dto.id,
|
|
139
|
+
status: dto.status,
|
|
140
|
+
productId: dto.product_id,
|
|
141
|
+
amountCents: dto.amount,
|
|
142
|
+
currency: dto.currency,
|
|
143
|
+
currentPeriodStart: dto.current_period_start ?? undefined,
|
|
144
|
+
currentPeriodEnd: dto.current_period_end ?? undefined,
|
|
145
|
+
cancelAtPeriodEnd: dto.cancel_at_period_end,
|
|
146
|
+
canceledAt: dto.canceled_at ?? null,
|
|
147
|
+
endsAt: dto.ends_at ?? null,
|
|
148
|
+
startedAt: dto.started_at ?? undefined,
|
|
149
|
+
customerId: dto.customer_id ?? '',
|
|
150
|
+
metadata: dto.metadata ?? {},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export class PolarBillingClient implements PaymentServerClient {
|
|
155
|
+
readonly provider = 'polar' as const;
|
|
156
|
+
private readonly http: HttpClient;
|
|
157
|
+
private readonly baseUrl: string;
|
|
158
|
+
private readonly headers: Record<string, string>;
|
|
159
|
+
|
|
160
|
+
constructor(config: BillingClientConfig) {
|
|
161
|
+
this.http = config.http ?? new FetchHttpClient();
|
|
162
|
+
this.baseUrl = resolveBaseUrl(config).replace(/\/$/, '');
|
|
163
|
+
this.headers = { Authorization: `Bearer ${config.apiToken}` };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async request<T>(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
|
|
167
|
+
const response = await this.http.request<T>({
|
|
168
|
+
method,
|
|
169
|
+
url: `${this.baseUrl}${path}`,
|
|
170
|
+
headers: this.headers,
|
|
171
|
+
body,
|
|
172
|
+
});
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new BillingApiError(
|
|
175
|
+
`Billing API ${method} ${path} failed with ${response.status}`,
|
|
176
|
+
response.status,
|
|
177
|
+
await response.text(),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
return (response.data ?? {}) as T;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async createProduct(params: CreateProductParams): Promise<CreatedProduct> {
|
|
184
|
+
const dto = await this.request<ProductDto>('POST', '/products', {
|
|
185
|
+
name: params.name,
|
|
186
|
+
recurring_interval: null,
|
|
187
|
+
...(params.description ? { description: params.description } : {}),
|
|
188
|
+
prices: [
|
|
189
|
+
{
|
|
190
|
+
amount_type: 'fixed',
|
|
191
|
+
price_currency: params.currency ?? 'usd',
|
|
192
|
+
price_amount: params.amountCents,
|
|
193
|
+
},
|
|
194
|
+
],
|
|
195
|
+
...(params.metadata ? { metadata: params.metadata } : {}),
|
|
196
|
+
});
|
|
197
|
+
return { id: dto.id, name: dto.name ?? params.name };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async createCheckout(params: CheckoutSessionParams): Promise<CheckoutSession> {
|
|
201
|
+
const dto = await this.request<CheckoutDto>('POST', '/checkouts', {
|
|
202
|
+
products: [params.productId],
|
|
203
|
+
...(params.amountCents !== undefined
|
|
204
|
+
? {
|
|
205
|
+
prices: {
|
|
206
|
+
[params.productId]: [
|
|
207
|
+
{
|
|
208
|
+
amount_type: 'fixed',
|
|
209
|
+
price_currency: params.currency ?? 'usd',
|
|
210
|
+
price_amount: params.amountCents,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
: {}),
|
|
216
|
+
...(params.successUrl ? { success_url: params.successUrl } : {}),
|
|
217
|
+
...(params.customerId ? { customer_id: params.customerId } : {}),
|
|
218
|
+
...(params.customerEmail ? { customer_email: params.customerEmail } : {}),
|
|
219
|
+
...(params.externalCustomerId ? { external_customer_id: params.externalCustomerId } : {}),
|
|
220
|
+
...(params.allowDiscountCodes !== undefined
|
|
221
|
+
? { allow_discount_codes: params.allowDiscountCodes }
|
|
222
|
+
: {}),
|
|
223
|
+
...(params.metadata ? { metadata: params.metadata } : {}),
|
|
224
|
+
});
|
|
225
|
+
return mapCheckout(dto);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async getCheckout(id: string): Promise<CheckoutSession> {
|
|
229
|
+
const dto = await this.request<CheckoutDto>('GET', `/checkouts/${encodeURIComponent(id)}`);
|
|
230
|
+
return mapCheckout(dto);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async createPortalSession(customerId: string, returnUrl?: string): Promise<PortalSession> {
|
|
234
|
+
const dto = await this.request<{ customer_portal_url?: string }>(
|
|
235
|
+
'POST',
|
|
236
|
+
'/customer-sessions',
|
|
237
|
+
{
|
|
238
|
+
customer_id: customerId,
|
|
239
|
+
...(returnUrl ? { return_url: returnUrl } : {}),
|
|
240
|
+
},
|
|
241
|
+
);
|
|
242
|
+
const url = dto.customer_portal_url;
|
|
243
|
+
if (!url) {
|
|
244
|
+
throw new BillingApiError('Billing customer portal session returned no URL', 500, JSON.stringify(dto));
|
|
245
|
+
}
|
|
246
|
+
return { url };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async getSubscription(id: string): Promise<SubscriptionRecord> {
|
|
250
|
+
const dto = await this.request<SubscriptionDto>('GET', `/subscriptions/${encodeURIComponent(id)}`);
|
|
251
|
+
return mapSubscription(dto);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async listSubscriptions(filter: { customerId?: string; productId?: string } = {}): Promise<SubscriptionRecord[]> {
|
|
255
|
+
const query = new URLSearchParams();
|
|
256
|
+
if (filter.customerId) query.set('customer_id', filter.customerId);
|
|
257
|
+
if (filter.productId) query.set('product_id', filter.productId);
|
|
258
|
+
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
259
|
+
const first = await this.request<SubscriptionListDto>('GET', `/subscriptions${suffix}`);
|
|
260
|
+
const records: SubscriptionDto[] = [...(first.items ?? [])];
|
|
261
|
+
const maxPage = typeof first.pagination?.max_page === 'number' ? first.pagination.max_page : 1;
|
|
262
|
+
for (let page = 2; page <= maxPage; page += 1) {
|
|
263
|
+
const next = await this.request<SubscriptionListDto>(
|
|
264
|
+
'GET',
|
|
265
|
+
`/subscriptions${suffix}${suffix ? '&' : '?'}page=${page}`,
|
|
266
|
+
);
|
|
267
|
+
records.push(...(next.items ?? []));
|
|
268
|
+
}
|
|
269
|
+
return records.map(mapSubscription);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async updateSubscription(
|
|
273
|
+
id: string,
|
|
274
|
+
patch: { cancelAtPeriodEnd?: boolean },
|
|
275
|
+
): Promise<SubscriptionRecord> {
|
|
276
|
+
const body = patch.cancelAtPeriodEnd !== undefined
|
|
277
|
+
? { cancel_at_period_end: patch.cancelAtPeriodEnd }
|
|
278
|
+
: undefined;
|
|
279
|
+
const dto = await this.request<SubscriptionDto>(
|
|
280
|
+
'PATCH',
|
|
281
|
+
`/subscriptions/${encodeURIComponent(id)}`,
|
|
282
|
+
body,
|
|
283
|
+
);
|
|
284
|
+
return mapSubscription(dto);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export const BillingClient = PolarBillingClient;
|
|
289
|
+
export type BillingClient = PolarBillingClient;
|
|
290
|
+
|
|
291
|
+
export type PaymentClientConfig =
|
|
292
|
+
| ({ provider?: 'polar' } & BillingClientConfig)
|
|
293
|
+
| { provider: 'stripe'; apiKey: string; [key: string]: unknown };
|
|
294
|
+
|
|
295
|
+
export function createPaymentClient(config: PaymentClientConfig): PaymentServerClient {
|
|
296
|
+
if (config.provider === 'stripe') {
|
|
297
|
+
throw new Error('Stripe provider client is not yet supported. Use provider "polar" or custom adapter.');
|
|
298
|
+
}
|
|
299
|
+
return new PolarBillingClient(config);
|
|
300
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { BillingClient, PolarBillingClient } from './billing.client';
|
|
2
|
+
|
|
3
|
+
export function sandboxEnabled(raw: string | boolean | undefined): boolean {
|
|
4
|
+
if (typeof raw === 'boolean') return raw;
|
|
5
|
+
return raw === 'true';
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createPolarClient(config: {
|
|
9
|
+
apiToken: string | undefined;
|
|
10
|
+
sandbox?: string | boolean | undefined;
|
|
11
|
+
}): PolarBillingClient {
|
|
12
|
+
return new PolarBillingClient({
|
|
13
|
+
apiToken: config.apiToken ?? '',
|
|
14
|
+
...(sandboxEnabled(config.sandbox) ? { sandbox: true } : {}),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createBillingClient(config: {
|
|
19
|
+
apiToken: string | undefined;
|
|
20
|
+
sandbox?: string | boolean | undefined;
|
|
21
|
+
}): BillingClient {
|
|
22
|
+
return createPolarClient(config);
|
|
23
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
|
|
2
|
+
import type { BillingAdapter } from '../../../domain/interfaces';
|
|
3
|
+
import type {
|
|
4
|
+
CancellationReason,
|
|
5
|
+
CancelResult,
|
|
6
|
+
CheckoutParams,
|
|
7
|
+
CheckoutResult,
|
|
8
|
+
OrderItem,
|
|
9
|
+
SubscriptionStatus,
|
|
10
|
+
SyncResult,
|
|
11
|
+
} from '../../../domain/entities';
|
|
12
|
+
|
|
13
|
+
export interface HttpAdapterConfig {
|
|
14
|
+
baseUrl?: string;
|
|
15
|
+
fetchImpl?: typeof fetch;
|
|
16
|
+
getUserId?: () => string | undefined | Promise<string | undefined>;
|
|
17
|
+
getHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ErrorEnvelope {
|
|
21
|
+
error?: string | { message?: string };
|
|
22
|
+
message?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function errorMessage(status: number, body: unknown): string {
|
|
26
|
+
const envelope = (body ?? {}) as ErrorEnvelope;
|
|
27
|
+
const errorDetail = typeof envelope.error === 'string' ? envelope.error : envelope.error?.message;
|
|
28
|
+
const detail = errorDetail ?? envelope.message ?? 'Billing request failed';
|
|
29
|
+
return `${detail} (HTTP ${status})`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createHttpAdapter(config: HttpAdapterConfig = {}): BillingAdapter {
|
|
33
|
+
const baseUrl = (config.baseUrl ?? '/api/billing').replace(/\/$/, '');
|
|
34
|
+
const doFetch = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
35
|
+
|
|
36
|
+
async function request<T>(
|
|
37
|
+
method: 'GET' | 'POST',
|
|
38
|
+
path: string,
|
|
39
|
+
body?: Record<string, unknown>,
|
|
40
|
+
explicitUserId?: string,
|
|
41
|
+
): Promise<T> {
|
|
42
|
+
const userId = explicitUserId || (config.getUserId ? await config.getUserId() : undefined);
|
|
43
|
+
const headers: Record<string, string> = {
|
|
44
|
+
Accept: 'application/json',
|
|
45
|
+
...(config.getHeaders ? await config.getHeaders() : {}),
|
|
46
|
+
};
|
|
47
|
+
const withUser = { ...(userId ? { userId } : {}), ...(body ?? {}) };
|
|
48
|
+
if (Object.keys(withUser).length > 0) headers['Content-Type'] = 'application/json';
|
|
49
|
+
|
|
50
|
+
const query = userId && method === 'GET' ? `?userId=${encodeURIComponent(userId)}` : '';
|
|
51
|
+
const response = await doFetch(`${baseUrl}${path}${query}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers,
|
|
54
|
+
...(method === 'POST' && Object.keys(withUser).length > 0 ? { body: JSON.stringify(withUser) } : {}),
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
let data: unknown = null;
|
|
58
|
+
const text = await response.text();
|
|
59
|
+
if (text.length > 0) {
|
|
60
|
+
try {
|
|
61
|
+
data = JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
data = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(errorMessage(response.status, data));
|
|
68
|
+
}
|
|
69
|
+
return data as T;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
getStatus(userId: string) {
|
|
74
|
+
return request<SubscriptionStatus>('GET', '/status', undefined, userId);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
createCheckout(params: CheckoutParams) {
|
|
78
|
+
return request<CheckoutResult>(
|
|
79
|
+
'POST',
|
|
80
|
+
'/checkout',
|
|
81
|
+
{
|
|
82
|
+
...(params.productId ? { productId: params.productId } : {}),
|
|
83
|
+
...(params.productKey ? { productKey: params.productKey } : {}),
|
|
84
|
+
...(params.itemId ? { itemId: params.itemId } : {}),
|
|
85
|
+
...(params.couponCode ? { couponCode: params.couponCode } : {}),
|
|
86
|
+
...(params.billingCycle ? { billingCycle: params.billingCycle } : {}),
|
|
87
|
+
...(params.successUrl ? { successUrl: params.successUrl } : {}),
|
|
88
|
+
...(params.planKey ? { planKey: params.planKey } : {}),
|
|
89
|
+
...(params.customerEmail ? { customerEmail: params.customerEmail } : {}),
|
|
90
|
+
...(params.seats !== undefined ? { seats: params.seats } : {}),
|
|
91
|
+
...(params.metadata ? { metadata: params.metadata } : {}),
|
|
92
|
+
},
|
|
93
|
+
params.userId,
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
syncSubscription(userId: string, checkoutId?: string) {
|
|
98
|
+
return request<SyncResult>('POST', '/sync', checkoutId ? { checkoutId } : undefined, userId);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async getBillingHistory(userId: string) {
|
|
102
|
+
const result = await request<OrderItem[] | { items?: OrderItem[]; orders?: OrderItem[] }>(
|
|
103
|
+
'GET',
|
|
104
|
+
'/history',
|
|
105
|
+
undefined,
|
|
106
|
+
userId,
|
|
107
|
+
);
|
|
108
|
+
if (Array.isArray(result)) return result;
|
|
109
|
+
return result.orders ?? result.items ?? [];
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
cancelSubscription(reason?: CancellationReason) {
|
|
113
|
+
return request<CancelResult>('POST', '/cancel', {
|
|
114
|
+
...(reason ? { reason } : {}),
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async getPortalUrl(userId: string) {
|
|
119
|
+
const result = await request<string | { url: string }>('GET', '/portal', undefined, userId);
|
|
120
|
+
return typeof result === 'string' ? result : result.url;
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|