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
@@ -37,6 +37,7 @@ const ProductAndPriceSchema = Joi.object({
37
37
  description: Joi.string().max(250).empty('').optional(),
38
38
  images: Joi.any().optional(),
39
39
  metadata: MetadataSchema,
40
+ tax_code: Joi.string().max(30).empty('').optional(),
40
41
  statement_descriptor: Joi.string()
41
42
  .max(22)
42
43
  .pattern(/^(?=.*[A-Za-z])[^\u4e00-\u9fa5<>"’\\]*$/)
@@ -92,6 +93,7 @@ export async function createProductAndPrices(payload: any) {
92
93
  'nft_factory',
93
94
  'features',
94
95
  'metadata',
96
+ 'tax_code',
95
97
  ]);
96
98
 
97
99
  if (Array.isArray(payload.vendor_config) && payload.vendor_config.length > 0) {
@@ -431,6 +433,7 @@ router.put('/:id', auth, async (req, res) => {
431
433
  'nft_factory',
432
434
  'metadata',
433
435
  'cross_sell',
436
+ 'tax_code',
434
437
  ]);
435
438
 
436
439
  if (Array.isArray(req.body.vendor_config)) {
@@ -538,7 +538,8 @@ router.put('/:id/recover', authPortal, async (req, res) => {
538
538
  cancel_at: 0,
539
539
  };
540
540
 
541
- await updateStripeSubscription(doc, { ...updates, cancellation_details: null });
541
+ await updateStripeSubscription(doc, { cancel_at_period_end: false });
542
+ await updateStripeSubscription(doc, { cancel_at: null });
542
543
 
543
544
  // @ts-ignore
544
545
  await doc.update({
@@ -0,0 +1,220 @@
1
+ import { Router } from 'express';
2
+ import Joi from 'joi';
3
+
4
+ import { CustomError } from '@blocklet/error';
5
+ import { literal } from 'sequelize';
6
+ import { authenticate } from '../libs/security';
7
+ import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
8
+ import { formatMetadata } from '../libs/util';
9
+ import { InvoiceItem, TaxRate } from '../store/models';
10
+
11
+ const router = Router();
12
+ const auth = authenticate({ component: true, roles: ['owner', 'admin'] });
13
+
14
+ const createTaxRateSchema = Joi.object({
15
+ country: Joi.string().length(2).uppercase().required(),
16
+ state: Joi.string().max(50).empty('').optional(),
17
+ postal_code: Joi.string().max(20).empty('').optional(),
18
+ tax_code: Joi.string().max(20).empty('').optional(),
19
+ percentage: Joi.number().min(0).max(99.9999).required(),
20
+ display_name: Joi.string().max(100).required(),
21
+ description: Joi.string().max(500).empty('').optional(),
22
+ active: Joi.boolean().default(true),
23
+ metadata: MetadataSchema,
24
+ }).unknown(true);
25
+
26
+ const updateTaxRateSchema = Joi.object({
27
+ active: Joi.boolean().optional(),
28
+ percentage: Joi.number().min(0).max(99.9999).optional(),
29
+ display_name: Joi.string().max(100).optional(),
30
+ description: Joi.string().max(500).empty('').optional(),
31
+ metadata: MetadataSchema,
32
+ }).unknown(true);
33
+
34
+ const listSchema = createListParamSchema<{
35
+ country?: string;
36
+ state?: string;
37
+ active?: boolean;
38
+ }>({
39
+ country: Joi.string().length(2).uppercase().optional(),
40
+ state: Joi.string().max(50).optional(),
41
+ active: Joi.boolean().optional(),
42
+ });
43
+
44
+ // List tax rates
45
+ router.get('/', auth, async (req, res) => {
46
+ try {
47
+ const { error, value: validated } = listSchema.validate(req.query, { stripUnknown: true });
48
+ if (error) {
49
+ res.status(400).json({ error: `Validation error: ${error.message}` });
50
+ return;
51
+ }
52
+
53
+ const { page, pageSize, ...query } = validated;
54
+ const where = getWhereFromKvQuery(query.q);
55
+
56
+ if (typeof req.livemode === 'boolean') {
57
+ where.livemode = req.livemode;
58
+ }
59
+ if (query.country) {
60
+ where.country = query.country;
61
+ }
62
+ if (query.state) {
63
+ where.state = query.state;
64
+ }
65
+ if (typeof query.active === 'boolean') {
66
+ where.active = query.active;
67
+ }
68
+
69
+ const { rows: list, count } = await TaxRate.findAndCountAll({
70
+ where,
71
+ order: getOrder(req.query, [['created_at', 'DESC']]),
72
+ offset: (page - 1) * pageSize,
73
+ limit: pageSize,
74
+ attributes: {
75
+ include: [
76
+ [
77
+ literal(`(
78
+ SELECT COUNT(DISTINCT ii.invoice_id)
79
+ FROM invoice_items AS ii
80
+ WHERE ii.tax_rate_id = TaxRate.id
81
+ )`),
82
+ 'invoice_count',
83
+ ],
84
+ ],
85
+ },
86
+ });
87
+
88
+ res.json({ count, list });
89
+ } catch (error) {
90
+ res.status(400).json({ error: error?.message });
91
+ }
92
+ });
93
+
94
+ // Get a specific tax rate
95
+ router.get('/:id', auth, async (req, res) => {
96
+ const taxRate = await TaxRate.findOne({
97
+ where: {
98
+ id: req.params.id,
99
+ livemode: req.livemode,
100
+ },
101
+ });
102
+
103
+ if (!taxRate) {
104
+ throw new CustomError(404, 'Tax rate not found');
105
+ }
106
+
107
+ res.json(taxRate);
108
+ });
109
+
110
+ // Create a new tax rate
111
+ router.post('/', auth, async (req, res) => {
112
+ const { error, value: data } = createTaxRateSchema.validate(req.body, { stripUnknown: true });
113
+ if (error) {
114
+ res.status(400).json({ error: `Validation failed: ${error.message}` });
115
+ return;
116
+ }
117
+
118
+ const existingRate = await TaxRate.findOne({
119
+ where: {
120
+ livemode: req.livemode,
121
+ country: data.country,
122
+ state: data.state || null,
123
+ postal_code: data.postal_code || null,
124
+ tax_code: data.tax_code || null,
125
+ },
126
+ });
127
+
128
+ if (existingRate) {
129
+ throw new CustomError(
130
+ 400,
131
+ `Tax rate ${existingRate.id} already exists, you can create a new tax rate with different region or update the existing tax rate`
132
+ );
133
+ }
134
+
135
+ const taxRate = await TaxRate.create({
136
+ ...data,
137
+ livemode: req.livemode,
138
+ metadata: formatMetadata(data.metadata),
139
+ });
140
+
141
+ res.json(taxRate);
142
+ });
143
+
144
+ // Update a tax rate
145
+ router.put('/:id', auth, async (req, res) => {
146
+ const { error, value: data } = updateTaxRateSchema.validate(req.body, { stripUnknown: true });
147
+ if (error) {
148
+ res.status(400).json({ error: `Validation failed: ${error.message}` });
149
+ return;
150
+ }
151
+
152
+ const taxRate = await TaxRate.findOne({
153
+ where: {
154
+ id: req.params.id,
155
+ livemode: req.livemode,
156
+ },
157
+ });
158
+
159
+ if (!taxRate) {
160
+ throw new CustomError(404, 'Tax rate not found');
161
+ }
162
+
163
+ await taxRate.update({
164
+ ...data,
165
+ metadata: data.metadata ? formatMetadata(data.metadata) : taxRate.metadata,
166
+ });
167
+
168
+ res.json(taxRate);
169
+ });
170
+
171
+ // Delete a tax rate
172
+ router.delete('/:id', auth, async (req, res) => {
173
+ const taxRate = await TaxRate.findOne({
174
+ where: {
175
+ id: req.params.id,
176
+ livemode: req.livemode,
177
+ },
178
+ });
179
+
180
+ if (!taxRate) {
181
+ throw new CustomError(404, 'Tax rate not found');
182
+ }
183
+
184
+ const existInvoice = await InvoiceItem.findOne({ where: { tax_rate_id: taxRate.id } });
185
+ if (existInvoice) {
186
+ throw new CustomError(400, 'Tax rate is used in an invoice, you can not delete it');
187
+ }
188
+
189
+ await taxRate.destroy();
190
+
191
+ res.json({ deleted: true, id: req.params.id });
192
+ });
193
+
194
+ // Find matching tax rate for a given location and tax code
195
+ router.post('/match', auth, async (req, res) => {
196
+ const schema = Joi.object({
197
+ country: Joi.string().length(2).uppercase().required(),
198
+ state: Joi.string().max(50).empty('').optional(),
199
+ postal_code: Joi.string().max(20).empty('').optional(),
200
+ tax_code: Joi.string().max(20).empty('').optional(),
201
+ });
202
+
203
+ const { error, value: data } = schema.validate(req.body, { stripUnknown: true });
204
+ if (error) {
205
+ res.status(400).json({ error: error.message });
206
+ return;
207
+ }
208
+
209
+ const matchedRate = await TaxRate.findMatchingRate({
210
+ country: data.country,
211
+ state: data.state,
212
+ postalCode: data.postal_code,
213
+ taxCode: data.tax_code,
214
+ livemode: req.livemode,
215
+ });
216
+
217
+ res.json(matchedRate);
218
+ });
219
+
220
+ export default router;
@@ -0,0 +1,20 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { safeApplyColumnChanges, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await safeApplyColumnChanges(context, {
6
+ products: [
7
+ {
8
+ name: 'tax_code',
9
+ field: {
10
+ type: DataTypes.STRING(20),
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ });
16
+ };
17
+
18
+ export const down: Migration = async ({ context }) => {
19
+ await context.removeColumn('products', 'tax_code');
20
+ };
@@ -0,0 +1,17 @@
1
+ import { createIndexIfNotExists, type Migration } from '../migrate';
2
+ import models from '../models';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await context.createTable('tax_rates', models.TaxRate.GENESIS_ATTRIBUTES);
6
+
7
+ await createIndexIfNotExists(
8
+ context,
9
+ 'tax_rates',
10
+ ['country', 'state', 'postal_code', 'tax_code'],
11
+ 'tax_rates_query_index'
12
+ );
13
+ };
14
+
15
+ export const down: Migration = async ({ context }) => {
16
+ await context.dropTable('tax_rates');
17
+ };
@@ -0,0 +1,24 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { createIndexIfNotExists, safeApplyColumnChanges, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await safeApplyColumnChanges(context, {
6
+ invoice_items: [
7
+ {
8
+ name: 'tax_rate_id',
9
+ field: {
10
+ type: DataTypes.STRING(30),
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ });
16
+ await createIndexIfNotExists(context, 'invoice_items', ['tax_rate_id'], 'idx_invoice_items_tax_rate_id');
17
+ await createIndexIfNotExists(context, 'invoice_items', ['invoice_id'], 'idx_invoice_items_invoice_id');
18
+ };
19
+
20
+ export const down: Migration = async ({ context }) => {
21
+ await context.removeColumn('invoice_items', 'tax_rate_id');
22
+ await context.removeIndex('invoice_items', 'idx_invoice_items_tax_rate_id');
23
+ await context.removeIndex('invoice_items', 'idx_invoice_items_invoice_id');
24
+ };
@@ -0,0 +1,21 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { safeApplyColumnChanges, type Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await safeApplyColumnChanges(context, {
6
+ prices: [
7
+ {
8
+ name: 'tax_behavior',
9
+ field: {
10
+ type: DataTypes.ENUM('inclusive', 'exclusive'),
11
+ allowNull: false,
12
+ defaultValue: 'inclusive',
13
+ },
14
+ },
15
+ ],
16
+ });
17
+ };
18
+
19
+ export const down: Migration = async ({ context }) => {
20
+ await context.removeColumn('prices', 'tax_behavior');
21
+ };
@@ -33,6 +33,7 @@ import { Meter, TMeter } from './meter';
33
33
  import { MeterEvent, TMeterEvent } from './meter-event';
34
34
  import { AutoRechargeConfig } from './auto-recharge-config';
35
35
  import { ProductVendor } from './product-vendor';
36
+ import { TaxRate } from './tax-rate';
36
37
 
37
38
  const models = {
38
39
  CheckoutSession,
@@ -69,6 +70,7 @@ const models = {
69
70
  MeterEvent,
70
71
  AutoRechargeConfig,
71
72
  ProductVendor,
73
+ TaxRate,
72
74
  };
73
75
 
74
76
  export function initialize(sequelize: any) {
@@ -119,6 +121,7 @@ export * from './meter';
119
121
  export * from './meter-event';
120
122
  export * from './auto-recharge-config';
121
123
  export * from './product-vendor';
124
+ export * from './tax-rate';
122
125
 
123
126
  export type TPriceExpanded = TPrice & {
124
127
  object: 'price';
@@ -32,6 +32,7 @@ export class InvoiceItem extends Model<InferAttributes<InvoiceItem>, InferCreati
32
32
  declare subscription_id?: string;
33
33
  declare subscription_item_id?: string;
34
34
  declare test_clock_id?: string;
35
+ declare tax_rate_id?: string;
35
36
 
36
37
  declare discountable: boolean;
37
38
  declare discount_amounts: DiscountAmount[];
@@ -106,6 +107,10 @@ export class InvoiceItem extends Model<InferAttributes<InvoiceItem>, InferCreati
106
107
  type: DataTypes.STRING(30),
107
108
  allowNull: true,
108
109
  },
110
+ tax_rate_id: {
111
+ type: DataTypes.STRING(30),
112
+ allowNull: true,
113
+ },
109
114
  discountable: {
110
115
  type: DataTypes.BOOLEAN,
111
116
  allowNull: false,
@@ -184,6 +189,11 @@ export class InvoiceItem extends Model<InferAttributes<InvoiceItem>, InferCreati
184
189
  foreignKey: 'id',
185
190
  as: 'subscriptionItem',
186
191
  });
192
+ this.hasOne(models.TaxRate, {
193
+ sourceKey: 'tax_rate_id',
194
+ foreignKey: 'id',
195
+ as: 'tax_rate',
196
+ });
187
197
  }
188
198
 
189
199
  public static async isPriceUsed(priceId: string) {
@@ -99,6 +99,8 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
99
99
  // Quantity limit per checkout, 0 means no limit
100
100
  declare quantity_limit_per_checkout: number;
101
101
 
102
+ declare tax_behavior?: LiteralUnion<'inclusive' | 'exclusive', string>;
103
+
102
104
  public static readonly GENESIS_ATTRIBUTES = {
103
105
  id: {
104
106
  type: DataTypes.STRING(32),
@@ -211,6 +213,11 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
211
213
  defaultValue: 0,
212
214
  allowNull: false,
213
215
  },
216
+ tax_behavior: {
217
+ type: DataTypes.ENUM('inclusive', 'exclusive'),
218
+ allowNull: false,
219
+ defaultValue: 'inclusive',
220
+ },
214
221
  },
215
222
  {
216
223
  sequelize,
@@ -47,6 +47,9 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
47
47
  // If set, we will mint an nft to consumer on purchase
48
48
  declare nft_factory?: string;
49
49
 
50
+ // Tax code for this product (e.g., txcd_10103000)
51
+ declare tax_code?: string;
52
+
50
53
  declare cross_sell?: {
51
54
  cross_sells_to_id: string;
52
55
  };
@@ -128,6 +131,10 @@ export class Product extends Model<InferAttributes<Product>, InferCreationAttrib
128
131
  type: DataTypes.STRING(40),
129
132
  allowNull: true,
130
133
  },
134
+ tax_code: {
135
+ type: DataTypes.STRING(20),
136
+ allowNull: true,
137
+ },
131
138
  created_at: {
132
139
  type: DataTypes.DATE,
133
140
  defaultValue: DataTypes.NOW,