payment-kit 1.20.11 → 1.20.12

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 (80) hide show
  1. package/api/src/index.ts +2 -0
  2. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  3. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  4. package/api/src/integrations/stripe/resource.ts +253 -2
  5. package/api/src/libs/currency.ts +31 -0
  6. package/api/src/libs/discount/coupon.ts +1061 -0
  7. package/api/src/libs/discount/discount.ts +349 -0
  8. package/api/src/libs/discount/nft.ts +239 -0
  9. package/api/src/libs/discount/redemption.ts +636 -0
  10. package/api/src/libs/discount/vc.ts +73 -0
  11. package/api/src/libs/invoice.ts +44 -10
  12. package/api/src/libs/math-utils.ts +6 -0
  13. package/api/src/libs/price.ts +43 -0
  14. package/api/src/libs/session.ts +242 -57
  15. package/api/src/libs/subscription.ts +2 -6
  16. package/api/src/queues/auto-recharge.ts +1 -1
  17. package/api/src/queues/discount-status.ts +200 -0
  18. package/api/src/queues/subscription.ts +98 -5
  19. package/api/src/queues/usage-record.ts +1 -1
  20. package/api/src/routes/auto-recharge-configs.ts +5 -3
  21. package/api/src/routes/checkout-sessions.ts +755 -64
  22. package/api/src/routes/connect/change-payment.ts +6 -1
  23. package/api/src/routes/connect/change-plan.ts +6 -1
  24. package/api/src/routes/connect/setup.ts +6 -1
  25. package/api/src/routes/connect/shared.ts +80 -9
  26. package/api/src/routes/connect/subscribe.ts +12 -2
  27. package/api/src/routes/coupons.ts +518 -0
  28. package/api/src/routes/index.ts +4 -0
  29. package/api/src/routes/invoices.ts +44 -3
  30. package/api/src/routes/meter-events.ts +2 -1
  31. package/api/src/routes/payment-currencies.ts +1 -0
  32. package/api/src/routes/promotion-codes.ts +482 -0
  33. package/api/src/routes/subscriptions.ts +23 -2
  34. package/api/src/store/migrations/20250904-discount.ts +136 -0
  35. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  36. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  37. package/api/src/store/models/checkout-session.ts +12 -0
  38. package/api/src/store/models/coupon.ts +144 -4
  39. package/api/src/store/models/discount.ts +23 -10
  40. package/api/src/store/models/index.ts +13 -2
  41. package/api/src/store/models/promotion-code.ts +295 -18
  42. package/api/src/store/models/types.ts +30 -1
  43. package/api/tests/libs/session.spec.ts +48 -27
  44. package/blocklet.yml +1 -1
  45. package/package.json +20 -20
  46. package/src/app.tsx +2 -0
  47. package/src/components/customer/link.tsx +1 -1
  48. package/src/components/discount/discount-info.tsx +178 -0
  49. package/src/components/invoice/table.tsx +140 -48
  50. package/src/components/invoice-pdf/styles.ts +6 -0
  51. package/src/components/invoice-pdf/template.tsx +59 -33
  52. package/src/components/metadata/form.tsx +14 -5
  53. package/src/components/payment-link/actions.tsx +42 -0
  54. package/src/components/price/form.tsx +91 -65
  55. package/src/components/product/vendor-config.tsx +5 -3
  56. package/src/components/promotion/active-redemptions.tsx +534 -0
  57. package/src/components/promotion/currency-multi-select.tsx +350 -0
  58. package/src/components/promotion/currency-restrictions.tsx +117 -0
  59. package/src/components/promotion/product-select.tsx +292 -0
  60. package/src/components/promotion/promotion-code-form.tsx +534 -0
  61. package/src/components/subscription/portal/list.tsx +6 -1
  62. package/src/components/subscription/vendor-service-list.tsx +13 -2
  63. package/src/locales/en.tsx +227 -0
  64. package/src/locales/zh.tsx +222 -1
  65. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  66. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  67. package/src/pages/admin/products/coupons/create.tsx +612 -0
  68. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  69. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  70. package/src/pages/admin/products/coupons/index.tsx +210 -3
  71. package/src/pages/admin/products/index.tsx +22 -3
  72. package/src/pages/admin/products/products/detail.tsx +12 -2
  73. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  74. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  75. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  76. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  77. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  78. package/src/pages/admin/products/vendors/index.tsx +17 -5
  79. package/src/pages/customer/subscription/detail.tsx +5 -0
  80. package/vite.config.ts +4 -3
@@ -0,0 +1,200 @@
1
+ import pAll from 'p-all';
2
+ import createQueue from '../libs/queue';
3
+ import { Coupon, PromotionCode } from '../store/models';
4
+ import logger from '../libs/logger';
5
+ import { events } from '../libs/event';
6
+ import { validCoupon, validPromotionCode } from '../libs/discount/coupon';
7
+
8
+ export type DiscountType = 'coupon' | 'promotion-code';
9
+
10
+ export interface DiscountStatusJobData {
11
+ type: DiscountType;
12
+ id: string;
13
+ }
14
+
15
+ /**
16
+ * Process discount status check for both coupons and promotion codes
17
+ */
18
+ export async function processDiscountStatus(job: DiscountStatusJobData) {
19
+ logger.info('Processing discount status check job', { job });
20
+ const { type, id } = job;
21
+
22
+ if (type === 'coupon') {
23
+ await processCouponStatus(id);
24
+ } else if (type === 'promotion-code') {
25
+ await processPromotionCodeStatus(id);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Process coupon status check
31
+ */
32
+ async function processCouponStatus(couponId: string) {
33
+ const coupon = await Coupon.findByPk(couponId);
34
+ if (!coupon) {
35
+ return;
36
+ }
37
+
38
+ // Skip if already invalid
39
+ if (!coupon.valid) {
40
+ logger.info('Coupon is already invalid, skipping', { couponId });
41
+ return;
42
+ }
43
+
44
+ // Use the existing validCoupon function to check validity
45
+ const validation = validCoupon(coupon);
46
+
47
+ if (!validation.valid) {
48
+ // Mark coupon as invalid
49
+ await coupon.update({ valid: false, metadata: { ...coupon.metadata, invalid_reason: validation.reason } });
50
+ logger.info('Coupon marked as invalid', {
51
+ couponId,
52
+ reason: validation.reason,
53
+ redeem_by: coupon.redeem_by,
54
+ max_redemptions: coupon.max_redemptions,
55
+ times_redeemed: coupon.times_redeemed,
56
+ });
57
+ const promotionCodes = await PromotionCode.findAll({ where: { coupon_id: couponId, active: true } });
58
+ await pAll(
59
+ promotionCodes.map(
60
+ (promotionCode) => () =>
61
+ promotionCode.update({
62
+ active: false,
63
+ metadata: { ...promotionCode.metadata, invalid_reason: validation.reason },
64
+ })
65
+ ),
66
+ { concurrency: 5 }
67
+ );
68
+ logger.info('Promotion codes marked as inactive', {
69
+ promotionCodeIds: promotionCodes.map((pc) => pc.id),
70
+ reason: validation.reason,
71
+ });
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Process promotion code status check
77
+ */
78
+ async function processPromotionCodeStatus(promotionCodeId: string) {
79
+ const promotionCode = await PromotionCode.findByPk(promotionCodeId);
80
+ if (!promotionCode) {
81
+ logger.warn('Promotion code not found for status check', { promotionCodeId });
82
+ return;
83
+ }
84
+
85
+ // Skip if already inactive
86
+ if (!promotionCode.active) {
87
+ logger.info('Promotion code is already inactive, skipping', { promotionCodeId });
88
+ return;
89
+ }
90
+
91
+ // Use the existing validPromotionCode function to check validity
92
+ const validation = await validPromotionCode(promotionCode, {});
93
+
94
+ if (!validation.valid) {
95
+ // Mark promotion code as inactive
96
+ await promotionCode.update({
97
+ active: false,
98
+ metadata: { ...promotionCode.metadata, invalid_reason: validation.reason },
99
+ });
100
+ logger.info('Promotion code marked as inactive', {
101
+ promotionCodeId,
102
+ reason: validation.reason,
103
+ expires_at: promotionCode.expires_at,
104
+ max_redemptions: promotionCode.max_redemptions,
105
+ times_redeemed: promotionCode.times_redeemed,
106
+ });
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Schedule discount status monitoring jobs
112
+ */
113
+ export async function addDiscountStatusJob(
114
+ record: Coupon | PromotionCode,
115
+ type: DiscountType = 'coupon',
116
+ replace: boolean = false,
117
+ options: { delay?: number; runAt?: number } = {}
118
+ ) {
119
+ const { id } = record;
120
+ const jobId = `discount-status-${type}-${id}`;
121
+
122
+ // Remove existing job if present
123
+ const existingJob = await discountStatusQueue.get(jobId);
124
+ if (existingJob && !replace) {
125
+ logger.info('Discount job already exists, skipping', { type, id, jobId });
126
+ return;
127
+ }
128
+ if (existingJob && replace) {
129
+ await discountStatusQueue.delete(jobId);
130
+ logger.info('Removed existing discount status job for update', { type, id, jobId });
131
+ }
132
+ discountStatusQueue.push({ id: jobId, job: { type, id }, ...options });
133
+ }
134
+
135
+ // Create unified discount status queue
136
+ export const discountStatusQueue = createQueue<DiscountStatusJobData>({
137
+ name: 'discount-status',
138
+ onJob: processDiscountStatus,
139
+ options: {
140
+ concurrency: 5,
141
+ maxRetries: 3,
142
+ enableScheduledJob: true,
143
+ },
144
+ });
145
+
146
+ export const startDiscountStatusQueue = async () => {
147
+ logger.info('Starting discount status queue...');
148
+
149
+ const coupons = await Coupon.findAll({ where: { valid: true } });
150
+ const promotionCodes = await PromotionCode.findAll({ where: { active: true } });
151
+ await pAll(
152
+ coupons.map(
153
+ (coupon) => () =>
154
+ addDiscountStatusJob(coupon, 'coupon', false, {
155
+ runAt: coupon.redeem_by,
156
+ })
157
+ ),
158
+ { concurrency: 5 }
159
+ );
160
+ await pAll(
161
+ promotionCodes.map(
162
+ (promotionCode) => () =>
163
+ addDiscountStatusJob(promotionCode, 'promotion-code', false, {
164
+ runAt: promotionCode.expires_at,
165
+ })
166
+ ),
167
+ { concurrency: 5 }
168
+ );
169
+ };
170
+
171
+ events.on('coupon.created', (coupon: Coupon) => {
172
+ logger.info('Received coupon.created event', { couponId: coupon.id });
173
+ addDiscountStatusJob(coupon, 'coupon', true, {
174
+ runAt: coupon.redeem_by,
175
+ });
176
+ });
177
+
178
+ events.on('promotion-code.created', (promotionCode: PromotionCode) => {
179
+ addDiscountStatusJob(promotionCode, 'promotion-code', true, {
180
+ runAt: promotionCode.expires_at,
181
+ });
182
+ });
183
+
184
+ events.on('promotion-code.updated', (promotionCode: PromotionCode) => {
185
+ if (promotionCode.active) {
186
+ addDiscountStatusJob(promotionCode, 'promotion-code', true, {
187
+ runAt: promotionCode.expires_at,
188
+ });
189
+ }
190
+ });
191
+
192
+ events.on('discount-status.queued', (record, type, replace) => {
193
+ try {
194
+ addDiscountStatusJob(record, type, replace);
195
+ events.emit('discount-status.queued.done', record, type, replace);
196
+ } catch (error) {
197
+ logger.error('Error in discount-status.queued', { recordId: record.id, type, replace });
198
+ events.emit('discount-status.queued.error', record, type, replace);
199
+ }
200
+ });
@@ -1,5 +1,6 @@
1
1
  import type { LiteralUnion } from 'type-fest';
2
2
 
3
+ import { BN } from '@ocap/util';
3
4
  import { Op } from 'sequelize';
4
5
  import { createEvent } from '../libs/audit';
5
6
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
@@ -34,8 +35,10 @@ import { Invoice } from '../store/models/invoice';
34
35
  import { Price } from '../store/models/price';
35
36
  import { Subscription } from '../store/models/subscription';
36
37
  import { SubscriptionItem } from '../store/models/subscription-item';
38
+ import { getValidDiscountsForSubscriptionBilling } from '../libs/discount/coupon';
37
39
  import { invoiceQueue } from './invoice';
38
40
  import { SubscriptionWillCanceledSchedule } from '../crons/subscription-will-canceled';
41
+ import { applySubscriptionDiscount } from '../libs/discount/discount';
39
42
 
40
43
  type SubscriptionJob = {
41
44
  subscriptionId: string;
@@ -190,7 +193,90 @@ const doHandleSubscriptionInvoice = async ({
190
193
  return null;
191
194
  }
192
195
 
193
- const amount = getSubscriptionCycleAmount(expandedItems, currency.id);
196
+ // Get valid discounts for this subscription billing period
197
+ const { validDiscounts, expiredDiscounts } = await getValidDiscountsForSubscriptionBilling({
198
+ subscriptionId: subscription.id,
199
+ customerId: subscription.customer_id,
200
+ });
201
+
202
+ logger.info('Valid discounts for subscription billing', {
203
+ subscriptionId: subscription.id,
204
+ validDiscounts: validDiscounts.map((d) => d.id),
205
+ });
206
+
207
+ // Log expired discounts for monitoring
208
+ if (expiredDiscounts.length > 0) {
209
+ logger.info('Found expired discounts during subscription billing', {
210
+ subscriptionId: subscription.id,
211
+ expiredDiscounts: expiredDiscounts.map((d) => d.id),
212
+ });
213
+ }
214
+
215
+ // Calculate subscription amount with discounts
216
+ const baseAmount = await getSubscriptionCycleAmount(expandedItems, currency.id);
217
+
218
+ // Apply discounts - simplified to use only one discount (first valid one)
219
+ const firstValidDiscount = validDiscounts?.[0] || null;
220
+ logger.info('First valid discount', {
221
+ subscriptionId: subscription.id,
222
+ firstValidDiscount: firstValidDiscount?.id,
223
+ });
224
+ let enhancedLineItems = expandedItems;
225
+ let discountSummary: {
226
+ appliedCoupon: string | null;
227
+ discountAmount: string;
228
+ totalDiscountAmount: string;
229
+ finalTotal: string;
230
+ } = {
231
+ appliedCoupon: null,
232
+ discountAmount: '0',
233
+ totalDiscountAmount: '0',
234
+ finalTotal: baseAmount.total,
235
+ };
236
+
237
+ if (firstValidDiscount) {
238
+ // Check if subscription is still in trial period for accurate discount calculation
239
+ const now = Math.floor(Date.now() / 1000);
240
+ const isTrialing = !!(subscription.trial_end && subscription.trial_end > now);
241
+
242
+ const discountApplication = await applySubscriptionDiscount({
243
+ lineItems: expandedItems,
244
+ discount: firstValidDiscount,
245
+ currency,
246
+ totalAmount: baseAmount.total,
247
+ billingContext: {
248
+ trialing: isTrialing,
249
+ },
250
+ });
251
+
252
+ logger.info('Discount application', {
253
+ subscriptionId: subscription.id,
254
+ discountApplication,
255
+ });
256
+
257
+ enhancedLineItems = discountApplication.enhancedLineItems;
258
+ discountSummary = discountApplication.discountSummary;
259
+ }
260
+
261
+ const { appliedCoupon, totalDiscountAmount, finalTotal } = discountSummary;
262
+
263
+ // Additional safety check to ensure final total is never negative
264
+ const safeFinalTotal = new BN(finalTotal || '0').lt(new BN('0')) ? '0' : finalTotal || '0';
265
+
266
+ // Prepare data for invoice creation
267
+ const appliedDiscounts = appliedCoupon && firstValidDiscount ? [firstValidDiscount.id] : [];
268
+ const discountBreakdownForInvoice =
269
+ appliedCoupon && firstValidDiscount ? [{ amount: totalDiscountAmount, discount: firstValidDiscount.id }] : [];
270
+
271
+ if (validDiscounts.length > 0) {
272
+ logger.info('Subscription billing discount calculation completed', {
273
+ subscriptionId: subscription.id,
274
+ originalTotal: baseAmount.total,
275
+ totalDiscount: totalDiscountAmount,
276
+ finalTotal,
277
+ appliedDiscounts: appliedDiscounts.length,
278
+ });
279
+ }
194
280
 
195
281
  const { invoice } = await ensureInvoiceAndItems({
196
282
  customer,
@@ -198,7 +284,7 @@ const doHandleSubscriptionInvoice = async ({
198
284
  subscription,
199
285
  trialing: false,
200
286
  metered: true,
201
- lineItems: expandedItems,
287
+ lineItems: enhancedLineItems,
202
288
  props: {
203
289
  livemode: subscription.livemode,
204
290
  description: `Subscription ${reason}`,
@@ -209,11 +295,18 @@ const doHandleSubscriptionInvoice = async ({
209
295
  status,
210
296
  billing_reason: `subscription_${reason}`,
211
297
  currency_id: subscription.currency_id,
212
- total: amount.total,
298
+ // Set correct subtotal (original amount) and total (after discount)
299
+ subtotal: baseAmount.total,
300
+ total: safeFinalTotal,
213
301
  payment_settings: subscription.payment_settings,
214
302
  default_payment_method_id: subscription.default_payment_method_id,
215
- metadata,
216
- } as Invoice,
303
+ // Discount information for Invoice
304
+ discounts: appliedDiscounts,
305
+ total_discount_amounts: discountBreakdownForInvoice,
306
+ metadata: {
307
+ ...metadata,
308
+ },
309
+ } as unknown as Invoice,
217
310
  });
218
311
 
219
312
  logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
@@ -4,7 +4,7 @@ import dayjs from '../libs/dayjs';
4
4
  import { getLock } from '../libs/lock';
5
5
  import logger from '../libs/logger';
6
6
  import createQueue from '../libs/queue';
7
- import { getPriceUintAmountByCurrency } from '../libs/session';
7
+ import { getPriceUintAmountByCurrency } from '../libs/price';
8
8
  import { Invoice, PaymentCurrency, Price, SubscriptionItem, TLineItemExpanded, UsageRecord } from '../store/models';
9
9
  import { Subscription } from '../store/models/subscription';
10
10
  import { invoiceQueue } from './invoice';
@@ -5,6 +5,7 @@ import { CustomError } from '@blocklet/error';
5
5
  import { Op } from 'sequelize';
6
6
  import sessionMiddleware from '@blocklet/sdk/lib/middlewares/session';
7
7
  import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
8
+ import { trimDecimals } from '../libs/math-utils';
8
9
  import {
9
10
  AutoRechargeConfig,
10
11
  Customer,
@@ -16,7 +17,8 @@ import {
16
17
  TPriceExpanded,
17
18
  } from '../store/models';
18
19
  import { isDelegationSufficientForPayment } from '../libs/payment';
19
- import { getPriceUintAmountByCurrency, validateStripePaymentAmounts } from '../libs/session';
20
+ import { validateStripePaymentAmounts } from '../libs/session';
21
+ import { getPriceUintAmountByCurrency } from '../libs/price';
20
22
  import { ensureStripeSetupIntentForAutoRecharge } from '../integrations/stripe/resource';
21
23
  import logger from '../libs/logger';
22
24
 
@@ -349,13 +351,13 @@ router.post('/submit', async (req, res) => {
349
351
  }
350
352
  let { threshold, daily_limits: dailyLimits } = configData;
351
353
  if (threshold) {
352
- threshold = fromTokenToUnit(parseFloat(threshold).toFixed(currency.decimal), currency.decimal).toString();
354
+ threshold = fromTokenToUnit(trimDecimals(threshold, currency.decimal), currency.decimal).toString();
353
355
  }
354
356
  if (dailyLimits) {
355
357
  dailyLimits = {
356
358
  max_attempts: dailyLimits.max_attempts || 0,
357
359
  max_amount: fromTokenToUnit(
358
- parseFloat(dailyLimits.max_amount || '0').toFixed(rechargeCurrency.decimal),
360
+ trimDecimals(dailyLimits.max_amount || '0', rechargeCurrency.decimal),
359
361
  rechargeCurrency.decimal
360
362
  ).toString(),
361
363
  };