payment-kit 1.21.16 → 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/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/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/customer/invoice/detail.tsx +59 -17
- package/src/pages/customer/subscription/detail.tsx +20 -1
package/api/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ import rechargeHandlers from './routes/connect/recharge';
|
|
|
53
53
|
import rechargeAccountHandlers from './routes/connect/recharge-account';
|
|
54
54
|
import setupHandlers from './routes/connect/setup';
|
|
55
55
|
import subscribeHandlers from './routes/connect/subscribe';
|
|
56
|
+
import changePayerHandlers from './routes/connect/change-payer';
|
|
56
57
|
import { initialize } from './store/models';
|
|
57
58
|
import { sequelize } from './store/sequelize';
|
|
58
59
|
|
|
@@ -92,6 +93,7 @@ handlers.attach(Object.assign({ app: router }, delegationHandlers));
|
|
|
92
93
|
handlers.attach(Object.assign({ app: router }, overdraftProtectionHandlers));
|
|
93
94
|
handlers.attach(Object.assign({ app: router }, reStakeHandlers));
|
|
94
95
|
handlers.attach(Object.assign({ app: router }, autoRechargeAuthorizationHandlers));
|
|
96
|
+
handlers.attach(Object.assign({ app: router }, changePayerHandlers));
|
|
95
97
|
router.use('/api', routes);
|
|
96
98
|
|
|
97
99
|
const isProduction = process.env.BLOCKLET_MODE === 'production';
|
|
@@ -91,7 +91,7 @@ export function getStripeInvoicePeriod(invoice: any) {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
export async function syncStripeInvoice(invoice: Invoice) {
|
|
94
|
+
export async function syncStripeInvoice(invoice: Invoice, sync = true) {
|
|
95
95
|
if (!invoice.metadata?.stripe_id) {
|
|
96
96
|
return;
|
|
97
97
|
}
|
|
@@ -103,6 +103,28 @@ export async function syncStripeInvoice(invoice: Invoice) {
|
|
|
103
103
|
|
|
104
104
|
const client = await method.getStripeClient();
|
|
105
105
|
const stripeInvoice = await client.invoices.retrieve(invoice.metadata.stripe_id);
|
|
106
|
+
const updates = [
|
|
107
|
+
'amount_due',
|
|
108
|
+
'amount_paid',
|
|
109
|
+
'amount_remaining',
|
|
110
|
+
'last_finalization_error',
|
|
111
|
+
'paid_out_of_band',
|
|
112
|
+
'paid',
|
|
113
|
+
'status',
|
|
114
|
+
'status_transitions',
|
|
115
|
+
'subtotal_excluding_tax',
|
|
116
|
+
'subtotal',
|
|
117
|
+
'tax',
|
|
118
|
+
'total_discount_amounts',
|
|
119
|
+
'total',
|
|
120
|
+
];
|
|
121
|
+
if (!sync && stripeInvoice.status !== 'paid' && invoice.status === 'uncollectible') {
|
|
122
|
+
// remove status from updates
|
|
123
|
+
const statusIndex = updates.indexOf('status');
|
|
124
|
+
if (statusIndex !== -1) {
|
|
125
|
+
updates.splice(statusIndex, 1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
106
128
|
if (stripeInvoice) {
|
|
107
129
|
const processDiscounts = await processInvoiceDiscounts(
|
|
108
130
|
stripeInvoice,
|
|
@@ -111,27 +133,9 @@ export async function syncStripeInvoice(invoice: Invoice) {
|
|
|
111
133
|
);
|
|
112
134
|
await invoice.update(
|
|
113
135
|
// @ts-ignore
|
|
114
|
-
merge(
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
'amount_paid',
|
|
118
|
-
'amount_remaining',
|
|
119
|
-
'last_finalization_error',
|
|
120
|
-
'paid_out_of_band',
|
|
121
|
-
'paid',
|
|
122
|
-
'status_transitions',
|
|
123
|
-
'status',
|
|
124
|
-
'subtotal_excluding_tax',
|
|
125
|
-
'subtotal',
|
|
126
|
-
'tax',
|
|
127
|
-
'total_discount_amounts',
|
|
128
|
-
'total',
|
|
129
|
-
]),
|
|
130
|
-
getStripeInvoicePeriod(stripeInvoice),
|
|
131
|
-
{
|
|
132
|
-
total_discount_amounts: processDiscounts,
|
|
133
|
-
}
|
|
134
|
-
)
|
|
136
|
+
merge(pick(stripeInvoice, updates), getStripeInvoicePeriod(stripeInvoice), {
|
|
137
|
+
total_discount_amounts: processDiscounts,
|
|
138
|
+
})
|
|
135
139
|
);
|
|
136
140
|
logger.info('stripe invoice synced', { locale: invoice.id, remote: stripeInvoice.id });
|
|
137
141
|
const failedStatuses = ['uncollectible', 'finalization_failed', 'payment_failed'];
|
|
@@ -416,7 +420,8 @@ export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe)
|
|
|
416
420
|
}
|
|
417
421
|
|
|
418
422
|
if (event.type === 'invoice.finalized') {
|
|
419
|
-
|
|
423
|
+
// invoice from draft to open
|
|
424
|
+
await invoice.update({ status: 'open', status_transitions: event.data.object.status_transitions });
|
|
420
425
|
logger.info('invoice finalized on stripe event', { locale: invoice.id });
|
|
421
426
|
return;
|
|
422
427
|
}
|
|
@@ -442,14 +447,14 @@ export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe)
|
|
|
442
447
|
|
|
443
448
|
if (event.type === 'invoice.finalization_failed') {
|
|
444
449
|
await invoice.update({
|
|
445
|
-
status: '
|
|
450
|
+
status: 'uncollectible',
|
|
446
451
|
last_finalization_error: event.data.object.last_finalization_error,
|
|
447
452
|
});
|
|
448
453
|
logger.info('invoice finalization failed on stripe event', { locale: invoice.id });
|
|
449
454
|
}
|
|
450
455
|
|
|
451
456
|
if (event.type === 'invoice.payment_failed') {
|
|
452
|
-
await invoice.update({ status: '
|
|
457
|
+
await invoice.update({ status: 'uncollectible' });
|
|
453
458
|
logger.info('invoice payment failed on stripe event', { locale: invoice.id });
|
|
454
459
|
}
|
|
455
460
|
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import type Stripe from 'stripe';
|
|
2
|
+
import { Op } from 'sequelize';
|
|
2
3
|
|
|
3
4
|
import logger from '../../../libs/logger';
|
|
4
5
|
import {
|
|
5
6
|
AutoRechargeConfig,
|
|
6
7
|
CheckoutSession,
|
|
8
|
+
Customer,
|
|
9
|
+
Invoice,
|
|
7
10
|
Lock,
|
|
11
|
+
PaymentCurrency,
|
|
12
|
+
PaymentMethod,
|
|
8
13
|
SetupIntent,
|
|
9
14
|
Subscription,
|
|
10
15
|
TEventExpanded,
|
|
@@ -150,6 +155,230 @@ async function handleAutoRechargeOnSetupSucceeded(event: TEventExpanded, stripeI
|
|
|
150
155
|
}
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
async function handleUpdateStripePaymentMethodOnSetupSucceeded(event: TEventExpanded, stripeIntentId: string) {
|
|
159
|
+
const { metadata } = event.data.object;
|
|
160
|
+
|
|
161
|
+
if (metadata?.action !== 'update_payment_method' || !metadata?.subscription_id) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (event.type !== 'setup_intent.succeeded') {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const subscription = await Subscription.findByPk(metadata.subscription_id);
|
|
170
|
+
if (!subscription) {
|
|
171
|
+
logger.warn('subscription not found for update payment method', {
|
|
172
|
+
id: event.id,
|
|
173
|
+
stripeIntentId,
|
|
174
|
+
subscriptionId: metadata.subscription_id,
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
180
|
+
if (!paymentMethod || paymentMethod.type !== 'stripe') {
|
|
181
|
+
logger.warn('stripe payment method not found for subscription', {
|
|
182
|
+
id: event.id,
|
|
183
|
+
stripeIntentId,
|
|
184
|
+
subscriptionId: subscription.id,
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const stripeSubscriptionId = subscription.payment_details?.stripe?.subscription_id;
|
|
190
|
+
if (!stripeSubscriptionId) {
|
|
191
|
+
logger.warn('stripe subscription id not found', {
|
|
192
|
+
id: event.id,
|
|
193
|
+
stripeIntentId,
|
|
194
|
+
subscriptionId: subscription.id,
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const client = paymentMethod.getStripeClient();
|
|
200
|
+
const stripePaymentMethodId = event.data.object.payment_method as string;
|
|
201
|
+
|
|
202
|
+
await client.subscriptions.update(stripeSubscriptionId, {
|
|
203
|
+
default_payment_method: stripePaymentMethodId,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
logger.info('stripe payment method updated via webhook', {
|
|
207
|
+
subscriptionId: subscription.id,
|
|
208
|
+
stripeIntentId,
|
|
209
|
+
paymentMethod: stripePaymentMethodId,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function handleBatchOverduePaymentOnSetupSucceeded(event: TEventExpanded, stripeIntentId: string) {
|
|
214
|
+
const { metadata } = event.data.object;
|
|
215
|
+
|
|
216
|
+
if (metadata?.action !== 'pay_overdue_batch' || !metadata?.currency_id) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (event.type !== 'setup_intent.succeeded') {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const paymentCurrency = await PaymentCurrency.findByPk(metadata.currency_id);
|
|
225
|
+
if (!paymentCurrency) {
|
|
226
|
+
logger.warn('payment currency not found for batch overdue payment', {
|
|
227
|
+
id: event.id,
|
|
228
|
+
stripeIntentId,
|
|
229
|
+
currencyId: metadata.currency_id,
|
|
230
|
+
});
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
235
|
+
if (!paymentMethod || paymentMethod.type !== 'stripe') {
|
|
236
|
+
logger.warn('stripe payment method not found or invalid type', {
|
|
237
|
+
id: event.id,
|
|
238
|
+
stripeIntentId,
|
|
239
|
+
currencyId: metadata.currency_id,
|
|
240
|
+
paymentMethodType: paymentMethod?.type,
|
|
241
|
+
});
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let invoices: Invoice[];
|
|
246
|
+
|
|
247
|
+
if (metadata.invoices) {
|
|
248
|
+
let invoiceIds: string[];
|
|
249
|
+
try {
|
|
250
|
+
invoiceIds = JSON.parse(metadata.invoices);
|
|
251
|
+
} catch (err) {
|
|
252
|
+
logger.error('failed to parse invoices from setup intent metadata', {
|
|
253
|
+
id: event.id,
|
|
254
|
+
stripeIntentId,
|
|
255
|
+
metadata,
|
|
256
|
+
});
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
invoices = await Invoice.findAll({
|
|
261
|
+
where: {
|
|
262
|
+
id: { [Op.in]: invoiceIds },
|
|
263
|
+
currency_id: metadata.currency_id,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
const subscriptionIds = [...new Set(invoices.map((inv) => inv.subscription_id).filter(Boolean) as string[])];
|
|
268
|
+
|
|
269
|
+
if (subscriptionIds.length > 0) {
|
|
270
|
+
const additionalInvoices = await Invoice.findAll({
|
|
271
|
+
where: {
|
|
272
|
+
subscription_id: { [Op.in]: subscriptionIds },
|
|
273
|
+
currency_id: metadata.currency_id,
|
|
274
|
+
status: { [Op.in]: ['open', 'uncollectible'] },
|
|
275
|
+
id: { [Op.notIn]: invoiceIds },
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
invoices = [...invoices, ...additionalInvoices];
|
|
280
|
+
|
|
281
|
+
logger.info('found additional overdue invoices for subscriptions', {
|
|
282
|
+
stripeIntentId,
|
|
283
|
+
subscriptionIds,
|
|
284
|
+
originalCount: invoiceIds.length,
|
|
285
|
+
additionalCount: additionalInvoices.length,
|
|
286
|
+
totalCount: invoices.length,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
} else if (metadata.subscription_id) {
|
|
290
|
+
invoices = await Invoice.findAll({
|
|
291
|
+
where: {
|
|
292
|
+
subscription_id: metadata.subscription_id,
|
|
293
|
+
currency_id: metadata.currency_id,
|
|
294
|
+
status: { [Op.in]: ['open', 'uncollectible'] },
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
} else if (metadata.customer_id) {
|
|
298
|
+
const customer = await Customer.findByPkOrDid(metadata.customer_id);
|
|
299
|
+
if (!customer) {
|
|
300
|
+
logger.warn('customer not found for batch overdue payment', {
|
|
301
|
+
id: event.id,
|
|
302
|
+
stripeIntentId,
|
|
303
|
+
customerId: metadata.customer_id,
|
|
304
|
+
});
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
invoices = await Invoice.findAll({
|
|
309
|
+
where: {
|
|
310
|
+
customer_id: customer.id,
|
|
311
|
+
currency_id: metadata.currency_id,
|
|
312
|
+
status: { [Op.in]: ['open', 'uncollectible'] },
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
} else {
|
|
316
|
+
logger.error('invalid metadata for batch overdue payment, must provide invoices, subscription_id or customer_id', {
|
|
317
|
+
id: event.id,
|
|
318
|
+
stripeIntentId,
|
|
319
|
+
metadata,
|
|
320
|
+
});
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (invoices.length === 0) {
|
|
325
|
+
logger.warn('no invoices found for batch overdue payment', {
|
|
326
|
+
id: event.id,
|
|
327
|
+
stripeIntentId,
|
|
328
|
+
metadata,
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const validInvoices = invoices.filter((inv) => inv.metadata?.stripe_id);
|
|
334
|
+
if (validInvoices.length === 0) {
|
|
335
|
+
logger.warn('no valid stripe invoices found for batch overdue payment', {
|
|
336
|
+
id: event.id,
|
|
337
|
+
stripeIntentId,
|
|
338
|
+
totalInvoices: invoices.length,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const client = paymentMethod.getStripeClient();
|
|
344
|
+
const stripePaymentMethodId = event.data.object.payment_method as string;
|
|
345
|
+
|
|
346
|
+
const payResults = await Promise.all(
|
|
347
|
+
validInvoices.map(async (invoice) => {
|
|
348
|
+
try {
|
|
349
|
+
await client.invoices.pay(invoice.metadata!.stripe_id, {
|
|
350
|
+
payment_method: stripePaymentMethodId,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
logger.info('stripe invoice payment initiated via webhook', {
|
|
354
|
+
invoiceId: invoice.id,
|
|
355
|
+
stripeInvoiceId: invoice.metadata!.stripe_id,
|
|
356
|
+
stripeIntentId,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
return { invoiceId: invoice.id, success: true };
|
|
360
|
+
} catch (err) {
|
|
361
|
+
logger.error('failed to pay stripe invoice via webhook', {
|
|
362
|
+
invoiceId: invoice.id,
|
|
363
|
+
stripeInvoiceId: invoice.metadata!.stripe_id,
|
|
364
|
+
stripeIntentId,
|
|
365
|
+
error: err,
|
|
366
|
+
});
|
|
367
|
+
return { invoiceId: invoice.id, success: false, error: err.message };
|
|
368
|
+
}
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
logger.info('batch overdue payment completed via webhook', {
|
|
373
|
+
stripeIntentId,
|
|
374
|
+
currencyId: metadata.currency_id,
|
|
375
|
+
totalInvoices: invoices.length,
|
|
376
|
+
validInvoices: validInvoices.length,
|
|
377
|
+
succeeded: payResults.filter((r) => r.success).length,
|
|
378
|
+
failed: payResults.filter((r) => !r.success).length,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
153
382
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
154
383
|
export async function handleSetupIntentEvent(event: TEventExpanded, _: Stripe) {
|
|
155
384
|
const stripeIntentId = event.data.object.id;
|
|
@@ -159,4 +388,6 @@ export async function handleSetupIntentEvent(event: TEventExpanded, _: Stripe) {
|
|
|
159
388
|
await handleSetupIntentOnSetupSucceeded(event, stripeIntentId);
|
|
160
389
|
await handleCheckoutSessionOnSetupSucceeded(event, stripeIntentId);
|
|
161
390
|
await handleAutoRechargeOnSetupSucceeded(event, stripeIntentId);
|
|
391
|
+
await handleUpdateStripePaymentMethodOnSetupSucceeded(event, stripeIntentId);
|
|
392
|
+
await handleBatchOverduePaymentOnSetupSucceeded(event, stripeIntentId);
|
|
162
393
|
}
|
|
@@ -197,11 +197,25 @@ export async function handleSubscriptionOnPaymentFailure(
|
|
|
197
197
|
eventType: string,
|
|
198
198
|
client: Stripe
|
|
199
199
|
) {
|
|
200
|
-
if (!subscription ||
|
|
201
|
-
logger.warn('Subscription is
|
|
200
|
+
if (!subscription || subscription.isImmutable()) {
|
|
201
|
+
logger.warn('Subscription is immutable or not found', { subscription: subscription.id });
|
|
202
202
|
return;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
206
|
+
if (paymentMethod && paymentMethod.type !== 'stripe') {
|
|
207
|
+
logger.info('Subscription payment method is not stripe', {
|
|
208
|
+
subscription: subscription.id,
|
|
209
|
+
paymentMethod: paymentMethod?.type,
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
logger.info('start handle stripe subscription on payment failure', {
|
|
215
|
+
subscription: subscription.id,
|
|
216
|
+
eventType,
|
|
217
|
+
});
|
|
218
|
+
|
|
205
219
|
const now = dayjs().unix();
|
|
206
220
|
const { interval } = subscription.pending_invoice_item_interval;
|
|
207
221
|
const dueUnit = getDueUnit(interval);
|
|
@@ -256,13 +270,25 @@ export async function handleSubscriptionOnPaymentFailure(
|
|
|
256
270
|
// sync to stripe
|
|
257
271
|
if (subscription.payment_details?.stripe?.subscription_id && client) {
|
|
258
272
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
273
|
+
if (paymentMethod && paymentMethod.type === 'stripe') {
|
|
274
|
+
if (cancelSubscription) {
|
|
275
|
+
await client.subscriptions.cancel(subscription.payment_details.stripe.subscription_id, {
|
|
276
|
+
cancellation_details: {
|
|
277
|
+
comment: 'exceed_current_period',
|
|
278
|
+
feedback: 'other',
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
logger.info('subscription in Stripe has canceled after payment failed', {
|
|
282
|
+
subscription: subscription.id,
|
|
283
|
+
stripeSubscription: subscription.payment_details.stripe.subscription_id,
|
|
284
|
+
eventType,
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
261
288
|
const stripeUpdates: any = {
|
|
262
289
|
cancellation_details: {
|
|
263
290
|
comment: 'past_due',
|
|
264
291
|
feedback: 'other',
|
|
265
|
-
reason: 'payment_failed',
|
|
266
292
|
},
|
|
267
293
|
};
|
|
268
294
|
|
|
@@ -271,10 +297,6 @@ export async function handleSubscriptionOnPaymentFailure(
|
|
|
271
297
|
} else if (cancelUpdates.cancel_at_period_end) {
|
|
272
298
|
stripeUpdates.cancel_at_period_end = true;
|
|
273
299
|
}
|
|
274
|
-
if (cancelSubscription) {
|
|
275
|
-
stripeUpdates.cancel_at = now;
|
|
276
|
-
stripeUpdates.cancel_at_period_end = false;
|
|
277
|
-
}
|
|
278
300
|
|
|
279
301
|
await client.subscriptions.update(subscription.payment_details.stripe.subscription_id, stripeUpdates);
|
|
280
302
|
logger.info(`[${eventType}] Updated subscription in Stripe after payment failed`, {
|
|
@@ -855,6 +855,35 @@ export async function ensureStripeSetupIntentForAutoRecharge(
|
|
|
855
855
|
return setupIntent;
|
|
856
856
|
}
|
|
857
857
|
|
|
858
|
+
export async function ensureStripeSetupIntentForInvoicePayment(
|
|
859
|
+
customer: Customer,
|
|
860
|
+
method: PaymentMethod,
|
|
861
|
+
metadata: Record<string, string>
|
|
862
|
+
) {
|
|
863
|
+
const client = method.getStripeClient();
|
|
864
|
+
const stripeCustomer = await ensureStripeCustomer(customer, method);
|
|
865
|
+
|
|
866
|
+
const setupIntent = await client.setupIntents.create({
|
|
867
|
+
customer: stripeCustomer.id,
|
|
868
|
+
payment_method_types: ['card'],
|
|
869
|
+
usage: 'off_session',
|
|
870
|
+
metadata: {
|
|
871
|
+
appPid: env.appPid,
|
|
872
|
+
customer_id: customer.id,
|
|
873
|
+
action: 'pay_overdue_batch',
|
|
874
|
+
...metadata,
|
|
875
|
+
},
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
logger.info('stripe setup intent created for invoice payment', {
|
|
879
|
+
customerId: customer.id,
|
|
880
|
+
setupIntentId: setupIntent.id,
|
|
881
|
+
metadata,
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
return setupIntent;
|
|
885
|
+
}
|
|
886
|
+
|
|
858
887
|
export async function updateAutoRechargeConfigPaymentMethod(params: {
|
|
859
888
|
stripePaymentMethodId: string;
|
|
860
889
|
autoRechargeConfig: AutoRechargeConfig;
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -25,7 +25,13 @@ import {
|
|
|
25
25
|
import type { TPaymentCurrency } from '../store/models/payment-currency';
|
|
26
26
|
import { blocklet, ethWallet, wallet, getVaultAddress } from './auth';
|
|
27
27
|
import logger from './logger';
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
formatLinkWithLocale,
|
|
30
|
+
getBlockletJson,
|
|
31
|
+
getUserOrAppInfo,
|
|
32
|
+
OCAP_PAYMENT_TX_TYPE,
|
|
33
|
+
resolveAddressChainTypes,
|
|
34
|
+
} from './util';
|
|
29
35
|
import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from './constants';
|
|
30
36
|
import { getTokenByAddress } from '../integrations/arcblock/stake';
|
|
31
37
|
import { isCreditMetered } from './session';
|
|
@@ -564,7 +570,7 @@ export async function isBalanceSufficientForRefund(args: {
|
|
|
564
570
|
throw new Error(`isBalanceSufficientForRefund: Payment method ${paymentMethod.type} not supported`);
|
|
565
571
|
}
|
|
566
572
|
|
|
567
|
-
export async function getDonationBenefits(paymentLink: PaymentLink, url?: string) {
|
|
573
|
+
export async function getDonationBenefits(paymentLink: PaymentLink, url?: string, locale?: string) {
|
|
568
574
|
const { donation_settings: donationSettings } = paymentLink;
|
|
569
575
|
if (!donationSettings) {
|
|
570
576
|
return [];
|
|
@@ -585,7 +591,7 @@ export async function getDonationBenefits(paymentLink: PaymentLink, url?: string
|
|
|
585
591
|
percent: (Number(share) * 100) / total,
|
|
586
592
|
name: name || info?.name || '',
|
|
587
593
|
avatar: avatar || info?.avatar || '',
|
|
588
|
-
url: info?.url || '',
|
|
594
|
+
url: formatLinkWithLocale(info?.url || '', locale),
|
|
589
595
|
type: info?.type || 'user',
|
|
590
596
|
};
|
|
591
597
|
} catch (error) {
|
package/api/src/libs/util.ts
CHANGED
|
@@ -603,3 +603,20 @@ export function formatNumber(
|
|
|
603
603
|
const [left, right] = result.split('.');
|
|
604
604
|
return right ? [left, trimEnd(right, '0')].filter(Boolean).join('.') : left;
|
|
605
605
|
}
|
|
606
|
+
|
|
607
|
+
export function formatLinkWithLocale(url: string, locale?: string) {
|
|
608
|
+
if (!locale || !url) {
|
|
609
|
+
return url;
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const urlObj = new URL(url);
|
|
613
|
+
urlObj.searchParams.set('locale', locale);
|
|
614
|
+
return urlObj.toString();
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (/[?&]locale=[^&]*/.test(url)) {
|
|
617
|
+
return url.replace(/([?&])locale=[^&]*/, `$1locale=${locale}`);
|
|
618
|
+
}
|
|
619
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
620
|
+
return `${url}${separator}locale=${locale}`;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
@@ -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
|
+
};
|