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.
Files changed (30) hide show
  1. package/api/src/index.ts +2 -0
  2. package/api/src/integrations/stripe/handlers/invoice.ts +30 -25
  3. package/api/src/integrations/stripe/handlers/setup-intent.ts +231 -0
  4. package/api/src/integrations/stripe/handlers/subscription.ts +31 -9
  5. package/api/src/integrations/stripe/resource.ts +29 -0
  6. package/api/src/libs/payment.ts +9 -3
  7. package/api/src/libs/util.ts +17 -0
  8. package/api/src/queues/vendors/return-processor.ts +52 -75
  9. package/api/src/queues/vendors/return-scanner.ts +38 -3
  10. package/api/src/routes/connect/change-payer.ts +148 -0
  11. package/api/src/routes/connect/shared.ts +30 -0
  12. package/api/src/routes/invoices.ts +141 -2
  13. package/api/src/routes/payment-links.ts +2 -1
  14. package/api/src/routes/subscriptions.ts +130 -3
  15. package/api/src/routes/vendor.ts +100 -72
  16. package/api/src/store/models/checkout-session.ts +1 -0
  17. package/blocklet.yml +1 -1
  18. package/package.json +6 -6
  19. package/src/components/invoice-pdf/template.tsx +30 -0
  20. package/src/components/subscription/payment-method-info.tsx +222 -0
  21. package/src/global.css +4 -0
  22. package/src/locales/en.tsx +13 -0
  23. package/src/locales/zh.tsx +13 -0
  24. package/src/pages/admin/billing/invoices/detail.tsx +5 -3
  25. package/src/pages/admin/billing/subscriptions/detail.tsx +16 -0
  26. package/src/pages/admin/overview.tsx +14 -14
  27. package/src/pages/admin/products/vendors/create.tsx +6 -40
  28. package/src/pages/admin/products/vendors/index.tsx +5 -1
  29. package/src/pages/customer/invoice/detail.tsx +59 -17
  30. 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
- // Only process vendors with 'completed' status
43
- if (vendor.status !== 'completed') {
44
- logger.info('Skipping vendor return because status is not completed', {
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
- 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
- }
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: error.message,
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 checkoutSession.update({ vendor_info: vendorInfoList });
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 checkoutSession.update({ fulfillment_status: 'returned' });
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: 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
- }
144
+ async function callVendorReturn(vendor: VendorInfo, checkoutSession: CheckoutSession) {
145
+ const vendorAdapter = await VendorFulfillmentService.getVendorAdapter(vendor.vendor_key);
156
146
 
157
- const returnResult = await vendorAdapter.requestReturn({
158
- orderId: vendor.order_id,
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: 'completed',
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
- const hasVendorNeedingReturn = vendorInfoList.some((vendor) => vendor.status === 'completed');
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
- if (doc.metadata?.stripe_id && (doc.status !== 'paid' || req.query.forceSync)) {
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 benefits = await getDonationBenefits(doc);
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 });