payment-kit 1.20.12 → 1.20.14
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/crons/index.ts +8 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/vendor-util/adapters/launcher-adapter.ts +1 -1
- package/api/src/libs/vendor-util/adapters/types.ts +1 -0
- package/api/src/libs/vendor-util/fulfillment.ts +1 -1
- package/api/src/queues/vendors/fulfillment-coordinator.ts +1 -29
- package/api/src/queues/vendors/return-processor.ts +184 -0
- package/api/src/queues/vendors/return-scanner.ts +119 -0
- package/api/src/queues/vendors/status-check.ts +1 -1
- package/api/src/routes/checkout-sessions.ts +15 -2
- package/api/src/routes/coupons.ts +7 -0
- package/api/src/routes/credit-grants.ts +8 -1
- package/api/src/routes/credit-transactions.ts +153 -13
- package/api/src/routes/invoices.ts +35 -1
- package/api/src/routes/meter-events.ts +31 -3
- package/api/src/routes/meters.ts +4 -0
- package/api/src/routes/payment-currencies.ts +2 -1
- package/api/src/routes/promotion-codes.ts +2 -2
- package/api/src/routes/subscription-items.ts +4 -0
- package/api/src/routes/vendor.ts +89 -2
- package/api/src/routes/webhook-endpoints.ts +4 -0
- package/api/src/store/migrations/20250918-add-vendor-extends.ts +20 -0
- package/api/src/store/migrations/20250919-add-source-data.ts +20 -0
- package/api/src/store/models/checkout-session.ts +5 -2
- package/api/src/store/models/credit-transaction.ts +5 -0
- package/api/src/store/models/meter-event.ts +22 -12
- package/api/src/store/models/product-vendor.ts +6 -0
- package/api/src/store/models/types.ts +18 -0
- package/blocklet.yml +1 -1
- package/package.json +5 -5
- package/src/components/customer/credit-overview.tsx +1 -1
- package/src/components/customer/related-credit-grants.tsx +194 -0
- package/src/components/meter/add-usage-dialog.tsx +8 -0
- package/src/components/meter/events-list.tsx +93 -96
- package/src/components/product/form.tsx +0 -1
- package/src/locales/en.tsx +9 -0
- package/src/locales/zh.tsx +9 -0
- package/src/pages/admin/billing/invoices/detail.tsx +21 -2
- package/src/pages/customer/invoice/detail.tsx +11 -2
package/api/src/crons/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
stripeSubscriptionCronTime,
|
|
20
20
|
subscriptionCronTime,
|
|
21
21
|
vendorStatusCheckCronTime,
|
|
22
|
+
vendorReturnScanCronTime,
|
|
22
23
|
} from '../libs/env';
|
|
23
24
|
import logger from '../libs/logger';
|
|
24
25
|
import { startCreditConsumeQueue } from '../queues/credit-consume';
|
|
@@ -31,6 +32,7 @@ import { createPaymentStat } from './payment-stat';
|
|
|
31
32
|
import { SubscriptionTrialWillEndSchedule } from './subscription-trial-will-end';
|
|
32
33
|
import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
|
|
33
34
|
import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
|
|
35
|
+
import { scheduleVendorReturnScan } from '../queues/vendors/return-scanner';
|
|
34
36
|
|
|
35
37
|
function init() {
|
|
36
38
|
Cron.init({
|
|
@@ -123,6 +125,12 @@ function init() {
|
|
|
123
125
|
fn: () => startVendorStatusCheckSchedule(),
|
|
124
126
|
options: { runOnInit: false },
|
|
125
127
|
},
|
|
128
|
+
{
|
|
129
|
+
name: 'vendor.return.scan',
|
|
130
|
+
time: vendorReturnScanCronTime,
|
|
131
|
+
fn: () => scheduleVendorReturnScan(),
|
|
132
|
+
options: { runOnInit: false },
|
|
133
|
+
},
|
|
126
134
|
],
|
|
127
135
|
onError: (error: Error, name: string) => {
|
|
128
136
|
logger.error('run job failed', { name, error });
|
package/api/src/libs/env.ts
CHANGED
|
@@ -15,6 +15,7 @@ export const meteringSubscriptionDetectionCronTime: string =
|
|
|
15
15
|
export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME || '0 */5 * * * *'; // 默认每 5 min 执行一次
|
|
16
16
|
export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
17
17
|
export const vendorStatusCheckCronTime: string = process.env.VENDOR_STATUS_CHECK_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
18
|
+
export const vendorReturnScanCronTime: string = process.env.VENDOR_RETURN_SCAN_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
18
19
|
export const vendorTimeoutMinutes: number = process.env.VENDOR_TIMEOUT_MINUTES
|
|
19
20
|
? +process.env.VENDOR_TIMEOUT_MINUTES
|
|
20
21
|
: 10; // 默认 10 分钟超时
|
|
@@ -73,6 +73,7 @@ export interface ReturnRequestParams {
|
|
|
73
73
|
export interface ReturnRequestResult {
|
|
74
74
|
status: 'requested' | 'accepted' | 'rejected' | 'failed';
|
|
75
75
|
message?: string;
|
|
76
|
+
success?: boolean;
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export interface CheckOrderStatusParams extends Record<string, any> {}
|
|
@@ -13,35 +13,7 @@ import { Refund } from '../../store/models/refund';
|
|
|
13
13
|
import { sequelize } from '../../store/sequelize';
|
|
14
14
|
import { depositVaultQueue } from '../payment';
|
|
15
15
|
|
|
16
|
-
type VendorInfo =
|
|
17
|
-
vendor_id: string;
|
|
18
|
-
vendor_key: string;
|
|
19
|
-
order_id: string;
|
|
20
|
-
status:
|
|
21
|
-
| 'pending'
|
|
22
|
-
| 'processing'
|
|
23
|
-
| 'completed'
|
|
24
|
-
| 'failed'
|
|
25
|
-
| 'cancelled'
|
|
26
|
-
| 'max_retries_exceeded'
|
|
27
|
-
| 'return_requested'
|
|
28
|
-
| 'sent';
|
|
29
|
-
service_url?: string;
|
|
30
|
-
error_message?: string;
|
|
31
|
-
amount: string;
|
|
32
|
-
|
|
33
|
-
attempts?: number;
|
|
34
|
-
lastAttemptAt?: string;
|
|
35
|
-
completedAt?: string;
|
|
36
|
-
commissionAmount?: string;
|
|
37
|
-
|
|
38
|
-
returnRequest?: {
|
|
39
|
-
reason: string;
|
|
40
|
-
requestedAt: string;
|
|
41
|
-
status: 'pending' | 'accepted' | 'rejected';
|
|
42
|
-
returnDetails?: string;
|
|
43
|
-
};
|
|
44
|
-
};
|
|
16
|
+
export type VendorInfo = NonNullable<CheckoutSession['vendor_info']>[number];
|
|
45
17
|
|
|
46
18
|
interface CoordinatorJob {
|
|
47
19
|
checkoutSessionId: string;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import logger from '../../libs/logger';
|
|
2
|
+
import createQueue from '../../libs/queue';
|
|
3
|
+
import { VendorFulfillmentService } from '../../libs/vendor-util/fulfillment';
|
|
4
|
+
import { CheckoutSession } from '../../store/models';
|
|
5
|
+
import { VendorInfo } from './fulfillment-coordinator';
|
|
6
|
+
|
|
7
|
+
type ReturnProcessorJob = {
|
|
8
|
+
checkoutSessionId: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const vendorReturnProcessorQueue = createQueue<ReturnProcessorJob>({
|
|
12
|
+
name: 'vendor-return-processor',
|
|
13
|
+
onJob: handleReturnProcessorJob,
|
|
14
|
+
options: {
|
|
15
|
+
concurrency: 1,
|
|
16
|
+
maxRetries: 2,
|
|
17
|
+
retryDelay: 30 * 1000,
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
async function handleReturnProcessorJob(job: ReturnProcessorJob): Promise<void> {
|
|
22
|
+
const { checkoutSessionId } = job;
|
|
23
|
+
|
|
24
|
+
logger.info('Starting vendor return processor job', {
|
|
25
|
+
checkoutSessionId,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
|
|
30
|
+
|
|
31
|
+
if (!checkoutSession) {
|
|
32
|
+
logger.warn('CheckoutSession not found', { checkoutSessionId });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const vendorInfoList = checkoutSession.vendor_info as VendorInfo[];
|
|
37
|
+
let hasChanges = false;
|
|
38
|
+
|
|
39
|
+
let i = -1;
|
|
40
|
+
for (const vendor of vendorInfoList) {
|
|
41
|
+
i++;
|
|
42
|
+
// Only process vendors with 'completed' status
|
|
43
|
+
if (vendor.status !== 'completed') {
|
|
44
|
+
logger.info('Skipping vendor return because status is not completed', {
|
|
45
|
+
checkoutSessionId,
|
|
46
|
+
vendorId: vendor.vendor_id,
|
|
47
|
+
orderId: vendor.order_id,
|
|
48
|
+
status: vendor.status,
|
|
49
|
+
});
|
|
50
|
+
// eslint-disable-next-line no-continue
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
logger.info('Processing vendor return', {
|
|
56
|
+
checkoutSessionId,
|
|
57
|
+
vendorId: vendor.vendor_id,
|
|
58
|
+
orderId: vendor.order_id,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// eslint-disable-next-line no-await-in-loop
|
|
62
|
+
const returnResult = await callVendorReturn(vendor, checkoutSession);
|
|
63
|
+
|
|
64
|
+
if (returnResult.success) {
|
|
65
|
+
// Return successful, update status to 'returned'
|
|
66
|
+
vendorInfoList[i] = {
|
|
67
|
+
...vendor,
|
|
68
|
+
status: 'returned',
|
|
69
|
+
lastAttemptAt: new Date().toISOString(),
|
|
70
|
+
};
|
|
71
|
+
hasChanges = true;
|
|
72
|
+
|
|
73
|
+
logger.info('Vendor return successful', {
|
|
74
|
+
checkoutSessionId,
|
|
75
|
+
vendorId: vendor.vendor_id,
|
|
76
|
+
orderId: vendor.order_id,
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
// Return failed, keep 'completed' status for next scan retry
|
|
80
|
+
vendorInfoList[i] = {
|
|
81
|
+
...vendor,
|
|
82
|
+
lastAttemptAt: new Date().toISOString(),
|
|
83
|
+
error_message: returnResult.message || 'Return request failed',
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
logger.warn('Vendor return failed', {
|
|
87
|
+
checkoutSessionId,
|
|
88
|
+
vendorId: vendor.vendor_id,
|
|
89
|
+
orderId: vendor.order_id,
|
|
90
|
+
error: returnResult.message,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
logger.error('Error processing vendor return', {
|
|
95
|
+
checkoutSessionId,
|
|
96
|
+
vendorId: vendor.vendor_id,
|
|
97
|
+
orderId: vendor.order_id,
|
|
98
|
+
error: error.message,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Record error but keep status unchanged for retry
|
|
102
|
+
vendorInfoList[i] = {
|
|
103
|
+
...vendor,
|
|
104
|
+
lastAttemptAt: new Date().toISOString(),
|
|
105
|
+
error_message: error.message,
|
|
106
|
+
};
|
|
107
|
+
hasChanges = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Update vendor_info if there are changes
|
|
112
|
+
if (hasChanges) {
|
|
113
|
+
await checkoutSession.update({ vendor_info: vendorInfoList });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Check if all vendors have been returned
|
|
117
|
+
const allReturned = vendorInfoList.every((vendor) => vendor.status === 'returned');
|
|
118
|
+
|
|
119
|
+
if (allReturned && checkoutSession.fulfillment_status !== 'returned') {
|
|
120
|
+
await checkoutSession.update({ fulfillment_status: 'returned' });
|
|
121
|
+
|
|
122
|
+
logger.info('All vendors returned, updated fulfillment status to returned', {
|
|
123
|
+
checkoutSessionId,
|
|
124
|
+
totalVendors: vendorInfoList.length,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
logger.info('Vendor return processor job completed', {
|
|
129
|
+
checkoutSessionId,
|
|
130
|
+
totalVendors: vendorInfoList.length,
|
|
131
|
+
allReturned,
|
|
132
|
+
hasChanges,
|
|
133
|
+
});
|
|
134
|
+
} catch (error: any) {
|
|
135
|
+
logger.error('Vendor return processor job failed', {
|
|
136
|
+
checkoutSessionId,
|
|
137
|
+
error,
|
|
138
|
+
});
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function callVendorReturn(
|
|
144
|
+
vendor: VendorInfo,
|
|
145
|
+
checkoutSession: CheckoutSession
|
|
146
|
+
): Promise<{ success: boolean; message?: string }> {
|
|
147
|
+
try {
|
|
148
|
+
const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
|
|
149
|
+
|
|
150
|
+
if (!vendorAdapter) {
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
message: `No adapter found for vendor: ${vendor.vendor_id}`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const returnResult = await vendorAdapter.requestReturn({
|
|
158
|
+
orderId: vendor.order_id,
|
|
159
|
+
reason: 'Subscription canceled',
|
|
160
|
+
paymentIntentId: checkoutSession.payment_intent_id || '',
|
|
161
|
+
customParams: {
|
|
162
|
+
checkoutSessionId: checkoutSession.id,
|
|
163
|
+
subscriptionId: checkoutSession.subscription_id,
|
|
164
|
+
vendorKey: vendor.vendor_key,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
success: returnResult.success || false,
|
|
170
|
+
message: returnResult.message,
|
|
171
|
+
};
|
|
172
|
+
} catch (error: any) {
|
|
173
|
+
logger.error('Failed to call vendor return API', {
|
|
174
|
+
vendorId: vendor.vendor_id,
|
|
175
|
+
orderId: vendor.order_id,
|
|
176
|
+
error: error.message,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
success: false,
|
|
181
|
+
message: error.message,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Op } from 'sequelize';
|
|
2
|
+
import logger from '../../libs/logger';
|
|
3
|
+
import createQueue from '../../libs/queue';
|
|
4
|
+
import { CheckoutSession, Subscription } from '../../store/models';
|
|
5
|
+
import { vendorReturnProcessorQueue } from './return-processor';
|
|
6
|
+
import { VendorInfo } from './fulfillment-coordinator';
|
|
7
|
+
|
|
8
|
+
export const vendorReturnScannerQueue = createQueue({
|
|
9
|
+
name: 'vendor-return-scanner',
|
|
10
|
+
onJob: handleReturnScannerJob,
|
|
11
|
+
options: {
|
|
12
|
+
concurrency: 1,
|
|
13
|
+
maxRetries: 3,
|
|
14
|
+
retryDelay: 60 * 1000,
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function handleReturnScannerJob(): Promise<void> {
|
|
19
|
+
try {
|
|
20
|
+
const sessionsNeedingReturn = await findSessionsNeedingVendorReturn();
|
|
21
|
+
if (sessionsNeedingReturn.length === 0) {
|
|
22
|
+
logger.info('No checkout sessions needing vendor return');
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
logger.info('Found checkout sessions needing vendor return', {
|
|
27
|
+
count: sessionsNeedingReturn.length,
|
|
28
|
+
sessionIds: sessionsNeedingReturn.map((s) => s.id),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
for (const session of sessionsNeedingReturn) {
|
|
32
|
+
const id = `vendor-return-process-${session.id}`;
|
|
33
|
+
// eslint-disable-next-line no-await-in-loop
|
|
34
|
+
const exists = await vendorReturnProcessorQueue.get(id);
|
|
35
|
+
if (!exists) {
|
|
36
|
+
vendorReturnProcessorQueue.push({
|
|
37
|
+
id,
|
|
38
|
+
job: {
|
|
39
|
+
checkoutSessionId: session.id,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (error: any) {
|
|
45
|
+
logger.error('Vendor return scanner job failed', {
|
|
46
|
+
error,
|
|
47
|
+
});
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function findSessionsNeedingVendorReturn(): Promise<CheckoutSession[]> {
|
|
53
|
+
try {
|
|
54
|
+
// First, find canceled subscriptions
|
|
55
|
+
const canceledSubscriptions = await Subscription.findAll({
|
|
56
|
+
where: { status: 'canceled' },
|
|
57
|
+
attributes: ['id'],
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const canceledSubscriptionIds = canceledSubscriptions.map((sub) => sub.id);
|
|
61
|
+
|
|
62
|
+
// Find checkout sessions with completed fulfillment and canceled subscriptions
|
|
63
|
+
const readyToReturnSessions = await CheckoutSession.findAll({
|
|
64
|
+
where: {
|
|
65
|
+
fulfillment_status: 'completed',
|
|
66
|
+
subscription_id: { [Op.in]: canceledSubscriptionIds },
|
|
67
|
+
},
|
|
68
|
+
order: [['updated_at', 'DESC']],
|
|
69
|
+
limit: 100,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Find checkout sessions already in returning status
|
|
73
|
+
const returningSessions = await CheckoutSession.findAll({
|
|
74
|
+
where: {
|
|
75
|
+
fulfillment_status: 'returning',
|
|
76
|
+
subscription_id: { [Op.ne]: null as any },
|
|
77
|
+
},
|
|
78
|
+
order: [['updated_at', 'DESC']],
|
|
79
|
+
limit: 100,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Update canceled sessions to returning status
|
|
83
|
+
if (readyToReturnSessions.length > 0) {
|
|
84
|
+
await CheckoutSession.update(
|
|
85
|
+
{
|
|
86
|
+
fulfillment_status: 'returning',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
where: {
|
|
90
|
+
id: { [Op.in]: readyToReturnSessions.map((s) => s.id) },
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sessions = [...readyToReturnSessions, ...returningSessions];
|
|
97
|
+
|
|
98
|
+
// Filter sessions that have vendors needing return
|
|
99
|
+
const filteredSessions = sessions.filter((session) => {
|
|
100
|
+
const vendorInfoList = session.vendor_info as VendorInfo[];
|
|
101
|
+
|
|
102
|
+
if (!vendorInfoList || vendorInfoList.length === 0) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const hasVendorNeedingReturn = vendorInfoList.some((vendor) => vendor.status === 'completed');
|
|
106
|
+
return hasVendorNeedingReturn;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return filteredSessions;
|
|
110
|
+
} catch (error: any) {
|
|
111
|
+
logger.error('Failed to find sessions needing vendor return', { error });
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function scheduleVendorReturnScan(): void {
|
|
117
|
+
const scanId = `scan-${Date.now()}`;
|
|
118
|
+
vendorReturnScannerQueue.push({ id: scanId, job: {} });
|
|
119
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { joinURL } from 'ufo';
|
|
2
|
-
import { VendorAuth } from '@blocklet/payment-vendor';
|
|
2
|
+
import { Auth as VendorAuth } from '@blocklet/payment-vendor';
|
|
3
3
|
import createQueue from '../../libs/queue';
|
|
4
4
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
5
5
|
import { ProductVendor } from '../../store/models';
|
|
@@ -668,7 +668,9 @@ const getBeneficiaryName = async (beneficiary: PaymentBeneficiary) => {
|
|
|
668
668
|
return beneficiary.name || (await getUserOrAppInfo(beneficiary.address || ''))?.name || beneficiary.address;
|
|
669
669
|
};
|
|
670
670
|
|
|
671
|
-
export async function getCrossSellItem(
|
|
671
|
+
export async function getCrossSellItem(
|
|
672
|
+
checkoutSession: CheckoutSession
|
|
673
|
+
): Promise<{ error?: string } | (TPriceExpanded & { product: any; error?: string })> {
|
|
672
674
|
// FIXME: perhaps we can support cross sell even if the current session have multiple items
|
|
673
675
|
if (checkoutSession.line_items.length > 1) {
|
|
674
676
|
return { error: 'Cross sell not supported for checkoutSession with multiple line items' };
|
|
@@ -2334,8 +2336,12 @@ router.put('/:id/expire', auth, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
2334
2336
|
router.get('/:id/cross-sell', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
2335
2337
|
try {
|
|
2336
2338
|
const checkoutSession = req.doc as CheckoutSession;
|
|
2339
|
+
const skipError = req.query.skipError === 'true';
|
|
2337
2340
|
const result = await getCrossSellItem(checkoutSession);
|
|
2338
|
-
|
|
2341
|
+
|
|
2342
|
+
if (skipError && result.error) {
|
|
2343
|
+
return res.status(200).json(result);
|
|
2344
|
+
}
|
|
2339
2345
|
return res.status(result.error ? 400 : 200).json(result);
|
|
2340
2346
|
} catch (err) {
|
|
2341
2347
|
logger.error(err);
|
|
@@ -2633,6 +2639,10 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
2633
2639
|
return res.status(400).json({ error: 'Coupon no longer valid' });
|
|
2634
2640
|
}
|
|
2635
2641
|
|
|
2642
|
+
const now = dayjs().unix();
|
|
2643
|
+
const trialSetup = getSubscriptionTrialSetup(checkoutSession.subscription_data as any, currency.id);
|
|
2644
|
+
const isTrialing = trialSetup.trialInDays > 0 || trialSetup.trialEnd > now;
|
|
2645
|
+
|
|
2636
2646
|
// Apply discount with new currency
|
|
2637
2647
|
const discountResult = await applyDiscountsToLineItems({
|
|
2638
2648
|
lineItems: expandedItems,
|
|
@@ -2640,6 +2650,9 @@ router.post('/:id/recalculate-promotion', user, ensureCheckoutSessionOpen, async
|
|
|
2640
2650
|
couponId,
|
|
2641
2651
|
customerId: customer.id,
|
|
2642
2652
|
currency,
|
|
2653
|
+
billingContext: {
|
|
2654
|
+
trialing: isTrialing,
|
|
2655
|
+
},
|
|
2643
2656
|
});
|
|
2644
2657
|
|
|
2645
2658
|
// Check if discount can still be applied with the new currency
|
|
@@ -373,6 +373,13 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
373
373
|
return res.status(404).json({ error: 'Coupon not found' });
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
if (req.body.metadata) {
|
|
377
|
+
const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
|
|
378
|
+
if (metadataError) {
|
|
379
|
+
return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
376
383
|
if (coupon.locked) {
|
|
377
384
|
const allowedUpdates = pick(value, ['name', 'metadata', 'description']);
|
|
378
385
|
if (Object.keys(allowedUpdates).length === 0) {
|
|
@@ -11,6 +11,7 @@ import { CreditGrant, Customer, PaymentCurrency, Price, Subscription } from '../
|
|
|
11
11
|
import { createCreditGrant } from '../libs/credit-grant';
|
|
12
12
|
import { getMeterPriceIdsFromSubscription } from '../libs/subscription';
|
|
13
13
|
import { blocklet } from '../libs/auth';
|
|
14
|
+
import { formatMetadata } from '../libs/util';
|
|
14
15
|
|
|
15
16
|
const router = Router();
|
|
16
17
|
const auth = authenticate<CreditGrant>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -264,7 +265,13 @@ router.put('/:id', auth, async (req, res) => {
|
|
|
264
265
|
if (error) {
|
|
265
266
|
return res.status(400).json({ error: `Credit grant update request invalid: ${error.message}` });
|
|
266
267
|
}
|
|
267
|
-
|
|
268
|
+
if (req.body.metadata) {
|
|
269
|
+
const { error: metadataError } = MetadataSchema.validate(req.body.metadata);
|
|
270
|
+
if (metadataError) {
|
|
271
|
+
return res.status(400).json({ error: `metadata invalid: ${metadataError.message}` });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
await creditGrant.update({ metadata: formatMetadata(req.body.metadata) });
|
|
268
275
|
return res.json({ success: true });
|
|
269
276
|
});
|
|
270
277
|
|