payment-kit 1.21.12 → 1.21.14

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 (57) hide show
  1. package/api/src/crons/payment-stat.ts +31 -23
  2. package/api/src/libs/invoice.ts +29 -4
  3. package/api/src/libs/product.ts +28 -4
  4. package/api/src/routes/checkout-sessions.ts +46 -1
  5. package/api/src/routes/connect/re-stake.ts +2 -0
  6. package/api/src/routes/index.ts +2 -0
  7. package/api/src/routes/invoices.ts +63 -2
  8. package/api/src/routes/payment-stats.ts +244 -22
  9. package/api/src/routes/products.ts +3 -0
  10. package/api/src/routes/subscriptions.ts +2 -1
  11. package/api/src/routes/tax-rates.ts +220 -0
  12. package/api/src/store/migrations/20251001-add-tax-code-to-products.ts +20 -0
  13. package/api/src/store/migrations/20251001-create-tax-rates.ts +17 -0
  14. package/api/src/store/migrations/20251007-relate-tax-rate-to-invoice.ts +24 -0
  15. package/api/src/store/migrations/20251009-add-tax-behavior.ts +21 -0
  16. package/api/src/store/models/index.ts +3 -0
  17. package/api/src/store/models/invoice-item.ts +10 -0
  18. package/api/src/store/models/price.ts +7 -0
  19. package/api/src/store/models/product.ts +7 -0
  20. package/api/src/store/models/tax-rate.ts +352 -0
  21. package/api/tests/models/tax-rate.spec.ts +777 -0
  22. package/blocklet.yml +2 -2
  23. package/package.json +6 -6
  24. package/public/currencies/dollar.png +0 -0
  25. package/src/components/collapse.tsx +3 -2
  26. package/src/components/drawer-form.tsx +2 -1
  27. package/src/components/invoice/list.tsx +38 -1
  28. package/src/components/invoice/table.tsx +48 -2
  29. package/src/components/metadata/form.tsx +2 -2
  30. package/src/components/payment-intent/list.tsx +19 -1
  31. package/src/components/payouts/list.tsx +19 -1
  32. package/src/components/price/currency-select.tsx +105 -48
  33. package/src/components/price/form.tsx +3 -1
  34. package/src/components/product/form.tsx +79 -5
  35. package/src/components/refund/list.tsx +20 -1
  36. package/src/components/subscription/items/actions.tsx +25 -15
  37. package/src/components/subscription/list.tsx +16 -1
  38. package/src/components/tax/actions.tsx +140 -0
  39. package/src/components/tax/filter-toolbar.tsx +230 -0
  40. package/src/components/tax/tax-code-select.tsx +633 -0
  41. package/src/components/tax/tax-rate-form.tsx +177 -0
  42. package/src/components/tax/tax-utils.ts +38 -0
  43. package/src/components/tax/taxCodes.json +10882 -0
  44. package/src/components/uploader.tsx +3 -0
  45. package/src/locales/en.tsx +152 -0
  46. package/src/locales/zh.tsx +149 -0
  47. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  48. package/src/pages/admin/index.tsx +2 -0
  49. package/src/pages/admin/overview.tsx +1114 -322
  50. package/src/pages/admin/products/vendors/index.tsx +4 -2
  51. package/src/pages/admin/tax/create.tsx +104 -0
  52. package/src/pages/admin/tax/detail.tsx +476 -0
  53. package/src/pages/admin/tax/edit.tsx +126 -0
  54. package/src/pages/admin/tax/index.tsx +86 -0
  55. package/src/pages/admin/tax/list.tsx +334 -0
  56. package/src/pages/customer/subscription/change-payment.tsx +1 -1
  57. package/src/pages/home.tsx +6 -3
@@ -0,0 +1,352 @@
1
+ /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
+
4
+ import { BN } from '@ocap/util';
5
+
6
+ import { createIdGenerator } from '../../libs/util';
7
+ import logger from '../../libs/logger';
8
+
9
+ const nextTaxRateId = createIdGenerator('txr', 16);
10
+
11
+ // eslint-disable-next-line prettier/prettier
12
+ export class TaxRate extends Model<InferAttributes<TaxRate>, InferCreationAttributes<TaxRate>> {
13
+ declare id: CreationOptional<string>;
14
+ declare livemode: boolean;
15
+ declare active: boolean;
16
+
17
+ declare country: string; // ISO 3166-1 alpha-2 country code
18
+ declare state?: string; // State, province, or region
19
+ declare postal_code?: string; // Postal code or ZIP code (supports wildcards like "95*")
20
+ declare tax_code?: string; // Product tax code (txcd_*)
21
+
22
+ declare percentage: number; // Tax rate percentage (e.g., 8.5 for 8.5%)
23
+
24
+ declare display_name: string; // Display name for the tax rate
25
+ declare description?: string; // Description of the tax rate
26
+
27
+ declare metadata?: Record<string, any>;
28
+
29
+ declare created_at: CreationOptional<Date>;
30
+ declare updated_at: CreationOptional<Date>;
31
+
32
+ public static readonly GENESIS_ATTRIBUTES = {
33
+ id: {
34
+ type: DataTypes.STRING(30),
35
+ primaryKey: true,
36
+ allowNull: false,
37
+ defaultValue: nextTaxRateId,
38
+ },
39
+ livemode: {
40
+ type: DataTypes.BOOLEAN,
41
+ allowNull: false,
42
+ },
43
+ active: {
44
+ type: DataTypes.BOOLEAN,
45
+ defaultValue: true,
46
+ },
47
+ country: {
48
+ type: DataTypes.STRING(2),
49
+ allowNull: false,
50
+ },
51
+ state: {
52
+ type: DataTypes.STRING(50),
53
+ allowNull: true,
54
+ },
55
+ postal_code: {
56
+ type: DataTypes.STRING(20),
57
+ allowNull: true,
58
+ },
59
+ tax_code: {
60
+ type: DataTypes.STRING(20),
61
+ allowNull: true,
62
+ },
63
+ percentage: {
64
+ type: DataTypes.DECIMAL(5, 4), // Supports up to 99.9999%
65
+ allowNull: false,
66
+ },
67
+ display_name: {
68
+ type: DataTypes.STRING(100),
69
+ allowNull: false,
70
+ },
71
+ description: {
72
+ type: DataTypes.STRING(500),
73
+ allowNull: true,
74
+ },
75
+ metadata: {
76
+ type: DataTypes.JSON,
77
+ allowNull: true,
78
+ },
79
+ created_at: {
80
+ type: DataTypes.DATE,
81
+ defaultValue: DataTypes.NOW,
82
+ allowNull: false,
83
+ },
84
+ updated_at: {
85
+ type: DataTypes.DATE,
86
+ defaultValue: DataTypes.NOW,
87
+ allowNull: false,
88
+ },
89
+ };
90
+
91
+ public static initialize(sequelize: any) {
92
+ this.init(TaxRate.GENESIS_ATTRIBUTES, {
93
+ sequelize,
94
+ modelName: 'TaxRate',
95
+ tableName: 'tax_rates',
96
+ createdAt: 'created_at',
97
+ updatedAt: 'updated_at',
98
+ indexes: [
99
+ {
100
+ fields: ['country'],
101
+ },
102
+ {
103
+ fields: ['country', 'state'],
104
+ },
105
+ {
106
+ fields: ['country', 'state', 'postal_code'],
107
+ },
108
+ ],
109
+ });
110
+ }
111
+
112
+ public static associate() {}
113
+
114
+ /**
115
+ * Find matching tax rate for given location and tax code
116
+ *
117
+ * Uses hierarchical scoring (lexicographic ordering) to match tax rates.
118
+ * Priority: postal_code > state > tax_code
119
+ *
120
+ * Note: Country is pre-filtered to ensure postal codes are compared within
121
+ * the correct national context (e.g., prevents US 90210 from matching CA 90210).
122
+ *
123
+ * Score ranges per dimension (0-100):
124
+ * - 100: Exact match
125
+ * - 50-95: Wildcard match (e.g., "902*" prefix matching)
126
+ * - 10: General fallback (rate has no restriction)
127
+ * - 5: Both empty
128
+ */
129
+ public static async findMatchingRate({
130
+ country,
131
+ state,
132
+ postalCode,
133
+ taxCode,
134
+ livemode = true,
135
+ }: {
136
+ country: string;
137
+ state?: string;
138
+ postalCode?: string;
139
+ taxCode?: string;
140
+ livemode?: boolean;
141
+ }): Promise<TaxRate | null> {
142
+ const normalizedCountry = country?.trim().toUpperCase();
143
+ const normalizedState = state?.trim().toLowerCase();
144
+ const normalizedPostal = postalCode?.trim().toUpperCase();
145
+ const normalizedTaxCode = taxCode?.trim().toLowerCase();
146
+
147
+ if (!normalizedCountry) {
148
+ logger.error('findMatchingRate: country is required');
149
+ return null;
150
+ }
151
+
152
+ try {
153
+ const { sequelize } = this;
154
+ if (!sequelize) {
155
+ throw new Error('Sequelize instance not found');
156
+ }
157
+
158
+ const candidates = await this.findAll({
159
+ where: sequelize.where(sequelize.fn('UPPER', sequelize.col('country')), normalizedCountry) as any,
160
+ attributes: {
161
+ include: [[sequelize.literal('UPPER(country)'), 'normalized_country']],
162
+ },
163
+ order: [['updated_at', 'DESC']],
164
+ });
165
+
166
+ const activeCandidates = candidates.filter((rate) => rate.active && rate.livemode === livemode);
167
+
168
+ if (!activeCandidates.length) {
169
+ logger.debug('findMatchingRate: no active candidates found', {
170
+ country: normalizedCountry,
171
+ state: normalizedState,
172
+ postal: normalizedPostal,
173
+ taxCode: normalizedTaxCode,
174
+ livemode,
175
+ totalCandidates: candidates.length,
176
+ });
177
+ return null;
178
+ }
179
+
180
+ let bestMatch: TaxRate | null = null;
181
+ let bestScore = { postal: 0, state: 0, tax: 0 };
182
+
183
+ for (const rate of activeCandidates) {
184
+ const rateTaxCode = rate.tax_code ? String(rate.tax_code).trim().toLowerCase() : null;
185
+ const ratePostal = rate.postal_code ? String(rate.postal_code).trim().toUpperCase() : null;
186
+ const rateState = rate.state ? String(rate.state).trim().toLowerCase() : null;
187
+
188
+ let isValid = true;
189
+ const scores = { postal: 0, state: 0, tax: 0 };
190
+
191
+ // Postal Code Matching
192
+ if (ratePostal && normalizedPostal) {
193
+ if (ratePostal.endsWith('*')) {
194
+ const prefix = ratePostal.slice(0, -1);
195
+ if (normalizedPostal.startsWith(prefix)) {
196
+ const prefixLength = Math.min(prefix.length, 20);
197
+ scores.postal = 50 + prefixLength * 5;
198
+ } else {
199
+ isValid = false;
200
+ }
201
+ } else if (ratePostal === normalizedPostal) {
202
+ scores.postal = 100;
203
+ } else {
204
+ isValid = false;
205
+ }
206
+ } else if (!ratePostal && !normalizedPostal) {
207
+ scores.postal = 5;
208
+ } else if (!ratePostal && normalizedPostal) {
209
+ scores.postal = 10;
210
+ } else if (ratePostal && !normalizedPostal) {
211
+ isValid = false;
212
+ }
213
+
214
+ // State Matching
215
+ if (isValid && rateState && normalizedState) {
216
+ if (rateState === normalizedState) {
217
+ scores.state = 100;
218
+ } else {
219
+ isValid = false;
220
+ }
221
+ } else if (isValid && !rateState && !normalizedState) {
222
+ scores.state = 5;
223
+ } else if (isValid && !rateState && normalizedState) {
224
+ scores.state = 10;
225
+ } else if (isValid && rateState && !normalizedState) {
226
+ isValid = false;
227
+ }
228
+
229
+ // Tax Code Matching
230
+ if (isValid) {
231
+ if (normalizedTaxCode) {
232
+ if (rateTaxCode === normalizedTaxCode) {
233
+ scores.tax = 100;
234
+ } else if (!rateTaxCode) {
235
+ scores.tax = 10;
236
+ } else {
237
+ isValid = false;
238
+ }
239
+ } else if (!rateTaxCode) {
240
+ scores.tax = 5;
241
+ }
242
+ }
243
+
244
+ if (!isValid) {
245
+ // eslint-disable-next-line no-continue
246
+ continue;
247
+ }
248
+
249
+ // Hierarchical comparison (lexicographic ordering)
250
+ // Use > not >= to prefer first match when scores are equal (newer rate due to DESC sort)
251
+ const isBetter =
252
+ scores.postal > bestScore.postal ||
253
+ (scores.postal === bestScore.postal && scores.state > bestScore.state) ||
254
+ (scores.postal === bestScore.postal && scores.state === bestScore.state && scores.tax > bestScore.tax);
255
+
256
+ if (isBetter) {
257
+ bestScore = scores;
258
+ bestMatch = rate;
259
+ }
260
+ }
261
+
262
+ if (!bestMatch) {
263
+ logger.error('findMatchingRate: no matches found after scoring', {
264
+ country: normalizedCountry,
265
+ state: normalizedState,
266
+ postal: normalizedPostal,
267
+ taxCode: normalizedTaxCode,
268
+ livemode,
269
+ candidatesCount: activeCandidates.length,
270
+ });
271
+ } else {
272
+ logger.info('findMatchingRate: found match', {
273
+ taxRateId: bestMatch.id,
274
+ score: bestScore,
275
+ displayName: bestMatch.display_name,
276
+ percentage: bestMatch.percentage,
277
+ });
278
+ }
279
+
280
+ return bestMatch;
281
+ } catch (error) {
282
+ logger.error('findMatchingRate failed', {
283
+ error,
284
+ country: normalizedCountry,
285
+ state: normalizedState,
286
+ postal: normalizedPostal,
287
+ taxCode: normalizedTaxCode,
288
+ livemode,
289
+ });
290
+ return null;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Calculate tax amount from tax-inclusive total
296
+ * Formula: tax = total × (rate / (100 + rate))
297
+ */
298
+ public static calculateTaxFromInclusive(total: string, percentage: number): string {
299
+ const totalBN = new BN(total);
300
+ if (totalBN.lte(new BN('0'))) return '0';
301
+
302
+ const rateBN = new BN(percentage.toString()).mul(new BN('100'));
303
+ const denominator = new BN('10000').add(rateBN);
304
+
305
+ // tax = total * rate / (10000 + rate)
306
+ const taxAmount = totalBN.mul(rateBN).div(denominator);
307
+ return taxAmount.toString();
308
+ }
309
+
310
+ /**
311
+ * Calculate subtotal from tax-inclusive total
312
+ * Formula: subtotal = total - tax
313
+ */
314
+ public static calculateSubtotalFromInclusive(total: string, percentage: number): string {
315
+ const totalBN = new BN(total);
316
+ if (totalBN.lte(new BN('0'))) return total;
317
+
318
+ const taxAmount = new BN(this.calculateTaxFromInclusive(total, percentage));
319
+ return totalBN.sub(taxAmount).toString();
320
+ }
321
+
322
+ /**
323
+ * Calculate tax amount for a single invoice item
324
+ * Formula: tax = amount × (rate / 100)
325
+ */
326
+ public static calculateTaxForItem(amount: string, percentage: number): string {
327
+ const amountBN = new BN(amount);
328
+ if (amountBN.lte(new BN('0'))) return '0';
329
+
330
+ const rateBN = new BN(percentage.toString()).mul(new BN('100'));
331
+
332
+ // tax = amount * rate / 10000
333
+ const taxAmount = amountBN.mul(rateBN).div(new BN('10000'));
334
+ return taxAmount.toString();
335
+ }
336
+
337
+ /**
338
+ * Calculate total tax for multiple invoice items
339
+ */
340
+ public static calculateTotalTax(
341
+ items: Array<{ amount: string; tax_rate_id?: string; tax_rate?: { percentage: number } }>
342
+ ): string {
343
+ return items.reduce((total, item) => {
344
+ if (!item.tax_rate_id || !item.tax_rate) return total;
345
+
346
+ const itemTax = this.calculateTaxForItem(item.amount, item.tax_rate.percentage);
347
+ return new BN(total).add(new BN(itemTax)).toString();
348
+ }, '0');
349
+ }
350
+ }
351
+
352
+ export type TTaxRate = InferAttributes<TaxRate>;