payment-kit 1.13.99 → 1.13.100

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.
@@ -42,15 +42,12 @@ export const handleInvoice = async (job: InvoiceJob) => {
42
42
  }
43
43
 
44
44
  // no payment required
45
- if (invoice.total === '0') {
45
+ if (invoice.amount_remaining === '0') {
46
46
  logger.warn(`invoice does not require payment: ${job.invoiceId}`);
47
47
 
48
48
  await invoice.update({
49
49
  paid: true,
50
50
  status: 'paid',
51
- amount_due: '0',
52
- amount_paid: '0',
53
- amount_remaining: '0',
54
51
  attempt_count: invoice.attempt_count + 1,
55
52
  attempted: true,
56
53
  status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
@@ -101,9 +98,9 @@ export const handleInvoice = async (job: InvoiceJob) => {
101
98
  // TODO: support partial payment from user balance
102
99
  paymentIntent = await PaymentIntent.create({
103
100
  livemode: !!invoice.livemode,
104
- amount: invoice.total,
101
+ amount: invoice.amount_remaining,
105
102
  amount_received: '0',
106
- amount_capturable: invoice.total,
103
+ amount_capturable: '0',
107
104
  customer_id: invoice.customer_id,
108
105
  description: descriptionMap[invoice.billing_reason] || '',
109
106
  currency_id: invoice.currency_id,
@@ -160,7 +157,7 @@ export const startInvoiceQueue = async () => {
160
157
  where: {
161
158
  status: 'open',
162
159
  collection_method: 'charge_automatically',
163
- amount_due: { [Op.gt]: '0' },
160
+ amount_remaining: { [Op.gt]: '0' },
164
161
  },
165
162
  });
166
163
 
@@ -5,7 +5,7 @@ import dayjs from '../libs/dayjs';
5
5
  import CustomError from '../libs/error';
6
6
  import { events } from '../libs/event';
7
7
  import logger from '../libs/logger';
8
- import { getGasPayerExtra, isBalanceSufficientForPayment, isDelegationSufficientForPayment } from '../libs/payment';
8
+ import { getGasPayerExtra, isDelegationSufficientForPayment } from '../libs/payment';
9
9
  import createQueue from '../libs/queue';
10
10
  import { getDaysUntilDue, getDueUnit } from '../libs/subscription';
11
11
  import { MAX_RETRY_COUNT, MIN_RETRY_MAIL, getNextRetry } from '../libs/util';
@@ -24,7 +24,7 @@ type PaymentJob = {
24
24
  retryOnError?: boolean;
25
25
  };
26
26
 
27
- export const handlePaymentSucceed = async (paymentIntent: PaymentIntent, invoiceUpdates: any = {}) => {
27
+ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent) => {
28
28
  let invoice;
29
29
  if (paymentIntent.invoice_id) {
30
30
  invoice = await Invoice.findByPk(paymentIntent.invoice_id);
@@ -46,15 +46,13 @@ export const handlePaymentSucceed = async (paymentIntent: PaymentIntent, invoice
46
46
  await invoice.update({
47
47
  paid: true,
48
48
  status: 'paid',
49
- amount_due: '0',
50
49
  amount_paid: paymentIntent.amount,
51
50
  amount_remaining: '0',
52
51
  attempt_count: invoice.attempt_count + 1,
53
52
  attempted: true,
54
53
  status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
55
- ...invoiceUpdates,
56
54
  });
57
- logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`, invoiceUpdates);
55
+ logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`);
58
56
  }
59
57
 
60
58
  if (invoice.subscription_id) {
@@ -297,37 +295,6 @@ export const handlePayment = async (job: PaymentJob) => {
297
295
  const client = paymentMethod.getOcapClient();
298
296
  const payer = paymentSettings?.payment_method_options.arcblock?.payer;
299
297
 
300
- // if we can complete purchase with customer balance
301
- const balance = isBalanceSufficientForPayment({
302
- paymentMethod,
303
- paymentCurrency,
304
- customer,
305
- amount: paymentIntent.amount,
306
- });
307
- if (balance.sufficient) {
308
- const tmp = await customer.decreaseTokenBalance(paymentCurrency.id, paymentIntent.amount);
309
- logger.info(`PaymentIntent capture done: ${paymentIntent.id} with customer balance`, tmp);
310
- await paymentIntent.update({
311
- status: 'succeeded',
312
- amount: '0', // update payment intent amount to 0
313
- amount_received: '0',
314
- payment_details: {
315
- arcblock: {
316
- tx_hash: '',
317
- payer: payer as string,
318
- },
319
- },
320
- });
321
-
322
- await handlePaymentSucceed(paymentIntent, {
323
- starting_token_balance: tmp.starting,
324
- ending_token_balance: tmp.ending,
325
- });
326
- return;
327
- }
328
-
329
- // FIXME: support partial payment from balance
330
-
331
298
  // check balance before capture with transaction
332
299
  result = await isDelegationSufficientForPayment({
333
300
  paymentMethod,
@@ -113,6 +113,7 @@ const handleSubscriptionInvoice = async (
113
113
 
114
114
  const { invoice } = await ensureInvoiceAndItems({
115
115
  customer,
116
+ currency,
116
117
  subscription,
117
118
  trailing: false,
118
119
  metered: true,
@@ -1,3 +1,5 @@
1
+ /* eslint-disable @typescript-eslint/indent */
2
+ /* eslint-disable prettier/prettier */
1
3
  import { BN } from '@ocap/util';
2
4
 
3
5
  import { estimateMaxGasForTx, hasStakedForGas } from '../../integrations/blockchain/stake';
@@ -252,8 +254,10 @@ export async function ensureInvoiceForCheckout({
252
254
  };
253
255
  }
254
256
 
257
+ const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
255
258
  const { invoice, items } = await ensureInvoiceAndItems({
256
259
  customer,
260
+ currency: currency as PaymentCurrency,
257
261
  subscription,
258
262
  lineItems: await Price.expand(checkoutSession.line_items, { product: true }),
259
263
  trailing: !!checkoutSession.subscription_data?.trial_period_days,
@@ -301,19 +305,37 @@ export async function ensureInvoiceForCheckout({
301
305
 
302
306
  export async function ensureInvoiceAndItems({
303
307
  customer,
308
+ currency,
304
309
  subscription,
305
310
  props,
306
311
  lineItems,
307
312
  trailing,
308
313
  metered,
314
+ applyCredit = true,
309
315
  }: {
310
316
  customer: Customer;
317
+ currency: PaymentCurrency;
311
318
  subscription?: Subscription;
312
319
  props: TInvoice;
313
320
  lineItems: TLineItemExpanded[];
314
321
  trailing: boolean; // do we have trailing
315
322
  metered: boolean; // is the quantity metered
323
+ applyCredit?: boolean; // should we apply customer credit?
316
324
  }): Promise<{ invoice: Invoice; items: InvoiceItem[] }> {
325
+ if (props.total === '0') {
326
+ throw new Error('Invoice total should not be 0');
327
+ }
328
+
329
+ // apply possible balance to invoice
330
+ let remaining = props.total;
331
+ let result = { starting: {}, ending: {} };
332
+ if (applyCredit) {
333
+ const balance = customer.getBalanceToApply(currency.id, props.total);
334
+ result = await customer.decreaseTokenBalance(currency.id, balance);
335
+ remaining = new BN(props.total).sub(new BN(balance)).toString();
336
+ logger.info(`Invoice will use customer credit: ${props.total}:${remaining}`, result);
337
+ }
338
+
317
339
  const invoice = await Invoice.create({
318
340
  livemode: props.livemode,
319
341
  number: await customer.getInvoiceNumber(),
@@ -336,19 +358,20 @@ export async function ensureInvoiceAndItems({
336
358
  subscription_id: subscription?.id,
337
359
  checkout_session_id: props.checkout_session_id || '',
338
360
 
361
+ total: props.total || '0',
339
362
  subtotal: props.total || '0',
340
- subtotal_excluding_tax: props.total || '0',
341
363
  tax: '0',
342
- total: props.total || '0',
343
- amount_due: props.total || '0',
364
+ subtotal_excluding_tax: props.total || '0',
365
+
366
+ amount_due: remaining,
344
367
  amount_paid: '0',
345
- amount_remaining: props.total || '0',
368
+ amount_remaining: remaining,
346
369
  amount_shipping: '0',
347
370
 
348
371
  starting_balance: '0',
349
372
  ending_balance: '0',
350
- starting_token_balance: {},
351
- ending_token_balance: {},
373
+ starting_token_balance: result.starting,
374
+ ending_token_balance: result.ending,
352
375
 
353
376
  attempt_count: 0,
354
377
  attempted: false,
@@ -508,6 +508,11 @@ const createProration = async (subscription: TSubscription, setup: ReturnType<ty
508
508
  throw new Error('Subscription should have latest invoice when create proration');
509
509
  }
510
510
 
511
+ const customer = await Customer.findByPk(subscription.customer_id);
512
+ if (!customer) {
513
+ throw new Error('Subscription should have customer when create proration');
514
+ }
515
+
511
516
  // 1. get last invoice, and invoice items, filter invoice items that are in licensed recurring mode
512
517
  const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: lastInvoice.id, proration: false } });
513
518
  const invoiceItemsExpanded = await Price.expand(invoiceItems.map((x) => x.toJSON()));
@@ -520,11 +525,11 @@ const createProration = async (subscription: TSubscription, setup: ReturnType<ty
520
525
  const prorationStart = lastInvoice.period_start;
521
526
  const prorationEnd = lastInvoice.period_end;
522
527
  const prorationRate = Math.ceil(((prorationEnd - now) / (prorationEnd - prorationStart)) * 1000000);
523
- let totalProrationAmount = new BN(0);
528
+ let proration = new BN(0);
524
529
  const prorations = await Promise.all(
525
530
  prorationItems.map((x: TLineItemExpanded & { [key: string]: any }) => {
526
531
  const unitAmount = getPriceUintAmountByCurrency(x.price, subscription.currency_id);
527
- const prorationAmount = new BN(unitAmount)
532
+ const amount = new BN(unitAmount)
528
533
  .mul(new BN(x.quantity))
529
534
  .mul(new BN(prorationRate))
530
535
  .div(new BN(1000000))
@@ -533,13 +538,13 @@ const createProration = async (subscription: TSubscription, setup: ReturnType<ty
533
538
  subscription: subscription.id,
534
539
  invoice: x.invoice_id,
535
540
  invoiceItem: x.id,
536
- prorationAmount,
541
+ amount,
537
542
  });
538
- totalProrationAmount = totalProrationAmount.add(new BN(prorationAmount));
543
+ proration = proration.add(new BN(amount));
539
544
 
540
545
  return {
541
546
  price_id: x.price_id,
542
- amount: `-${prorationAmount}`,
547
+ amount: `-${amount}`,
543
548
  quantity: x.quantity,
544
549
  // @ts-ignore
545
550
  description: `Unused time on ${x.price.product.name} after ${dayjs().format('lll')}`,
@@ -564,14 +569,20 @@ const createProration = async (subscription: TSubscription, setup: ReturnType<ty
564
569
  });
565
570
 
566
571
  // 5. adjust invoice total && update customer token balance
567
- let total = new BN(setup.amount.setup);
568
- let credit = new BN(setup.amount.setup);
569
- if (total.gte(totalProrationAmount)) {
570
- total = total.sub(totalProrationAmount).toString();
571
- credit = '0';
572
+ const total = setup.amount.setup;
573
+ let remaining = setup.amount.setup;
574
+ let newCredit = '0';
575
+ let appliedCredit = '0';
576
+ if (new BN(total).gte(proration)) {
577
+ // Proration amount is less than total, all proration are used,
578
+ remaining = new BN(total).sub(proration).toString();
579
+ // Besides, we need to try to apply customer credit
580
+ appliedCredit = customer.getBalanceToApply(subscription.currency_id, remaining);
581
+ remaining = new BN(remaining).sub(new BN(appliedCredit)).toString();
572
582
  } else {
573
- credit = totalProrationAmount.sub(total).toString();
574
- total = '0';
583
+ // Proration amount is greater than total, we need to increase customer credit
584
+ newCredit = proration.sub(new BN(total)).toString();
585
+ remaining = '0';
575
586
  }
576
587
 
577
588
  logger.info('subscription proration result', {
@@ -579,16 +590,20 @@ const createProration = async (subscription: TSubscription, setup: ReturnType<ty
579
590
  prorationStart,
580
591
  prorationEnd,
581
592
  prorationRate,
582
- totalProrationAmount,
593
+ proration,
583
594
  total,
584
- credit,
595
+ remaining,
596
+ newCredit,
597
+ appliedCredit,
585
598
  });
586
599
 
587
600
  return {
588
601
  lastInvoice,
589
602
  total,
590
- credit,
603
+ remaining,
591
604
  prorations,
605
+ newCredit,
606
+ appliedCredit,
592
607
  };
593
608
  };
594
609
 
@@ -738,16 +753,21 @@ router.put('/:id', authPortal, async (req, res) => {
738
753
  const prorationBehavior = updates.proration_behavior || subscription.proration_behavior || 'none';
739
754
  if (prorationBehavior === 'create_prorations') {
740
755
  // 1. create proration
741
- const { lastInvoice, total, credit, prorations } = await createProration(subscription, setup);
756
+ const { lastInvoice, remaining, newCredit, appliedCredit, prorations } = await createProration(
757
+ subscription,
758
+ setup
759
+ );
742
760
 
743
761
  // 2. create new invoice: amount according to new subscription items
744
762
  // 3. create new invoice items: amount according to new subscription items
745
763
  const result = await ensureInvoiceAndItems({
746
764
  customer,
765
+ currency: paymentCurrency,
747
766
  subscription,
748
767
  trailing: false,
749
768
  metered: false,
750
769
  lineItems: newItems,
770
+ applyCredit: false,
751
771
  props: {
752
772
  status: 'draft',
753
773
  livemode: subscription.livemode,
@@ -755,7 +775,7 @@ router.put('/:id', authPortal, async (req, res) => {
755
775
  statement_descriptor: lastInvoice.statement_descriptor,
756
776
  period_start: setup.period.start,
757
777
  period_end: setup.period.end,
758
- auto_advance: true, // FIXME: this should be calculated dynamically
778
+ auto_advance: true,
759
779
  billing_reason: 'subscription_update',
760
780
  total: setup.amount.setup,
761
781
  currency_id: paymentCurrency.id,
@@ -793,32 +813,33 @@ router.put('/:id', authPortal, async (req, res) => {
793
813
  });
794
814
 
795
815
  // 5. adjust invoice total or update customer credit balance
796
- if (total !== '0') {
797
- await invoice.update({
798
- status: 'open',
799
- subtotal: total,
800
- subtotal_excluding_tax: total,
801
- total,
802
- amount_due: total,
803
- amount_remaining: total,
816
+ const invoiceUpdates: Partial<Invoice> = {
817
+ status: 'open',
818
+ amount_due: remaining,
819
+ amount_remaining: remaining,
820
+ };
821
+ if (appliedCredit !== '0') {
822
+ const creditResult = await customer.decreaseTokenBalance(paymentCurrency.id, appliedCredit);
823
+ invoiceUpdates.starting_token_balance = creditResult.starting;
824
+ invoiceUpdates.ending_token_balance = creditResult.ending;
825
+ logger.info('customer credit applied to invoice after proration', {
826
+ subscription: req.params.id,
827
+ appliedCredit,
828
+ creditResult,
804
829
  });
805
- logger.info('subscription proration used on invoice', { subscription: req.params.id, total });
806
830
  }
807
- if (credit !== '0') {
808
- const balance = await customer.increaseTokenBalance(paymentCurrency.id, credit);
809
- await invoice.update({
810
- status: 'open',
811
- subtotal: '0',
812
- subtotal_excluding_tax: '0',
813
- total: '0',
814
- amount_due: '0',
815
- amount_remaining: '0',
816
- starting_token_balance: balance.starting,
817
- ending_token_balance: balance.ending,
831
+ if (newCredit !== '0') {
832
+ const creditResult = await customer.increaseTokenBalance(paymentCurrency.id, newCredit);
833
+ invoiceUpdates.starting_token_balance = creditResult.starting;
834
+ invoiceUpdates.ending_token_balance = creditResult.ending;
835
+ logger.info('subscription proration credit applied to customer', {
836
+ subscription: req.params.id,
837
+ newCredit,
838
+ creditResult,
818
839
  });
819
- logger.info('subscription proration credit to customer', { subscription: req.params.id, credit });
820
840
  }
821
841
 
842
+ await invoice.update(invoiceUpdates);
822
843
  await subscription.update(updates);
823
844
 
824
845
  // 6. process the invoice as usual: push into queue
@@ -989,10 +1010,7 @@ router.post('/:id/update', authPortal, async (req, res) => {
989
1010
  }
990
1011
 
991
1012
  // validate the request
992
- const { newItems, addedItems, deletedItems, updatedItems } = await validateSubscriptionUpdateRequest(
993
- subscription,
994
- req.body.items
995
- );
1013
+ const { newItems } = await validateSubscriptionUpdateRequest(subscription, req.body.items);
996
1014
 
997
1015
  // do the simulation
998
1016
  const setup = getSubscriptionCreateSetup(newItems, subscription.currency_id, 0);
@@ -1001,12 +1019,11 @@ router.post('/:id/update', authPortal, async (req, res) => {
1001
1019
  return res.json({
1002
1020
  setup,
1003
1021
  total: result.total,
1004
- credit: result.credit,
1022
+ newCredit: result.newCredit,
1023
+ appliedCredit: result.appliedCredit,
1024
+ remaining: result.remaining,
1005
1025
  prorations: result.prorations,
1006
1026
  items: newItems,
1007
- addedItems,
1008
- deletedItems,
1009
- updatedItems,
1010
1027
  });
1011
1028
  } catch (err) {
1012
1029
  console.error(err);
@@ -154,7 +154,17 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
154
154
  return `${this.invoice_prefix}-${padStart(sequence.toString(), 4, '0')}`;
155
155
  }
156
156
 
157
- public async decreaseTokenBalance(currencyId: string, amount: string) {
157
+ public getBalanceToApply(currencyId: string, amount: string) {
158
+ const tokens = this.token_balance || {};
159
+ const balance = tokens[currencyId] || '0';
160
+ return new BN(balance).lt(new BN(amount)) ? balance : amount;
161
+ }
162
+
163
+ public async decreaseTokenBalance(currencyId: string, amount: string, dryRun: boolean = false) {
164
+ if (amount === '0') {
165
+ return { starting: {}, ending: {} };
166
+ }
167
+
158
168
  const tokens = this.token_balance || {};
159
169
  const balance = tokens[currencyId] || '0';
160
170
  if (new BN(balance).lt(new BN(amount))) {
@@ -164,17 +174,21 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
164
174
  const starting = cloneDeep(tokens);
165
175
  // NOTE: new object is required to trick sequelize to update the field
166
176
  const ending = { ...starting, [currencyId]: new BN(balance).sub(new BN(amount)).toString() };
167
- await this.update({ token_balance: ending });
177
+ if (!dryRun) {
178
+ await this.update({ token_balance: ending });
179
+ }
168
180
  return { starting, ending };
169
181
  }
170
182
 
171
- public async increaseTokenBalance(currencyId: string, amount: string) {
183
+ public async increaseTokenBalance(currencyId: string, amount: string, dryRun: boolean = false) {
172
184
  const tokens = this.token_balance || {};
173
185
  const balance = tokens[currencyId] || '0';
174
186
  const starting = cloneDeep(tokens);
175
187
  // NOTE: new object is required to trick sequelize to update the field
176
188
  const ending = { ...starting, [currencyId]: new BN(balance).add(new BN(amount)).toString() };
177
- await this.update({ token_balance: ending });
189
+ if (!dryRun) {
190
+ await this.update({ token_balance: ending });
191
+ }
178
192
  return { starting, ending };
179
193
  }
180
194
 
@@ -64,13 +64,14 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
64
64
  declare subtotal_excluding_tax: string;
65
65
  declare tax: string;
66
66
  declare total: string;
67
- declare amount_due: string;
67
+
68
+ declare amount_due: string; // total - amount_paid
68
69
  declare amount_paid: string;
69
- declare amount_remaining: string;
70
+ declare amount_remaining: string; // amount_due - amount_paid
70
71
  declare amount_shipping: string;
71
72
 
72
- declare starting_balance: string;
73
- declare ending_balance: string;
73
+ declare starting_balance: string; // usd credit
74
+ declare ending_balance: string; // usd credit
74
75
  declare starting_token_balance?: Record<string, string>; // token balances
75
76
  declare ending_token_balance?: Record<string, string>; // token balances
76
77
 
package/blocklet.yml CHANGED
@@ -14,7 +14,7 @@ repository:
14
14
  type: git
15
15
  url: git+https://github.com/blocklet/payment-kit.git
16
16
  specVersion: 1.2.8
17
- version: 1.13.99
17
+ version: 1.13.100
18
18
  logo: logo.png
19
19
  files:
20
20
  - dist
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.13.99",
3
+ "version": "1.13.100",
4
4
  "scripts": {
5
5
  "dev": "COMPONENT_STORE_URL=https://test.store.blocklet.dev blocklet dev",
6
6
  "eject": "vite eject",
@@ -110,7 +110,7 @@
110
110
  "@abtnode/types": "1.16.21",
111
111
  "@arcblock/eslint-config": "^0.2.4",
112
112
  "@arcblock/eslint-config-ts": "^0.2.4",
113
- "@did-pay/types": "1.13.99",
113
+ "@did-pay/types": "1.13.100",
114
114
  "@types/cookie-parser": "^1.4.6",
115
115
  "@types/cors": "^2.8.17",
116
116
  "@types/dotenv-flow": "^3.3.3",
@@ -149,5 +149,5 @@
149
149
  "parser": "typescript"
150
150
  }
151
151
  },
152
- "gitHead": "abb8f6d451350b07f5755fe4d599e8a438b3cfe1"
152
+ "gitHead": "d2334fda5fe27f35f3a7d89a1de0251972f20613"
153
153
  }
@@ -54,7 +54,7 @@ export default function CustomerSubscriptionUpdate() {
54
54
  loading: false,
55
55
  priceId: '',
56
56
  total: '',
57
- credit: '',
57
+ remaining: '',
58
58
  setup: null,
59
59
  prorations: [],
60
60
  items: [],
@@ -86,7 +86,7 @@ export default function CustomerSubscriptionUpdate() {
86
86
 
87
87
  const deleted = data.subscription.items.find((si) => data.table.items.some((ti) => ti.price_id === si.price_id));
88
88
  if (deleted!.price_id === priceId) {
89
- setState({ priceId: '', total: '', credit: '', setup: null, prorations: [], items: [] });
89
+ setState({ priceId: '', total: '', remaining: '', setup: null, prorations: [], items: [] });
90
90
  return;
91
91
  }
92
92
 
@@ -108,7 +108,7 @@ export default function CustomerSubscriptionUpdate() {
108
108
  setState({ priceId, ...result });
109
109
  } catch (err) {
110
110
  Toast.error(formatError(err));
111
- setState({ priceId: '', total: '', credit: '', setup: null, prorations: [], items: [] });
111
+ setState({ priceId: '', total: '', remaining: '', setup: null, prorations: [], items: [] });
112
112
  }
113
113
  };
114
114
 
@@ -249,7 +249,7 @@ export default function CustomerSubscriptionUpdate() {
249
249
  {t('customer.upgrade.due')}
250
250
  </Typography>
251
251
  <Typography component="p" style={{ fontWeight: 'bold' }}>
252
- {fromUnitToToken(state.total, data.subscription.paymentCurrency.decimal)}{' '}
252
+ {fromUnitToToken(state.remaining, data.subscription.paymentCurrency.decimal)}{' '}
253
253
  {data.subscription.paymentCurrency.symbol}
254
254
  </Typography>
255
255
  </Stack>