payment-kit 1.20.20 → 1.20.21

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 (30) hide show
  1. package/api/src/integrations/stripe/resource.ts +1 -1
  2. package/api/src/libs/discount/coupon.ts +41 -73
  3. package/api/src/libs/invoice.ts +17 -0
  4. package/api/src/libs/notification/template/subscription-renew-failed.ts +22 -1
  5. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -0
  6. package/api/src/locales/en.ts +1 -0
  7. package/api/src/locales/zh.ts +1 -0
  8. package/api/src/queues/checkout-session.ts +2 -2
  9. package/api/src/routes/checkout-sessions.ts +84 -0
  10. package/api/src/routes/connect/collect-batch.ts +2 -2
  11. package/api/src/routes/connect/pay.ts +1 -1
  12. package/api/src/store/migrations/20250926-change-customer-did-unique.ts +49 -0
  13. package/api/src/store/models/customer.ts +1 -0
  14. package/api/tests/libs/coupon.spec.ts +219 -0
  15. package/api/tests/libs/discount.spec.ts +250 -0
  16. package/blocklet.yml +1 -1
  17. package/package.json +7 -7
  18. package/src/components/discount/discount-info.tsx +0 -1
  19. package/src/components/invoice/action.tsx +26 -0
  20. package/src/components/invoice/table.tsx +2 -9
  21. package/src/components/invoice-pdf/styles.ts +2 -0
  22. package/src/components/invoice-pdf/template.tsx +44 -12
  23. package/src/components/metadata/list.tsx +1 -0
  24. package/src/components/subscription/metrics.tsx +7 -3
  25. package/src/locales/en.tsx +7 -0
  26. package/src/locales/zh.tsx +7 -0
  27. package/src/pages/admin/billing/subscriptions/detail.tsx +11 -3
  28. package/src/pages/admin/products/coupons/applicable-products.tsx +20 -37
  29. package/src/pages/customer/invoice/detail.tsx +1 -1
  30. package/src/pages/customer/subscription/detail.tsx +12 -3
@@ -0,0 +1,219 @@
1
+ import {
2
+ validCoupon,
3
+ validPromotionCode,
4
+ calculateDiscountAmount,
5
+ checkPromotionCodeEligibility,
6
+ getValidDiscountsForSubscriptionBilling,
7
+ expandLineItemsWithCouponInfo,
8
+ expandDiscountsWithDetails,
9
+ createDiscountRecordsForCheckout,
10
+ updateSubscriptionDiscountReferences,
11
+ } from '../../src/libs/discount/coupon';
12
+ import { Coupon, Customer, Discount, PromotionCode, Subscription, PaymentCurrency } from '../../src/store/models';
13
+
14
+ jest.mock('../../src/libs/logger', () => ({
15
+ __esModule: true,
16
+ default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
17
+ }));
18
+
19
+ jest.mock('../../src/libs/event', () => ({ emitAsync: jest.fn() }));
20
+
21
+ describe('libs/discount/coupon.ts', () => {
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ jest.restoreAllMocks();
25
+ });
26
+
27
+ describe('validCoupon', () => {
28
+ it('rejects invalid or expired or fully redeemed coupon', () => {
29
+ expect(validCoupon({ valid: false } as any).valid).toBe(false);
30
+ const expired = Math.floor(Date.now() / 1000) - 10;
31
+ expect(validCoupon({ valid: true, redeem_by: expired } as any).valid).toBe(false);
32
+ expect(validCoupon({ valid: true, max_redemptions: 1, times_redeemed: 1 } as any).valid).toBe(false);
33
+ });
34
+
35
+ it('enforces applies_to products if provided', () => {
36
+ const coupon = { valid: true, applies_to: { products: ['p1'] } } as any;
37
+ const lineItems = [{ price: { product_id: 'p2' } }] as any;
38
+ const res = validCoupon(coupon, lineItems);
39
+ expect(res.valid).toBe(false);
40
+ });
41
+
42
+ it('passes when valid and applicable', () => {
43
+ const coupon = { valid: true } as any;
44
+ expect(validCoupon(coupon).valid).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('validPromotionCode', () => {
49
+ it('rejects inactive/expired/max redeemed', async () => {
50
+ const now = Math.floor(Date.now() / 1000);
51
+ await expect(validPromotionCode({ active: false } as any, {})).resolves.toEqual({
52
+ valid: false,
53
+ reason: expect.any(String),
54
+ });
55
+ await expect(validPromotionCode({ active: true, expires_at: now - 30 } as any, {})).resolves.toEqual({
56
+ valid: false,
57
+ reason: expect.any(String),
58
+ });
59
+ await expect(
60
+ validPromotionCode({ active: true, max_redemptions: 1, times_redeemed: 1 } as any, {})
61
+ ).resolves.toEqual({ valid: false, reason: expect.any(String) });
62
+ });
63
+
64
+ it('checks user restriction and first time transaction', async () => {
65
+ jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ did: 'did:user:1' } as any);
66
+ const pc: any = { active: true, verification_type: 'user_restricted', customer_dids: ['did:user:1'] };
67
+ await expect(validPromotionCode(pc, { customerId: 'c1' })).resolves.toEqual({ valid: true });
68
+
69
+ jest.spyOn(Discount, 'count').mockResolvedValue(1 as any);
70
+ const pc2: any = {
71
+ active: true,
72
+ restrictions: { first_time_transaction: true },
73
+ };
74
+ await expect(validPromotionCode(pc2, { customerId: 'c1' })).resolves.toEqual({
75
+ valid: false,
76
+ reason: expect.any(String),
77
+ });
78
+ });
79
+
80
+ it('checks minimum amount by currency', async () => {
81
+ jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'usd', decimal: 2, symbol: 'USD' } as any);
82
+ const pc: any = {
83
+ active: true,
84
+ restrictions: { minimum_amount: '200', minimum_amount_currency: 'usd' },
85
+ };
86
+ await expect(validPromotionCode(pc, { amount: '100', currencyId: 'usd' })).resolves.toEqual({
87
+ valid: false,
88
+ reason: expect.stringContaining('minimum purchase'),
89
+ });
90
+ });
91
+ });
92
+
93
+ describe('calculateDiscountAmount', () => {
94
+ it('handles percent_off and amount_off with cap', () => {
95
+ expect(
96
+ calculateDiscountAmount({ percent_off: 25 } as any, '400', { id: 'usd', decimal: 2, symbol: 'USD' } as any)
97
+ ).toBe('100');
98
+
99
+ expect(
100
+ calculateDiscountAmount({ amount_off: '150', currency_id: 'usd' } as any, '100', {
101
+ id: 'usd',
102
+ decimal: 2,
103
+ symbol: 'USD',
104
+ } as any)
105
+ ).toBe('100');
106
+ });
107
+ });
108
+
109
+ describe('checkPromotionCodeEligibility', () => {
110
+ it('validates promotion and coupon together', async () => {
111
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ valid: true } as any);
112
+ jest.spyOn(Customer, 'findByPkOrDid').mockResolvedValue({ did: 'did:user:1' } as any);
113
+ jest.spyOn(PaymentCurrency, 'findByPk').mockResolvedValue({ id: 'usd', decimal: 2, symbol: 'USD' } as any);
114
+ jest.spyOn(Discount, 'count').mockResolvedValue(0 as any);
115
+ const couponModule = await import('../../src/libs/discount/coupon');
116
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: true } as any);
117
+ const result = await checkPromotionCodeEligibility({
118
+ promotionCode: { active: true } as any,
119
+ couponId: 'c1',
120
+ customerId: 'u1',
121
+ amount: '100',
122
+ currencyId: 'usd',
123
+ });
124
+ expect(result.eligible).toBe(true);
125
+ });
126
+ });
127
+
128
+ describe('validateDiscountForBilling & getValidDiscountsForSubscriptionBilling', () => {
129
+ it('filters discounts by duration logic', async () => {
130
+ const now = Math.floor(Date.now() / 1000);
131
+ jest.spyOn(Discount, 'findAll').mockResolvedValue([
132
+ { id: 'd_once', coupon_id: 'c_once' },
133
+ { id: 'd_repired', coupon_id: 'c_repired', end: now - 1 },
134
+ { id: 'd_forever', coupon_id: 'c_forever' },
135
+ ] as any);
136
+
137
+ jest.spyOn(Coupon, 'findByPk').mockImplementation((id) => {
138
+ if (id === 'c_once') return { duration: 'once' } as any;
139
+ if (id === 'c_repired') return { duration: 'repeating' } as any;
140
+ return { duration: 'forever' } as any;
141
+ });
142
+
143
+ const { validDiscounts, expiredDiscounts } = await getValidDiscountsForSubscriptionBilling({
144
+ subscriptionId: 'sub_1',
145
+ customerId: 'u1',
146
+ });
147
+
148
+ expect(validDiscounts.map((d) => d.id)).toEqual(['d_forever']);
149
+ expect(expiredDiscounts.map((d) => d.id).sort()).toEqual(['d_once', 'd_repired'].sort());
150
+ });
151
+ });
152
+
153
+ describe('expandLineItemsWithCouponInfo & expandDiscountsWithDetails', () => {
154
+ it('expands discount info on line items', async () => {
155
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
156
+ id: 'c1',
157
+ name: 'c',
158
+ amount_off: '10',
159
+ percent_off: 0,
160
+ currency_id: 'usd',
161
+ duration: 'once',
162
+ } as any);
163
+ jest.spyOn(PromotionCode, 'findByPk').mockResolvedValue({ id: 'pc1', code: 'CODE1' } as any);
164
+
165
+ const items = [{ id: 'i1', discount_amounts: [{ amount: '10', coupon: 'c1' }] }] as any;
166
+
167
+ const res = await expandLineItemsWithCouponInfo(items, [{ coupon: 'c1', promotion_code: 'pc1' }], 'usd');
168
+ expect(res[0]?.discount_amounts[0]?.coupon.id).toBe('c1');
169
+ expect(res[0]?.discount_amounts[0]?.promotion_code.id).toBe('pc1');
170
+ });
171
+
172
+ it('expands discounts array with details', async () => {
173
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ id: 'c1', name: 'c' } as any);
174
+ jest.spyOn(PromotionCode, 'findByPk').mockResolvedValue({ id: 'pc1', code: 'CODE1' } as any);
175
+ const res = await expandDiscountsWithDetails([{ coupon: 'c1', promotion_code: 'pc1' }]);
176
+ expect(res[0].coupon_details.id).toBe('c1');
177
+ expect(res[0].promotion_code_details.id).toBe('pc1');
178
+ });
179
+ });
180
+
181
+ describe('createDiscountRecordsForCheckout & updateSubscriptionDiscountReferences', () => {
182
+ it('creates/updates records and usage counts per discount config', async () => {
183
+ const checkoutSession: any = {
184
+ id: 'cs_1',
185
+ discounts: [{ coupon: 'c1', promotion_code: 'pc1', discount_amount: '50' }],
186
+ };
187
+
188
+ jest.spyOn(Discount, 'findAll').mockResolvedValue([] as any);
189
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ id: 'c1', livemode: false, duration: 'once' } as any);
190
+ jest.spyOn(PromotionCode, 'findByPk').mockResolvedValue({ id: 'pc1', verification_type: 'none' } as any);
191
+ jest
192
+ .spyOn(Discount, 'create')
193
+ .mockImplementation(
194
+ (data: any) => Promise.resolve({ id: `d_${Date.now()}`, update: jest.fn(), ...data }) as any
195
+ );
196
+
197
+ // Stubs for usage update path
198
+ jest.spyOn(Coupon.prototype as any, 'update').mockResolvedValue(undefined);
199
+ jest.spyOn(PromotionCode.prototype as any, 'update').mockResolvedValue(undefined);
200
+
201
+ const { discountRecords, subscriptionsUpdated } = await createDiscountRecordsForCheckout({
202
+ checkoutSession,
203
+ customerId: 'u1',
204
+ subscriptionIds: ['sub_1'],
205
+ });
206
+
207
+ expect(discountRecords.length).toBeGreaterThan(0);
208
+ expect(subscriptionsUpdated).toEqual(['sub_1']);
209
+
210
+ // updateSubscriptionDiscountReferences
211
+ jest.spyOn(Subscription, 'update').mockResolvedValue([1] as any);
212
+ const { updatedSubscriptions } = await updateSubscriptionDiscountReferences({
213
+ discountRecords,
214
+ subscriptionsUpdated,
215
+ });
216
+ expect(updatedSubscriptions).toEqual(['sub_1']);
217
+ });
218
+ });
219
+ });
@@ -0,0 +1,250 @@
1
+ import {
2
+ applyDiscountsToLineItems,
3
+ applySubscriptionDiscount,
4
+ rollbackDiscountUsageForCheckoutSession,
5
+ } from '../../src/libs/discount/discount';
6
+ import { Coupon, Discount, CheckoutSession } from '../../src/store/models';
7
+
8
+ // Mock logger and event emitter to keep test output clean
9
+ jest.mock('../../src/libs/logger', () => ({
10
+ __esModule: true,
11
+ default: {
12
+ info: jest.fn(),
13
+ warn: jest.fn(),
14
+ error: jest.fn(),
15
+ debug: jest.fn(),
16
+ },
17
+ }));
18
+
19
+ jest.mock('../../src/libs/event', () => ({
20
+ emitAsync: jest.fn(),
21
+ }));
22
+
23
+ // Mock price util so we can control unit amounts per item
24
+ jest.mock('../../src/libs/price', () => ({
25
+ getPriceUintAmountByCurrency: (price: any, currencyId: string) => {
26
+ if (!price) return '0';
27
+ if (price.unit_amount_map && price.unit_amount_map[currencyId]) return String(price.unit_amount_map[currencyId]);
28
+ if (price.unit_amount) return String(price.unit_amount);
29
+ if (Array.isArray(price.currency_options)) {
30
+ const opt = price.currency_options.find((c: any) => c.currency_id === currencyId);
31
+ return String(opt?.unit_amount || '0');
32
+ }
33
+ return '0';
34
+ },
35
+ }));
36
+
37
+ describe('libs/discount/discount.ts', () => {
38
+ const currency = { id: 'usd', decimal: 2, symbol: 'USD' } as any;
39
+
40
+ beforeEach(() => {
41
+ jest.clearAllMocks();
42
+ jest.restoreAllMocks();
43
+ });
44
+
45
+ describe('applyDiscountsToLineItems - percent_off over eligible items', () => {
46
+ it('applies percentage discount only to eligible products and returns per-item breakdown', async () => {
47
+ const lineItems: any[] = [
48
+ {
49
+ id: 'li_1',
50
+ quantity: 1,
51
+ price: { product_id: 'p1', unit_amount_map: { usd: '100' } },
52
+ },
53
+ {
54
+ id: 'li_2',
55
+ quantity: 3,
56
+ price: { product_id: 'p2', unit_amount_map: { usd: '100' } }, // amount 300
57
+ },
58
+ {
59
+ id: 'li_3',
60
+ quantity: 2,
61
+ price: { product_id: 'p3', unit_amount_map: { usd: '50' } }, // ineligible
62
+ },
63
+ ];
64
+
65
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
66
+ id: 'c1',
67
+ percent_off: 50,
68
+ amount_off: null,
69
+ applies_to: { products: ['p1', 'p2'] },
70
+ valid: true,
71
+ } as any);
72
+
73
+ // Force coupon validation to pass
74
+ const couponModule = await import('../../src/libs/discount/coupon');
75
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: true } as any);
76
+
77
+ const result = await applyDiscountsToLineItems({
78
+ lineItems,
79
+ couponId: 'c1',
80
+ customerId: 'cust_1',
81
+ currency,
82
+ });
83
+
84
+ const { enhancedLineItems, discountSummary } = result;
85
+ // li_1 amount=100 -> 50% => 50; li_2 amount=300 -> 150; li_3 ineligible
86
+ const li1 = enhancedLineItems.find((x: any) => x.id === 'li_1');
87
+ const li2 = enhancedLineItems.find((x: any) => x.id === 'li_2');
88
+ const li3 = enhancedLineItems.find((x: any) => x.id === 'li_3');
89
+
90
+ expect(li1).toBeDefined();
91
+ expect(li2).toBeDefined();
92
+ expect(li3).toBeDefined();
93
+ expect((li1 as any).discountable).toBe(true);
94
+ expect((li1 as any).discount_amounts[0].amount).toBe('50');
95
+ expect((li2 as any).discountable).toBe(true);
96
+ expect((li2 as any).discount_amounts[0].amount).toBe('150');
97
+ expect((li3 as any).discountable).toBe(false);
98
+
99
+ expect(discountSummary.appliedCoupon).toBe('c1');
100
+ expect(discountSummary.totalDiscountAmount).toBe('200');
101
+ // base total = 100 + 300 + 100 = 500; final = 300
102
+ expect(discountSummary.finalTotal).toBe('300');
103
+ expect(result.notValidReason).toBeUndefined();
104
+ });
105
+ });
106
+
107
+ describe('applyDiscountsToLineItems - amount_off distributed proportionally', () => {
108
+ it('distributes fixed amount discount proportionally among eligible items', async () => {
109
+ const lineItems: any[] = [
110
+ { id: 'a', quantity: 1, price: { product_id: 'pa', unit_amount_map: { usd: '100' } } },
111
+ { id: 'b', quantity: 3, price: { product_id: 'pb', unit_amount_map: { usd: '100' } } }, // 300
112
+ ];
113
+
114
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
115
+ id: 'c2',
116
+ percent_off: 0,
117
+ amount_off: '150',
118
+ currency_id: 'usd',
119
+ applies_to: { products: [] },
120
+ valid: true,
121
+ } as any);
122
+
123
+ const couponModule = await import('../../src/libs/discount/coupon');
124
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: true } as any);
125
+
126
+ const { enhancedLineItems, discountSummary } = await applyDiscountsToLineItems({
127
+ lineItems,
128
+ couponId: 'c2',
129
+ customerId: 'cust_1',
130
+ currency,
131
+ });
132
+
133
+ // Total eligible = 400; adjustedDiscount=150
134
+ // a: 100/400 * 150 = 37; b: 300/400 * 150 = 112 (integer division)
135
+ const a = enhancedLineItems.find((x: any) => x.id === 'a');
136
+ const b = enhancedLineItems.find((x: any) => x.id === 'b');
137
+ expect(a).toBeDefined();
138
+ expect(b).toBeDefined();
139
+ expect((a as any).discount_amounts[0].amount).toBe('37');
140
+ expect((b as any).discount_amounts[0].amount).toBe('112');
141
+ expect(discountSummary.totalDiscountAmount).toBe('150');
142
+ expect(discountSummary.finalTotal).toBe('250');
143
+ });
144
+ });
145
+
146
+ describe('applyDiscountsToLineItems - invalid or missing coupon', () => {
147
+ it('returns not valid when coupon not found', async () => {
148
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue(null as any);
149
+ const { discountSummary, notValidReason } = await applyDiscountsToLineItems({
150
+ lineItems: [{ id: 'x', quantity: 1, price: { product_id: 'p1', unit_amount_map: { usd: '100' } } }] as any,
151
+ couponId: 'missing',
152
+ customerId: 'cust_1',
153
+ currency,
154
+ });
155
+ expect(discountSummary.totalDiscountAmount).toBe('0');
156
+ expect(notValidReason).toBe('Coupon not found');
157
+ });
158
+
159
+ it('returns not valid when validation fails', async () => {
160
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({ id: 'c3', valid: false } as any);
161
+ const couponModule = await import('../../src/libs/discount/coupon');
162
+ jest.spyOn(couponModule, 'validCoupon').mockReturnValue({ valid: false, reason: 'invalid' } as any);
163
+
164
+ const res = await applyDiscountsToLineItems({
165
+ lineItems: [{ id: 'x', quantity: 1, price: { product_id: 'p1', unit_amount_map: { usd: '100' } } }] as any,
166
+ couponId: 'c3',
167
+ customerId: 'cust_1',
168
+ currency,
169
+ });
170
+ expect(res.discountSummary.totalDiscountAmount).toBe('0');
171
+ expect(res.notValidReason).toBe('invalid');
172
+ });
173
+ });
174
+
175
+ describe('applySubscriptionDiscount', () => {
176
+ it('returns computed discount when coupon exists', async () => {
177
+ const items: any[] = [{ id: 'a', quantity: 2, price: { product_id: 'pa', unit_amount_map: { usd: '50' } } }];
178
+
179
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue({
180
+ id: 'c4',
181
+ percent_off: 20,
182
+ applies_to: { products: [] },
183
+ valid: true,
184
+ } as any);
185
+
186
+ const result = await applySubscriptionDiscount({
187
+ lineItems: items as any,
188
+ discount: { coupon_id: 'c4', id: 'd1' } as any,
189
+ currency,
190
+ });
191
+
192
+ expect(result.discountSummary.appliedCoupon).toBe('c4');
193
+ // amount = 2*50=100; 20% => 20; final 80
194
+ expect(result.discountSummary.totalDiscountAmount).toBe('20');
195
+ expect(result.discountSummary.finalTotal).toBe('80');
196
+ });
197
+
198
+ it('returns original items when coupon missing', async () => {
199
+ jest.spyOn(Coupon, 'findByPk').mockResolvedValue(null as any);
200
+ const items: any[] = [{ id: 'a', quantity: 1, price: { unit_amount_map: { usd: '100' } } }];
201
+ const res = await applySubscriptionDiscount({
202
+ lineItems: items as any,
203
+ discount: { coupon_id: 'missing', id: 'd2' } as any,
204
+ currency,
205
+ });
206
+ expect(res.discountSummary.totalDiscountAmount).toBe('0');
207
+ expect((res.enhancedLineItems[0] as any).discountable).toBe(false);
208
+ expect(res.discountSummary.finalTotal).toBe('100');
209
+ });
210
+ });
211
+
212
+ describe('rollbackDiscountUsageForCheckoutSession', () => {
213
+ it('rolls back confirmed discounts and destroys records', async () => {
214
+ jest.spyOn(CheckoutSession, 'findByPk').mockResolvedValue({ id: 'cs_1', status: 'open' } as any);
215
+
216
+ const coupon = { id: 'c1', times_redeemed: 2, update: jest.fn().mockResolvedValue(undefined) } as any;
217
+ const pc = { id: 'pc1', times_redeemed: 3, update: jest.fn().mockResolvedValue(undefined) } as any;
218
+
219
+ const discounts = [
220
+ { id: 'd1', confirmed: true, coupon, promotionCode: pc, destroy: jest.fn().mockResolvedValue(undefined) },
221
+ { id: 'd2', confirmed: true, coupon, promotionCode: pc, destroy: jest.fn().mockResolvedValue(undefined) },
222
+ { id: 'd3', confirmed: false, coupon, promotionCode: pc, destroy: jest.fn().mockResolvedValue(undefined) },
223
+ ] as any;
224
+
225
+ jest.spyOn(Discount, 'findAll').mockResolvedValue(discounts);
226
+
227
+ await rollbackDiscountUsageForCheckoutSession('cs_1');
228
+
229
+ // For unique coupon and promotion code, update should be called once each
230
+ expect(coupon.update).toHaveBeenCalledWith({ times_redeemed: 1, valid: true });
231
+ expect(pc.update).toHaveBeenCalledWith({ times_redeemed: 2, active: true });
232
+ // All discounts should be destroyed
233
+ discounts.forEach((d: any) => expect(d.destroy).toHaveBeenCalled());
234
+ });
235
+
236
+ it('early returns when session not found or completed', async () => {
237
+ const findByPkSpy = jest.spyOn(CheckoutSession, 'findByPk');
238
+ const discountFindAllSpy = jest.spyOn(Discount, 'findAll');
239
+
240
+ findByPkSpy.mockResolvedValueOnce(null as any);
241
+ await rollbackDiscountUsageForCheckoutSession('missing');
242
+
243
+ findByPkSpy.mockResolvedValueOnce({ id: 'cs_2', status: 'complete' } as any);
244
+ await rollbackDiscountUsageForCheckoutSession('cs_2');
245
+
246
+ // Should not query discounts in either case
247
+ expect(discountFindAllSpy).not.toHaveBeenCalled();
248
+ });
249
+ });
250
+ });
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.20.20
17
+ version: 1.20.21
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.20.20",
3
+ "version": "1.20.21",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -52,13 +52,13 @@
52
52
  "@arcblock/jwt": "^1.25.3",
53
53
  "@arcblock/ux": "^3.1.41",
54
54
  "@arcblock/validator": "^1.25.3",
55
- "@blocklet/did-space-js": "^1.1.26",
55
+ "@blocklet/did-space-js": "^1.1.27",
56
56
  "@blocklet/error": "^0.2.5",
57
57
  "@blocklet/js-sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
58
58
  "@blocklet/logger": "^1.16.52-beta-20250912-112002-e3499e9c",
59
- "@blocklet/payment-broker-client": "1.20.20",
60
- "@blocklet/payment-react": "1.20.20",
61
- "@blocklet/payment-vendor": "1.20.20",
59
+ "@blocklet/payment-broker-client": "1.20.21",
60
+ "@blocklet/payment-react": "1.20.21",
61
+ "@blocklet/payment-vendor": "1.20.21",
62
62
  "@blocklet/sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
63
63
  "@blocklet/ui-react": "^3.1.41",
64
64
  "@blocklet/uploader": "^0.2.12",
@@ -128,7 +128,7 @@
128
128
  "devDependencies": {
129
129
  "@abtnode/types": "^1.16.52-beta-20250912-112002-e3499e9c",
130
130
  "@arcblock/eslint-config-ts": "^0.3.3",
131
- "@blocklet/payment-types": "1.20.20",
131
+ "@blocklet/payment-types": "1.20.21",
132
132
  "@types/cookie-parser": "^1.4.9",
133
133
  "@types/cors": "^2.8.19",
134
134
  "@types/debug": "^4.1.12",
@@ -175,5 +175,5 @@
175
175
  "parser": "typescript"
176
176
  }
177
177
  },
178
- "gitHead": "0aa636abf410af4ba08cf03fd4a15ae3beab115d"
178
+ "gitHead": "43b26f9e7373a99000f6a14762fdab860a562967"
179
179
  }
@@ -92,7 +92,6 @@ export default function DiscountInfo({ discountStats = null }: DiscountInfoProps
92
92
  bgcolor: 'grey.50',
93
93
  borderRadius: 1.5,
94
94
  p: 2.5,
95
- my: 2,
96
95
  border: '1px solid',
97
96
  borderColor: 'divider',
98
97
  }}>
@@ -62,6 +62,16 @@ export default function InvoiceActions({ data, variant = 'compact', onChange, mo
62
62
  Toast.error(result.error);
63
63
  }
64
64
  }
65
+ if (state.action === 'retry-uncollectible') {
66
+ await api
67
+ .get('/api/invoices/retry-uncollectible', {
68
+ params: {
69
+ invoiceId: data.id,
70
+ },
71
+ })
72
+ .then((res) => res.data);
73
+ Toast.success(t('admin.invoice.retryUncollectible.success'));
74
+ }
65
75
  if (state.action === 'void') {
66
76
  await api.post(`/api/invoices/${data.id}/void`).then((res) => res.data);
67
77
  Toast.success(t('admin.invoice.void.success'));
@@ -89,6 +99,12 @@ export default function InvoiceActions({ data, variant = 'compact', onChange, mo
89
99
  disabled: data.status !== 'draft',
90
100
  divider: true,
91
101
  },
102
+ isAdmin &&
103
+ data.status === 'uncollectible' && {
104
+ label: t('admin.invoice.retryUncollectible.title'),
105
+ handler: () => setState({ action: 'retry-uncollectible' }),
106
+ color: 'primary',
107
+ },
92
108
  showReturnStake &&
93
109
  stakeResult &&
94
110
  stakeResult.value !== '0' && {
@@ -134,6 +150,16 @@ export default function InvoiceActions({ data, variant = 'compact', onChange, mo
134
150
  return (
135
151
  <ClickBoundary>
136
152
  <Actions variant={variant} actions={actions as any} onOpenCallback={fetchStakeResultAsync} />
153
+ {state.action === 'retry-uncollectible' && (
154
+ <ConfirmDialog
155
+ onConfirm={handleAction}
156
+ onCancel={() => setState({ action: '' })}
157
+ title={t('admin.invoice.retryUncollectible.title')}
158
+ message={t('admin.invoice.retryUncollectible.tip')}
159
+ loading={state.loading}
160
+ color="primary"
161
+ />
162
+ )}
137
163
  {state.action === 'return-stake' && (
138
164
  <ConfirmDialog
139
165
  onConfirm={handleAction}
@@ -210,7 +210,7 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
210
210
 
211
211
  const columns = [
212
212
  {
213
- label: t('common.description'),
213
+ label: t('admin.subscription.product'),
214
214
  name: 'product',
215
215
  options: {
216
216
  customBodyRenderLite: (_: string, index: number) => {
@@ -386,17 +386,10 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
386
386
  xs: 1,
387
387
  md: 1,
388
388
  },
389
- width: {
390
- xs: '100%',
391
- md: 'auto',
392
- },
389
+ width: '100%',
393
390
  minWidth: {
394
391
  md: '280px',
395
392
  },
396
- maxWidth: {
397
- xs: '100%',
398
- md: '400px',
399
- },
400
393
  }}>
401
394
  {summary.map((line, index) => {
402
395
  const isTotal = line.key === 'common.total';
@@ -60,6 +60,7 @@ export const pdfStyles: InvoiceStyles = {
60
60
  'w-40': { width: '40%' },
61
61
  'w-48': { width: '48%' },
62
62
  'w-38': { width: '38%' },
63
+ 'w-83': { width: '83%' },
63
64
  'w-17': { width: '17%' },
64
65
  'w-18': { width: '18%' },
65
66
  'w-15': { width: '15%' },
@@ -106,6 +107,7 @@ export const pdfStyles: InvoiceStyles = {
106
107
  },
107
108
  block: { display: 'block' },
108
109
  'ml-40': { marginLeft: '40px' },
110
+ 'text-right': { textAlign: 'right' },
109
111
  };
110
112
 
111
113
  export const styles = `