payment-kit 1.16.17 → 1.16.19

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 (70) hide show
  1. package/api/src/crons/index.ts +1 -1
  2. package/api/src/hooks/pre-start.ts +2 -0
  3. package/api/src/index.ts +2 -0
  4. package/api/src/integrations/arcblock/stake.ts +7 -1
  5. package/api/src/integrations/stripe/resource.ts +1 -1
  6. package/api/src/libs/env.ts +12 -0
  7. package/api/src/libs/event.ts +8 -0
  8. package/api/src/libs/invoice.ts +585 -3
  9. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -2
  10. package/api/src/libs/notification/template/subscription-trial-will-end.ts +2 -2
  11. package/api/src/libs/notification/template/subscription-will-renew.ts +6 -2
  12. package/api/src/libs/notification/template/subscription.overdraft-protection.exhausted.ts +139 -0
  13. package/api/src/libs/overdraft-protection.ts +86 -0
  14. package/api/src/libs/payment.ts +1 -65
  15. package/api/src/libs/queue/index.ts +0 -1
  16. package/api/src/libs/subscription.ts +532 -2
  17. package/api/src/libs/util.ts +4 -0
  18. package/api/src/locales/en.ts +5 -0
  19. package/api/src/locales/zh.ts +5 -0
  20. package/api/src/queues/event.ts +3 -2
  21. package/api/src/queues/invoice.ts +28 -3
  22. package/api/src/queues/notification.ts +25 -3
  23. package/api/src/queues/payment.ts +154 -3
  24. package/api/src/queues/refund.ts +2 -2
  25. package/api/src/queues/subscription.ts +215 -4
  26. package/api/src/queues/webhook.ts +1 -0
  27. package/api/src/routes/connect/change-payment.ts +1 -1
  28. package/api/src/routes/connect/change-plan.ts +1 -1
  29. package/api/src/routes/connect/overdraft-protection.ts +120 -0
  30. package/api/src/routes/connect/recharge.ts +2 -1
  31. package/api/src/routes/connect/setup.ts +1 -1
  32. package/api/src/routes/connect/shared.ts +117 -350
  33. package/api/src/routes/connect/subscribe.ts +1 -1
  34. package/api/src/routes/customers.ts +2 -2
  35. package/api/src/routes/invoices.ts +9 -4
  36. package/api/src/routes/subscriptions.ts +172 -2
  37. package/api/src/store/migrate.ts +9 -10
  38. package/api/src/store/migrations/20240905-index.ts +95 -60
  39. package/api/src/store/migrations/20241203-overdraft-protection.ts +25 -0
  40. package/api/src/store/migrations/20241216-update-overdraft-protection.ts +30 -0
  41. package/api/src/store/models/customer.ts +2 -2
  42. package/api/src/store/models/invoice.ts +7 -0
  43. package/api/src/store/models/lock.ts +7 -0
  44. package/api/src/store/models/subscription.ts +15 -0
  45. package/api/src/store/sequelize.ts +6 -1
  46. package/blocklet.yml +1 -1
  47. package/package.json +23 -23
  48. package/src/components/customer/overdraft-protection.tsx +367 -0
  49. package/src/components/event/list.tsx +3 -4
  50. package/src/components/product/edit-price.tsx +2 -2
  51. package/src/components/subscription/actions/cancel.tsx +3 -0
  52. package/src/components/subscription/portal/actions.tsx +324 -77
  53. package/src/components/uploader.tsx +31 -26
  54. package/src/env.d.ts +1 -0
  55. package/src/hooks/subscription.ts +30 -0
  56. package/src/libs/env.ts +4 -0
  57. package/src/locales/en.tsx +41 -0
  58. package/src/locales/zh.tsx +37 -0
  59. package/src/pages/admin/billing/invoices/detail.tsx +16 -15
  60. package/src/pages/admin/index.tsx +3 -1
  61. package/src/pages/admin/products/prices/detail.tsx +1 -1
  62. package/src/pages/admin/products/products/detail.tsx +6 -2
  63. package/src/pages/customer/index.tsx +7 -2
  64. package/src/pages/customer/invoice/detail.tsx +29 -5
  65. package/src/pages/customer/invoice/past-due.tsx +18 -4
  66. package/src/pages/customer/recharge.tsx +2 -4
  67. package/src/pages/customer/subscription/change-payment.tsx +7 -1
  68. package/src/pages/customer/subscription/detail.tsx +69 -51
  69. package/tsconfig.json +0 -5
  70. package/api/tests/libs/payment.spec.ts +0 -168
@@ -1,6 +1,6 @@
1
1
  /* eslint-disable no-await-in-loop */
2
2
  import component from '@blocklet/sdk/lib/component';
3
- import { BN } from '@ocap/util';
3
+ import { BN, fromUnitToToken } from '@ocap/util';
4
4
  import isEmpty from 'lodash/isEmpty';
5
5
  import trim from 'lodash/trim';
6
6
  import pick from 'lodash/pick';
@@ -14,6 +14,7 @@ import {
14
14
  InvoiceItem,
15
15
  Lock,
16
16
  PaymentCurrency,
17
+ PaymentIntent,
17
18
  PaymentMethod,
18
19
  Price,
19
20
  PriceRecurring,
@@ -31,6 +32,10 @@ import env from './env';
31
32
  import logger from './logger';
32
33
  import { getPriceCurrencyOptions, getPriceUintAmountByCurrency, getRecurringPeriod } from './session';
33
34
  import { getConnectQueryParam, getCustomerStakeAddress } from './util';
35
+ import { wallet } from './auth';
36
+ import { getGasPayerExtra } from './payment';
37
+ import { getLock } from './lock';
38
+ import { emitAsync } from './event';
34
39
 
35
40
  export function getCustomerSubscriptionPageUrl({
36
41
  subscriptionId,
@@ -307,7 +312,7 @@ export async function createProration(
307
312
  return {
308
313
  lastInvoice,
309
314
  total: '0',
310
- due: '0',
315
+ shouldPay: '0',
311
316
  used: '0',
312
317
  unused: '0',
313
318
  prorations: [],
@@ -522,6 +527,77 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
522
527
  };
523
528
  }
524
529
 
530
+ export async function getPastInvoicesAmount(subscriptionId: string, aggregationRule: 'sum' | 'avg' | 'max' = 'max') {
531
+ const subscription = await Subscription.findByPk(subscriptionId);
532
+ if (!subscription) {
533
+ throw new Error('Subscription not found');
534
+ }
535
+
536
+ const currency = await PaymentCurrency.findByPk(subscription.currency_id);
537
+
538
+ // 获取所有的账单周期
539
+ const pastInvoices = await Invoice.findAll({
540
+ where: {
541
+ subscription_id: subscriptionId,
542
+ currency_id: subscription.currency_id,
543
+ billing_reason: [
544
+ 'subscription_create',
545
+ 'subscription_cycle',
546
+ 'subscription_update',
547
+ 'subscription_recover',
548
+ 'subscription_threshold',
549
+ 'subscription_cancel',
550
+ 'subscription',
551
+ ],
552
+ },
553
+ order: [['created_at', 'DESC']],
554
+ });
555
+
556
+ const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, subscription.current_period_end);
557
+ let start = setup.period.start - setup.cycle / 1000;
558
+ let end = setup.period.end - setup.cycle / 1000;
559
+ const createdAt = dayjs(subscription.created_at).unix();
560
+ const amountsByPeriod: { [key: string]: BN } = {};
561
+
562
+ while (start < end && start >= createdAt) {
563
+ const periodKey = `${start}-${end}`;
564
+ // eslint-disable-next-line @typescript-eslint/no-loop-func
565
+ const invoices = pastInvoices.filter((x) => x.period_start >= start && x.period_end <= end);
566
+ const amount = invoices.reduce((acc, x) => acc.add(new BN(x.total)), new BN(0));
567
+ amountsByPeriod[periodKey] = amount;
568
+ start -= setup.cycle / 1000;
569
+ end -= setup.cycle / 1000;
570
+ }
571
+
572
+ let result = new BN(0);
573
+ switch (aggregationRule) {
574
+ case 'sum':
575
+ result = Object.values(amountsByPeriod).reduce((acc, curr) => acc.add(curr), new BN(0));
576
+ break;
577
+ case 'avg':
578
+ result =
579
+ Object.values(amountsByPeriod).length > 0
580
+ ? Object.values(amountsByPeriod)
581
+ .reduce((acc, curr) => acc.add(curr), new BN(0))
582
+ .div(new BN(Object.values(amountsByPeriod).length))
583
+ : new BN(0);
584
+ break;
585
+ case 'max':
586
+ result =
587
+ Object.values(amountsByPeriod).length > 0
588
+ ? Object.values(amountsByPeriod).reduce((acc, curr) => (acc.gt(curr) ? acc : curr), new BN(0))
589
+ : new BN(0);
590
+ break;
591
+ default:
592
+ throw new Error('Invalid aggregation rule');
593
+ }
594
+
595
+ return {
596
+ amount: result.toString(),
597
+ currency,
598
+ };
599
+ }
600
+
525
601
  export async function finalizeSubscriptionUpdate({
526
602
  subscription,
527
603
  customer,
@@ -1033,3 +1109,457 @@ export function getSubscriptionPaymentAddress(subscription: Subscription, paymen
1033
1109
  subscription.payment_settings?.payment_method_options?.[paymentMethodType]?.payer
1034
1110
  );
1035
1111
  }
1112
+ export async function getPaymentAmountForCycleSubscription(
1113
+ subscription: Subscription,
1114
+ paymentCurrency: PaymentCurrency
1115
+ ) {
1116
+ const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
1117
+ if (subscriptionItems.length === 0) {
1118
+ logger.info('subscription items not found in getPaymentAmountForCycleSubscription', {
1119
+ subscription: subscription.id,
1120
+ });
1121
+ return 0;
1122
+ }
1123
+ let expandedItems = await Price.expand(
1124
+ subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity })),
1125
+ { product: true }
1126
+ );
1127
+ if (expandedItems.length === 0) {
1128
+ logger.info('expanded items not found in getPaymentAmountForCycleSubscription', {
1129
+ subscription: subscription.id,
1130
+ });
1131
+ return 0;
1132
+ }
1133
+ const previousPeriodEnd =
1134
+ subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
1135
+ const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
1136
+ // get usage summaries for this billing cycle
1137
+ expandedItems = await Promise.all(
1138
+ expandedItems.map(async (x: any) => {
1139
+ // For metered billing, we need to get usage summary for this billing cycle
1140
+ // @link https://stripe.com/docs/products-prices/pricing-models#usage-types
1141
+ if (x.price.recurring?.usage_type === 'metered') {
1142
+ const rawQuantity = await UsageRecord.getSummary({
1143
+ id: x.id,
1144
+ start: setup.period.start - setup.cycle / 1000,
1145
+ end: setup.period.end - setup.cycle / 1000,
1146
+ method: x.price.recurring?.aggregate_usage,
1147
+ dryRun: true,
1148
+ });
1149
+ x.quantity = x.price.transformQuantity(rawQuantity);
1150
+ // record raw quantity in metadata
1151
+ x.metadata = x.metadata || {};
1152
+ x.metadata.quantity = rawQuantity;
1153
+ }
1154
+ return x;
1155
+ })
1156
+ );
1157
+ if (expandedItems.length > 0) {
1158
+ const amount = getSubscriptionCycleAmount(expandedItems, paymentCurrency.id);
1159
+ return +fromUnitToToken(amount?.total || '0', paymentCurrency.decimal);
1160
+ }
1161
+ return 0;
1162
+ }
1163
+
1164
+ // check if subscription overdraft protection is enabled
1165
+ export async function isSubscriptionOverdraftProtectionEnabled(subscription: Subscription, paymentCurrencyId?: string) {
1166
+ try {
1167
+ const paymentCurrency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
1168
+ if (!paymentCurrency) {
1169
+ throw new Error(`PaymentCurrency not found in ${subscription.id}`);
1170
+ }
1171
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
1172
+ if (!paymentMethod) {
1173
+ throw new Error(`PaymentMethod not found in ${subscription.id}`);
1174
+ }
1175
+
1176
+ if (paymentMethod.type !== 'arcblock') {
1177
+ throw new Error(`PaymentMethod type not supported in ${subscription.id}`);
1178
+ }
1179
+ if (!subscription.overdraft_protection) {
1180
+ return {
1181
+ enabled: false,
1182
+ remaining: '0',
1183
+ used: '0',
1184
+ shouldPay: '0',
1185
+ unused: '0',
1186
+ revokedStake: '0',
1187
+ };
1188
+ }
1189
+ const client = paymentMethod.getOcapClient();
1190
+ const address = subscription?.overdraft_protection?.payment_details?.arcblock?.staking?.address;
1191
+ if (!address) {
1192
+ logger.info('Seems you have no staking for overdraft protection', { subscription: subscription.id });
1193
+ return {
1194
+ enabled: false,
1195
+ remaining: '0',
1196
+ used: '0',
1197
+ shouldPay: '0',
1198
+ unused: '0',
1199
+ revokedStake: '0',
1200
+ };
1201
+ }
1202
+ const { state } = await client.getStakeState({ address });
1203
+ if (!state) {
1204
+ throw new Error(`Stake state not found in ${subscription.id}`);
1205
+ }
1206
+ const data = JSON.parse(state.data?.value || '{}');
1207
+ if (!data[subscription.id] && !state.nonce) {
1208
+ throw new Error(`No staking for subscription: ${subscription.id}, address: ${address}`);
1209
+ }
1210
+
1211
+ // calculate remaining stake, need to subtract refunding amount
1212
+ let remainingStake = new BN(state.tokens.find((x: any) => x.address === paymentCurrency.contract)?.value || '0');
1213
+ const revokedStake = new BN(
1214
+ state.revokedTokens?.find((x: any) => x.address === paymentCurrency.contract)?.value || '0'
1215
+ );
1216
+
1217
+ const refunds = await Refund.findAll({
1218
+ where: {
1219
+ subscription_id: subscription.id,
1220
+ type: 'stake_return',
1221
+ status: {
1222
+ [Op.notIn]: ['canceled', 'succeeded'],
1223
+ },
1224
+ currency_id: paymentCurrency.id,
1225
+ },
1226
+ include: [{ model: Invoice, as: 'invoice' }],
1227
+ });
1228
+ const refundAmount = refunds
1229
+ .filter((x: any) => x.invoice?.billing_reason === 'stake_overdraft_protection')
1230
+ .reduce((acc, x) => acc.add(new BN(x.amount)), new BN(0));
1231
+ remainingStake = remainingStake.sub(refundAmount);
1232
+
1233
+ const invoices = await Invoice.findAll({
1234
+ where: {
1235
+ subscription_id: subscription.id,
1236
+ status: ['open', 'uncollectible'],
1237
+ currency_id: paymentCurrency.id,
1238
+ },
1239
+ });
1240
+ let usedAmount = new BN(0);
1241
+ let dueAmount = new BN(0);
1242
+ invoices.forEach((invoice) => {
1243
+ usedAmount = usedAmount.add(new BN(invoice.amount_remaining));
1244
+ if (invoice.billing_reason !== 'overdraft_protection') {
1245
+ dueAmount = dueAmount.add(new BN(invoice.amount_remaining));
1246
+ }
1247
+ });
1248
+ return {
1249
+ enabled: subscription.overdraft_protection.enabled && usedAmount.lte(remainingStake),
1250
+ remaining: remainingStake.toString(),
1251
+ used: usedAmount.toString(),
1252
+ shouldPay: dueAmount.toString(),
1253
+ revoked: revokedStake.toString(),
1254
+ unused: remainingStake.gte(usedAmount) ? remainingStake.sub(usedAmount).toString() : '0',
1255
+ };
1256
+ } catch (error) {
1257
+ logger.error('error in isSubscriptionOverdraftProtectionEnabled', { error });
1258
+ return {
1259
+ enabled: false,
1260
+ remaining: '0',
1261
+ used: '0',
1262
+ unused: '0',
1263
+ shouldPay: '0',
1264
+ revokedStake: '0',
1265
+ };
1266
+ }
1267
+ }
1268
+
1269
+ // return remaining overdraft protection stake to customer
1270
+ export async function returnOverdraftProtectionStake(
1271
+ subscription: Subscription,
1272
+ paymentCurrencyId?: string,
1273
+ refundReason?: string
1274
+ ) {
1275
+ // return remaining stake to customer
1276
+ logger.info('returnOverdraftProtectionStake', { subscription: subscription.id, paymentCurrencyId });
1277
+ try {
1278
+ await slashOverdraftProtectionStake(subscription, paymentCurrencyId);
1279
+ const { unused } = await isSubscriptionOverdraftProtectionEnabled(subscription, paymentCurrencyId);
1280
+ if (unused !== '0') {
1281
+ // return unused stake to customer
1282
+ const address = subscription?.overdraft_protection?.payment_details?.arcblock?.staking?.address;
1283
+ if (!address) {
1284
+ logger.info('Stake return skipped due to missing staking address', { subscription: subscription.id });
1285
+ return;
1286
+ }
1287
+ const invoice = await Invoice.findOne({
1288
+ where: {
1289
+ subscription_id: subscription.id,
1290
+ billing_reason: 'stake_overdraft_protection',
1291
+ currency_id: paymentCurrencyId || subscription.currency_id,
1292
+ status: 'paid',
1293
+ },
1294
+ order: [['created_at', 'DESC']],
1295
+ });
1296
+ const item = await Refund.create({
1297
+ type: 'stake_return',
1298
+ amount: unused,
1299
+ description: 'overdraft protection_return_for_subscription',
1300
+ status: 'pending',
1301
+ reason: refundReason || 'requested_by_admin',
1302
+ subscription_id: subscription.id,
1303
+ currency_id: paymentCurrencyId || subscription.currency_id,
1304
+ livemode: subscription.livemode,
1305
+ customer_id: subscription.customer_id,
1306
+ invoice_id: invoice?.id,
1307
+ payment_intent_id: invoice?.payment_intent_id || '',
1308
+ payment_method_id: subscription.overdraft_protection?.payment_method_id || '',
1309
+ starting_balance: unused,
1310
+ ending_balance: '0',
1311
+ attempt_count: 0,
1312
+ attempted: false,
1313
+ next_attempt: 0,
1314
+ last_attempt_error: null,
1315
+ starting_token_balance: {},
1316
+ ending_token_balance: {},
1317
+ payment_details: {
1318
+ // @ts-ignore
1319
+ arcblock: {
1320
+ receiver:
1321
+ subscription?.overdraft_protection?.payment_details?.arcblock?.payer ||
1322
+ getSubscriptionPaymentAddress(subscription, 'arcblock'),
1323
+ staking: {
1324
+ address: address || '',
1325
+ tx_hash: '',
1326
+ },
1327
+ },
1328
+ },
1329
+ });
1330
+ logger.info('returnOverdraftProtectionStake Refund created', { item });
1331
+ }
1332
+ } catch (error) {
1333
+ logger.error('returnOverdraftProtectionStake failed', { error });
1334
+ throw error;
1335
+ }
1336
+ }
1337
+
1338
+ // slash overdraft protection stake
1339
+ export async function slashOverdraftProtectionStake(subscription: Subscription, paymentCurrencyId?: string) {
1340
+ const lock = getLock(
1341
+ `slash_overdraft_protection_stake_${subscription.id}-${paymentCurrencyId || subscription.currency_id}`
1342
+ );
1343
+ logger.info('slashOverdraftProtectionStake', { subscription: subscription.id, paymentCurrencyId });
1344
+ try {
1345
+ await lock.acquire();
1346
+ if (!subscription.overdraft_protection) {
1347
+ logger.info('slashOverdraftProtectionStake skipped due to missing overdraft protection', {
1348
+ subscription: subscription.id,
1349
+ });
1350
+ return;
1351
+ }
1352
+
1353
+ const paymentCurrency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
1354
+ if (!paymentCurrency) {
1355
+ throw new Error(`PaymentCurrency not found in ${subscription.id}`);
1356
+ }
1357
+
1358
+ const { used, remaining, revoked } = await isSubscriptionOverdraftProtectionEnabled(
1359
+ subscription,
1360
+ paymentCurrency.id
1361
+ );
1362
+ const remainingStake = new BN(remaining).add(new BN(revoked)).toString();
1363
+ if (used === '0') {
1364
+ logger.info('slashOverdraftProtectionStake skipped due to no unpaid invoices', {
1365
+ subscription: subscription.id,
1366
+ paymentCurrencyId: paymentCurrency.id,
1367
+ });
1368
+ return;
1369
+ }
1370
+
1371
+ if (remainingStake === '0') {
1372
+ logger.info('slashOverdraftProtectionStake skipped due to no remaining stake', {
1373
+ subscription: subscription.id,
1374
+ paymentCurrencyId: paymentCurrency.id,
1375
+ });
1376
+ return;
1377
+ }
1378
+
1379
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
1380
+ if (!paymentMethod || paymentMethod.type !== 'arcblock') {
1381
+ throw new Error(`PaymentMethod type not supported in ${subscription.id}`);
1382
+ }
1383
+
1384
+ const address = subscription?.overdraft_protection?.payment_details?.arcblock?.staking?.address;
1385
+
1386
+ if (!address) {
1387
+ // no staking address
1388
+ logger.info('Stake slashing skipped due to missing staking address', { subscription: subscription.id });
1389
+ return;
1390
+ }
1391
+
1392
+ const client = paymentMethod.getOcapClient();
1393
+ const { state } = await client.getStakeState({ address });
1394
+
1395
+ if (!state || !state.data?.value) {
1396
+ throw new Error(
1397
+ `Stake slashing aborted due to missing staking state for subscription: ${subscription.id}, address: ${address}`
1398
+ );
1399
+ }
1400
+
1401
+ const data = JSON.parse(state.data.value || '{}');
1402
+ if (!data[subscription.id] && !state.nonce) {
1403
+ throw new Error(
1404
+ `Stake slashing aborted because no staking for subscription: ${subscription.id}, address: ${address}`
1405
+ );
1406
+ }
1407
+
1408
+ const invoices = await Invoice.findAll({
1409
+ where: {
1410
+ subscription_id: subscription.id,
1411
+ status: ['open', 'uncollectible'],
1412
+ currency_id: paymentCurrency.id,
1413
+ },
1414
+ order: [['created_at', 'ASC']],
1415
+ });
1416
+
1417
+ let slashAmount = new BN(0);
1418
+ const slashInvoices: Invoice[] = [];
1419
+ const updatePromises = invoices.map(async (invoice) => {
1420
+ if (!invoice.payment_intent_id) {
1421
+ await emitAsync(
1422
+ 'invoice.queued',
1423
+ invoice.id,
1424
+ { invoiceId: invoice.id, retryOnError: false, justCreate: true },
1425
+ {
1426
+ sync: true,
1427
+ }
1428
+ );
1429
+ await invoice.reload();
1430
+ }
1431
+ const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
1432
+ if (paymentIntent) {
1433
+ if (paymentIntent.status === 'succeeded' && invoice.status === 'paid') {
1434
+ logger.info('PaymentIntent and Invoice already updated', {
1435
+ subscription: subscription.id,
1436
+ paymentIntent: paymentIntent.id,
1437
+ invoice: invoice.id,
1438
+ });
1439
+ return;
1440
+ }
1441
+ const tmp = slashAmount.add(new BN(invoice.amount_remaining));
1442
+
1443
+ if (tmp.lte(new BN(remainingStake))) {
1444
+ slashAmount = tmp;
1445
+ slashInvoices.push(invoice);
1446
+ try {
1447
+ const stakeEnough = await checkRemainingStake(
1448
+ paymentMethod,
1449
+ paymentCurrency,
1450
+ address,
1451
+ invoice.amount_remaining
1452
+ );
1453
+ if (!stakeEnough.enough) {
1454
+ throw new Error(`Stake slashing aborted because no enough staking for invoice: ${invoice.id}`);
1455
+ }
1456
+ // do the slash
1457
+ const signed = await client.signSlashStakeTx({
1458
+ tx: {
1459
+ itx: {
1460
+ address,
1461
+ outputs: [
1462
+ {
1463
+ owner: wallet.address,
1464
+ tokens: [{ address: paymentCurrency.contract, value: invoice.amount_remaining }],
1465
+ },
1466
+ ],
1467
+ message: 'overdraft_exceeded',
1468
+ data: {
1469
+ typeUrl: 'json',
1470
+ // @ts-ignore
1471
+ value: {
1472
+ appId: wallet.address,
1473
+ reason: 'overdraft_exceeded',
1474
+ subscriptionId: subscription.id,
1475
+ invoiceId: invoice.id,
1476
+ paymentIntentId: paymentIntent.id,
1477
+ },
1478
+ },
1479
+ },
1480
+ },
1481
+ wallet,
1482
+ });
1483
+ // @ts-ignore
1484
+ const { buffer } = await client.encodeSlashStakeTx({ tx: signed });
1485
+ // @ts-ignore
1486
+ const txHash = await client.sendSlashStakeTx({ tx: signed, wallet }, getGasPayerExtra(buffer));
1487
+
1488
+ await paymentIntent.update({
1489
+ status: 'succeeded',
1490
+ amount_received: invoice.amount_remaining,
1491
+ capture_method: 'manual',
1492
+ last_payment_error: null,
1493
+ payment_details: {
1494
+ arcblock: {
1495
+ tx_hash: txHash,
1496
+ payer:
1497
+ subscription.overdraft_protection?.payment_details?.arcblock?.payer ||
1498
+ getSubscriptionPaymentAddress(subscription, 'arcblock'),
1499
+ type: 'slash',
1500
+ },
1501
+ },
1502
+ });
1503
+ logger.info('PaymentIntent updated after stake slash', {
1504
+ subscription: subscription.id,
1505
+ paymentIntent: paymentIntent.id,
1506
+ status: 'succeeded',
1507
+ });
1508
+
1509
+ await invoice.update({
1510
+ status: 'paid',
1511
+ paid: true,
1512
+ amount_paid: invoice.amount_remaining,
1513
+ amount_remaining: '0',
1514
+ attempted: true,
1515
+ attempt_count: invoice.attempt_count + 1,
1516
+ status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
1517
+ });
1518
+ logger.info('Invoice updated after stake slash', {
1519
+ subscription: subscription.id,
1520
+ invoice: invoice.id,
1521
+ status: 'paid',
1522
+ });
1523
+ } catch (updateError) {
1524
+ logger.error('stake slash failed', {
1525
+ subscription: subscription.id,
1526
+ invoice: invoice.id,
1527
+ error: updateError,
1528
+ });
1529
+ throw updateError;
1530
+ }
1531
+ }
1532
+ }
1533
+ });
1534
+
1535
+ await Promise.all(updatePromises);
1536
+ logger.info(`${slashInvoices.length} invoices updated after stake slash`, {
1537
+ subscription: subscription.id,
1538
+ invoices: slashInvoices.map((x) => x.id),
1539
+ slashAmount: slashAmount.toString(),
1540
+ });
1541
+ } catch (error) {
1542
+ logger.error('Error in slashOverdraftProtectionStake', { error });
1543
+ throw error;
1544
+ } finally {
1545
+ lock.release();
1546
+ }
1547
+ }
1548
+
1549
+ export async function getSubscriptionUnpaidInvoicesCount(subscription: Subscription) {
1550
+ try {
1551
+ const count = await Invoice.count({
1552
+ where: {
1553
+ subscription_id: subscription.id,
1554
+ status: ['open', 'uncollectible'],
1555
+ billing_reason: {
1556
+ [Op.notIn]: ['overdraft_protection'],
1557
+ },
1558
+ },
1559
+ });
1560
+ return count;
1561
+ } catch (error) {
1562
+ logger.error('getSubscriptionUnpaidInvoicesCount failed', { error });
1563
+ return 0;
1564
+ }
1565
+ }
@@ -372,3 +372,7 @@ export function getAdminBillingSubscriptionUrl({
372
372
  }) {
373
373
  return getUrl(withQuery(`admin/billing/${subscriptionId}`, { locale, ...getConnectQueryParam({ userDid }) }));
374
374
  }
375
+
376
+ export function getCustomerIndexUrl({ locale, userDid }: { locale: string; userDid: string }) {
377
+ return getUrl(withQuery('customer', { locale, ...getConnectQueryParam({ userDid }) }));
378
+ }
@@ -192,5 +192,10 @@ export default flat({
192
192
  paymentFailed: 'Payment failed',
193
193
  stakeRevoked: 'Stake revoked',
194
194
  },
195
+
196
+ overdraftProtectionExhausted: {
197
+ title: 'Insufficient Credit for Overdraft Protection',
198
+ body: 'Your subscription to {productName} has insufficient staked credit for overdraft protection. Please increase your stake to maintain the service or disable it if no longer needed.',
199
+ },
195
200
  },
196
201
  });
@@ -185,5 +185,10 @@ export default flat({
185
185
  paymentFailed: '扣费失败',
186
186
  stakeRevoked: '撤销质押',
187
187
  },
188
+
189
+ overdraftProtectionExhausted: {
190
+ title: '透支保护额度不足',
191
+ body: '您订阅的 {productName} 透支保护额度不足,为了避免影响您的透支保护服务,请及时充值。如不再需要透支保护服务,可关闭该功能。',
192
+ },
188
193
  },
189
194
  });
@@ -59,6 +59,7 @@ export const handleEvent = async (job: EventJob) => {
59
59
  webhookQueue.push({
60
60
  id: jobId,
61
61
  job: { eventId: event.id, webhookId: webhook.id },
62
+ persist: false,
62
63
  });
63
64
  }
64
65
  }
@@ -90,7 +91,7 @@ export const startEventQueue = async () => {
90
91
  const exist = await eventQueue.get(x.id);
91
92
  if (!exist) {
92
93
  logger.info(`Pushing event ${x.id} to queue`);
93
- eventQueue.push({ id: x.id, job: { eventId: x.id } });
94
+ eventQueue.push({ id: x.id, job: { eventId: x.id }, persist: false });
94
95
  }
95
96
  });
96
97
 
@@ -102,5 +103,5 @@ eventQueue.on('failed', ({ id, job, error }) => {
102
103
  });
103
104
 
104
105
  events.on('event.created', (event) => {
105
- eventQueue.push({ id: event.id, job: { eventId: event.id } });
106
+ eventQueue.push({ id: event.id, job: { eventId: event.id }, persist: false });
106
107
  });
@@ -1,7 +1,6 @@
1
1
  import { Op } from 'sequelize';
2
2
 
3
- import { getInvoiceShouldPayTotal } from '../libs/invoice';
4
- import { batchHandleStripeInvoices } from '../integrations/stripe/resource';
3
+ import { getInvoiceShouldPayTotal, handleOverdraftProtectionInvoiceAfterPayment } from '../libs/invoice';
5
4
  import { createEvent } from '../libs/audit';
6
5
  import dayjs from '../libs/dayjs';
7
6
  import logger from '../libs/logger';
@@ -14,6 +13,7 @@ import { paymentQueue } from './payment';
14
13
 
15
14
  import { getLock } from '../libs/lock';
16
15
  import { events } from '../libs/event';
16
+ import { batchHandleStripeInvoices } from '../integrations/stripe/resource';
17
17
 
18
18
  type InvoiceJob = {
19
19
  invoiceId: string;
@@ -132,6 +132,7 @@ export const handleInvoice = async (job: InvoiceJob) => {
132
132
  subscription_update: 'Subscription update',
133
133
  subscription_threshold: 'Subscription threshold',
134
134
  slash_stake: 'Slash stake',
135
+ overdraft_protection: 'Overdraft protection',
135
136
  };
136
137
  // TODO: support partial payment from user balance
137
138
  paymentIntent = await PaymentIntent.create({
@@ -220,7 +221,7 @@ export const startInvoiceQueue = async () => {
220
221
  status: 'open',
221
222
  collection_method: 'charge_automatically',
222
223
  amount_remaining: { [Op.gt]: '0' },
223
- billing_reason: { [Op.ne]: 'stake' },
224
+ billing_reason: { [Op.notIn]: ['stake', 'overdraft_protection'] },
224
225
  },
225
226
  });
226
227
 
@@ -276,4 +277,28 @@ events.on('invoice.paid', async ({ id: invoiceId }) => {
276
277
  logger.info('Invoice paid successfully with correct amount', { invoiceId, total: invoice.total });
277
278
  }
278
279
  }
280
+ await handleOverdraftProtectionInvoiceAfterPayment(invoice);
281
+ });
282
+
283
+ events.on('invoice.queued', async (id, job, args = {}) => {
284
+ const { sync, ...extraArgs } = args;
285
+ if (sync) {
286
+ try {
287
+ await invoiceQueue.pushAndWait({
288
+ id,
289
+ job,
290
+ ...extraArgs,
291
+ });
292
+ events.emit('invoice.queued.done');
293
+ } catch (error) {
294
+ logger.error('Error in invoice.queued', { id, job, error });
295
+ events.emit('invoice.queued.error', error);
296
+ }
297
+ return;
298
+ }
299
+ invoiceQueue.push({
300
+ id,
301
+ job,
302
+ ...extraArgs,
303
+ });
279
304
  });