payment-kit 1.16.17 → 1.16.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/crons/index.ts +1 -1
- package/api/src/hooks/pre-start.ts +2 -0
- package/api/src/index.ts +2 -0
- package/api/src/integrations/arcblock/stake.ts +7 -1
- package/api/src/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/env.ts +12 -0
- package/api/src/libs/event.ts +8 -0
- package/api/src/libs/invoice.ts +585 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -2
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +6 -2
- package/api/src/libs/notification/template/subscription.overdraft-protection.exhausted.ts +139 -0
- package/api/src/libs/overdraft-protection.ts +85 -0
- package/api/src/libs/payment.ts +1 -65
- package/api/src/libs/queue/index.ts +0 -1
- package/api/src/libs/subscription.ts +532 -2
- package/api/src/libs/util.ts +4 -0
- package/api/src/locales/en.ts +5 -0
- package/api/src/locales/zh.ts +5 -0
- package/api/src/queues/event.ts +3 -2
- package/api/src/queues/invoice.ts +28 -3
- package/api/src/queues/notification.ts +25 -3
- package/api/src/queues/payment.ts +154 -3
- package/api/src/queues/refund.ts +2 -2
- package/api/src/queues/subscription.ts +215 -4
- package/api/src/queues/webhook.ts +1 -0
- package/api/src/routes/connect/change-payment.ts +1 -1
- package/api/src/routes/connect/change-plan.ts +1 -1
- package/api/src/routes/connect/overdraft-protection.ts +120 -0
- package/api/src/routes/connect/recharge.ts +2 -1
- package/api/src/routes/connect/setup.ts +1 -1
- package/api/src/routes/connect/shared.ts +117 -350
- package/api/src/routes/connect/subscribe.ts +1 -1
- package/api/src/routes/customers.ts +2 -2
- package/api/src/routes/invoices.ts +9 -4
- package/api/src/routes/subscriptions.ts +172 -2
- package/api/src/store/migrate.ts +9 -10
- package/api/src/store/migrations/20240905-index.ts +95 -60
- package/api/src/store/migrations/20241203-overdraft-protection.ts +25 -0
- package/api/src/store/migrations/20241216-update-overdraft-protection.ts +30 -0
- package/api/src/store/models/customer.ts +2 -2
- package/api/src/store/models/invoice.ts +7 -0
- package/api/src/store/models/lock.ts +7 -0
- package/api/src/store/models/subscription.ts +15 -0
- package/api/src/store/sequelize.ts +6 -1
- package/blocklet.yml +1 -1
- package/package.json +23 -23
- package/src/components/customer/overdraft-protection.tsx +367 -0
- package/src/components/event/list.tsx +3 -4
- package/src/components/subscription/actions/cancel.tsx +3 -0
- package/src/components/subscription/portal/actions.tsx +324 -77
- package/src/components/uploader.tsx +31 -26
- package/src/env.d.ts +1 -0
- package/src/hooks/subscription.ts +30 -0
- package/src/libs/env.ts +4 -0
- package/src/locales/en.tsx +41 -0
- package/src/locales/zh.tsx +37 -0
- package/src/pages/admin/billing/invoices/detail.tsx +16 -15
- package/src/pages/customer/index.tsx +7 -2
- package/src/pages/customer/invoice/detail.tsx +29 -5
- package/src/pages/customer/invoice/past-due.tsx +18 -4
- package/src/pages/customer/recharge.tsx +2 -4
- package/src/pages/customer/subscription/change-payment.tsx +7 -1
- package/src/pages/customer/subscription/detail.tsx +69 -51
- package/tsconfig.json +0 -5
- 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
|
-
|
|
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
|
+
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -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
|
+
}
|
package/api/src/locales/en.ts
CHANGED
|
@@ -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
|
});
|
package/api/src/locales/zh.ts
CHANGED
package/api/src/queues/event.ts
CHANGED
|
@@ -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.
|
|
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
|
});
|