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.
- package/api/src/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/discount/coupon.ts +41 -73
- package/api/src/libs/invoice.ts +17 -0
- package/api/src/libs/notification/template/subscription-renew-failed.ts +22 -1
- package/api/src/libs/notification/template/subscription-will-renew.ts +22 -0
- package/api/src/locales/en.ts +1 -0
- package/api/src/locales/zh.ts +1 -0
- package/api/src/queues/checkout-session.ts +2 -2
- package/api/src/routes/checkout-sessions.ts +84 -0
- package/api/src/routes/connect/collect-batch.ts +2 -2
- package/api/src/routes/connect/pay.ts +1 -1
- package/api/src/store/migrations/20250926-change-customer-did-unique.ts +49 -0
- package/api/src/store/models/customer.ts +1 -0
- package/api/tests/libs/coupon.spec.ts +219 -0
- package/api/tests/libs/discount.spec.ts +250 -0
- package/blocklet.yml +1 -1
- package/package.json +7 -7
- package/src/components/discount/discount-info.tsx +0 -1
- package/src/components/invoice/action.tsx +26 -0
- package/src/components/invoice/table.tsx +2 -9
- package/src/components/invoice-pdf/styles.ts +2 -0
- package/src/components/invoice-pdf/template.tsx +44 -12
- package/src/components/metadata/list.tsx +1 -0
- package/src/components/subscription/metrics.tsx +7 -3
- package/src/locales/en.tsx +7 -0
- package/src/locales/zh.tsx +7 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +11 -3
- package/src/pages/admin/products/coupons/applicable-products.tsx +20 -37
- package/src/pages/customer/invoice/detail.tsx +1 -1
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.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.
|
|
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.
|
|
60
|
-
"@blocklet/payment-react": "1.20.
|
|
61
|
-
"@blocklet/payment-vendor": "1.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.
|
|
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": "
|
|
178
|
+
"gitHead": "43b26f9e7373a99000f6a14762fdab860a562967"
|
|
179
179
|
}
|
|
@@ -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('
|
|
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 = `
|