@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.
Files changed (150) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -2
  3. package/dist/billing-adapter.interface-CDkeeowZ.d.mts +94 -0
  4. package/dist/billing-adapter.interface-m7yE50I0.d.ts +94 -0
  5. package/dist/billing-cycle.enum--hJRYlgP.d.mts +10 -0
  6. package/dist/billing-cycle.enum--hJRYlgP.d.ts +10 -0
  7. package/dist/billing-env-D4YjvA3M.d.mts +8 -0
  8. package/dist/billing-env-D4YjvA3M.d.ts +8 -0
  9. package/dist/checkout-metadata-D7Wp_DAD.d.mts +27 -0
  10. package/dist/checkout-metadata-D7Wp_DAD.d.ts +27 -0
  11. package/dist/chunk-2KV7JFYF.mjs +61 -0
  12. package/dist/chunk-2NH5DOLT.mjs +101 -0
  13. package/dist/chunk-3AXPZOCV.mjs +106 -0
  14. package/dist/chunk-4N3BASZ6.mjs +51 -0
  15. package/dist/chunk-NHKK3ENU.mjs +1128 -0
  16. package/dist/chunk-PIWNUAKW.mjs +30 -0
  17. package/dist/chunk-QFB7AOSQ.mjs +33 -0
  18. package/dist/chunk-RQTMBR74.mjs +30 -0
  19. package/dist/chunk-U45FS4FF.mjs +110 -0
  20. package/dist/chunk-X6R76FZT.mjs +0 -0
  21. package/dist/chunk-XQ2HJTTA.mjs +252 -0
  22. package/dist/core.d.mts +17 -0
  23. package/dist/core.d.ts +17 -0
  24. package/dist/core.js +425 -0
  25. package/dist/core.mjs +66 -0
  26. package/dist/http.d.mts +12 -0
  27. package/dist/http.d.ts +12 -0
  28. package/dist/http.js +115 -0
  29. package/dist/http.mjs +88 -0
  30. package/dist/index.d.mts +17 -0
  31. package/dist/index.d.ts +17 -0
  32. package/dist/index.js +1929 -0
  33. package/dist/index.mjs +153 -0
  34. package/dist/memory.d.mts +14 -0
  35. package/dist/memory.d.ts +14 -0
  36. package/dist/memory.js +130 -0
  37. package/dist/memory.mjs +6 -0
  38. package/dist/portal.d.mts +445 -0
  39. package/dist/portal.d.ts +445 -0
  40. package/dist/portal.js +2927 -0
  41. package/dist/portal.mjs +2737 -0
  42. package/dist/product.mapper-CnY1ISOp.d.ts +97 -0
  43. package/dist/product.mapper-noVl-sel.d.mts +97 -0
  44. package/dist/products.d.mts +64 -0
  45. package/dist/products.d.ts +64 -0
  46. package/dist/products.js +370 -0
  47. package/dist/products.mjs +21 -0
  48. package/dist/react.d.mts +182 -0
  49. package/dist/react.d.ts +182 -0
  50. package/dist/react.js +1874 -0
  51. package/dist/react.mjs +105 -0
  52. package/dist/server.d.mts +177 -0
  53. package/dist/server.d.ts +177 -0
  54. package/dist/server.js +622 -0
  55. package/dist/server.mjs +417 -0
  56. package/dist/visual-tokens-BCLVR85G.d.mts +94 -0
  57. package/dist/visual-tokens-BCLVR85G.d.ts +94 -0
  58. package/package.json +122 -4
  59. package/src/core.ts +81 -0
  60. package/src/domain/entities/cancellation.entity.ts +28 -0
  61. package/src/domain/entities/checkout.entity.ts +21 -0
  62. package/src/domain/entities/index.ts +10 -0
  63. package/src/domain/entities/order.entity.ts +15 -0
  64. package/src/domain/entities/product.entity.ts +16 -0
  65. package/src/domain/entities/subscription.entity.ts +24 -0
  66. package/src/domain/entities/sync.entity.ts +4 -0
  67. package/src/domain/enums/amount-type.enum.ts +10 -0
  68. package/src/domain/enums/billing-cycle.enum.ts +30 -0
  69. package/src/domain/enums/index.ts +5 -0
  70. package/src/domain/enums/payment-provider.enum.ts +14 -0
  71. package/src/domain/enums/subscription-status.enum.ts +32 -0
  72. package/src/domain/index.ts +5 -0
  73. package/src/domain/interfaces/billing-adapter.interface.ts +18 -0
  74. package/src/domain/interfaces/index.ts +1 -0
  75. package/src/domain/mappers/product.mapper.ts +176 -0
  76. package/src/domain/utils/minor-units.ts +20 -0
  77. package/src/domain/utils/percent-discount.ts +17 -0
  78. package/src/http.ts +3 -0
  79. package/src/index.ts +12 -0
  80. package/src/infrastructure/browser/hosted-checkout.ts +26 -0
  81. package/src/infrastructure/config/billing-env.ts +31 -0
  82. package/src/infrastructure/constants/billing.constants.ts +10 -0
  83. package/src/infrastructure/http/http-client.ts +83 -0
  84. package/src/infrastructure/pagination/page-iterator.ts +43 -0
  85. package/src/infrastructure/query/query-builder.ts +26 -0
  86. package/src/infrastructure/services/billing.client.ts +300 -0
  87. package/src/infrastructure/services/create-billing-client.ts +23 -0
  88. package/src/infrastructure/services/http/create-http-adapter.ts +123 -0
  89. package/src/infrastructure/services/memory/create-memory-adapter.ts +126 -0
  90. package/src/infrastructure/services/product.repository.ts +198 -0
  91. package/src/infrastructure/utils/checkout-metadata.ts +55 -0
  92. package/src/infrastructure/utils/date-renderer.ts +53 -0
  93. package/src/infrastructure/utils/money-formatter.ts +39 -0
  94. package/src/infrastructure/utils/normalization.util.ts +22 -0
  95. package/src/infrastructure/utils/pagination-clamp.ts +11 -0
  96. package/src/infrastructure/utils/receipt.util.ts +6 -0
  97. package/src/infrastructure/utils/security.util.ts +23 -0
  98. package/src/infrastructure/utils/type-guards.util.ts +23 -0
  99. package/src/infrastructure/utils/validation.util.ts +10 -0
  100. package/src/infrastructure/webhook/parse-event.ts +135 -0
  101. package/src/infrastructure/webhook/verify-webhook.ts +124 -0
  102. package/src/memory.ts +3 -0
  103. package/src/portal.ts +2 -0
  104. package/src/presentation/components/BillingHistoryList.tsx +172 -0
  105. package/src/presentation/components/BillingProvider.tsx +179 -0
  106. package/src/presentation/components/CancelSubscriptionButton.tsx +155 -0
  107. package/src/presentation/components/CheckoutButton.tsx +76 -0
  108. package/src/presentation/components/CustomerPortalLink.tsx +71 -0
  109. package/src/presentation/components/PricingTable.tsx +225 -0
  110. package/src/presentation/components/SubscriptionStatusBadge.tsx +102 -0
  111. package/src/presentation/context/billing-context.ts +30 -0
  112. package/src/presentation/hooks/useBilling.ts +3 -0
  113. package/src/presentation/hooks/useCancelSubscription.ts +47 -0
  114. package/src/presentation/hooks/useCheckoutButton.ts +49 -0
  115. package/src/presentation/hooks/useProducts.ts +76 -0
  116. package/src/presentation/navigation/navigation.strategy.ts +16 -0
  117. package/src/presentation/portal/TurnkeyBillingPage.tsx +144 -0
  118. package/src/presentation/portal/components/BillingLayout.tsx +163 -0
  119. package/src/presentation/portal/components/BillingPage.tsx +22 -0
  120. package/src/presentation/portal/components/BillingPortal.tsx +248 -0
  121. package/src/presentation/portal/components/InvoiceCard.tsx +275 -0
  122. package/src/presentation/portal/components/OverviewTab.tsx +137 -0
  123. package/src/presentation/portal/components/PaymentMethodsList.tsx +268 -0
  124. package/src/presentation/portal/components/PlanComparison.tsx +405 -0
  125. package/src/presentation/portal/components/PlanTab.tsx +197 -0
  126. package/src/presentation/portal/components/PortalButton.tsx +89 -0
  127. package/src/presentation/portal/components/PortalSpinner.tsx +30 -0
  128. package/src/presentation/portal/components/UsageCard.tsx +158 -0
  129. package/src/presentation/portal/components/index.ts +11 -0
  130. package/src/presentation/portal/constants.ts +44 -0
  131. package/src/presentation/portal/create-portal-client.ts +202 -0
  132. package/src/presentation/portal/hooks/index.ts +3 -0
  133. package/src/presentation/portal/hooks/useBilling.ts +240 -0
  134. package/src/presentation/portal/index.ts +97 -0
  135. package/src/presentation/portal/labels.tsx +218 -0
  136. package/src/presentation/portal/styles.ts +70 -0
  137. package/src/presentation/portal/types.ts +213 -0
  138. package/src/presentation/portal/utils/billing.ts +193 -0
  139. package/src/presentation/portal/utils/index.ts +27 -0
  140. package/src/presentation/portal/utils/plans.ts +71 -0
  141. package/src/presentation/theme/visual-tokens.ts +103 -0
  142. package/src/presentation/utils/cancellation-reason-catalog.ts +18 -0
  143. package/src/presentation/utils/checkout-button-visual.ts +50 -0
  144. package/src/presentation/utils/inline-error-visual.ts +21 -0
  145. package/src/presentation/utils/product-pricing.ts +33 -0
  146. package/src/presentation/utils/status-label.ts +23 -0
  147. package/src/presentation/utils/status-tone.ts +25 -0
  148. package/src/products.ts +20 -0
  149. package/src/react.tsx +63 -0
  150. package/src/server.ts +79 -0
@@ -0,0 +1,126 @@
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
+ import { isActiveSubscription } from '../../../domain/entities';
13
+
14
+ export interface MemoryAdapterConfig {
15
+ initialStatus?: Partial<SubscriptionStatus>;
16
+ initialHistory?: OrderItem[];
17
+ checkoutUrlBase?: string;
18
+ portalUrl?: string;
19
+ onCheckout?: (params: CheckoutParams) => void;
20
+ onCancel?: (reason?: CancellationReason) => void;
21
+ }
22
+
23
+ interface PendingCheckout {
24
+ id: string;
25
+ params: CheckoutParams;
26
+ }
27
+
28
+ export function createMemoryBillingAdapter(config: MemoryAdapterConfig = {}): BillingAdapter {
29
+ let status: SubscriptionStatus = {
30
+ plan: 'free',
31
+ subscriptionStatus: 'none',
32
+ billingCycle: 'monthly',
33
+ cancelAtPeriodEnd: false,
34
+ ...config.initialStatus,
35
+ };
36
+
37
+ const history: OrderItem[] = config.initialHistory ? [...config.initialHistory] : [];
38
+ const pending = new Map<string, PendingCheckout>();
39
+ let sequence = 0;
40
+
41
+ function checkoutUrl(checkout: PendingCheckout): string {
42
+ if (checkout.params.successUrl) {
43
+ return checkout.params.successUrl.replace('{CHECKOUT_ID}', checkout.id);
44
+ }
45
+ const base = config.checkoutUrlBase ?? 'https://checkout.example';
46
+ return `${base.replace(/\/$/, '')}/${checkout.id}`;
47
+ }
48
+
49
+ function fulfill(checkout: PendingCheckout): SubscriptionStatus {
50
+ const planName = checkout.params.planKey ?? checkout.params.productId ?? checkout.params.productKey ?? 'default';
51
+ return {
52
+ ...status,
53
+ plan: planName,
54
+ subscriptionStatus: 'active',
55
+ subscriptionId: `sub_${checkout.id.slice(4)}`,
56
+ billingCycle: checkout.params.billingCycle ?? status.billingCycle,
57
+ cancelAtPeriodEnd: false,
58
+ currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
59
+ };
60
+ }
61
+
62
+ return {
63
+ async getStatus(_userId: string): Promise<SubscriptionStatus> {
64
+ return { ...status };
65
+ },
66
+
67
+ async createCheckout(params: CheckoutParams): Promise<CheckoutResult> {
68
+ config.onCheckout?.(params);
69
+ sequence += 1;
70
+ const checkout: PendingCheckout = {
71
+ id: `chk_${Date.now()}_${sequence}`,
72
+ params,
73
+ };
74
+ pending.set(checkout.id, checkout);
75
+ return {
76
+ id: checkout.id,
77
+ url: checkoutUrl(checkout),
78
+ };
79
+ },
80
+
81
+ async syncSubscription(_userId: string, checkoutId?: string): Promise<SyncResult> {
82
+ const checkout = checkoutId ? pending.get(checkoutId) : undefined;
83
+ const last = checkout ? undefined : [...pending.values()].pop();
84
+ const target = checkout ?? last;
85
+ if (!target) {
86
+ return { synced: false, plan: status.plan };
87
+ }
88
+ status = fulfill(target);
89
+ pending.delete(target.id);
90
+ history.unshift({
91
+ id: `ord_${target.id}`,
92
+ amount: 0,
93
+ currency: 'usd',
94
+ createdAt: new Date().toISOString(),
95
+ productName: target.params.planKey ?? target.params.productId,
96
+ status: 'paid',
97
+ paid: true,
98
+ });
99
+ return { synced: true, plan: status.plan };
100
+ },
101
+
102
+ async getBillingHistory(_userId: string): Promise<OrderItem[]> {
103
+ return [...history];
104
+ },
105
+
106
+ async cancelSubscription(reason?: CancellationReason): Promise<CancelResult> {
107
+ config.onCancel?.(reason);
108
+ if (!isActiveSubscription(status)) {
109
+ return { success: false };
110
+ }
111
+ status = {
112
+ ...status,
113
+ subscriptionStatus: 'canceled',
114
+ cancelAtPeriodEnd: true,
115
+ };
116
+ return {
117
+ success: true,
118
+ endsAt: status.currentPeriodEnd ?? new Date().toISOString(),
119
+ };
120
+ },
121
+
122
+ async getPortalUrl(_userId: string): Promise<string> {
123
+ return config.portalUrl ?? 'https://portal.example/billing';
124
+ },
125
+ };
126
+ }
@@ -0,0 +1,198 @@
1
+
2
+ import {
3
+ API_BASE_URL,
4
+ API_MAX_PAGE_SIZE,
5
+ } from '../constants/billing.constants';
6
+ import { HttpClient } from '../http/http-client';
7
+ import { FetchHttpClient } from '../http/http-client';
8
+ import { QueryBuilder } from '../query/query-builder';
9
+ import { PageIterator, type Page, type PageFetcher } from '../pagination/page-iterator';
10
+ import { PaginationClamp } from '../utils/pagination-clamp';
11
+ import {
12
+ mapProduct,
13
+ mapProductList,
14
+ type Product,
15
+ type ProductDto,
16
+ type ProductListParams,
17
+ type ProductListResponse,
18
+ } from '../../domain/mappers/product.mapper';
19
+
20
+ export interface ProductApiConfig {
21
+ apiToken: string;
22
+ baseUrl?: string;
23
+ }
24
+
25
+ export interface ProductRequestOptions {
26
+ signal?: AbortSignal;
27
+ timeoutMs?: number;
28
+ }
29
+
30
+ export class BillingApiError extends Error {
31
+ constructor(
32
+ message: string,
33
+ public readonly status: number,
34
+ public readonly body?: string,
35
+ ) {
36
+ super(message);
37
+ this.name = 'BillingApiError';
38
+ Object.setPrototypeOf(this, BillingApiError.prototype);
39
+ }
40
+ }
41
+
42
+ export function isBillingApiError(error: unknown): error is BillingApiError {
43
+ return error instanceof BillingApiError;
44
+ }
45
+
46
+ interface RawProductPage {
47
+ items: ProductDto[];
48
+ pagination: { total_count: number; max_page: number };
49
+ }
50
+
51
+ export class ProductRepository {
52
+ private readonly http: HttpClient;
53
+ private readonly apiToken: string;
54
+ private readonly baseUrl: string;
55
+
56
+ constructor(config: ProductApiConfig, http: HttpClient = new FetchHttpClient()) {
57
+ this.http = http;
58
+ this.apiToken = config.apiToken;
59
+ this.baseUrl = config.baseUrl ?? API_BASE_URL;
60
+ }
61
+
62
+ async list(
63
+ params?: ProductListParams,
64
+ options?: ProductRequestOptions,
65
+ ): Promise<ProductListResponse> {
66
+ const query = new QueryBuilder({
67
+ organization_id: params?.organizationId,
68
+ is_recurring: params?.isRecurring,
69
+ page: params?.page,
70
+ limit: this.clampLimit(params?.limit),
71
+ });
72
+ const url = `${this.baseUrl}/products?${query.toString()}`;
73
+
74
+ const response = await this.http.request<RawProductPage>({
75
+ method: 'GET',
76
+ url,
77
+ headers: this.authHeaders(),
78
+ signal: options?.signal,
79
+ timeoutMs: options?.timeoutMs,
80
+ });
81
+
82
+ if (!response.ok) {
83
+ const body = await response.text();
84
+ throw new BillingApiError(
85
+ `Billing API error: ${response.status}`,
86
+ response.status,
87
+ body,
88
+ );
89
+ }
90
+
91
+ return mapProductList(response.data);
92
+ }
93
+
94
+ async findById(
95
+ productId: string,
96
+ options?: ProductRequestOptions,
97
+ ): Promise<Product | null> {
98
+ const url = `${this.baseUrl}/products/${encodeURIComponent(productId)}`;
99
+
100
+ const response = await this.http.request<ProductDto>({
101
+ method: 'GET',
102
+ url,
103
+ headers: this.authHeaders(),
104
+ signal: options?.signal,
105
+ timeoutMs: options?.timeoutMs,
106
+ });
107
+
108
+ if (response.status === 404) return null;
109
+ if (!response.ok) {
110
+ const body = await response.text();
111
+ throw new BillingApiError(
112
+ `Billing API error: ${response.status}`,
113
+ response.status,
114
+ body,
115
+ );
116
+ }
117
+
118
+ if (!response.data) {
119
+ throw new BillingApiError(
120
+ 'Billing API returned an empty product body',
121
+ response.status,
122
+ );
123
+ }
124
+
125
+ return mapProduct(response.data);
126
+ }
127
+
128
+ iterateAll(
129
+ params?: Omit<ProductListParams, 'page' | 'limit'>,
130
+ options: { limit?: number; signal?: AbortSignal } = {},
131
+ ): AsyncIterable<Product> {
132
+ const limit = this.clampLimit(options.limit);
133
+ const fetcher: PageFetcher<Product> = {
134
+ fetchPage: async (page, pageLimit) => {
135
+ const response = await this.list(
136
+ { ...params, page, limit: pageLimit },
137
+ { signal: options.signal },
138
+ );
139
+ const pageData: Page<Product> = {
140
+ items: response.items,
141
+ page,
142
+ totalCount: response.pagination.totalCount,
143
+ maxPage: response.pagination.maxPage,
144
+ isLast: page >= response.pagination.maxPage,
145
+ };
146
+ return pageData;
147
+ },
148
+ };
149
+ return new PageIterator(fetcher, { limit, signal: options.signal });
150
+ }
151
+
152
+ async listAll(
153
+ params?: Omit<ProductListParams, 'page' | 'limit'>,
154
+ options: { limit?: number; signal?: AbortSignal } = {},
155
+ ): Promise<Product[]> {
156
+ const out: Product[] = [];
157
+ for await (const product of this.iterateAll(params, options)) {
158
+ out.push(product);
159
+ }
160
+ return out;
161
+ }
162
+
163
+
164
+ private authHeaders(): Record<string, string> {
165
+ return { Authorization: `Bearer ${this.apiToken}` };
166
+ }
167
+
168
+ private clampLimit(limit: number | undefined): number | undefined {
169
+ return PaginationClamp.pageSize(limit, API_MAX_PAGE_SIZE);
170
+ }
171
+ }
172
+
173
+
174
+ const defaultClient = new FetchHttpClient();
175
+
176
+ export async function getProducts(
177
+ config: ProductApiConfig,
178
+ params?: ProductListParams,
179
+ options?: ProductRequestOptions,
180
+ ): Promise<ProductListResponse> {
181
+ return new ProductRepository(config, defaultClient).list(params, options);
182
+ }
183
+
184
+ export async function getProductById(
185
+ config: ProductApiConfig,
186
+ productId: string,
187
+ options?: ProductRequestOptions,
188
+ ): Promise<Product | null> {
189
+ return new ProductRepository(config, defaultClient).findById(productId, options);
190
+ }
191
+
192
+ export async function getAllProducts(
193
+ config: ProductApiConfig,
194
+ params?: Omit<ProductListParams, 'page' | 'limit'>,
195
+ options: { limit?: number; signal?: AbortSignal } = {},
196
+ ): Promise<Product[]> {
197
+ return new ProductRepository(config, defaultClient).listAll(params, options);
198
+ }
@@ -0,0 +1,55 @@
1
+
2
+ export interface CheckoutOrderMetadata {
3
+ userId: string;
4
+ itemType: string;
5
+ itemId: string;
6
+ couponCode?: string;
7
+ }
8
+
9
+ export interface BuildCheckoutMetadataInput {
10
+ userId: string;
11
+ itemType: string;
12
+ itemId: string;
13
+ couponCode?: string;
14
+ extra?: Record<string, string>;
15
+ }
16
+
17
+ function asNonEmptyString(value: unknown): string | undefined {
18
+ if (typeof value !== 'string' || value.length === 0) return undefined;
19
+ return value;
20
+ }
21
+
22
+ export function buildCheckoutMetadata(
23
+ input: BuildCheckoutMetadataInput,
24
+ ): Record<string, string> {
25
+ const meta: Record<string, string> = {
26
+ userId: input.userId,
27
+ itemType: input.itemType,
28
+ itemId: input.itemId,
29
+ };
30
+ if (input.couponCode && input.couponCode.trim().length > 0) {
31
+ meta.couponCode = input.couponCode.trim();
32
+ }
33
+ if (input.extra) {
34
+ for (const [key, value] of Object.entries(input.extra)) {
35
+ if (typeof value === 'string' && value.length > 0) {
36
+ meta[key] = value;
37
+ }
38
+ }
39
+ }
40
+ return meta;
41
+ }
42
+
43
+ export function readCheckoutMetadata(
44
+ metadata: Record<string, unknown> | undefined,
45
+ allowedItemTypes: readonly string[],
46
+ ): CheckoutOrderMetadata | null {
47
+ if (metadata === undefined) return null;
48
+ const userId = asNonEmptyString(metadata.userId);
49
+ const itemType = asNonEmptyString(metadata.itemType);
50
+ const itemId = asNonEmptyString(metadata.itemId);
51
+ if (!userId || !itemType || !itemId) return null;
52
+ if (!allowedItemTypes.includes(itemType)) return null;
53
+ const couponCode = asNonEmptyString(metadata.couponCode);
54
+ return { userId, itemType, itemId, ...(couponCode ? { couponCode } : {}) };
55
+ }
@@ -0,0 +1,53 @@
1
+
2
+ const DEFAULT_LOCALE = 'en-US';
3
+
4
+ const ISO_DATE_DISPLAY_LENGTH = 10;
5
+ const FALLBACK_DATE_LABEL = 'soon';
6
+ const FALLBACK_LONG_DATE = '—';
7
+
8
+ export class DateRenderer {
9
+ private readonly dateLength: number;
10
+ private readonly shortFormatter: Intl.DateTimeFormat;
11
+ private readonly longFormatter: Intl.DateTimeFormat;
12
+
13
+ constructor(
14
+ locale: string = DEFAULT_LOCALE,
15
+ dateLength: number = ISO_DATE_DISPLAY_LENGTH,
16
+ ) {
17
+ this.dateLength = dateLength;
18
+ this.shortFormatter = new Intl.DateTimeFormat(locale, {
19
+ year: 'numeric',
20
+ month: 'short',
21
+ day: 'numeric',
22
+ });
23
+ this.longFormatter = new Intl.DateTimeFormat(locale, {
24
+ year: 'numeric',
25
+ month: 'long',
26
+ day: 'numeric',
27
+ });
28
+ }
29
+
30
+ short(iso: string | null | undefined): string {
31
+ if (!iso) return FALLBACK_LONG_DATE;
32
+ return iso.slice(0, this.dateLength);
33
+ }
34
+
35
+ renewal(iso: string | null | undefined, fallback: string = FALLBACK_DATE_LABEL): string {
36
+ const date = DateRenderer.parse(iso);
37
+ if (!date) return fallback;
38
+ return this.shortFormatter.format(date);
39
+ }
40
+
41
+ long(iso: string | null | undefined): string {
42
+ if (!iso) return FALLBACK_LONG_DATE;
43
+ const date = DateRenderer.parse(iso);
44
+ if (!date) return iso;
45
+ return this.longFormatter.format(date);
46
+ }
47
+
48
+ static parse(iso: string | null | undefined): Date | null {
49
+ if (!iso) return null;
50
+ const date = new Date(iso);
51
+ return Number.isNaN(date.getTime()) ? null : date;
52
+ }
53
+ }
@@ -0,0 +1,39 @@
1
+
2
+ import { minorUnitDivisor } from '../../domain/utils/minor-units';
3
+
4
+ const DEFAULT_LOCALE = 'en-US';
5
+ const DEFAULT_CURRENCY = 'USD';
6
+
7
+ export interface MoneyFormatOptions {
8
+ locale?: string;
9
+ fallbackCurrency?: string;
10
+ maximumFractionDigits?: number;
11
+ }
12
+
13
+ export class MoneyFormatter {
14
+ private readonly locale: string;
15
+
16
+ constructor(locale: string = DEFAULT_LOCALE) {
17
+ this.locale = locale;
18
+ }
19
+
20
+ format(
21
+ amount: number | undefined,
22
+ currency: string | undefined,
23
+ options: MoneyFormatOptions = {},
24
+ ): string {
25
+ if (amount === undefined || amount === null) {
26
+ return 'Custom';
27
+ }
28
+ const code = (currency ?? options.fallbackCurrency ?? DEFAULT_CURRENCY).toUpperCase();
29
+ try {
30
+ return new Intl.NumberFormat(this.locale, {
31
+ style: 'currency',
32
+ currency: code,
33
+ maximumFractionDigits: options.maximumFractionDigits ?? 2,
34
+ }).format(amount / minorUnitDivisor(code));
35
+ } catch {
36
+ return `${amount} ${code}`;
37
+ }
38
+ }
39
+ }
@@ -0,0 +1,22 @@
1
+
2
+ import {
3
+ SUBSCRIPTION_STATUS,
4
+ isKnownSubscriptionStatus,
5
+ type SubscriptionStatusValue,
6
+ } from '../../domain/enums/subscription-status.enum';
7
+ import {
8
+ BILLING_CYCLE,
9
+ parseBillingCycle,
10
+ type BillingCycle,
11
+ } from '../../domain/enums/billing-cycle.enum';
12
+
13
+ export function normalizeStatus(raw: string | null | undefined): SubscriptionStatusValue {
14
+ if (typeof raw !== 'string') return SUBSCRIPTION_STATUS.NONE;
15
+ return isKnownSubscriptionStatus(raw) ? raw : SUBSCRIPTION_STATUS.NONE;
16
+ }
17
+
18
+ export function normalizeBillingCycle(
19
+ interval: string | null | undefined,
20
+ ): BillingCycle {
21
+ return parseBillingCycle(interval) ?? BILLING_CYCLE.MONTHLY;
22
+ }
@@ -0,0 +1,11 @@
1
+
2
+ import { API_MAX_PAGE_SIZE } from '../constants/billing.constants';
3
+
4
+ export const MIN_PAGE_SIZE = 1;
5
+
6
+ export class PaginationClamp {
7
+ static pageSize(raw: number | undefined, max: number = API_MAX_PAGE_SIZE): number {
8
+ if (raw === undefined) return max;
9
+ return Math.min(Math.max(MIN_PAGE_SIZE, raw), max);
10
+ }
11
+ }
@@ -0,0 +1,6 @@
1
+ export function formatReceiptNumber(id: string, prefix = 'RCP'): string {
2
+ if (!id || typeof id !== 'string') return `${prefix}-00000000`;
3
+ const clean = id.replace(/[^a-zA-Z0-9]/g, '');
4
+ const tail = clean.length >= 8 ? clean.slice(-8) : clean.padStart(8, '0');
5
+ return `${prefix}-${tail.toUpperCase()}`;
6
+ }
@@ -0,0 +1,23 @@
1
+
2
+ const LOOPBACK_HOSTS: ReadonlySet<string> = new Set(['localhost', '127.0.0.1', '0.0.0.0', '[::1]']);
3
+
4
+ export function isValidCheckoutUrl(url: string): boolean {
5
+ return typeof url === 'string' && (url.startsWith('https://') || url.startsWith('http://'));
6
+ }
7
+
8
+ export function isInsecureUrl(url: string): boolean {
9
+ return typeof url === 'string' && url.startsWith('http://');
10
+ }
11
+
12
+ export function isLoopbackHost(url: string): boolean {
13
+ try {
14
+ const host = new URL(url).hostname.toLowerCase();
15
+ return LOOPBACK_HOSTS.has(host);
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ export function isProductionInsecureUrl(url: string): boolean {
22
+ return isInsecureUrl(url) && !isLoopbackHost(url);
23
+ }
@@ -0,0 +1,23 @@
1
+
2
+ export function asString(value: unknown): string | undefined {
3
+ return typeof value === 'string' ? value : undefined;
4
+ }
5
+
6
+ export function asBoolean(value: unknown): boolean | undefined {
7
+ return typeof value === 'boolean' ? value : undefined;
8
+ }
9
+
10
+ export function isTimestamp(value: unknown): value is { toDate(): Date } {
11
+ return (
12
+ typeof value === 'object' &&
13
+ value !== null &&
14
+ 'toDate' in value &&
15
+ typeof (value as { toDate?: unknown }).toDate === 'function'
16
+ );
17
+ }
18
+
19
+ export function asRecord(value: unknown): Record<string, unknown> {
20
+ return value !== null && typeof value === 'object'
21
+ ? (value as Record<string, unknown>)
22
+ : {};
23
+ }
@@ -0,0 +1,10 @@
1
+
2
+ export function normalizeUserId(userId: string | undefined): string | undefined {
3
+ if (typeof userId !== 'string') return undefined;
4
+ const trimmed = userId.trim();
5
+ return trimmed.length > 0 ? trimmed : undefined;
6
+ }
7
+
8
+ export function isValidProductId(productId: unknown): productId is string {
9
+ return typeof productId === 'string' && productId.trim().length > 0;
10
+ }