payment-kit 1.20.20 → 1.20.22
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/queues/vendors/fulfillment-coordinator.ts +11 -2
- package/api/src/queues/vendors/status-check.ts +1 -3
- 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/routes/vendor.ts +93 -47
- package/api/src/store/migrations/20250926-change-customer-did-unique.ts +49 -0
- package/api/src/store/models/checkout-session.ts +1 -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/components/subscription/vendor-service-list.tsx +56 -58
- package/src/locales/en.tsx +9 -2
- package/src/locales/zh.tsx +9 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +43 -4
- package/src/pages/admin/products/coupons/applicable-products.tsx +20 -37
- package/src/pages/admin/products/products/detail.tsx +4 -14
- package/src/pages/admin/products/vendors/index.tsx +57 -48
- package/src/pages/customer/invoice/detail.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +17 -4
package/api/src/routes/vendor.ts
CHANGED
|
@@ -146,13 +146,19 @@ async function createVendor(req: any, res: any) {
|
|
|
146
146
|
vendor_type: type,
|
|
147
147
|
name,
|
|
148
148
|
description,
|
|
149
|
-
app_url: appUrl,
|
|
150
149
|
metadata,
|
|
151
150
|
app_pid: appPid,
|
|
152
151
|
app_logo: appLogo,
|
|
153
152
|
status,
|
|
154
153
|
} = value;
|
|
155
154
|
|
|
155
|
+
let appUrl = '';
|
|
156
|
+
try {
|
|
157
|
+
appUrl = new URL(value.app_url).origin;
|
|
158
|
+
} catch {
|
|
159
|
+
return res.status(400).json({ error: 'Invalid app URL' });
|
|
160
|
+
}
|
|
161
|
+
|
|
156
162
|
const vendorType = (type || 'launcher') as 'launcher' | 'didnames';
|
|
157
163
|
const vendorDid = VENDOR_DID[vendorType];
|
|
158
164
|
|
|
@@ -215,16 +221,14 @@ async function updateVendor(req: any, res: any) {
|
|
|
215
221
|
});
|
|
216
222
|
}
|
|
217
223
|
|
|
218
|
-
const {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
app_url
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
app_logo: appLogo,
|
|
227
|
-
} = value;
|
|
224
|
+
const { vendor_type: type, name, description, status, metadata, app_pid: appPid, app_logo: appLogo } = value;
|
|
225
|
+
|
|
226
|
+
let appUrl = '';
|
|
227
|
+
try {
|
|
228
|
+
appUrl = new URL(value.app_url).origin;
|
|
229
|
+
} catch {
|
|
230
|
+
return res.status(400).json({ error: 'Invalid app URL' });
|
|
231
|
+
}
|
|
228
232
|
|
|
229
233
|
const vendorType = (type || 'launcher') as 'launcher' | 'didnames';
|
|
230
234
|
const vendorDid = VENDOR_DID[vendorType];
|
|
@@ -312,42 +316,46 @@ async function testVendorConnection(req: any, res: any) {
|
|
|
312
316
|
}
|
|
313
317
|
}
|
|
314
318
|
|
|
315
|
-
|
|
319
|
+
async function executeVendorOperation(vendorId: string, orderId: string, operation: 'getOrder' | 'getOrderStatus') {
|
|
316
320
|
if (!vendorId || !orderId) {
|
|
317
|
-
|
|
321
|
+
return {
|
|
322
|
+
error: 'Bad Request',
|
|
323
|
+
message: `vendorId or orderId is required, vendorId: ${vendorId}, orderId: ${orderId}`,
|
|
324
|
+
code: 400,
|
|
325
|
+
};
|
|
318
326
|
}
|
|
319
327
|
|
|
320
328
|
const vendor = await ProductVendor.findByPk(vendorId);
|
|
321
329
|
if (!vendor) {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
const data = await vendorAdapter.getOrder(vendor, orderId);
|
|
328
|
-
|
|
329
|
-
return data;
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
async function getVendorStatusById(vendorId: string, orderId: string) {
|
|
333
|
-
if (!vendorId || !orderId) {
|
|
334
|
-
throw new Error(`vendorId or orderId is required, vendorId: ${vendorId}, orderId: ${orderId}`);
|
|
330
|
+
return {
|
|
331
|
+
error: 'Not Found',
|
|
332
|
+
message: `vendor not found, vendorId: ${vendorId}`,
|
|
333
|
+
code: 404,
|
|
334
|
+
};
|
|
335
335
|
}
|
|
336
336
|
|
|
337
|
-
|
|
337
|
+
try {
|
|
338
|
+
const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
|
|
339
|
+
const data = await vendorAdapter[operation](vendor, orderId);
|
|
338
340
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
+
return { data: { ...data, vendorType: vendor.vendor_type }, code: 200 };
|
|
342
|
+
} catch (error: any) {
|
|
343
|
+
const operationName = operation === 'getOrder' ? 'order' : 'order status';
|
|
344
|
+
logger.error(`Failed to get vendor ${operationName}`, {
|
|
345
|
+
error,
|
|
346
|
+
vendorId,
|
|
347
|
+
orderId,
|
|
348
|
+
vendorKey: vendor.vendor_key,
|
|
349
|
+
});
|
|
350
|
+
return {
|
|
351
|
+
error: 'Service Unavailable',
|
|
352
|
+
message: `Failed to get vendor ${operationName}`,
|
|
353
|
+
code: 503,
|
|
354
|
+
};
|
|
341
355
|
}
|
|
342
|
-
|
|
343
|
-
const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
|
|
344
|
-
|
|
345
|
-
const data = await vendorAdapter.getOrderStatus(vendor, orderId);
|
|
346
|
-
|
|
347
|
-
return { ...data, vendorType: vendor.vendor_type };
|
|
348
356
|
}
|
|
349
357
|
|
|
350
|
-
async function
|
|
358
|
+
async function processVendorOrders(sessionId: string, operation: 'getOrder' | 'getOrderStatus') {
|
|
351
359
|
const doc = await CheckoutSession.findByPk(sessionId);
|
|
352
360
|
|
|
353
361
|
if (!doc) {
|
|
@@ -378,8 +386,39 @@ async function doRequestVendor(sessionId: string, func: (vendorId: string, order
|
|
|
378
386
|
};
|
|
379
387
|
}
|
|
380
388
|
|
|
381
|
-
const vendors = doc.vendor_info.map((item) => {
|
|
382
|
-
|
|
389
|
+
const vendors = doc.vendor_info.map(async (item) => {
|
|
390
|
+
if (!item.order_id) {
|
|
391
|
+
return {
|
|
392
|
+
key: item.vendor_key,
|
|
393
|
+
progress: 0,
|
|
394
|
+
status: 'pending',
|
|
395
|
+
vendorType: item.vendor_type,
|
|
396
|
+
appUrl: item.app_url,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const result = await executeVendorOperation(item.vendor_id, item.order_id, operation);
|
|
401
|
+
|
|
402
|
+
// Handle error responses from vendor functions
|
|
403
|
+
if (result.error) {
|
|
404
|
+
logger.warn('Vendor operation returned error', {
|
|
405
|
+
vendorId: item.vendor_id,
|
|
406
|
+
orderId: item.order_id,
|
|
407
|
+
operation,
|
|
408
|
+
error: result.error,
|
|
409
|
+
message: result.message,
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
key: item.vendor_key,
|
|
413
|
+
error: result.error,
|
|
414
|
+
message: result.message,
|
|
415
|
+
status: 'error',
|
|
416
|
+
vendorType: item.vendor_type,
|
|
417
|
+
appUrl: item.app_url,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return result.data;
|
|
383
422
|
});
|
|
384
423
|
|
|
385
424
|
return {
|
|
@@ -392,7 +431,7 @@ async function doRequestVendor(sessionId: string, func: (vendorId: string, order
|
|
|
392
431
|
}
|
|
393
432
|
|
|
394
433
|
async function getVendorStatus(sessionId: string) {
|
|
395
|
-
const result: any = await
|
|
434
|
+
const result: any = await processVendorOrders(sessionId, 'getOrderStatus');
|
|
396
435
|
|
|
397
436
|
if (result.subscriptionId) {
|
|
398
437
|
const subscriptionUrl = getUrl(`/customer/subscription/${result.subscriptionId}`);
|
|
@@ -407,10 +446,6 @@ async function getVendorStatus(sessionId: string) {
|
|
|
407
446
|
return result;
|
|
408
447
|
}
|
|
409
448
|
|
|
410
|
-
function getVendor(sessionId: string) {
|
|
411
|
-
return doRequestVendor(sessionId, getVendorById);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
449
|
async function getVendorFulfillmentStatus(req: any, res: any) {
|
|
415
450
|
const { sessionId } = req.params;
|
|
416
451
|
|
|
@@ -430,7 +465,7 @@ async function getVendorFulfillmentDetail(req: any, res: any) {
|
|
|
430
465
|
const { sessionId } = req.params;
|
|
431
466
|
|
|
432
467
|
try {
|
|
433
|
-
const detail = await
|
|
468
|
+
const detail = await processVendorOrders(sessionId, 'getOrder');
|
|
434
469
|
if (detail.code) {
|
|
435
470
|
return res.status(detail.code).json({ error: detail.error });
|
|
436
471
|
}
|
|
@@ -461,17 +496,28 @@ async function redirectToVendor(req: any, res: any) {
|
|
|
461
496
|
return res.redirect('/404');
|
|
462
497
|
}
|
|
463
498
|
|
|
464
|
-
const
|
|
465
|
-
|
|
499
|
+
const isOwner = req.user.did === checkoutSession.customer_did;
|
|
500
|
+
|
|
501
|
+
if (!isOwner) {
|
|
502
|
+
if (order.app_url) {
|
|
503
|
+
return res.redirect(order.app_url);
|
|
504
|
+
}
|
|
505
|
+
return res.redirect('/404');
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const result = await executeVendorOperation(vendorId, order.order_id || '', 'getOrder');
|
|
509
|
+
if (result.error || !result.data) {
|
|
466
510
|
logger.warn('Vendor status detail not found', {
|
|
467
511
|
subscriptionId,
|
|
468
512
|
vendorId,
|
|
469
513
|
orderId: order.order_id,
|
|
514
|
+
error: result.error,
|
|
515
|
+
message: result.message,
|
|
470
516
|
});
|
|
471
517
|
return res.redirect('/404');
|
|
472
518
|
}
|
|
473
519
|
|
|
474
|
-
const redirectUrl = target === 'dashboard' ?
|
|
520
|
+
const redirectUrl = target === 'dashboard' ? result.data.dashboardUrl : result.data.homeUrl;
|
|
475
521
|
return res.redirect(redirectUrl);
|
|
476
522
|
} catch (error: any) {
|
|
477
523
|
logger.error('Failed to redirect to vendor service', {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import { DataTypes, QueryTypes } from 'sequelize';
|
|
3
|
+
import { Migration } from '../migrate';
|
|
4
|
+
|
|
5
|
+
export const up: Migration = async ({ context }) => {
|
|
6
|
+
try {
|
|
7
|
+
await context.changeColumn('customers', 'did', {
|
|
8
|
+
type: DataTypes.STRING(40),
|
|
9
|
+
allowNull: false,
|
|
10
|
+
unique: true,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
console.log('Successfully added unique constraint to customers.did');
|
|
14
|
+
} catch (error) {
|
|
15
|
+
try {
|
|
16
|
+
const duplicates = await context.sequelize.query(
|
|
17
|
+
`SELECT did, COUNT(*) as count
|
|
18
|
+
FROM customers
|
|
19
|
+
GROUP BY did
|
|
20
|
+
HAVING COUNT(*) > 1
|
|
21
|
+
LIMIT 5`,
|
|
22
|
+
{ type: QueryTypes.SELECT }
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (duplicates.length > 0) {
|
|
26
|
+
console.warn('Found duplicate DIDs in customers table (showing first 5):', duplicates);
|
|
27
|
+
console.warn('Skipping unique constraint addition. Please clean up duplicate data manually if needed.');
|
|
28
|
+
} else {
|
|
29
|
+
console.error('Failed to add unique constraint for unknown reason:', error.message);
|
|
30
|
+
}
|
|
31
|
+
} catch (checkError) {
|
|
32
|
+
console.error('Failed to check for duplicates:', checkError.message);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const down: Migration = async ({ context }) => {
|
|
38
|
+
try {
|
|
39
|
+
await context.changeColumn('customers', 'did', {
|
|
40
|
+
type: DataTypes.STRING(40),
|
|
41
|
+
allowNull: false,
|
|
42
|
+
unique: false,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.log('Successfully removed unique constraint from customers.did');
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.warn('Failed to remove unique constraint from customers.did:', error.message);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
@@ -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
|
+
});
|