payment-kit 1.15.33 → 1.15.35

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 (63) hide show
  1. package/api/src/integrations/stripe/handlers/setup-intent.ts +3 -1
  2. package/api/src/integrations/stripe/handlers/subscription.ts +2 -8
  3. package/api/src/integrations/stripe/resource.ts +0 -11
  4. package/api/src/libs/invoice.ts +202 -1
  5. package/api/src/libs/notification/template/subscription-canceled.ts +15 -2
  6. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -1
  7. package/api/src/libs/notification/template/subscription-renewed.ts +1 -1
  8. package/api/src/libs/notification/template/subscription-trial-will-end.ts +9 -5
  9. package/api/src/libs/notification/template/subscription-will-canceled.ts +9 -5
  10. package/api/src/libs/notification/template/subscription-will-renew.ts +10 -12
  11. package/api/src/libs/payment.ts +3 -2
  12. package/api/src/libs/refund.ts +4 -0
  13. package/api/src/libs/subscription.ts +58 -14
  14. package/api/src/queues/invoice.ts +1 -0
  15. package/api/src/queues/payment.ts +3 -1
  16. package/api/src/queues/refund.ts +9 -8
  17. package/api/src/queues/subscription.ts +111 -40
  18. package/api/src/routes/checkout-sessions.ts +22 -6
  19. package/api/src/routes/connect/change-payment.ts +51 -34
  20. package/api/src/routes/connect/change-plan.ts +25 -3
  21. package/api/src/routes/connect/recharge.ts +28 -3
  22. package/api/src/routes/connect/setup.ts +27 -6
  23. package/api/src/routes/connect/shared.ts +223 -1
  24. package/api/src/routes/connect/subscribe.ts +25 -3
  25. package/api/src/routes/customers.ts +2 -2
  26. package/api/src/routes/invoices.ts +27 -105
  27. package/api/src/routes/payment-links.ts +3 -0
  28. package/api/src/routes/refunds.ts +22 -1
  29. package/api/src/routes/subscriptions.ts +112 -21
  30. package/api/src/routes/webhook-attempts.ts +14 -1
  31. package/api/src/store/models/invoice.ts +3 -1
  32. package/blocklet.yml +1 -1
  33. package/package.json +4 -4
  34. package/src/app.tsx +3 -1
  35. package/src/components/invoice/list.tsx +83 -31
  36. package/src/components/invoice/recharge.tsx +244 -0
  37. package/src/components/payment-intent/actions.tsx +2 -1
  38. package/src/components/payment-link/actions.tsx +6 -6
  39. package/src/components/payment-link/item.tsx +53 -18
  40. package/src/components/pricing-table/actions.tsx +14 -3
  41. package/src/components/pricing-table/payment-settings.tsx +1 -1
  42. package/src/components/refund/actions.tsx +43 -1
  43. package/src/components/refund/list.tsx +1 -1
  44. package/src/components/subscription/actions/cancel.tsx +10 -7
  45. package/src/components/subscription/metrics.tsx +1 -1
  46. package/src/components/subscription/portal/actions.tsx +22 -1
  47. package/src/components/subscription/portal/list.tsx +1 -0
  48. package/src/components/webhook/attempts.tsx +19 -121
  49. package/src/components/webhook/request-info.tsx +139 -0
  50. package/src/locales/en.tsx +4 -0
  51. package/src/locales/zh.tsx +8 -0
  52. package/src/pages/admin/billing/invoices/detail.tsx +15 -0
  53. package/src/pages/admin/billing/invoices/index.tsx +1 -1
  54. package/src/pages/admin/billing/subscriptions/detail.tsx +12 -4
  55. package/src/pages/admin/customers/customers/detail.tsx +1 -0
  56. package/src/pages/admin/payments/refunds/detail.tsx +2 -2
  57. package/src/pages/admin/products/links/create.tsx +4 -1
  58. package/src/pages/customer/index.tsx +1 -1
  59. package/src/pages/customer/invoice/detail.tsx +34 -14
  60. package/src/pages/customer/recharge.tsx +45 -35
  61. package/src/pages/customer/subscription/change-plan.tsx +8 -1
  62. package/src/pages/customer/subscription/detail.tsx +12 -22
  63. package/src/pages/customer/subscription/embed.tsx +3 -1
@@ -1,7 +1,7 @@
1
1
  import type Stripe from 'stripe';
2
2
 
3
3
  import logger from '../../../libs/logger';
4
- import { CheckoutSession, SetupIntent, Subscription, TEventExpanded } from '../../../store/models';
4
+ import { CheckoutSession, Lock, SetupIntent, Subscription, TEventExpanded } from '../../../store/models';
5
5
 
6
6
  async function handleSubscriptionOnSetupSucceeded(event: TEventExpanded, stripeIntentId: string) {
7
7
  const subscription = await Subscription.findOne({
@@ -69,6 +69,8 @@ async function handleSetupIntentOnSetupSucceeded(event: TEventExpanded, stripeIn
69
69
  payment_method_options: {},
70
70
  },
71
71
  });
72
+ // lock the subscription to prevent concurrent change plan
73
+ await Lock.acquire(`${subscription.id}-change-plan`, subscription.current_period_end);
72
74
  logger.info('subscription payment changed to stripe on stripe setup intent succeeded', {
73
75
  subscriptionId: subscription.id,
74
76
  setupIntentId: setupIntent.id,
@@ -70,19 +70,13 @@ export async function handleSubscriptionEvent(event: TEventExpanded, _: Stripe)
70
70
  return;
71
71
  }
72
72
 
73
- const fields = [
74
- 'cancel_at',
75
- 'cancel_at_period_end',
76
- 'canceled_at',
77
- 'current_period_start',
78
- 'current_period_end',
79
- 'trial_end',
80
- ];
73
+ const fields = ['cancel_at', 'cancel_at_period_end', 'canceled_at', 'current_period_start', 'current_period_end'];
81
74
  if (subscription.payment_settings?.payment_method_types?.includes('stripe')) {
82
75
  fields.push('pause_collection');
83
76
  }
84
77
  if (subscription.status === 'trialing' && event.data.object.status === 'active') {
85
78
  fields.push('status');
79
+ fields.push('trial_end');
86
80
  }
87
81
 
88
82
  await finalizeStripeSubscriptionUpdate({
@@ -289,18 +289,7 @@ export async function ensureStripeSubscription(
289
289
  }
290
290
  })
291
291
  );
292
-
293
- await internal.update({
294
- payment_details: {
295
- stripe: {
296
- customer_id: customer.id,
297
- subscription_id: stripeSubscription.id,
298
- setup_intent_id: stripeSubscription.pending_setup_intent?.id,
299
- },
300
- },
301
- });
302
292
  }
303
-
304
293
  return stripeSubscription;
305
294
  }
306
295
 
@@ -3,20 +3,25 @@ import type { LiteralUnion } from 'type-fest';
3
3
  import { withQuery } from 'ufo';
4
4
 
5
5
  import { fromUnitToToken } from '@ocap/util';
6
+ import { Op } from 'sequelize';
7
+ import { cloneDeep, pick } from 'lodash';
6
8
  import {
9
+ Customer,
7
10
  Invoice,
8
11
  InvoiceItem,
9
12
  PaymentCurrency,
10
13
  PaymentMethod,
11
14
  Price,
12
15
  Product,
16
+ Refund,
17
+ SetupIntent,
13
18
  Subscription,
14
19
  SubscriptionItem,
15
20
  UsageRecord,
16
21
  } from '../store/models';
17
22
  import { getConnectQueryParam } from './util';
18
23
  import { expandLineItems } from './session';
19
- import { getSubscriptionCycleAmount, getSubscriptionCycleSetup } from './subscription';
24
+ import { getSubscriptionCycleAmount, getSubscriptionCycleSetup, getSubscriptionStakeAmountSetup } from './subscription';
20
25
 
21
26
  export function getCustomerInvoicePageUrl({
22
27
  invoiceId,
@@ -144,3 +149,199 @@ export async function getInvoiceShouldPayTotal(invoice: Invoice) {
144
149
  return invoice.total;
145
150
  }
146
151
  }
152
+
153
+ export async function getReturnStakeInvoices(subscription: Subscription) {
154
+ const method = await PaymentMethod.findOne({ where: { type: 'arcblock', livemode: subscription.livemode } });
155
+ const returnStakeInvoice: Invoice[] = [];
156
+ const refunds = await Refund.findAll({
157
+ where: { subscription_id: subscription.id, status: 'succeeded', type: 'stake_return' },
158
+ include: [
159
+ { model: Invoice, as: 'invoice' },
160
+ { model: Customer, as: 'customer' },
161
+ { model: PaymentCurrency, as: 'paymentCurrency' },
162
+ ],
163
+ });
164
+
165
+ await Promise.all(
166
+ refunds.map(async (r: any) => {
167
+ const invoice =
168
+ r.invoice ||
169
+ (await Invoice.findOne({
170
+ where: {
171
+ subscription_id: subscription.id,
172
+ currency_id: r.currency_id,
173
+ },
174
+ order: [['created_at', 'ASC']],
175
+ }));
176
+ returnStakeInvoice.push({
177
+ id: r.id,
178
+ status: 'paid',
179
+ billing_reason: 'return_stake',
180
+ description: 'Return Subscription staking',
181
+ total: r.amount,
182
+ amount_due: '0',
183
+ amount_paid: r.amount,
184
+ amount_remaining: '0',
185
+ subscription_id: subscription.id,
186
+ paid: true,
187
+ ...pick(invoice, ['number', 'auto_advance', 'period_start', 'period_end']),
188
+ ...pick(r, ['created_at', 'updated_at', 'currency_id', 'customer_id']),
189
+ // @ts-ignore
190
+ paymentCurrency: r.paymentCurrency,
191
+ paymentMethod: method,
192
+ customer: r.customer,
193
+ metadata: {
194
+ payment_details: {
195
+ arcblock: {
196
+ tx_hash: r?.payment_details?.arcblock?.tx_hash,
197
+ payer: r?.payment_details?.arcblock?.payer,
198
+ },
199
+ },
200
+ },
201
+ });
202
+ })
203
+ );
204
+ return returnStakeInvoice;
205
+ }
206
+
207
+ export async function getSetupInvoice(
208
+ subscription: Subscription,
209
+ paymentMethod: PaymentMethod,
210
+ currencyId: string,
211
+ stakingProps: { tx_hash: string; payer: string; address: string }
212
+ ) {
213
+ const currency = await PaymentCurrency.findByPk(currencyId);
214
+ if (!currency) {
215
+ return null;
216
+ }
217
+ if (!stakingProps?.tx_hash) {
218
+ return null;
219
+ }
220
+ if (paymentMethod.type !== 'arcblock') {
221
+ return null;
222
+ }
223
+ const firstInvoice = await Invoice.findOne({
224
+ where: { subscription_id: subscription.id, currency_id: currency.id },
225
+ order: [['created_at', 'ASC']],
226
+ include: [{ model: Customer, as: 'customer' }],
227
+ });
228
+ if (!firstInvoice) {
229
+ return null;
230
+ }
231
+ const stakeAmountResult = await getSubscriptionStakeAmountSetup(subscription, paymentMethod, stakingProps.tx_hash);
232
+ // @ts-ignore
233
+ const stakeAmount = stakeAmountResult?.[currency?.contract] || '0';
234
+ return {
235
+ id: stakingProps.address as string,
236
+ status: 'paid',
237
+ description: 'Subscription staking',
238
+ billing_reason: 'stake',
239
+ total: stakeAmount,
240
+ amount_due: '0',
241
+ amount_paid: stakeAmount,
242
+ amount_remaining: '0',
243
+ ...pick(firstInvoice, [
244
+ 'number',
245
+ 'paid',
246
+ 'auto_advance',
247
+ 'currency_id',
248
+ 'customer_id',
249
+ 'subscription_id',
250
+ 'period_start',
251
+ 'period_end',
252
+ 'created_at',
253
+ 'updated_at',
254
+ ]),
255
+ // @ts-ignore
256
+ paymentCurrency: currency,
257
+ paymentMethod,
258
+ // @ts-ignore
259
+ customer: firstInvoice?.customer,
260
+ metadata: {
261
+ payment_details: {
262
+ arcblock: {
263
+ tx_hash: stakingProps?.tx_hash,
264
+ payer: stakingProps?.payer,
265
+ },
266
+ },
267
+ },
268
+ };
269
+ }
270
+
271
+ export async function getStakingInvoices(subscription: Subscription): Promise<Invoice[]> {
272
+ const method = await PaymentMethod.findOne({ where: { type: 'arcblock', livemode: subscription.livemode } });
273
+ if (!method) {
274
+ return [];
275
+ }
276
+ const stakeInvoices = await Invoice.findAll({
277
+ where: { subscription_id: subscription.id, billing_reason: 'stake', status: 'paid' },
278
+ include: [
279
+ { model: Customer, as: 'customer' },
280
+ { model: PaymentCurrency, as: 'paymentCurrency' },
281
+ { model: PaymentMethod, as: 'paymentMethod' },
282
+ ],
283
+ });
284
+ const invoices = cloneDeep(stakeInvoices);
285
+ const setups = await SetupIntent.findAll({
286
+ where: {
287
+ customer_id: subscription.customer_id,
288
+ payment_method_id: method?.id,
289
+ status: 'succeeded',
290
+ [Op.and]: [
291
+ { metadata: { [Op.not]: null } },
292
+ { 'metadata.subscription_id': subscription.id },
293
+ { setup_details: { [Op.not]: null } },
294
+ { 'setup_details.arcblock': { [Op.not]: null } },
295
+ { 'setup_details.arcblock.staking': { [Op.not]: null } },
296
+ ] as any,
297
+ },
298
+ });
299
+ if (setups.length === 0) {
300
+ // No payment change, use the first invoice
301
+ if (stakeInvoices.length > 0) {
302
+ return stakeInvoices;
303
+ }
304
+ const newInvoice = await getSetupInvoice(subscription, method, subscription.currency_id, {
305
+ tx_hash: subscription.payment_details?.arcblock?.staking?.tx_hash!,
306
+ payer: subscription.payment_details?.arcblock?.payer!,
307
+ address: subscription.payment_details?.arcblock?.staking?.address!,
308
+ });
309
+ // @ts-ignore
310
+ return [newInvoice].filter(Boolean);
311
+ }
312
+
313
+ await Promise.all(
314
+ setups.map(async (setup: SetupIntent, index: number) => {
315
+ if (
316
+ index === 0 &&
317
+ setup.metadata?.from_currency &&
318
+ !stakeInvoices.find((x) => x.currency_id === setup?.metadata?.from_currency)
319
+ ) {
320
+ const paymentDetails =
321
+ setup?.metadata?.from_payment_details?.arcblock || subscription.payment_details?.arcblock;
322
+ const fromMethod = await PaymentMethod.findByPk(setup?.metadata?.from_payment_method_id);
323
+ const newInvoice = await getSetupInvoice(subscription, fromMethod!, setup.metadata?.from_currency, {
324
+ tx_hash: paymentDetails?.staking?.tx_hash!,
325
+ payer: paymentDetails?.payer!,
326
+ address: paymentDetails?.staking?.address!,
327
+ });
328
+ if (newInvoice) {
329
+ // @ts-ignore
330
+ invoices.push(newInvoice);
331
+ }
332
+ }
333
+ if (setup.metadata?.to_currency && !stakeInvoices.find((x) => x.currency_id === setup?.metadata?.to_currency)) {
334
+ const newInvoice = await getSetupInvoice(subscription, method, setup.metadata?.to_currency, {
335
+ tx_hash: setup.setup_details?.arcblock?.staking?.tx_hash!,
336
+ payer: setup.setup_details?.arcblock?.payer!,
337
+ address: setup.setup_details?.arcblock?.staking?.address!,
338
+ });
339
+ if (newInvoice) {
340
+ // @ts-ignore
341
+ invoices.push(newInvoice);
342
+ }
343
+ }
344
+ })
345
+ );
346
+ return invoices;
347
+ }
@@ -3,6 +3,7 @@
3
3
  import { fromUnitToToken } from '@ocap/util';
4
4
  import prettyMsI18n from 'pretty-ms-i18n';
5
5
 
6
+ import { Op } from 'sequelize';
6
7
  import { getUserLocale } from '../../../integrations/blocklet/notification';
7
8
  import { translate } from '../../../locales';
8
9
  import { Customer, PaymentMethod, Refund, Subscription } from '../../../store/models';
@@ -56,7 +57,15 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
56
57
  throw new Error(`Customer(${subscription.customer_id}) not found`);
57
58
  }
58
59
 
59
- const invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
60
+ const invoice = await Invoice.findOne({
61
+ where: {
62
+ id: subscription.latest_invoice_id,
63
+ },
64
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
65
+ });
66
+ if (!invoice) {
67
+ throw new Error(`Invoice(${subscription.latest_invoice_id}) not found`);
68
+ }
60
69
  const paymentCurrency = (await PaymentCurrency.findOne({
61
70
  where: {
62
71
  id: subscription.currency_id,
@@ -68,7 +77,8 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
68
77
  const productName = await getMainProductName(subscription.id);
69
78
  const at: string = formatTime(subscription.canceled_at * 1000);
70
79
 
71
- const paymentInfo: string = `${fromUnitToToken(invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
80
+ // @ts-ignore
81
+ const paymentInfo: string = `${fromUnitToToken(invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}`;
72
82
  const currentPeriodStart: string = formatTime(invoice.period_start * 1000);
73
83
  const currentPeriodEnd: string = formatTime(invoice.period_end * 1000);
74
84
  const duration: string = prettyMsI18n(
@@ -98,6 +108,9 @@ export class SubscriptionCanceledEmailTemplate implements BaseEmailTemplate<Subs
98
108
  where: {
99
109
  subscription_id: subscription.id,
100
110
  type: 'refund',
111
+ status: {
112
+ [Op.not]: 'canceled',
113
+ },
101
114
  },
102
115
  });
103
116
  const conditions = [
@@ -91,7 +91,7 @@ export class SubscriptionRenewFailedEmailTemplate
91
91
  const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
92
92
  const paymentCurrency = (await PaymentCurrency.findOne({
93
93
  where: {
94
- id: subscription.currency_id,
94
+ id: invoice.currency_id,
95
95
  },
96
96
  })) as PaymentCurrency;
97
97
 
@@ -82,7 +82,7 @@ export class SubscriptionRenewedEmailTemplate implements BaseEmailTemplate<Subsc
82
82
  const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
83
83
  const paymentCurrency = (await PaymentCurrency.findOne({
84
84
  where: {
85
- id: subscription.currency_id,
85
+ id: invoice.currency_id,
86
86
  },
87
87
  })) as PaymentCurrency;
88
88
 
@@ -4,7 +4,7 @@ import { fromUnitToToken } from '@ocap/util';
4
4
  import type { ManipulateType } from 'dayjs';
5
5
  import prettyMsI18n from 'pretty-ms-i18n';
6
6
 
7
- import { getTokenSummaryByDid } from '../../../integrations/arcblock/stake';
7
+ import { getTokenByAddress } from '../../../integrations/arcblock/stake';
8
8
  import { getUserLocale } from '../../../integrations/blocklet/notification';
9
9
  import { translate } from '../../../locales';
10
10
  import { Customer, PaymentMethod, Subscription, PaymentCurrency } from '../../../store/models';
@@ -76,6 +76,10 @@ export class SubscriptionTrialWilEndEmailTemplate
76
76
 
77
77
  const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
78
78
 
79
+ if (!paymentMethod) {
80
+ throw new Error(`Payment method not found: ${paymentCurrency.payment_method_id}`);
81
+ }
82
+
79
83
  const userDid = customer.did;
80
84
  const locale = await getUserLocale(userDid);
81
85
  const productName = await getMainProductName(subscription.id);
@@ -86,14 +90,14 @@ export class SubscriptionTrialWilEndEmailTemplate
86
90
  const at: string = formatTime((subscription.trial_end as number) * 1000);
87
91
 
88
92
  const willEndDuration: string = getSimplifyDuration((subscription.trial_end! - dayjs().unix()) * 1000, locale);
89
- // const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice);
90
93
 
91
94
  const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
92
95
  const paymentDetail = { price: paymentAmount, balance: 0, symbol: paymentCurrency.symbol };
96
+ // @ts-ignore
97
+ const paymentAddress = subscription.payment_details?.[paymentMethod.type]?.payer ?? undefined;
98
+ const balance = await getTokenByAddress(paymentAddress, paymentMethod, paymentCurrency);
99
+ paymentDetail.balance = +fromUnitToToken(balance, paymentCurrency.decimal);
93
100
 
94
- const token = await getTokenSummaryByDid(userDid, customer.livemode);
95
-
96
- paymentDetail.balance = +fromUnitToToken(token?.[paymentCurrency.id] || '0', paymentCurrency.decimal);
97
101
  const paymentInfo: string = `${paymentAmount} ${paymentCurrency.symbol}`;
98
102
  const currentPeriodStart: string = formatTime((subscription.trial_start as number) * 1000);
99
103
  const currentPeriodEnd: string = formatTime((subscription.trial_end as number) * 1000);
@@ -78,19 +78,23 @@ export class SubscriptionWillCanceledEmailTemplate
78
78
  throw new Error(`Customer not found: ${subscription.customer_id}`);
79
79
  }
80
80
 
81
- const invoice = (await Invoice.findByPk(subscription.latest_invoice_id)) as Invoice;
82
- const paymentCurrency = (await PaymentCurrency.findOne({
81
+ const invoice = await Invoice.findOne({
83
82
  where: {
84
- id: subscription.currency_id,
83
+ id: subscription.latest_invoice_id,
85
84
  },
86
- })) as PaymentCurrency;
85
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
86
+ });
87
+ if (!invoice) {
88
+ throw new Error(`Invoice(${subscription.latest_invoice_id}) not found`);
89
+ }
87
90
 
88
91
  const userDid = customer.did;
89
92
  const locale = await getUserLocale(userDid);
90
93
  const productName = await getMainProductName(subscription.id);
91
94
  const at: string = formatTime(cancelAt * 1000);
92
95
  const willCancelDuration: string = getSimplifyDuration((cancelAt - now) * 1000, locale);
93
- const paymentInfo: string = `${fromUnitToToken(+invoice.total, paymentCurrency.decimal)} ${paymentCurrency.symbol}`;
96
+ // @ts-ignore
97
+ const paymentInfo: string = `${fromUnitToToken(+invoice.total, invoice?.paymentCurrency?.decimal)} ${invoice?.paymentCurrency?.symbol}`;
94
98
 
95
99
  let body: string = translate('notification.subscriptWillCanceled.body', locale, {
96
100
  productName,
@@ -6,7 +6,7 @@ import type { LiteralUnion } from 'type-fest';
6
6
 
7
7
  import { fromUnitToToken } from '@ocap/util';
8
8
  import dayjs from '../../dayjs';
9
- import { getTokenSummaryByDid } from '../../../integrations/arcblock/stake';
9
+ import { getTokenByAddress } from '../../../integrations/arcblock/stake';
10
10
  import { getUserLocale } from '../../../integrations/blocklet/notification';
11
11
  import { translate } from '../../../locales';
12
12
  import {
@@ -93,19 +93,19 @@ export class SubscriptionWillRenewEmailTemplate
93
93
  const productName = await getMainProductName(subscription.id);
94
94
  const at: string = formatTime(invoice.period_end * 1000);
95
95
  const willRenewDuration = getSimplifyDuration((invoice.period_end - dayjs().unix()) * 1000, locale);
96
- // const upcomingInvoiceAmount = await getUpcomingInvoiceAmount(subscription.id);
97
- // const amount: string = fromUnitToToken(+upcomingInvoiceAmount.amount, upcomingInvoiceAmount.currency?.decimal);
98
- // const paymentDetail: PaymentDetail = await getPaymentDetail(userDid, invoice, amount);
99
- // paymentDetail.price = +amount;
100
96
 
101
97
  const paymentAmount = await getPaymentAmountForCycleSubscription(subscription, paymentCurrency);
102
98
  const paymentDetail = { price: paymentAmount, balance: 0, symbol: paymentCurrency.symbol, balanceFormatted: '0' };
103
99
 
104
- const token = await getTokenSummaryByDid(userDid, customer.livemode);
105
-
106
- const balance = fromUnitToToken(token?.[paymentCurrency.id] || '0', paymentCurrency.decimal);
107
- paymentDetail.balanceFormatted = balance;
108
- paymentDetail.balance = +balance;
100
+ const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
101
+ if (!paymentMethod) {
102
+ throw new Error(`Payment method not found: ${paymentCurrency.payment_method_id}`);
103
+ }
104
+ // @ts-ignore
105
+ const paymentAddress = subscription.payment_details?.[paymentMethod.type]?.payer ?? undefined;
106
+ const balance = await getTokenByAddress(paymentAddress, paymentMethod, paymentCurrency);
107
+ paymentDetail.balanceFormatted = fromUnitToToken(balance, paymentCurrency.decimal);
108
+ paymentDetail.balance = +paymentDetail.balanceFormatted;
109
109
 
110
110
  const { isPrePaid, interval } = await this.getPaymentCategory({
111
111
  subscriptionId: subscription.id,
@@ -132,8 +132,6 @@ export class SubscriptionWillRenewEmailTemplate
132
132
  locale,
133
133
  userDid,
134
134
  });
135
- const paymentMethod: PaymentMethod | null = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
136
-
137
135
  const addFundsLink: string = getCustomerRechargeLink({
138
136
  locale,
139
137
  userDid,
@@ -211,13 +211,14 @@ export async function getPaymentDetail(
211
211
  if (!paymentMethod) {
212
212
  return defaultResult;
213
213
  }
214
-
214
+ const paymentSettings = invoice?.payment_settings;
215
215
  if (['arcblock', 'ethereum'].includes(paymentMethod.type)) {
216
216
  // balance enough token for payment?
217
+ const payer = paymentSettings?.payment_method_options.arcblock?.payer as string;
217
218
  const result = await isDelegationSufficientForPayment({
218
219
  paymentMethod,
219
220
  paymentCurrency,
220
- userDid,
221
+ userDid: payer || userDid,
221
222
  amount: inputAmount,
222
223
  });
223
224
 
@@ -1,6 +1,7 @@
1
1
  import { BN } from '@ocap/util';
2
2
  import { Op, type WhereOptions } from 'sequelize';
3
3
  import { PaymentIntent, Refund } from '../store/models';
4
+ import logger from './logger';
4
5
 
5
6
  export async function getRefundAmountSetup({
6
7
  currencyId,
@@ -45,6 +46,9 @@ export async function getRefundAmountSetup({
45
46
  include: [],
46
47
  });
47
48
  if (count === 0) {
49
+ logger.info('No refund found for payment intent', {
50
+ paymentIntentId,
51
+ });
48
52
  return {
49
53
  amount: paymentIntent.amount_received,
50
54
  totalAmount: paymentIntent.amount_received,
@@ -313,6 +313,8 @@ export async function createProration(
313
313
  prorations: [],
314
314
  newCredit: '0',
315
315
  appliedCredit: '0',
316
+ remaining: '0',
317
+ remainingUnused: '0',
316
318
  };
317
319
  }
318
320
 
@@ -321,7 +323,7 @@ export async function createProration(
321
323
  const prorations = await Promise.all(
322
324
  prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
323
325
  const price = getSubscriptionItemPrice(x);
324
- const unitAmount = getPriceUintAmountByCurrency(price, subscription.currency_id);
326
+ const unitAmount = getPriceUintAmountByCurrency(price, lastInvoice.currency_id);
325
327
  const amount = new BN(unitAmount)
326
328
  .mul(new BN(x.quantity))
327
329
  .mul(new BN(prorationRate))
@@ -363,6 +365,25 @@ export async function createProration(
363
365
 
364
366
  // 5. adjust invoice total && update customer token balance
365
367
  const total = setup.amount.setup;
368
+
369
+ // 6. calculate remaining amount
370
+ const refunds = await Refund.findAll({
371
+ where: {
372
+ status: { [Op.not]: 'canceled' },
373
+ subscription_id: subscription.id,
374
+ currency_id: lastInvoice?.currency_id,
375
+ invoice_id: lastInvoice?.id,
376
+ },
377
+ });
378
+ const refundAmount = refunds.reduce((acc, x) => acc.add(new BN(x.amount || '0')), new BN(0));
379
+
380
+ // 7. calculate remaining amount, default refund recurring amount
381
+ const calcRemaining = (amount: BN, subtract: BN) =>
382
+ amount.sub(subtract).lt(new BN(0)) ? '0' : amount.sub(subtract).toString();
383
+
384
+ const remaining = calcRemaining(new BN(total), refundAmount);
385
+ const remainingUnused = calcRemaining(unused, refundAmount);
386
+
366
387
  let due = setup.amount.setup;
367
388
  let newCredit = '0';
368
389
  let appliedCredit = '0';
@@ -384,6 +405,8 @@ export async function createProration(
384
405
  prorationEnd,
385
406
  prorationRate,
386
407
  unused: unused.toString(),
408
+ remaining,
409
+ remainingUnused,
387
410
  total,
388
411
  due,
389
412
  newCredit,
@@ -394,6 +417,8 @@ export async function createProration(
394
417
  lastInvoice,
395
418
  total,
396
419
  due,
420
+ remaining,
421
+ remainingUnused,
397
422
  used: new BN(lastInvoice.amount_due).sub(unused).toString(),
398
423
  unused: unused.toString(),
399
424
  prorations,
@@ -402,10 +427,10 @@ export async function createProration(
402
427
  };
403
428
  }
404
429
 
405
- export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number) {
430
+ export async function getSubscriptionRefundSetup(subscription: Subscription, anchor: number, currencyId?: string) {
406
431
  const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
407
432
  const expanded = await Price.expand(items.map((x) => x.toJSON()));
408
- const setup = getSubscriptionCreateSetup(expanded, subscription.currency_id, 0);
433
+ const setup = getSubscriptionCreateSetup(expanded, currencyId || subscription.currency_id, 0);
409
434
  return createProration(subscription, setup, anchor);
410
435
  }
411
436
 
@@ -452,7 +477,7 @@ export async function getUpcomingInvoiceAmount(subscriptionId: string) {
452
477
  throw new Error('Subscription not found');
453
478
  }
454
479
 
455
- if (subscription.isActive() === false) {
480
+ if (subscription.isActive() === false && subscription.status !== 'past_due') {
456
481
  throw new Error(`Subscription not active for ${subscriptionId}, so usage check is skipped`);
457
482
  }
458
483
 
@@ -726,10 +751,17 @@ export async function getRemainingStakes(subscriptionIds: string[], subscription
726
751
  export async function getSubscriptionStakeSlashSetup(
727
752
  subscription: Subscription,
728
753
  address: string,
729
- paymentMethod: PaymentMethod
754
+ paymentMethod: PaymentMethod,
755
+ paymentCurrencyId?: string
730
756
  ) {
731
757
  const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
732
- const result = await getSubscriptionRemainingStakeSetup(subscription, address, paymentMethod, 'slash');
758
+ const result = await getSubscriptionRemainingStakeSetup(
759
+ subscription,
760
+ address,
761
+ paymentMethod,
762
+ 'slash',
763
+ paymentCurrencyId
764
+ );
733
765
  return {
734
766
  ...result,
735
767
  lastInvoice,
@@ -739,10 +771,17 @@ export async function getSubscriptionStakeSlashSetup(
739
771
  export async function getSubscriptionStakeReturnSetup(
740
772
  subscription: Subscription,
741
773
  address: string,
742
- paymentMethod: PaymentMethod
774
+ paymentMethod: PaymentMethod,
775
+ paymentCurrencyId?: string
743
776
  ) {
744
777
  const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
745
- const result = await getSubscriptionRemainingStakeSetup(subscription, address, paymentMethod, 'return');
778
+ const result = await getSubscriptionRemainingStakeSetup(
779
+ subscription,
780
+ address,
781
+ paymentMethod,
782
+ 'return',
783
+ paymentCurrencyId
784
+ );
746
785
  return {
747
786
  ...result,
748
787
  lastInvoice,
@@ -753,9 +792,10 @@ export async function getSubscriptionRemainingStakeSetup(
753
792
  subscription: Subscription,
754
793
  address: string,
755
794
  paymentMethod: PaymentMethod,
756
- action: 'return' | 'slash' = 'return'
795
+ action: 'return' | 'slash' = 'return',
796
+ paymentCurrencyId?: string
757
797
  ) {
758
- const currency = await PaymentCurrency.findByPk(subscription.currency_id);
798
+ const currency = await PaymentCurrency.findByPk(paymentCurrencyId || subscription.currency_id);
759
799
  if (!currency) {
760
800
  return {
761
801
  total: '0',
@@ -782,12 +822,12 @@ export async function getSubscriptionRemainingStakeSetup(
782
822
  }
783
823
  const [summary] = await Invoice.getUncollectibleAmount({
784
824
  subscriptionId: subscription.id,
785
- currencyId: subscription.currency_id,
825
+ currencyId: currency.id,
786
826
  customerId: subscription.customer_id,
787
827
  });
788
828
  const subscriptionInitStakes = JSON.parse(state.data?.value || '{}');
789
829
  const initStake = subscriptionInitStakes[subscription.id];
790
- const uncollectibleAmountBN = new BN(summary?.[subscription.currency_id] || '0');
830
+ const uncollectibleAmountBN = new BN(summary?.[currency.id] || '0');
791
831
  if (state.nonce) {
792
832
  const returnStake = total.sub(uncollectibleAmountBN);
793
833
  return {
@@ -909,11 +949,15 @@ export async function getSubscriptionStakeCancellation(
909
949
  return cancellation;
910
950
  }
911
951
 
912
- export async function getSubscriptionStakeAmountSetup(subscription: Subscription, paymentMethod: PaymentMethod) {
952
+ export async function getSubscriptionStakeAmountSetup(
953
+ subscription: Subscription,
954
+ paymentMethod: PaymentMethod,
955
+ stakingTxHash?: string
956
+ ) {
913
957
  if (paymentMethod.type !== 'arcblock') {
914
958
  return null;
915
959
  }
916
- const txHash = subscription?.payment_details?.arcblock?.staking?.tx_hash;
960
+ const txHash = stakingTxHash || subscription?.payment_details?.arcblock?.staking?.tx_hash;
917
961
  if (!txHash) {
918
962
  return null;
919
963
  }