payment-kit 1.20.11 → 1.20.13

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 (92) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/env.ts +1 -0
  13. package/api/src/libs/invoice.ts +44 -10
  14. package/api/src/libs/math-utils.ts +6 -0
  15. package/api/src/libs/price.ts +43 -0
  16. package/api/src/libs/session.ts +242 -57
  17. package/api/src/libs/subscription.ts +2 -6
  18. package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
  19. package/api/src/libs/vendor-util/adapters/types.ts +1 -0
  20. package/api/src/libs/vendor-util/fulfillment.ts +1 -1
  21. package/api/src/queues/auto-recharge.ts +1 -1
  22. package/api/src/queues/discount-status.ts +200 -0
  23. package/api/src/queues/subscription.ts +98 -5
  24. package/api/src/queues/usage-record.ts +1 -1
  25. package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
  26. package/api/src/queues/vendors/return-processor.ts +184 -0
  27. package/api/src/queues/vendors/return-scanner.ts +119 -0
  28. package/api/src/queues/vendors/status-check.ts +1 -1
  29. package/api/src/routes/auto-recharge-configs.ts +5 -3
  30. package/api/src/routes/checkout-sessions.ts +755 -64
  31. package/api/src/routes/connect/change-payment.ts +6 -1
  32. package/api/src/routes/connect/change-plan.ts +6 -1
  33. package/api/src/routes/connect/setup.ts +6 -1
  34. package/api/src/routes/connect/shared.ts +80 -9
  35. package/api/src/routes/connect/subscribe.ts +12 -2
  36. package/api/src/routes/coupons.ts +518 -0
  37. package/api/src/routes/index.ts +4 -0
  38. package/api/src/routes/invoices.ts +44 -3
  39. package/api/src/routes/meter-events.ts +2 -1
  40. package/api/src/routes/payment-currencies.ts +1 -0
  41. package/api/src/routes/promotion-codes.ts +482 -0
  42. package/api/src/routes/subscriptions.ts +23 -2
  43. package/api/src/routes/vendor.ts +89 -2
  44. package/api/src/store/migrations/20250904-discount.ts +136 -0
  45. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  46. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  47. package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
  48. package/api/src/store/models/checkout-session.ts +17 -2
  49. package/api/src/store/models/coupon.ts +144 -4
  50. package/api/src/store/models/discount.ts +23 -10
  51. package/api/src/store/models/index.ts +13 -2
  52. package/api/src/store/models/product-vendor.ts +6 -0
  53. package/api/src/store/models/promotion-code.ts +295 -18
  54. package/api/src/store/models/types.ts +30 -1
  55. package/api/tests/libs/session.spec.ts +48 -27
  56. package/blocklet.yml +1 -1
  57. package/package.json +20 -20
  58. package/src/app.tsx +2 -0
  59. package/src/components/customer/link.tsx +1 -1
  60. package/src/components/discount/discount-info.tsx +178 -0
  61. package/src/components/invoice/table.tsx +140 -48
  62. package/src/components/invoice-pdf/styles.ts +6 -0
  63. package/src/components/invoice-pdf/template.tsx +59 -33
  64. package/src/components/metadata/form.tsx +14 -5
  65. package/src/components/payment-link/actions.tsx +42 -0
  66. package/src/components/price/form.tsx +91 -65
  67. package/src/components/product/vendor-config.tsx +5 -3
  68. package/src/components/promotion/active-redemptions.tsx +534 -0
  69. package/src/components/promotion/currency-multi-select.tsx +350 -0
  70. package/src/components/promotion/currency-restrictions.tsx +117 -0
  71. package/src/components/promotion/product-select.tsx +292 -0
  72. package/src/components/promotion/promotion-code-form.tsx +534 -0
  73. package/src/components/subscription/portal/list.tsx +6 -1
  74. package/src/components/subscription/vendor-service-list.tsx +13 -2
  75. package/src/locales/en.tsx +227 -0
  76. package/src/locales/zh.tsx +222 -1
  77. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  78. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  79. package/src/pages/admin/products/coupons/create.tsx +612 -0
  80. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  81. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  82. package/src/pages/admin/products/coupons/index.tsx +210 -3
  83. package/src/pages/admin/products/index.tsx +22 -3
  84. package/src/pages/admin/products/products/detail.tsx +12 -2
  85. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  86. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  87. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  88. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  89. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  90. package/src/pages/admin/products/vendors/index.tsx +17 -5
  91. package/src/pages/customer/subscription/detail.tsx +5 -0
  92. package/vite.config.ts +4 -3
@@ -0,0 +1,136 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { createIndexIfNotExists, Migration, safeApplyColumnChanges } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await safeApplyColumnChanges(context, {
6
+ checkout_sessions: [
7
+ {
8
+ name: 'discounts',
9
+ field: {
10
+ type: DataTypes.JSON,
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ discounts: [
16
+ {
17
+ name: 'verification_method',
18
+ field: {
19
+ type: DataTypes.STRING(50),
20
+ allowNull: true,
21
+ },
22
+ },
23
+ {
24
+ name: 'verification_data',
25
+ field: {
26
+ type: DataTypes.JSON,
27
+ allowNull: true,
28
+ },
29
+ },
30
+ {
31
+ name: 'metadata',
32
+ field: {
33
+ type: DataTypes.JSON,
34
+ allowNull: true,
35
+ },
36
+ },
37
+ ],
38
+ promotion_codes: [
39
+ {
40
+ name: 'verification_type',
41
+ field: {
42
+ type: DataTypes.ENUM('code', 'nft', 'vc', 'user_restricted'),
43
+ defaultValue: 'code',
44
+ allowNull: false,
45
+ },
46
+ },
47
+ {
48
+ name: 'nft_config',
49
+ field: {
50
+ type: DataTypes.JSON,
51
+ allowNull: true,
52
+ },
53
+ },
54
+ {
55
+ name: 'vc_config',
56
+ field: {
57
+ type: DataTypes.JSON,
58
+ allowNull: true,
59
+ },
60
+ },
61
+ {
62
+ name: 'customer_dids',
63
+ field: {
64
+ type: DataTypes.JSON,
65
+ allowNull: true,
66
+ },
67
+ },
68
+ {
69
+ name: 'metadata',
70
+ field: {
71
+ type: DataTypes.JSON,
72
+ allowNull: true,
73
+ },
74
+ },
75
+ {
76
+ name: 'created_via',
77
+ field: {
78
+ type: DataTypes.ENUM('api', 'dashboard', 'portal'),
79
+ allowNull: true,
80
+ },
81
+ },
82
+ {
83
+ name: 'locked',
84
+ field: {
85
+ type: DataTypes.BOOLEAN,
86
+ allowNull: false,
87
+ defaultValue: false,
88
+ },
89
+ },
90
+ ],
91
+ coupons: [
92
+ {
93
+ name: 'created_via',
94
+ field: {
95
+ type: DataTypes.ENUM('api', 'dashboard', 'portal'),
96
+ allowNull: true,
97
+ },
98
+ },
99
+ {
100
+ name: 'locked',
101
+ field: {
102
+ type: DataTypes.BOOLEAN,
103
+ allowNull: false,
104
+ defaultValue: false,
105
+ },
106
+ },
107
+ ],
108
+ });
109
+
110
+ await createIndexIfNotExists(context, 'discounts', ['customer_id'], 'idx_discounts_customer_id');
111
+ await createIndexIfNotExists(
112
+ context,
113
+ 'promotion_codes',
114
+ ['verification_type', 'coupon_id'],
115
+ 'idx_promotion_codes_verification_type_coupon_id'
116
+ );
117
+ };
118
+
119
+ export const down: Migration = async ({ context }) => {
120
+ await context.removeIndex('discounts', 'idx_discounts_customer_id');
121
+ await context.removeIndex('promotion_codes', 'idx_promotion_codes_verification_type_coupon_id');
122
+
123
+ await context.removeColumn('checkout_sessions', 'discounts');
124
+ await context.removeColumn('discounts', 'verification_method');
125
+ await context.removeColumn('discounts', 'verification_data');
126
+ await context.removeColumn('discounts', 'metadata');
127
+ await context.removeColumn('promotion_codes', 'verification_type');
128
+ await context.removeColumn('promotion_codes', 'nft_config');
129
+ await context.removeColumn('promotion_codes', 'vc_config');
130
+ await context.removeColumn('promotion_codes', 'customer_dids');
131
+ await context.removeColumn('promotion_codes', 'metadata');
132
+ await context.removeColumn('promotion_codes', 'created_via');
133
+ await context.removeColumn('promotion_codes', 'locked');
134
+ await context.removeColumn('coupons', 'created_via');
135
+ await context.removeColumn('coupons', 'locked');
136
+ };
@@ -0,0 +1,116 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { Migration } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ const isColumnInteger = async (tableName: string, columnName: string): Promise<boolean> => {
6
+ const [results] = await context.sequelize.query(`PRAGMA table_info(${tableName})`);
7
+ const columnInfo = (results as any[]).find((col: any) => col.name === columnName);
8
+ return columnInfo && columnInfo.type.toUpperCase() === 'INTEGER';
9
+ };
10
+
11
+ const migrateDateToInteger = async (
12
+ tableName: string,
13
+ columnName: string,
14
+ options: { allowNull: boolean; convertAllRows?: boolean } = { allowNull: true }
15
+ ) => {
16
+ if (await isColumnInteger(tableName, columnName)) {
17
+ return;
18
+ }
19
+
20
+ const tempColumnName = `${columnName}_new`;
21
+
22
+ await context.addColumn(tableName, tempColumnName, {
23
+ type: DataTypes.INTEGER,
24
+ allowNull: true,
25
+ });
26
+
27
+ const updateQuery = options.convertAllRows
28
+ ? `UPDATE ${tableName} SET ${tempColumnName} = strftime('%s', ${columnName})`
29
+ : `UPDATE ${tableName} SET ${tempColumnName} = strftime('%s', ${columnName}) WHERE ${columnName} IS NOT NULL`;
30
+
31
+ await context.sequelize.query(updateQuery);
32
+
33
+ // Replace old column
34
+ await context.removeColumn(tableName, columnName);
35
+ await context.renameColumn(tableName, tempColumnName, columnName);
36
+
37
+ // Apply final column type and constraints
38
+ await context.changeColumn(tableName, columnName, {
39
+ type: DataTypes.INTEGER,
40
+ allowNull: options.allowNull,
41
+ });
42
+ };
43
+
44
+ // Define columns to migrate
45
+ const migrations = [
46
+ { table: 'promotion_codes', column: 'expires_at', allowNull: true },
47
+ { table: 'coupons', column: 'redeem_by', allowNull: true },
48
+ { table: 'discounts', column: 'start', allowNull: false, convertAllRows: true },
49
+ { table: 'discounts', column: 'end', allowNull: true },
50
+ ];
51
+
52
+ // Execute migrations
53
+ for (const migration of migrations) {
54
+ // eslint-disable-next-line no-await-in-loop
55
+ await migrateDateToInteger(migration.table, migration.column, {
56
+ allowNull: migration.allowNull,
57
+ convertAllRows: migration.convertAllRows,
58
+ });
59
+ }
60
+ };
61
+
62
+ export const down: Migration = async ({ context }) => {
63
+ const isColumnDate = async (tableName: string, columnName: string): Promise<boolean> => {
64
+ const [results] = await context.sequelize.query(`PRAGMA table_info(${tableName})`);
65
+ const columnInfo = (results as any[]).find((col: any) => col.name === columnName);
66
+ return columnInfo && (columnInfo.type.toUpperCase() === 'DATE' || columnInfo.type.toUpperCase() === 'DATETIME');
67
+ };
68
+
69
+ const migrateIntegerToDate = async (
70
+ tableName: string,
71
+ columnName: string,
72
+ options: { allowNull: boolean; convertAllRows?: boolean } = { allowNull: true }
73
+ ) => {
74
+ if (await isColumnDate(tableName, columnName)) {
75
+ return;
76
+ }
77
+
78
+ const tempColumnName = `${columnName}_old`;
79
+
80
+ // Add temporary DATE column
81
+ await context.addColumn(tableName, tempColumnName, {
82
+ type: DataTypes.DATE,
83
+ allowNull: true,
84
+ });
85
+
86
+ const updateQuery = options.convertAllRows
87
+ ? `UPDATE ${tableName} SET ${tempColumnName} = datetime(${columnName}, 'unixepoch')`
88
+ : `UPDATE ${tableName} SET ${tempColumnName} = datetime(${columnName}, 'unixepoch') WHERE ${columnName} IS NOT NULL`;
89
+
90
+ await context.sequelize.query(updateQuery);
91
+
92
+ // Replace old column
93
+ await context.removeColumn(tableName, columnName);
94
+ await context.renameColumn(tableName, tempColumnName, columnName);
95
+
96
+ await context.changeColumn(tableName, columnName, {
97
+ type: DataTypes.DATE,
98
+ allowNull: options.allowNull,
99
+ });
100
+ };
101
+
102
+ // Define columns to rollback (reverse order)
103
+ const rollbacks = [
104
+ { table: 'discounts', column: 'end', allowNull: true },
105
+ { table: 'discounts', column: 'start', allowNull: false, convertAllRows: true },
106
+ { table: 'coupons', column: 'redeem_by', allowNull: true },
107
+ { table: 'promotion_codes', column: 'expires_at', allowNull: true },
108
+ ];
109
+ for (const rollback of rollbacks) {
110
+ // eslint-disable-next-line no-await-in-loop
111
+ await migrateIntegerToDate(rollback.table, rollback.column, {
112
+ allowNull: rollback.allowNull,
113
+ convertAllRows: rollback.convertAllRows,
114
+ });
115
+ }
116
+ };
@@ -0,0 +1,30 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { Migration, safeApplyColumnChanges } from '../migrate';
3
+
4
+ export const up: Migration = async ({ context }) => {
5
+ await safeApplyColumnChanges(context, {
6
+ coupons: [
7
+ {
8
+ name: 'description',
9
+ field: {
10
+ type: DataTypes.TEXT,
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ promotion_codes: [
16
+ {
17
+ name: 'description',
18
+ field: {
19
+ type: DataTypes.TEXT,
20
+ allowNull: true,
21
+ },
22
+ },
23
+ ],
24
+ });
25
+ };
26
+
27
+ export const down: Migration = async ({ context }) => {
28
+ await context.removeColumn('coupons', 'description');
29
+ await context.removeColumn('promotion_codes', 'description');
30
+ };
@@ -0,0 +1,20 @@
1
+ import { safeApplyColumnChanges, type Migration } from '../migrate';
2
+
3
+ export const up: Migration = async ({ context }) => {
4
+ // Add extends column to product_vendors table
5
+ await safeApplyColumnChanges(context, {
6
+ product_vendors: [
7
+ {
8
+ name: 'extends',
9
+ field: {
10
+ type: 'JSON',
11
+ allowNull: true,
12
+ },
13
+ },
14
+ ],
15
+ });
16
+ };
17
+
18
+ export const down: Migration = async ({ context }) => {
19
+ await context.removeColumn('product_vendors', 'extends');
20
+ };
@@ -196,6 +196,14 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
196
196
 
197
197
  declare cross_sell_behavior?: LiteralUnion<'auto' | 'required', string>;
198
198
 
199
+ // FIXME: Only exist on creation
200
+ declare discounts?: Array<{
201
+ coupon?: string;
202
+ promotion_code?: string;
203
+ discount_amount?: string;
204
+ verification_method?: string;
205
+ verification_data?: Record<string, any>;
206
+ }>;
199
207
  declare fulfillment_status?: LiteralUnion<
200
208
  | 'pending'
201
209
  | 'processing'
@@ -204,7 +212,9 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
204
212
  | 'cancelled'
205
213
  | 'max_retries_exceeded'
206
214
  | 'return_requested'
207
- | 'sent',
215
+ | 'sent'
216
+ | 'returning'
217
+ | 'returned',
208
218
  string
209
219
  >;
210
220
  declare vendor_info?: Array<{
@@ -219,7 +229,8 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
219
229
  | 'cancelled'
220
230
  | 'max_retries_exceeded'
221
231
  | 'return_requested'
222
- | 'sent';
232
+ | 'sent'
233
+ | 'returned';
223
234
  service_url?: string;
224
235
  app_url?: string;
225
236
  error_message?: string;
@@ -425,6 +436,10 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
425
436
  this.init(
426
437
  {
427
438
  ...CheckoutSession.GENESIS_ATTRIBUTES,
439
+ discounts: {
440
+ type: DataTypes.JSON,
441
+ allowNull: true,
442
+ },
428
443
  payment_intent_data: {
429
444
  type: DataTypes.JSON,
430
445
  allowNull: true,
@@ -1,8 +1,13 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
2
  import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
3
  import type { LiteralUnion } from 'type-fest';
4
+ import { fromTokenToUnit } from '@ocap/util';
4
5
 
5
- import { createCodeGenerator } from '../../libs/util';
6
+ import { createEvent } from '../../libs/audit';
7
+ import { createCodeGenerator, formatMetadata } from '../../libs/util';
8
+ import { trimDecimals } from '../../libs/math-utils';
9
+ import logger from '../../libs/logger';
10
+ import type { TPaymentCurrency } from './payment-currency';
6
11
 
7
12
  const nextId = createCodeGenerator('', 8);
8
13
 
@@ -10,6 +15,7 @@ const nextId = createCodeGenerator('', 8);
10
15
  export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttributes<Coupon>> {
11
16
  declare id: CreationOptional<string>;
12
17
  declare livemode: boolean;
18
+ declare locked: CreationOptional<boolean>;
13
19
 
14
20
  declare amount_off: string;
15
21
  declare percent_off: number;
@@ -22,23 +28,32 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
22
28
 
23
29
  declare name: string;
24
30
 
31
+ declare description?: string;
32
+
25
33
  declare metadata: Record<string, any>;
26
34
 
27
35
  declare applies_to?: {
28
36
  products: string[];
29
37
  };
30
38
 
31
- declare currency_options?: Record<string, number>;
39
+ declare currency_options?: Record<
40
+ string,
41
+ {
42
+ amount_off: string;
43
+ }
44
+ >;
32
45
 
33
46
  declare max_redemptions?: number;
34
47
 
35
- declare redeem_by?: Date;
48
+ declare redeem_by?: number;
36
49
 
37
50
  declare times_redeemed?: number;
38
51
 
39
52
  declare valid?: boolean;
53
+ declare created_via: LiteralUnion<'api' | 'dashboard' | 'portal', string>;
40
54
 
41
55
  declare created_at: CreationOptional<Date>;
56
+
42
57
  declare updated_at: CreationOptional<Date>;
43
58
 
44
59
  public static readonly GENESIS_ATTRIBUTES = {
@@ -52,6 +67,10 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
52
67
  type: DataTypes.BOOLEAN,
53
68
  allowNull: false,
54
69
  },
70
+ locked: {
71
+ type: DataTypes.BOOLEAN,
72
+ defaultValue: false,
73
+ },
55
74
  amount_off: {
56
75
  type: DataTypes.STRING(32),
57
76
  defaultValue: '0',
@@ -63,18 +82,25 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
63
82
  currency_id: {
64
83
  type: DataTypes.STRING(15),
65
84
  allowNull: false,
85
+ defaultValue: '',
66
86
  },
67
87
  duration: {
68
88
  type: DataTypes.ENUM('forever', 'once', 'repeating'),
89
+ allowNull: false,
69
90
  },
70
91
  duration_in_months: {
71
92
  type: DataTypes.INTEGER,
72
93
  allowNull: false,
94
+ defaultValue: 0,
73
95
  },
74
96
  name: {
75
97
  type: DataTypes.STRING(64),
76
98
  allowNull: false,
77
99
  },
100
+ description: {
101
+ type: DataTypes.TEXT,
102
+ allowNull: true,
103
+ },
78
104
  applies_to: {
79
105
  type: DataTypes.JSON,
80
106
  allowNull: true,
@@ -88,7 +114,7 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
88
114
  allowNull: true,
89
115
  },
90
116
  redeem_by: {
91
- type: DataTypes.DATE,
117
+ type: DataTypes.INTEGER,
92
118
  allowNull: true,
93
119
  },
94
120
  times_redeemed: {
@@ -103,6 +129,10 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
103
129
  type: DataTypes.JSON,
104
130
  allowNull: true,
105
131
  },
132
+ created_via: {
133
+ type: DataTypes.ENUM('api', 'dashboard', 'portal'),
134
+ allowNull: false,
135
+ },
106
136
  created_at: {
107
137
  type: DataTypes.DATE,
108
138
  defaultValue: DataTypes.NOW,
@@ -122,6 +152,14 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
122
152
  tableName: 'coupons',
123
153
  createdAt: 'created_at',
124
154
  updatedAt: 'updated_at',
155
+ hooks: {
156
+ afterCreate: (model: Coupon, options) =>
157
+ createEvent('Coupon', 'coupon.created', model, options).catch(console.error),
158
+ afterUpdate: (model: Coupon, options) =>
159
+ createEvent('Coupon', 'coupon.updated', model, options).catch(console.error),
160
+ afterDestroy: (model: Coupon, options) =>
161
+ createEvent('Coupon', 'coupon.deleted', model, options).catch(console.error),
162
+ },
125
163
  });
126
164
  }
127
165
 
@@ -132,6 +170,108 @@ export class Coupon extends Model<InferAttributes<Coupon>, InferCreationAttribut
132
170
  as: 'currency',
133
171
  });
134
172
  }
173
+
174
+ public static formatBeforeSave(coupon: Partial<TCoupon>) {
175
+ if (coupon.metadata) {
176
+ coupon.metadata = formatMetadata(coupon.metadata);
177
+ }
178
+
179
+ if (coupon.max_redemptions) {
180
+ coupon.max_redemptions = Number(coupon.max_redemptions);
181
+ }
182
+
183
+ if (coupon.times_redeemed) {
184
+ coupon.times_redeemed = Number(coupon.times_redeemed);
185
+ }
186
+
187
+ if (coupon.duration === 'repeating') {
188
+ coupon.duration_in_months = Number(coupon.duration_in_months || 0);
189
+ }
190
+
191
+ return coupon;
192
+ }
193
+
194
+ public static async insert(coupon: Partial<TCoupon>) {
195
+ const formattedData = this.formatBeforeSave({
196
+ locked: false,
197
+ times_redeemed: 0,
198
+ valid: true,
199
+ metadata: {},
200
+ amount_off: '0',
201
+ currency_options: {},
202
+ currency_id: '',
203
+ duration_in_months: 0,
204
+ ...coupon,
205
+ });
206
+ logger.info('Formatted data', { formattedData });
207
+ try {
208
+ return await this.create(formattedData as any);
209
+ } catch (error) {
210
+ logger.error('Coupon creation error details:', {
211
+ error: error.message,
212
+ formattedData,
213
+ validationErrors: error.errors || [],
214
+ sqlMessage: error.original?.message || 'No SQL message',
215
+ });
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ public async isUsed() {
221
+ const { Discount } = this.sequelize.models;
222
+ const discountCount = await Discount!.count({
223
+ where: { coupon_id: this.id },
224
+ });
225
+
226
+ if (discountCount > 0) {
227
+ return true;
228
+ }
229
+
230
+ return false;
231
+ }
232
+
233
+ public async getAppliedProducts() {
234
+ if (!this.applies_to || !this.applies_to?.products) {
235
+ return [];
236
+ }
237
+ const { Product } = this.sequelize.models;
238
+ const productsWithPrices = await Promise.all(
239
+ // @ts-ignore
240
+ this.applies_to.products.map((productId) => Product!.expand(productId))
241
+ );
242
+ return productsWithPrices;
243
+ }
244
+
245
+ /**
246
+ * Format currency options for storage - convert unit amounts to token amounts
247
+ */
248
+ public static formatCurrencyOptions(
249
+ currencyOptions: Record<string, { amount_off: string }>,
250
+ currencies: TPaymentCurrency[]
251
+ ): Record<
252
+ string,
253
+ {
254
+ amount_off: string;
255
+ }
256
+ > {
257
+ const formatted: Record<string, { amount_off: string }> = {};
258
+
259
+ for (const [currencyId, amountInfo] of Object.entries(currencyOptions || {})) {
260
+ const currency = currencies.find((c) => c.id === currencyId);
261
+ if (!currency) {
262
+ throw new Error(`currency ${currencyId} used in coupon not found or inactive`);
263
+ }
264
+
265
+ formatted[currencyId] = {
266
+ amount_off: fromTokenToUnit(
267
+ trimDecimals(amountInfo.amount_off || '0', currency.decimal),
268
+ currency.decimal
269
+ ).toString(),
270
+ };
271
+ }
272
+
273
+ return formatted;
274
+ }
135
275
  }
136
276
 
137
277
  export type TCoupon = InferAttributes<Coupon>;
@@ -20,7 +20,11 @@ export class Discount extends Model<InferAttributes<Discount>, InferCreationAttr
20
20
  declare invoice_item_id?: string;
21
21
 
22
22
  declare start: number;
23
- declare end: number;
23
+ declare end: number | null;
24
+
25
+ declare verification_method?: string;
26
+ declare verification_data?: Record<string, any>;
27
+ declare metadata?: Record<string, any>;
24
28
 
25
29
  declare created_at: CreationOptional<Date>;
26
30
  declare updated_at: CreationOptional<Date>;
@@ -65,11 +69,19 @@ export class Discount extends Model<InferAttributes<Discount>, InferCreationAttr
65
69
  allowNull: true,
66
70
  },
67
71
  start: {
68
- type: DataTypes.DATE,
72
+ type: DataTypes.INTEGER,
69
73
  allowNull: false,
70
74
  },
71
75
  end: {
72
- type: DataTypes.DATE,
76
+ type: DataTypes.INTEGER,
77
+ allowNull: true,
78
+ },
79
+ verification_method: {
80
+ type: DataTypes.STRING(50),
81
+ allowNull: true,
82
+ },
83
+ verification_data: {
84
+ type: DataTypes.JSON,
73
85
  allowNull: true,
74
86
  },
75
87
  metadata: {
@@ -98,18 +110,19 @@ export class Discount extends Model<InferAttributes<Discount>, InferCreationAttr
98
110
  });
99
111
  }
100
112
 
101
- // FIXME:
102
113
  public static associate(models: any) {
103
- this.hasOne(models.Customer, {
104
- sourceKey: 'customer_id',
105
- foreignKey: 'id',
114
+ this.belongsTo(models.Customer, {
115
+ foreignKey: 'customer_id',
106
116
  as: 'customer',
107
117
  });
108
- this.hasOne(models.Coupon, {
109
- sourceKey: 'coupon_id',
110
- foreignKey: 'id',
118
+ this.belongsTo(models.Coupon, {
119
+ foreignKey: 'coupon_id',
111
120
  as: 'coupon',
112
121
  });
122
+ this.belongsTo(models.PromotionCode, {
123
+ foreignKey: 'promotion_code_id',
124
+ as: 'promotionCode',
125
+ });
113
126
  }
114
127
  }
115
128
 
@@ -1,5 +1,5 @@
1
1
  import { CheckoutSession, TCheckoutSession } from './checkout-session';
2
- import { Coupon } from './coupon';
2
+ import { Coupon, TCoupon } from './coupon';
3
3
  import { Customer, TCustomer } from './customer';
4
4
  import { Discount } from './discount';
5
5
  import { Event, TEvent } from './event';
@@ -16,7 +16,7 @@ import { Payout, TPayout } from './payout';
16
16
  import { Price, TPrice } from './price';
17
17
  import { PricingTable, TPricingTable } from './pricing-table';
18
18
  import { Product, TProduct } from './product';
19
- import { PromotionCode } from './promotion-code';
19
+ import { PromotionCode, TPromotionCode } from './promotion-code';
20
20
  import { Refund, TRefund } from './refund';
21
21
  import { SetupIntent, TSetupIntent } from './setup-intent';
22
22
  import { Subscription, TSubscription } from './subscription';
@@ -321,3 +321,14 @@ export type CreditGrantSummary = {
321
321
  remainingAmount: string;
322
322
  grantCount: number;
323
323
  };
324
+
325
+ export type TCouponExpanded = TCoupon & {
326
+ object: 'coupon';
327
+ applied_products?: TProduct[];
328
+ promotion_codes?: TPromotionCode[];
329
+ };
330
+
331
+ export type TPromotionCodeExpanded = TPromotionCode & {
332
+ object: 'promotion_code';
333
+ coupon?: TCoupon;
334
+ };
@@ -19,6 +19,7 @@ export class ProductVendor extends Model<InferAttributes<ProductVendor>, InferCr
19
19
 
20
20
  declare status: 'active' | 'inactive';
21
21
  declare metadata: Record<string, any>;
22
+ declare extends: Record<string, any>;
22
23
 
23
24
  declare created_by: string;
24
25
  declare created_at: CreationOptional<Date>;
@@ -75,6 +76,11 @@ export class ProductVendor extends Model<InferAttributes<ProductVendor>, InferCr
75
76
  allowNull: true,
76
77
  defaultValue: {},
77
78
  },
79
+ extends: {
80
+ type: DataTypes.JSON,
81
+ allowNull: true,
82
+ defaultValue: {},
83
+ },
78
84
  created_by: {
79
85
  type: DataTypes.STRING(30),
80
86
  allowNull: true,