payment-kit 1.21.15 → 1.21.17
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/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +30 -25
- package/api/src/integrations/stripe/handlers/setup-intent.ts +231 -0
- package/api/src/integrations/stripe/handlers/subscription.ts +31 -9
- package/api/src/integrations/stripe/resource.ts +29 -0
- package/api/src/libs/payment.ts +9 -3
- package/api/src/libs/util.ts +17 -0
- package/api/src/queues/vendors/return-processor.ts +52 -75
- package/api/src/queues/vendors/return-scanner.ts +38 -3
- package/api/src/routes/connect/change-payer.ts +148 -0
- package/api/src/routes/connect/shared.ts +30 -0
- package/api/src/routes/invoices.ts +141 -2
- package/api/src/routes/payment-links.ts +2 -1
- package/api/src/routes/subscriptions.ts +130 -3
- package/api/src/routes/vendor.ts +100 -72
- package/api/src/store/models/checkout-session.ts +1 -0
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/invoice-pdf/template.tsx +30 -0
- package/src/components/subscription/payment-method-info.tsx +222 -0
- package/src/global.css +4 -0
- package/src/locales/en.tsx +13 -0
- package/src/locales/zh.tsx +13 -0
- package/src/pages/admin/billing/invoices/detail.tsx +5 -3
- package/src/pages/admin/billing/subscriptions/detail.tsx +16 -0
- package/src/pages/admin/overview.tsx +14 -14
- package/src/pages/admin/products/vendors/create.tsx +6 -40
- package/src/pages/admin/products/vendors/index.tsx +5 -1
- package/src/pages/customer/invoice/detail.tsx +59 -17
- package/src/pages/customer/subscription/detail.tsx +20 -1
|
@@ -4,6 +4,8 @@ import { VendorFulfillmentService } from '../../libs/vendor-util/fulfillment';
|
|
|
4
4
|
import { CheckoutSession } from '../../store/models';
|
|
5
5
|
import { VendorInfo } from './fulfillment-coordinator';
|
|
6
6
|
|
|
7
|
+
export const MAX_RETURN_RETRY = 3;
|
|
8
|
+
|
|
7
9
|
type ReturnProcessorJob = {
|
|
8
10
|
checkoutSessionId: string;
|
|
9
11
|
};
|
|
@@ -39,13 +41,14 @@ async function handleReturnProcessorJob(job: ReturnProcessorJob): Promise<void>
|
|
|
39
41
|
let i = -1;
|
|
40
42
|
for (const vendor of vendorInfoList) {
|
|
41
43
|
i++;
|
|
42
|
-
|
|
43
|
-
if (vendor.status
|
|
44
|
-
logger.info('Skipping vendor return because status is
|
|
44
|
+
const returnRetry = vendor.returnRetry ? vendor.returnRetry + 1 : 1;
|
|
45
|
+
if (vendor.status === 'returned') {
|
|
46
|
+
logger.info('Skipping vendor return because status is returned', {
|
|
45
47
|
checkoutSessionId,
|
|
46
48
|
vendorId: vendor.vendor_id,
|
|
47
49
|
orderId: vendor.order_id,
|
|
48
50
|
status: vendor.status,
|
|
51
|
+
returnRetry,
|
|
49
52
|
});
|
|
50
53
|
// eslint-disable-next-line no-continue
|
|
51
54
|
continue;
|
|
@@ -56,53 +59,51 @@ async function handleReturnProcessorJob(job: ReturnProcessorJob): Promise<void>
|
|
|
56
59
|
checkoutSessionId,
|
|
57
60
|
vendorId: vendor.vendor_id,
|
|
58
61
|
orderId: vendor.order_id,
|
|
62
|
+
returnRetry,
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
// eslint-disable-next-line no-await-in-loop
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
}
|
|
66
|
+
await callVendorReturn(vendor, checkoutSession);
|
|
67
|
+
|
|
68
|
+
// Return successful, update status to 'returned'
|
|
69
|
+
vendorInfoList[i] = {
|
|
70
|
+
...vendor,
|
|
71
|
+
status: 'returned',
|
|
72
|
+
lastAttemptAt: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
hasChanges = true;
|
|
75
|
+
|
|
76
|
+
logger.info('Vendor return successful', {
|
|
77
|
+
checkoutSessionId,
|
|
78
|
+
vendorId: vendor.vendor_id,
|
|
79
|
+
orderId: vendor.order_id,
|
|
80
|
+
returnRetry,
|
|
81
|
+
});
|
|
93
82
|
} catch (error: any) {
|
|
94
83
|
logger.error('Error processing vendor return', {
|
|
95
84
|
checkoutSessionId,
|
|
96
85
|
vendorId: vendor.vendor_id,
|
|
97
86
|
orderId: vendor.order_id,
|
|
98
|
-
error
|
|
87
|
+
error,
|
|
88
|
+
returnRetry,
|
|
99
89
|
});
|
|
100
90
|
|
|
91
|
+
if (returnRetry >= MAX_RETURN_RETRY) {
|
|
92
|
+
logger.warn('Skipping vendor return because return retry is greater than 5', {
|
|
93
|
+
checkoutSessionId,
|
|
94
|
+
vendorId: vendor.vendor_id,
|
|
95
|
+
orderId: vendor.order_id,
|
|
96
|
+
returnRetry,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
101
100
|
// Record error but keep status unchanged for retry
|
|
102
101
|
vendorInfoList[i] = {
|
|
103
102
|
...vendor,
|
|
103
|
+
status: returnRetry >= MAX_RETURN_RETRY ? 'returned' : vendor.status,
|
|
104
104
|
lastAttemptAt: new Date().toISOString(),
|
|
105
105
|
error_message: error.message,
|
|
106
|
+
returnRetry,
|
|
106
107
|
};
|
|
107
108
|
hasChanges = true;
|
|
108
109
|
}
|
|
@@ -110,14 +111,14 @@ async function handleReturnProcessorJob(job: ReturnProcessorJob): Promise<void>
|
|
|
110
111
|
|
|
111
112
|
// Update vendor_info if there are changes
|
|
112
113
|
if (hasChanges) {
|
|
113
|
-
await
|
|
114
|
+
await CheckoutSession.update({ vendor_info: vendorInfoList }, { where: { id: checkoutSessionId } });
|
|
114
115
|
}
|
|
115
116
|
|
|
116
117
|
// Check if all vendors have been returned
|
|
117
118
|
const allReturned = vendorInfoList.every((vendor) => vendor.status === 'returned');
|
|
118
119
|
|
|
119
120
|
if (allReturned && checkoutSession.fulfillment_status !== 'returned') {
|
|
120
|
-
await
|
|
121
|
+
await CheckoutSession.update({ fulfillment_status: 'returned' }, { where: { id: checkoutSessionId } });
|
|
121
122
|
|
|
122
123
|
logger.info('All vendors returned, updated fulfillment status to returned', {
|
|
123
124
|
checkoutSessionId,
|
|
@@ -140,44 +141,20 @@ async function handleReturnProcessorJob(job: ReturnProcessorJob): Promise<void>
|
|
|
140
141
|
}
|
|
141
142
|
}
|
|
142
143
|
|
|
143
|
-
async function callVendorReturn(
|
|
144
|
-
vendor
|
|
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
|
-
}
|
|
144
|
+
async function callVendorReturn(vendor: VendorInfo, checkoutSession: CheckoutSession) {
|
|
145
|
+
const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
|
|
156
146
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
reason: 'Subscription canceled',
|
|
160
|
-
customParams: {
|
|
161
|
-
checkoutSessionId: checkoutSession.id,
|
|
162
|
-
subscriptionId: checkoutSession.subscription_id,
|
|
163
|
-
vendorKey: vendor.vendor_key,
|
|
164
|
-
},
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
return {
|
|
168
|
-
success: returnResult.success || false,
|
|
169
|
-
message: returnResult.message,
|
|
170
|
-
};
|
|
171
|
-
} catch (error: any) {
|
|
172
|
-
logger.error('Failed to call vendor return API', {
|
|
173
|
-
vendorId: vendor.vendor_id,
|
|
174
|
-
orderId: vendor.order_id,
|
|
175
|
-
error: error.message,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
success: false,
|
|
180
|
-
message: error.message,
|
|
181
|
-
};
|
|
147
|
+
if (!vendorAdapter) {
|
|
148
|
+
throw new Error(`No adapter found for vendor: ${vendor.vendor_id}`);
|
|
182
149
|
}
|
|
150
|
+
|
|
151
|
+
return vendorAdapter.requestReturn({
|
|
152
|
+
orderId: vendor.order_id,
|
|
153
|
+
reason: 'Subscription canceled',
|
|
154
|
+
customParams: {
|
|
155
|
+
checkoutSessionId: checkoutSession.id,
|
|
156
|
+
subscriptionId: checkoutSession.subscription_id,
|
|
157
|
+
vendorKey: vendor.vendor_key,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
183
160
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { Op } from 'sequelize';
|
|
2
|
+
import dayjs from 'dayjs';
|
|
2
3
|
import logger from '../../libs/logger';
|
|
3
4
|
import createQueue from '../../libs/queue';
|
|
4
5
|
import { CheckoutSession, Subscription } from '../../store/models';
|
|
5
6
|
import { vendorReturnProcessorQueue } from './return-processor';
|
|
6
7
|
import { VendorInfo } from './fulfillment-coordinator';
|
|
8
|
+
import { events } from '../../libs/event';
|
|
7
9
|
|
|
8
10
|
export const vendorReturnScannerQueue = createQueue({
|
|
9
11
|
name: 'vendor-return-scanner',
|
|
@@ -52,8 +54,10 @@ async function handleReturnScannerJob(): Promise<void> {
|
|
|
52
54
|
async function findSessionsNeedingVendorReturn(): Promise<CheckoutSession[]> {
|
|
53
55
|
try {
|
|
54
56
|
// First, find canceled subscriptions
|
|
57
|
+
const oneWeekAgo = dayjs().subtract(7, 'day').unix();
|
|
58
|
+
|
|
55
59
|
const canceledSubscriptions = await Subscription.findAll({
|
|
56
|
-
where: { status: 'canceled' },
|
|
60
|
+
where: { status: 'canceled', canceled_at: { [Op.gt]: oneWeekAgo } },
|
|
57
61
|
attributes: ['id'],
|
|
58
62
|
});
|
|
59
63
|
|
|
@@ -62,7 +66,7 @@ async function findSessionsNeedingVendorReturn(): Promise<CheckoutSession[]> {
|
|
|
62
66
|
// Find checkout sessions with completed fulfillment and canceled subscriptions
|
|
63
67
|
const readyToReturnSessions = await CheckoutSession.findAll({
|
|
64
68
|
where: {
|
|
65
|
-
fulfillment_status: '
|
|
69
|
+
fulfillment_status: { [Op.notIn]: ['returning', 'returned', 'failed'] },
|
|
66
70
|
subscription_id: { [Op.in]: canceledSubscriptionIds },
|
|
67
71
|
},
|
|
68
72
|
order: [['updated_at', 'DESC']],
|
|
@@ -102,7 +106,10 @@ async function findSessionsNeedingVendorReturn(): Promise<CheckoutSession[]> {
|
|
|
102
106
|
if (!vendorInfoList || vendorInfoList.length === 0) {
|
|
103
107
|
return false;
|
|
104
108
|
}
|
|
105
|
-
|
|
109
|
+
|
|
110
|
+
const hasVendorNeedingReturn = vendorInfoList.some(
|
|
111
|
+
(vendor) => !['cancelled', 'return_requested', 'returned'].includes(vendor.status)
|
|
112
|
+
);
|
|
106
113
|
return hasVendorNeedingReturn;
|
|
107
114
|
});
|
|
108
115
|
|
|
@@ -117,3 +124,31 @@ export function scheduleVendorReturnScan(): void {
|
|
|
117
124
|
const scanId = `scan-${Date.now()}`;
|
|
118
125
|
vendorReturnScannerQueue.push({ id: scanId, job: {} });
|
|
119
126
|
}
|
|
127
|
+
|
|
128
|
+
events.on('customer.subscription.deleted', async (subscription: Subscription) => {
|
|
129
|
+
logger.info('Customer subscription deleted', { subscription });
|
|
130
|
+
if (subscription.status !== 'canceled') {
|
|
131
|
+
logger.info('Subscription is not canceled, skipping vendor return process[customer.subscription.deleted]', {
|
|
132
|
+
subscriptionId: subscription.id,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const session = await CheckoutSession.findOne({
|
|
138
|
+
where: { subscription_id: subscription.id },
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
if (session) {
|
|
142
|
+
const id = `vendor-return-process-${session.id}`;
|
|
143
|
+
// eslint-disable-next-line no-await-in-loop
|
|
144
|
+
const exists = await vendorReturnProcessorQueue.get(id);
|
|
145
|
+
if (!exists) {
|
|
146
|
+
vendorReturnProcessorQueue.push({
|
|
147
|
+
id,
|
|
148
|
+
job: {
|
|
149
|
+
checkoutSessionId: session.id,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { executeEvmTransaction, waitForEvmTxConfirm } from '../../integrations/ethereum/tx';
|
|
2
|
+
import type { CallbackArgs } from '../../libs/auth';
|
|
3
|
+
import { getTxMetadata } from '../../libs/util';
|
|
4
|
+
import { type TLineItemExpanded } from '../../store/models';
|
|
5
|
+
import {
|
|
6
|
+
ensurePayerChangeContext,
|
|
7
|
+
executeOcapTransactions,
|
|
8
|
+
getAuthPrincipalClaim,
|
|
9
|
+
getDelegationTxClaim,
|
|
10
|
+
} from './shared';
|
|
11
|
+
import { EVM_CHAIN_TYPES } from '../../libs/constants';
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
action: 'change-payer',
|
|
15
|
+
authPrincipal: false,
|
|
16
|
+
persistentDynamicClaims: true,
|
|
17
|
+
claims: {
|
|
18
|
+
authPrincipal: async ({ extraParams }: CallbackArgs) => {
|
|
19
|
+
const { paymentMethod } = await ensurePayerChangeContext(extraParams.subscriptionId);
|
|
20
|
+
return getAuthPrincipalClaim(paymentMethod, 'continue');
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
|
|
24
|
+
const { subscriptionId } = extraParams;
|
|
25
|
+
const { subscription, paymentMethod, paymentCurrency, payerAddress } =
|
|
26
|
+
await ensurePayerChangeContext(subscriptionId);
|
|
27
|
+
|
|
28
|
+
if (userDid === payerAddress) {
|
|
29
|
+
throw new Error('The current payer is the same as the new payer, please use another account to change payer');
|
|
30
|
+
}
|
|
31
|
+
const claimsList: any[] = [];
|
|
32
|
+
// @ts-ignore
|
|
33
|
+
const items = subscription!.items as TLineItemExpanded[];
|
|
34
|
+
const trialing = true;
|
|
35
|
+
const billingThreshold = Number(subscription.billing_thresholds?.amount_gte || 0);
|
|
36
|
+
|
|
37
|
+
if (paymentMethod.type === 'arcblock') {
|
|
38
|
+
claimsList.push({
|
|
39
|
+
signature: await getDelegationTxClaim({
|
|
40
|
+
mode: 'delegation',
|
|
41
|
+
userDid,
|
|
42
|
+
userPk,
|
|
43
|
+
nonce: subscription.id,
|
|
44
|
+
data: getTxMetadata({ subscriptionId: subscription.id }),
|
|
45
|
+
paymentCurrency,
|
|
46
|
+
paymentMethod,
|
|
47
|
+
trialing,
|
|
48
|
+
billingThreshold,
|
|
49
|
+
items,
|
|
50
|
+
requiredStake: false,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
return claimsList;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (EVM_CHAIN_TYPES.includes(paymentMethod.type)) {
|
|
57
|
+
if (!paymentCurrency.contract) {
|
|
58
|
+
throw new Error(`Payment currency ${paymentMethod.type}:${paymentCurrency.id} does not support subscription`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
claimsList.push({
|
|
62
|
+
signature: await getDelegationTxClaim({
|
|
63
|
+
mode: 'subscription',
|
|
64
|
+
userDid,
|
|
65
|
+
userPk,
|
|
66
|
+
nonce: `change-payer-${subscription!.id}`,
|
|
67
|
+
data: getTxMetadata({ subscriptionId: subscription!.id }),
|
|
68
|
+
paymentCurrency,
|
|
69
|
+
paymentMethod,
|
|
70
|
+
trialing,
|
|
71
|
+
billingThreshold,
|
|
72
|
+
items,
|
|
73
|
+
}),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return claimsList;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new Error(`ChangePayer: Payment method ${paymentMethod.type} not supported`);
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
onAuth: async ({ request, userDid, userPk, claims, extraParams, step }: CallbackArgs) => {
|
|
83
|
+
const { subscriptionId } = extraParams;
|
|
84
|
+
const { subscription, paymentMethod, paymentCurrency } = await ensurePayerChangeContext(subscriptionId);
|
|
85
|
+
|
|
86
|
+
const result = request?.context?.store?.result || [];
|
|
87
|
+
result.push({
|
|
88
|
+
step,
|
|
89
|
+
claim: claims?.[0],
|
|
90
|
+
stepRequest: {
|
|
91
|
+
headers: request?.headers,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const claimsList = result.map((x: any) => x.claim);
|
|
95
|
+
|
|
96
|
+
const afterTxExecution = async (paymentDetails: any) => {
|
|
97
|
+
await subscription?.update({
|
|
98
|
+
payment_settings: {
|
|
99
|
+
payment_method_types: [paymentMethod.type],
|
|
100
|
+
payment_method_options: {
|
|
101
|
+
[paymentMethod.type]: { payer: userDid },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
payment_details: {
|
|
105
|
+
...subscription.payment_details,
|
|
106
|
+
[paymentMethod.type]: {
|
|
107
|
+
...(subscription.payment_details?.[paymentMethod.type as keyof typeof subscription.payment_details] || {}),
|
|
108
|
+
type: 'delegate',
|
|
109
|
+
payer: userDid,
|
|
110
|
+
tx_hash: paymentDetails.tx_hash,
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
if (paymentMethod.type === 'arcblock') {
|
|
117
|
+
const requestArray = result
|
|
118
|
+
.map((item: { stepRequest?: Request }) => item.stepRequest)
|
|
119
|
+
.filter(Boolean) as Request[];
|
|
120
|
+
const requestSource = requestArray.length > 0 ? requestArray : request;
|
|
121
|
+
|
|
122
|
+
const paymentDetails = await executeOcapTransactions(
|
|
123
|
+
userDid,
|
|
124
|
+
userPk,
|
|
125
|
+
claimsList,
|
|
126
|
+
paymentMethod,
|
|
127
|
+
requestSource,
|
|
128
|
+
subscription?.id,
|
|
129
|
+
paymentCurrency.contract
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await afterTxExecution(paymentDetails);
|
|
133
|
+
return { hash: paymentDetails.tx_hash };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (EVM_CHAIN_TYPES.includes(paymentMethod.type)) {
|
|
137
|
+
const paymentDetails = await executeEvmTransaction('approve', userDid, claimsList, paymentMethod);
|
|
138
|
+
waitForEvmTxConfirm(paymentMethod.getEvmClient(), +paymentDetails.block_height, paymentMethod.confirmation.block)
|
|
139
|
+
.then(async () => {
|
|
140
|
+
await afterTxExecution(paymentDetails);
|
|
141
|
+
})
|
|
142
|
+
.catch(console.error);
|
|
143
|
+
return { hash: paymentDetails.tx_hash };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
throw new Error(`ChangePayer: Payment method ${paymentMethod.type} not supported`);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
@@ -1224,6 +1224,36 @@ export async function ensureChangePaymentContext(subscriptionId: string) {
|
|
|
1224
1224
|
};
|
|
1225
1225
|
}
|
|
1226
1226
|
|
|
1227
|
+
export async function ensurePayerChangeContext(subscriptionId: string) {
|
|
1228
|
+
const subscription = await Subscription.findByPk(subscriptionId);
|
|
1229
|
+
if (!subscription) {
|
|
1230
|
+
throw new Error(`Subscription not found: ${subscriptionId}`);
|
|
1231
|
+
}
|
|
1232
|
+
if (!['active', 'trialing', 'past_due'].includes(subscription.status)) {
|
|
1233
|
+
throw new Error(`Subscription ${subscriptionId} is not in a valid status to change payer`);
|
|
1234
|
+
}
|
|
1235
|
+
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
1236
|
+
if (!paymentMethod) {
|
|
1237
|
+
throw new Error(`Payment method not found for subscription ${subscriptionId}`);
|
|
1238
|
+
}
|
|
1239
|
+
const payerAddress = getSubscriptionPaymentAddress(subscription, paymentMethod?.type);
|
|
1240
|
+
const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
1241
|
+
if (!paymentCurrency) {
|
|
1242
|
+
throw new Error(`PaymentCurrency ${subscription.currency_id} not found for subscription ${subscriptionId}`);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// @ts-ignore
|
|
1246
|
+
subscription.items = await expandSubscriptionItems(subscription.id);
|
|
1247
|
+
|
|
1248
|
+
return {
|
|
1249
|
+
subscription,
|
|
1250
|
+
paymentCurrency,
|
|
1251
|
+
paymentMethod,
|
|
1252
|
+
customer: await Customer.findByPk(subscription.customer_id),
|
|
1253
|
+
payerAddress,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1227
1257
|
export async function ensureReStakeContext(subscriptionId: string) {
|
|
1228
1258
|
const subscription = await Subscription.findByPk(subscriptionId);
|
|
1229
1259
|
if (!subscription) {
|
|
@@ -8,6 +8,7 @@ import { Op } from 'sequelize';
|
|
|
8
8
|
import { BN } from '@ocap/util';
|
|
9
9
|
import { syncStripeInvoice } from '../integrations/stripe/handlers/invoice';
|
|
10
10
|
import { syncStripePayment } from '../integrations/stripe/handlers/payment-intent';
|
|
11
|
+
import { ensureStripeCustomer, ensureStripeSetupIntentForInvoicePayment } from '../integrations/stripe/resource';
|
|
11
12
|
import { createListParamSchema, getOrder, getWhereFromKvQuery, MetadataSchema } from '../libs/api';
|
|
12
13
|
import { authenticate } from '../libs/security';
|
|
13
14
|
import { expandLineItems } from '../libs/session';
|
|
@@ -662,9 +663,11 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
662
663
|
})) as TInvoiceExpanded | null;
|
|
663
664
|
|
|
664
665
|
if (doc) {
|
|
665
|
-
|
|
666
|
+
const shouldSync = req.query.sync === 'true' || !!req.query.forceSync;
|
|
667
|
+
// Sync Stripe invoice when sync=true query parameter is present
|
|
668
|
+
if (doc.metadata?.stripe_id && doc.status !== 'paid') {
|
|
666
669
|
// @ts-ignore
|
|
667
|
-
await syncStripeInvoice(doc);
|
|
670
|
+
await syncStripeInvoice(doc, shouldSync);
|
|
668
671
|
}
|
|
669
672
|
if (doc.payment_intent_id) {
|
|
670
673
|
const paymentIntent = await PaymentIntent.findByPk(doc.payment_intent_id);
|
|
@@ -799,6 +802,142 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
799
802
|
}
|
|
800
803
|
});
|
|
801
804
|
|
|
805
|
+
router.post('/pay-stripe', authPortal, async (req, res) => {
|
|
806
|
+
try {
|
|
807
|
+
const { invoice_ids, subscription_id, customer_id, currency_id } = req.body;
|
|
808
|
+
|
|
809
|
+
if (!currency_id) {
|
|
810
|
+
return res.status(400).json({ error: 'currency_id is required' });
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (!invoice_ids && !subscription_id && !customer_id) {
|
|
814
|
+
return res.status(400).json({ error: 'Must provide invoice_ids, subscription_id, or customer_id' });
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
let invoices: Invoice[];
|
|
818
|
+
let customer: Customer | null;
|
|
819
|
+
let paymentMethod: PaymentMethod | null = null;
|
|
820
|
+
|
|
821
|
+
if (invoice_ids && Array.isArray(invoice_ids) && invoice_ids.length > 0) {
|
|
822
|
+
invoices = await Invoice.findAll({
|
|
823
|
+
where: {
|
|
824
|
+
id: { [Op.in]: invoice_ids },
|
|
825
|
+
currency_id,
|
|
826
|
+
status: { [Op.in]: ['open', 'uncollectible'] },
|
|
827
|
+
},
|
|
828
|
+
include: [
|
|
829
|
+
{ model: Customer, as: 'customer' },
|
|
830
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
831
|
+
],
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
if (invoices.length === 0) {
|
|
835
|
+
return res.status(404).json({ error: 'No payable invoices found' });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// @ts-ignore
|
|
839
|
+
customer = invoices[0]?.customer;
|
|
840
|
+
paymentMethod = await PaymentMethod.findByPk(invoices[0]!.default_payment_method_id);
|
|
841
|
+
} else if (subscription_id) {
|
|
842
|
+
const subscription = await Subscription.findByPk(subscription_id, {
|
|
843
|
+
include: [{ model: Customer, as: 'customer' }],
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
if (!subscription) {
|
|
847
|
+
return res.status(404).json({ error: 'Subscription not found' });
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// @ts-ignore
|
|
851
|
+
customer = subscription.customer;
|
|
852
|
+
paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
853
|
+
|
|
854
|
+
invoices = await Invoice.findAll({
|
|
855
|
+
where: {
|
|
856
|
+
subscription_id,
|
|
857
|
+
currency_id,
|
|
858
|
+
status: { [Op.in]: ['open', 'uncollectible'] },
|
|
859
|
+
},
|
|
860
|
+
include: [
|
|
861
|
+
{ model: Customer, as: 'customer' },
|
|
862
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
863
|
+
],
|
|
864
|
+
});
|
|
865
|
+
} else {
|
|
866
|
+
customer = await Customer.findByPkOrDid(customer_id!);
|
|
867
|
+
if (!customer) {
|
|
868
|
+
return res.status(404).json({ error: 'Customer not found' });
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
invoices = await Invoice.findAll({
|
|
872
|
+
where: {
|
|
873
|
+
customer_id: customer.id,
|
|
874
|
+
currency_id,
|
|
875
|
+
status: { [Op.in]: ['open', 'uncollectible'] },
|
|
876
|
+
},
|
|
877
|
+
include: [
|
|
878
|
+
{ model: Customer, as: 'customer' },
|
|
879
|
+
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
880
|
+
],
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
if (invoices.length === 0) {
|
|
884
|
+
return res.status(404).json({ error: 'No payable invoices found' });
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
paymentMethod = await PaymentMethod.findByPk(invoices[0]!.default_payment_method_id);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (!customer) {
|
|
891
|
+
return res.status(404).json({ error: 'Customer not found' });
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (!paymentMethod || paymentMethod.type !== 'stripe') {
|
|
895
|
+
return res.status(400).json({ error: 'Not using Stripe payment method' });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if (invoices.length === 0) {
|
|
899
|
+
return res.status(400).json({ error: 'No payable invoices found' });
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
await ensureStripeCustomer(customer, paymentMethod);
|
|
903
|
+
|
|
904
|
+
const settings = PaymentMethod.decryptSettings(paymentMethod.settings);
|
|
905
|
+
|
|
906
|
+
const paymentCurrency = await PaymentCurrency.findByPk(currency_id);
|
|
907
|
+
if (!paymentCurrency) {
|
|
908
|
+
return res.status(404).json({ error: `Payment currency ${currency_id} not found` });
|
|
909
|
+
}
|
|
910
|
+
const totalAmount = invoices.reduce((sum, invoice) => {
|
|
911
|
+
const amount = invoice.amount_remaining || '0';
|
|
912
|
+
return new BN(sum).add(new BN(amount)).toString();
|
|
913
|
+
}, '0');
|
|
914
|
+
|
|
915
|
+
const metadata: any = {
|
|
916
|
+
currency_id,
|
|
917
|
+
customer_id: customer.id,
|
|
918
|
+
invoices: JSON.stringify(invoices.map((inv) => inv.id)),
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
const setupIntent = await ensureStripeSetupIntentForInvoicePayment(customer, paymentMethod, metadata);
|
|
922
|
+
|
|
923
|
+
return res.json({
|
|
924
|
+
client_secret: setupIntent.client_secret,
|
|
925
|
+
publishable_key: settings.stripe?.publishable_key,
|
|
926
|
+
setup_intent_id: setupIntent.id,
|
|
927
|
+
invoices: invoices.map((inv) => inv.id),
|
|
928
|
+
amount: totalAmount,
|
|
929
|
+
currency: paymentCurrency,
|
|
930
|
+
customer,
|
|
931
|
+
});
|
|
932
|
+
} catch (err) {
|
|
933
|
+
logger.error('Failed to create setup intent for stripe payment', {
|
|
934
|
+
error: err,
|
|
935
|
+
body: req.body,
|
|
936
|
+
});
|
|
937
|
+
return res.status(400).json({ error: err.message });
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
|
|
802
941
|
// eslint-disable-next-line consistent-return
|
|
803
942
|
router.put('/:id', authAdmin, async (req, res) => {
|
|
804
943
|
try {
|
|
@@ -449,7 +449,8 @@ router.get('/:id/benefits', async (req, res) => {
|
|
|
449
449
|
if (!doc) {
|
|
450
450
|
return res.status(404).json({ error: 'payment link not found' });
|
|
451
451
|
}
|
|
452
|
-
const
|
|
452
|
+
const locale = req.query.locale as string;
|
|
453
|
+
const benefits = await getDonationBenefits(doc, '', locale);
|
|
453
454
|
return res.json(benefits);
|
|
454
455
|
} catch (err) {
|
|
455
456
|
logger.error('Get donation benefits error', { error: err.message, stack: err.stack, id: req.params.id });
|