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.
- package/api/src/crons/payment-stat.ts +31 -23
- package/api/src/libs/invoice.ts +29 -4
- package/api/src/libs/product.ts +28 -4
- package/api/src/routes/checkout-sessions.ts +46 -1
- package/api/src/routes/connect/re-stake.ts +2 -0
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/invoices.ts +63 -2
- package/api/src/routes/payment-stats.ts +244 -22
- package/api/src/routes/products.ts +3 -0
- package/api/src/routes/subscriptions.ts +2 -1
- package/api/src/routes/tax-rates.ts +220 -0
- package/api/src/store/migrations/20251001-add-tax-code-to-products.ts +20 -0
- package/api/src/store/migrations/20251001-create-tax-rates.ts +17 -0
- package/api/src/store/migrations/20251007-relate-tax-rate-to-invoice.ts +24 -0
- package/api/src/store/migrations/20251009-add-tax-behavior.ts +21 -0
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/invoice-item.ts +10 -0
- package/api/src/store/models/price.ts +7 -0
- package/api/src/store/models/product.ts +7 -0
- package/api/src/store/models/tax-rate.ts +352 -0
- package/api/tests/models/tax-rate.spec.ts +777 -0
- package/blocklet.yml +2 -2
- package/package.json +6 -6
- package/public/currencies/dollar.png +0 -0
- package/src/components/collapse.tsx +3 -2
- package/src/components/drawer-form.tsx +2 -1
- package/src/components/invoice/list.tsx +38 -1
- package/src/components/invoice/table.tsx +48 -2
- package/src/components/metadata/form.tsx +2 -2
- package/src/components/payment-intent/list.tsx +19 -1
- package/src/components/payouts/list.tsx +19 -1
- package/src/components/price/currency-select.tsx +105 -48
- package/src/components/price/form.tsx +3 -1
- package/src/components/product/form.tsx +79 -5
- package/src/components/refund/list.tsx +20 -1
- package/src/components/subscription/items/actions.tsx +25 -15
- package/src/components/subscription/list.tsx +16 -1
- package/src/components/tax/actions.tsx +140 -0
- package/src/components/tax/filter-toolbar.tsx +230 -0
- package/src/components/tax/tax-code-select.tsx +633 -0
- package/src/components/tax/tax-rate-form.tsx +177 -0
- package/src/components/tax/tax-utils.ts +38 -0
- package/src/components/tax/taxCodes.json +10882 -0
- package/src/components/uploader.tsx +3 -0
- package/src/locales/en.tsx +152 -0
- package/src/locales/zh.tsx +149 -0
- package/src/pages/admin/billing/invoices/detail.tsx +1 -1
- package/src/pages/admin/index.tsx +2 -0
- package/src/pages/admin/overview.tsx +1114 -322
- package/src/pages/admin/products/vendors/index.tsx +4 -2
- package/src/pages/admin/tax/create.tsx +104 -0
- package/src/pages/admin/tax/detail.tsx +476 -0
- package/src/pages/admin/tax/edit.tsx +126 -0
- package/src/pages/admin/tax/index.tsx +86 -0
- package/src/pages/admin/tax/list.tsx +334 -0
- package/src/pages/customer/subscription/change-payment.tsx +1 -1
- 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>;
|