payment-kit 1.13.73 → 1.13.74

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 (65) hide show
  1. package/api/src/{schedule → crons}/base.ts +1 -1
  2. package/api/src/index.ts +7 -7
  3. package/api/src/integrations/stripe/handlers/customer.ts +24 -0
  4. package/api/src/integrations/stripe/handlers/index.ts +4 -0
  5. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -1
  6. package/api/src/integrations/stripe/resource.ts +1 -1
  7. package/api/src/libs/audit.ts +34 -28
  8. package/api/src/libs/payment.ts +26 -0
  9. package/api/src/libs/queue/index.ts +18 -1
  10. package/api/src/libs/queue/store.ts +6 -5
  11. package/api/src/libs/session.ts +13 -12
  12. package/api/src/libs/subscription.ts +26 -0
  13. package/api/src/libs/util.ts +5 -1
  14. package/api/src/{jobs → queues}/checkout-session.ts +11 -0
  15. package/api/src/{jobs → queues}/invoice.ts +15 -6
  16. package/api/src/{jobs → queues}/payment.ts +182 -30
  17. package/api/src/{jobs → queues}/subscription.ts +36 -104
  18. package/api/src/{jobs → queues}/webhook.ts +2 -0
  19. package/api/src/routes/checkout-sessions.ts +68 -19
  20. package/api/src/routes/connect/collect.ts +2 -2
  21. package/api/src/routes/connect/pay.ts +1 -1
  22. package/api/src/routes/connect/setup.ts +2 -2
  23. package/api/src/routes/connect/shared.ts +94 -45
  24. package/api/src/routes/connect/subscribe.ts +3 -3
  25. package/api/src/routes/pricing-table.ts +2 -0
  26. package/api/src/routes/subscription-items.ts +1 -1
  27. package/api/src/routes/subscriptions.ts +434 -13
  28. package/api/src/store/migrate.ts +0 -1
  29. package/api/src/store/migrations/20231204-subupdate.ts +50 -0
  30. package/api/src/store/models/checkout-session.ts +4 -0
  31. package/api/src/store/models/customer.ts +52 -15
  32. package/api/src/store/models/invoice-item.ts +6 -1
  33. package/api/src/store/models/invoice.ts +41 -22
  34. package/api/src/store/models/payment-intent.ts +4 -0
  35. package/api/src/store/models/setup-intent.ts +4 -0
  36. package/api/src/store/models/subscription-item.ts +0 -4
  37. package/api/src/store/models/subscription.ts +77 -44
  38. package/api/src/store/models/types.ts +1 -0
  39. package/api/src/store/sequelize.ts +6 -0
  40. package/api/third.d.ts +2 -0
  41. package/blocklet.yml +1 -1
  42. package/jest.config.js +14 -0
  43. package/package.json +24 -19
  44. package/src/components/blockchain/tx.tsx +20 -11
  45. package/src/components/checkout/form/index.tsx +1 -1
  46. package/src/components/invoice/table.tsx +58 -19
  47. package/src/components/layout/admin.tsx +17 -5
  48. package/src/components/portal/invoice/list.tsx +12 -8
  49. package/src/components/portal/subscription/list.tsx +114 -77
  50. package/src/components/subscription/status.tsx +21 -19
  51. package/src/global.css +4 -0
  52. package/src/locales/en.tsx +14 -1
  53. package/src/locales/zh.tsx +14 -0
  54. package/src/pages/admin/customers/customers/detail.tsx +47 -3
  55. package/src/pages/admin/overview.tsx +21 -1
  56. package/src/pages/admin/payments/intents/detail.tsx +12 -3
  57. package/src/pages/customer/invoice.tsx +15 -1
  58. package/src/pages/customer/subscription/index.tsx +9 -2
  59. package/tests/api/libs/subscription.spec.ts +45 -0
  60. /package/api/src/{schedule → crons}/index.ts +0 -0
  61. /package/api/src/{schedule → crons}/interface/diff.ts +0 -0
  62. /package/api/src/{schedule → crons}/subscription-trail-will-end.ts +0 -0
  63. /package/api/src/{schedule → crons}/subscription-will-renew.ts +0 -0
  64. /package/api/src/{jobs → queues}/event.ts +0 -0
  65. /package/api/src/{jobs → queues}/notification.ts +0 -0
@@ -1,21 +1,37 @@
1
+ /* eslint-disable no-await-in-loop */
1
2
  import { isValid } from '@arcblock/did';
3
+ import { BN } from '@ocap/util';
2
4
  import { Router } from 'express';
3
5
  import Joi from 'joi';
4
- import type { WhereOptions } from 'sequelize';
6
+ import pick from 'lodash/pick';
7
+ import uniq from 'lodash/uniq';
8
+ import { Transaction, WhereOptions } from 'sequelize';
5
9
 
6
- import { subscriptionQueue } from '../jobs/subscription';
7
10
  import dayjs from '../libs/dayjs';
8
11
  import logger from '../libs/logger';
9
12
  import { authenticate } from '../libs/security';
10
- import { expandLineItems } from '../libs/session';
11
- import { formatMetadata } from '../libs/util';
13
+ import {
14
+ expandLineItems,
15
+ getPriceUintAmountByCurrency,
16
+ getSubscriptionCreateSetup,
17
+ isLineItemAligned,
18
+ } from '../libs/session';
19
+ import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '../libs/util';
20
+ import { invoiceQueue } from '../queues/invoice';
21
+ import { subscriptionQueue } from '../queues/subscription';
12
22
  import { Customer } from '../store/models/customer';
23
+ import { Invoice } from '../store/models/invoice';
24
+ import { InvoiceItem } from '../store/models/invoice-item';
13
25
  import { PaymentCurrency } from '../store/models/payment-currency';
26
+ import { PaymentIntent } from '../store/models/payment-intent';
14
27
  import { PaymentMethod } from '../store/models/payment-method';
15
28
  import { Price } from '../store/models/price';
16
29
  import { Product } from '../store/models/product';
17
- import { Subscription } from '../store/models/subscription';
18
- import { SubscriptionItem } from '../store/models/subscription-item';
30
+ import { Subscription, TSubscription } from '../store/models/subscription';
31
+ import { SubscriptionItem, TSubscriptionItem } from '../store/models/subscription-item';
32
+ import { UsageRecord } from '../store/models/usage-record';
33
+ import { sequelize } from '../store/sequelize';
34
+ import { ensureInvoiceAndItems } from './connect/shared';
19
35
 
20
36
  const router = Router();
21
37
  const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
@@ -30,7 +46,7 @@ const authPortal = authenticate<Subscription>({
30
46
  },
31
47
  });
32
48
 
33
- const updateStripSubscription = async (doc: Subscription, updates: any) => {
49
+ const updateStripeSubscription = async (doc: Subscription, updates: any) => {
34
50
  if (doc.payment_details?.stripe?.subscription_id) {
35
51
  const method = await PaymentMethod.findByPk(doc.default_payment_method_id);
36
52
  if (method && method.type === 'stripe') {
@@ -222,7 +238,7 @@ router.put('/:id/recover', authPortal, async (req, res) => {
222
238
  return res.status(400).json({ error: 'Subscription not recoverable from cancellation' });
223
239
  }
224
240
 
225
- await updateStripSubscription(doc, { cancel_at_period_end: false });
241
+ await updateStripeSubscription(doc, { cancel_at_period_end: false });
226
242
  await doc.update({ cancel_at_period_end: false });
227
243
 
228
244
  // reschedule jobs
@@ -256,7 +272,7 @@ router.put('/:id/pause', auth, async (req, res) => {
256
272
 
257
273
  const timestamp = type === 'custom' ? dayjs(resumesAt).unix() : 0;
258
274
 
259
- await updateStripSubscription(doc, {
275
+ await updateStripeSubscription(doc, {
260
276
  pause_collection: {
261
277
  resumes_at: timestamp || null,
262
278
  behavior: behavior || 'keep_as_draft',
@@ -292,7 +308,7 @@ router.put('/:id/resume', auth, async (req, res) => {
292
308
  return res.status(400).json({ error: 'Subscription not paused' });
293
309
  }
294
310
 
295
- await updateStripSubscription(doc, { pause_collection: null });
311
+ await updateStripeSubscription(doc, { pause_collection: null });
296
312
  await doc.update({ status: 'active', pause_collection: undefined });
297
313
 
298
314
  subscriptionQueue
@@ -303,16 +319,421 @@ router.put('/:id/resume', auth, async (req, res) => {
303
319
  return res.json(doc);
304
320
  });
305
321
 
322
+ const isValidSubscriptionItemChange = (item: TSubscriptionItem & { [key: string]: any }) => {
323
+ if (item.deleted) {
324
+ if (!item.id) {
325
+ throw new Error('You must delete subscription item with id');
326
+ }
327
+ } else if (item.clear_usage) {
328
+ if (!item.id) {
329
+ throw new Error('You must clear usage of subscription item with id');
330
+ }
331
+ if (!item.deleted) {
332
+ throw new Error('You must clear usage of subscription item on delete');
333
+ }
334
+ } else {
335
+ if (!item.price_id) {
336
+ throw new Error('You must add subscription item with price_id');
337
+ }
338
+ if (!item.quantity) {
339
+ throw new Error('You must add subscription item with quantity');
340
+ }
341
+ }
342
+
343
+ return true;
344
+ };
345
+
346
+ // TODO: forward changes to stripe
347
+ const updateSchema = Joi.object<{
348
+ description?: string;
349
+ metadata?: Record<string, any>;
350
+ payment_behavior?: string;
351
+ proration_behavior?: string;
352
+ billing_cycle_anchor?: string;
353
+ items: {
354
+ id?: string;
355
+ deleted?: boolean;
356
+ clear_usage?: boolean;
357
+ price_id?: string;
358
+ quantity?: number;
359
+ }[];
360
+ }>({
361
+ description: Joi.string().min(1).optional(),
362
+ metadata: Joi.any().optional(),
363
+ payment_behavior: Joi.string().allow('allow_incomplete', 'error_if_incomplete', 'pending_if_incomplete').optional(),
364
+ proration_behavior: Joi.string().allow('always_invoice', 'create_prorations', 'none').optional(),
365
+ billing_cycle_anchor: Joi.string().allow('now', 'unchanged').optional(),
366
+ items: Joi.array()
367
+ .items(
368
+ Joi.object({
369
+ id: Joi.string().optional(),
370
+ price_id: Joi.string().optional(),
371
+ quantity: Joi.number().optional(),
372
+ deleted: Joi.boolean().optional(),
373
+ clear_usage: Joi.boolean().optional(),
374
+ })
375
+ )
376
+ .optional()
377
+ .default([]),
378
+ });
379
+ // eslint-disable-next-line consistent-return
306
380
  router.put('/:id', auth, async (req, res) => {
381
+ logger.debug('subscription update request', { subscription: req.params.id, body: req.body });
382
+ try {
383
+ const { error } = updateSchema.validate(req.body);
384
+ if (error) {
385
+ return res.status(400).json({ error: `Subscription update request invalid: ${error.message}` });
386
+ }
387
+
388
+ const subscription = await Subscription.findByPk(req.params.id);
389
+ if (!subscription) {
390
+ return res.status(404).json({ error: 'Subscription not found' });
391
+ }
392
+ if (['trailing', 'active'].includes(subscription.status) === false) {
393
+ return res.status(400).json({ error: 'Subscription can only be updated when in trailing or active mode' });
394
+ }
395
+
396
+ const lastInvoice = await Invoice.findByPk(subscription.latest_invoice_id);
397
+ if (!lastInvoice) {
398
+ throw new Error('Subscription should have latest invoice');
399
+ }
400
+ const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
401
+ if (!paymentCurrency) {
402
+ throw new Error('Subscription should have payment currency');
403
+ }
404
+ const customer = await Customer.findByPk(subscription.customer_id);
405
+ if (!customer) {
406
+ throw new Error('Subscription should have customer');
407
+ }
408
+
409
+ // handle updates
410
+ const updates: Partial<TSubscription> = {};
411
+ if (req.body.metadata) {
412
+ updates.metadata = formatMetadata(req.body.metadata);
413
+ }
414
+ if (req.body.description) {
415
+ updates.description = req.body.description;
416
+ }
417
+ if (req.body.payment_behavior) {
418
+ updates.payment_behavior = req.body.payment_behavior;
419
+ }
420
+ if (req.body.proration_behavior) {
421
+ updates.proration_behavior = req.body.proration_behavior;
422
+ }
423
+
424
+ let invoice: Invoice;
425
+
426
+ await sequelize.transaction({ isolationLevel: Transaction.ISOLATION_LEVELS.READ_UNCOMMITTED }, async () => {
427
+ // handle subscription item changes
428
+ if (Array.isArray(req.body.items) && req.body.items.length > 0) {
429
+ // validate
430
+ req.body.items.every(isValidSubscriptionItemChange);
431
+
432
+ // ensure no duplicate id
433
+ const ids = req.body.items.filter((x: any) => x.id).map((x: any) => x.id);
434
+ if (uniq(ids).length !== ids.length) {
435
+ throw new Error('Subscription item should not have duplicate id');
436
+ }
437
+
438
+ // ensure no duplicate price_id
439
+ const priceIds = req.body.items.filter((x: any) => x.price_id).map((x: any) => x.price_id);
440
+ if (uniq(priceIds).length !== priceIds.length) {
441
+ throw new Error('Subscription item should not have duplicate price_id');
442
+ }
443
+
444
+ const addedItems = await Price.expand(req.body.items.filter((x: any) => x.price_id && !x.id));
445
+ if (addedItems.some((x) => !x.price)) {
446
+ throw new Error('Subscription item should not use non-exist price');
447
+ }
448
+ if (addedItems.some((x) => !x.price.active)) {
449
+ throw new Error('Subscription item should not use archived price');
450
+ }
451
+
452
+ const existingItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
453
+ const deletedItems = req.body.items.filter((x: any) => x.deleted && x.id).map((x: any) => x.id);
454
+ if (existingItems.length - deletedItems.length + addedItems.length === 0) {
455
+ throw new Error('Subscription should have at least one subscription item');
456
+ }
457
+
458
+ // FIXME: following logic should be extracted
459
+ const existingExpanded = await Price.expand(
460
+ existingItems.filter((x) => deletedItems.includes(x.id) === false).map((x) => x.toJSON())
461
+ );
462
+ const newItems: any[] = [...existingExpanded, ...addedItems];
463
+ if (newItems.length > MAX_SUBSCRIPTION_ITEM_COUNT) {
464
+ throw new Error(`Subscription should not have more than ${MAX_SUBSCRIPTION_ITEM_COUNT} items`);
465
+ }
466
+ for (let i = 0; i < newItems.length; i++) {
467
+ const result = isLineItemAligned(newItems, i);
468
+ if (result.currency === false) {
469
+ throw new Error('Subscription item should have same currency');
470
+ }
471
+ if (result.recurring === false) {
472
+ throw new Error('Subscription item should have same recurring');
473
+ }
474
+ }
475
+
476
+ // update subscription items
477
+ for (const item of req.body.items) {
478
+ if (item.deleted) {
479
+ if (item.clear_usage) {
480
+ await UsageRecord.destroy({ where: { subscription_item_id: item.id } });
481
+ logger.info('subscription item usage cleared', { subscription: req.params.id, item: item.id });
482
+ }
483
+ await SubscriptionItem.destroy({ where: { id: item.id } });
484
+ logger.info('subscription item deleted', { subscription: req.params.id, item: item.id });
485
+ } else {
486
+ const exist = existingItems.find((x) => x.id === item.id);
487
+ if (exist) {
488
+ await exist.update(pick(item, ['quantity', 'metadata', 'billing_thresholds']));
489
+ logger.info('subscription item updated', { subscription: req.params.id, item: item.id });
490
+ } else {
491
+ await SubscriptionItem.create({
492
+ ...item,
493
+ livemode: subscription.livemode,
494
+ subscription_id: subscription.id,
495
+ });
496
+ logger.info('subscription item added', { subscription: req.params.id, item: item.id });
497
+ }
498
+ }
499
+ }
500
+
501
+ // update subscription period settings
502
+ // HINT: if we are adding new items, we need to reset the anchor to now
503
+ const setup = getSubscriptionCreateSetup(newItems, paymentCurrency.id, 0);
504
+ if (addedItems.some((x) => x.price.type === 'recurring')) {
505
+ updates.pending_invoice_item_interval = setup.recurring;
506
+ updates.current_period_start = setup.period.start;
507
+ updates.current_period_end = setup.period.end;
508
+ updates.billing_cycle_anchor = setup.cycle.anchor;
509
+ logger.info('subscription updates on reset anchor', { subscription: req.params.id, updates });
510
+ }
511
+
512
+ // handle proration
513
+ const prorationBehavior = updates.proration_behavior || subscription.proration_behavior || 'none';
514
+ if (prorationBehavior === 'create_prorations') {
515
+ // 0. get last invoice, and invoice items, filter invoice items that are in licensed recurring mode
516
+ const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: lastInvoice.id } });
517
+ const invoiceItemsExpanded = await Price.expand(invoiceItems.map((x) => x.toJSON()));
518
+ const prorationItems = invoiceItemsExpanded.filter(
519
+ (x) => x.price.type === 'recurring' && x.price.recurring?.usage_type === 'licensed'
520
+ );
521
+
522
+ // 1. calculate proration amount based on the filtered invoice items
523
+ const now = dayjs().unix();
524
+ const prorationStart = lastInvoice.period_start;
525
+ const prorationEnd = lastInvoice.period_end;
526
+ const prorationRate = Math.ceil(((prorationEnd - now) / (prorationEnd - prorationStart)) * 1000000);
527
+ let totalProrationAmount = new BN(0);
528
+
529
+ // 2. create new invoice: amount according to new subscription items
530
+ // 3. create new invoice items: amount according to new subscription items
531
+ const result = await ensureInvoiceAndItems({
532
+ customer,
533
+ subscription,
534
+ trailing: false,
535
+ metered: false,
536
+ lineItems: newItems,
537
+ props: {
538
+ status: 'draft',
539
+ livemode: subscription.livemode,
540
+ description: 'Subscription update',
541
+ statement_descriptor: lastInvoice.statement_descriptor,
542
+ period_start: setup.period.start,
543
+ period_end: setup.period.end,
544
+ auto_advance: true, // FIXME: this should be calculated dynamically
545
+ billing_reason: 'subscription_update',
546
+ total: setup.amount.setup,
547
+ currency_id: paymentCurrency.id,
548
+ default_payment_method_id: subscription.default_payment_method_id,
549
+ custom_fields: lastInvoice.custom_fields || [],
550
+ footer: lastInvoice.footer || '',
551
+ } as Invoice,
552
+ });
553
+ invoice = result.invoice;
554
+ updates.latest_invoice_id = invoice.id;
555
+ logger.info('subscription update invoice created', { subscription: req.params.id, invoice: invoice.id });
556
+
557
+ // 4. create proration invoice items: amount according to proration amount
558
+ const prorationInvoiceItems = await Promise.all(
559
+ prorationItems.map((x) => {
560
+ const unitAmount = getPriceUintAmountByCurrency(x.price, subscription.currency_id);
561
+ const prorationAmount = new BN(unitAmount)
562
+ .mul(new BN(x.quantity))
563
+ .mul(new BN(prorationRate))
564
+ .div(new BN(1000000))
565
+ .toString();
566
+ logger.info('subscription proration invoice item', { subscription: req.params.id, prorationAmount });
567
+ totalProrationAmount = totalProrationAmount.add(new BN(prorationAmount));
568
+
569
+ return InvoiceItem.create({
570
+ livemode: subscription.livemode,
571
+ amount: `-${prorationAmount}`,
572
+ quantity: x.quantity,
573
+ // @ts-ignore
574
+ description: `Unused time on ${x.price.product.name} after ${dayjs().format('lll')}`,
575
+ period: {
576
+ start: lastInvoice.period_start,
577
+ end: lastInvoice.period_end,
578
+ },
579
+ currency_id: subscription.currency_id,
580
+ customer_id: customer.id,
581
+ price_id: x.price_id,
582
+ invoice_id: invoice.id,
583
+ subscription_id: subscription.id,
584
+ // @ts-ignore
585
+ subscription_item_id: x.subscription_item_id,
586
+ discountable: false,
587
+ discounts: [],
588
+ discount_amounts: [],
589
+ proration: true,
590
+ proration_details: {
591
+ credited_items: {
592
+ invoice_id: lastInvoice.id,
593
+ // @ts-ignore
594
+ invoice_line_items: [x.id],
595
+ },
596
+ },
597
+ metadata: {},
598
+ });
599
+ })
600
+ );
601
+ logger.info('subscription proration invoice items created', {
602
+ subscription: req.params.id,
603
+ items: prorationInvoiceItems.map((x) => x.id),
604
+ });
605
+
606
+ // 5. adjust invoice total && update customer token balance
607
+ const invoiceTotal = new BN(setup.amount.setup);
608
+ if (invoiceTotal.gt(totalProrationAmount)) {
609
+ const invoiceAmount = invoiceTotal.sub(totalProrationAmount).toString();
610
+ await invoice.update({
611
+ status: 'open',
612
+ subtotal: invoiceAmount,
613
+ subtotal_excluding_tax: invoiceAmount,
614
+ total: invoiceAmount,
615
+ amount_due: invoiceAmount,
616
+ amount_remaining: invoiceAmount,
617
+ });
618
+ logger.info('subscription proration used on invoice', {
619
+ subscription: req.params.id,
620
+ prorationStart,
621
+ prorationEnd,
622
+ prorationRate,
623
+ totalProrationAmount,
624
+ invoiceAmount,
625
+ });
626
+ } else {
627
+ const customerCredit = totalProrationAmount.sub(invoiceTotal).toString();
628
+ const balance = await customer.increaseTokenBalance(paymentCurrency.id, customerCredit);
629
+ await invoice.update({
630
+ status: 'open',
631
+ subtotal: '0',
632
+ subtotal_excluding_tax: '0',
633
+ total: '0',
634
+ amount_due: '0',
635
+ amount_remaining: '0',
636
+ starting_token_balance: balance.starting,
637
+ ending_token_balance: balance.ending,
638
+ });
639
+ logger.info('subscription proration credit to customer', {
640
+ subscription: req.params.id,
641
+ prorationStart,
642
+ prorationEnd,
643
+ prorationRate,
644
+ totalProrationAmount,
645
+ customerCredit,
646
+ });
647
+ }
648
+
649
+ // 6. process the invoice as usual: push into queue
650
+ logger.info('subscription update invoice processing', { subscription: subscription.id, invoice: invoice.id });
651
+ await invoiceQueue.pushAndWait({
652
+ id: invoice.id,
653
+ job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
654
+ });
655
+ logger.info('subscription update invoice processed', { subscription: subscription.id, invoice: invoice.id });
656
+
657
+ // client side
658
+ // 0. get invoice payment mode: credit/manual
659
+ // 1. wait for invoice.payment_succeeded event if payment mode is credit
660
+ // 2. popup to pay invoice if payment mode is manual
661
+ }
662
+ } else if (req.body.billing_cycle_anchor === 'now') {
663
+ // FIXME: handle billing cycle anchor change without any item change
664
+ }
665
+
666
+ await subscription.update(updates);
667
+
668
+ return res.json({ subscription, invoice });
669
+ });
670
+ } catch (err) {
671
+ console.error(err);
672
+ return res.status(500).json({ code: err.code, error: err.message });
673
+ }
674
+ });
675
+
676
+ // FIXME: this should be removed in future
677
+ // Clean up subscriptions that have invalid invoices and payments
678
+ router.delete('/cleanup', auth, async (_, res) => {
679
+ const subscriptions = await Subscription.findAll();
680
+ const canceled: string[] = [];
681
+
682
+ await Promise.all(
683
+ subscriptions.map(async (x) => {
684
+ const invoices = await Invoice.findAll({ where: { subscription_id: x.id, status: 'uncollectible' } });
685
+ if (invoices.length <= 1) {
686
+ return;
687
+ }
688
+
689
+ canceled.push(x.id);
690
+
691
+ const now = dayjs().unix();
692
+ if (x.status === 'active' || x.status === 'trialing') {
693
+ await x.update({
694
+ status: 'canceled',
695
+ canceled_at: now,
696
+ cancelation_details: {
697
+ reason: 'too_many_uncollectible_invoices',
698
+ feedback: 'other',
699
+ comment: `Too many uncollectible invoices: ${invoices.length}`,
700
+ },
701
+ });
702
+ }
703
+ await Invoice.update({ status: 'void' }, { where: { id: invoices.map((i) => i.id) } });
704
+ await PaymentIntent.update(
705
+ { status: 'canceled', canceled_at: now, cancellation_reason: 'abandoned' },
706
+ { where: { invoice_id: invoices.map((i) => i.id) } }
707
+ );
708
+ logger.warn('subscription canceled since too much uncollectible invoices', {
709
+ subscription: x.id,
710
+ count: invoices.length,
711
+ });
712
+ })
713
+ );
714
+
715
+ res.json(canceled);
716
+ });
717
+
718
+ // Delete subscription and all related data
719
+ router.delete('/:id', auth, async (req, res) => {
720
+ if (process.env.BLOCKLET_MODE === 'production') {
721
+ return res.status(404).json({ error: 'Subscription delete not allowed in production' });
722
+ }
723
+
307
724
  const doc = await Subscription.findByPk(req.params.id);
308
725
 
309
726
  if (!doc) {
310
727
  return res.status(404).json({ error: 'Subscription not found' });
311
728
  }
312
729
 
313
- if (req.body.metadata) {
314
- await doc.update({ metadata: formatMetadata(req.body.metadata) });
315
- }
730
+ await InvoiceItem.destroy({ where: { subscription_id: doc.id } });
731
+ await Invoice.destroy({ where: { subscription_id: doc.id } });
732
+ const items = await SubscriptionItem.findAll({ where: { subscription_id: doc.id }, attributes: ['id'] });
733
+ await UsageRecord.destroy({ where: { subscription_item_id: items.map((x) => x.id) } });
734
+ await SubscriptionItem.destroy({ where: { subscription_id: doc.id } });
735
+ await doc.destroy();
736
+ logger.info('subscription deleted', { subscription: req.params.id });
316
737
 
317
738
  return res.json(doc);
318
739
  });
@@ -19,7 +19,6 @@ export default function migrate() {
19
19
 
20
20
  type ColumnChanges = Record<string, { name: string; field: any }[]>;
21
21
  export async function safeApplyColumnChanges(context: QueryInterface, changes: ColumnChanges) {
22
- console.info('safeApplyColumnChanges', changes);
23
22
  for (const [table, columns] of Object.entries(changes)) {
24
23
  const schema = await context.describeTable(table);
25
24
  for (const { name, field } of columns) {
@@ -0,0 +1,50 @@
1
+ /* eslint-disable no-await-in-loop */
2
+ import { DataTypes } from 'sequelize';
3
+
4
+ import { Migration, safeApplyColumnChanges } from '../migrate';
5
+
6
+ export const up: Migration = async ({ context }) => {
7
+ await safeApplyColumnChanges(context, {
8
+ subscriptions: [
9
+ {
10
+ name: 'proration_behavior',
11
+ field: { type: DataTypes.ENUM('always_invoice', 'create_prorations', 'none'), defaultValue: 'none' },
12
+ },
13
+ {
14
+ name: 'payment_behavior',
15
+ field: {
16
+ type: DataTypes.ENUM(
17
+ 'default_incomplete',
18
+ 'allow_incomplete',
19
+ 'error_if_incomplete',
20
+ 'pending_if_incomplete'
21
+ ),
22
+ defaultValue: 'default_incomplete',
23
+ },
24
+ },
25
+ ],
26
+ customers: [
27
+ {
28
+ name: 'token_balance',
29
+ // NOTE: we must use stringified empty object as default value here
30
+ field: { type: DataTypes.JSON, defaultValue: '{}' },
31
+ },
32
+ ],
33
+ invoices: [
34
+ {
35
+ name: 'starting_token_balance',
36
+ field: { type: DataTypes.JSON, defaultValue: '{}' },
37
+ },
38
+ {
39
+ name: 'ending_token_balance',
40
+ field: { type: DataTypes.JSON, defaultValue: '{}' },
41
+ },
42
+ ],
43
+ });
44
+ };
45
+
46
+ export const down: Migration = async ({ context }) => {
47
+ await context.removeColumn('subscriptions', 'proration_behavior');
48
+ await context.removeColumn('subscriptions', 'payment_behavior');
49
+ await context.removeColumn('customers', 'token_balance');
50
+ };
@@ -465,6 +465,10 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
465
465
  ...options,
466
466
  });
467
467
  }
468
+
469
+ public isImmutable() {
470
+ return ['complete', 'expired'].includes(this.status);
471
+ }
468
472
  }
469
473
 
470
474
  export type TCheckoutSession = InferAttributes<CheckoutSession>;
@@ -1,4 +1,6 @@
1
1
  /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import { BN } from '@ocap/util';
3
+ import cloneDeep from 'lodash/cloneDeep';
2
4
  import padStart from 'lodash/padStart';
3
5
  import {
4
6
  CreationOptional,
@@ -11,6 +13,7 @@ import {
11
13
  } from 'sequelize';
12
14
 
13
15
  import { createEvent } from '../../libs/audit';
16
+ import CustomError from '../../libs/error';
14
17
  import { createIdGenerator } from '../../libs/util';
15
18
  import type { CustomerAddress, CustomerShipping } from './types';
16
19
 
@@ -30,7 +33,8 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
30
33
  declare address?: CustomerAddress;
31
34
  declare shipping?: CustomerShipping;
32
35
 
33
- declare balance?: string;
36
+ declare balance?: string; // synced with stripe
37
+ declare token_balance?: Record<string, string>; // token balances
34
38
  declare currency_id?: string;
35
39
  declare delinquent: boolean;
36
40
  declare discount_id?: string;
@@ -150,22 +154,55 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
150
154
  return `${this.invoice_prefix}-${padStart(sequence.toString(), 4, '0')}`;
151
155
  }
152
156
 
157
+ public async decreaseTokenBalance(currencyId: string, amount: string) {
158
+ const tokens = this.token_balance || {};
159
+ const balance = tokens[currencyId] || '0';
160
+ if (new BN(balance).lt(new BN(amount))) {
161
+ throw new CustomError('INSUFFICIENT_BALANCE', 'Insufficient customer balance');
162
+ }
163
+
164
+ const starting = cloneDeep(tokens);
165
+ // NOTE: new object is required to trick sequelize to update the field
166
+ const ending = { ...starting, [currencyId]: new BN(balance).sub(new BN(amount)).toString() };
167
+ await this.update({ token_balance: ending });
168
+ return { starting, ending };
169
+ }
170
+
171
+ public async increaseTokenBalance(currencyId: string, amount: string) {
172
+ const tokens = this.token_balance || {};
173
+ const balance = tokens[currencyId] || '0';
174
+ const starting = cloneDeep(tokens);
175
+ // NOTE: new object is required to trick sequelize to update the field
176
+ const ending = { ...starting, [currencyId]: new BN(balance).add(new BN(amount)).toString() };
177
+ await this.update({ token_balance: ending });
178
+ return { starting, ending };
179
+ }
180
+
153
181
  public static initialize(sequelize: any) {
154
- this.init(Customer.GENESIS_ATTRIBUTES, {
155
- sequelize,
156
- modelName: 'Customer',
157
- tableName: 'customers',
158
- createdAt: 'created_at',
159
- updatedAt: 'updated_at',
160
- hooks: {
161
- afterCreate: (model: Customer, options) =>
162
- createEvent('Customer', 'customer.created', model, options).catch(console.error),
163
- afterUpdate: (model: Customer, options) =>
164
- createEvent('Customer', 'customer.updated', model, options).catch(console.error),
165
- afterDestroy: (model: Customer, options) =>
166
- createEvent('Customer', 'customer.deleted', model, options).catch(console.error),
182
+ this.init(
183
+ {
184
+ ...Customer.GENESIS_ATTRIBUTES,
185
+ token_balance: {
186
+ type: DataTypes.JSON,
187
+ defaultValue: {},
188
+ },
167
189
  },
168
- });
190
+ {
191
+ sequelize,
192
+ modelName: 'Customer',
193
+ tableName: 'customers',
194
+ createdAt: 'created_at',
195
+ updatedAt: 'updated_at',
196
+ hooks: {
197
+ afterCreate: (model: Customer, options) =>
198
+ createEvent('Customer', 'customer.created', model, options).catch(console.error),
199
+ afterUpdate: (model: Customer, options) =>
200
+ createEvent('Customer', 'customer.updated', model, options).catch(console.error),
201
+ afterDestroy: (model: Customer, options) =>
202
+ createEvent('Customer', 'customer.deleted', model, options).catch(console.error),
203
+ },
204
+ }
205
+ );
169
206
  }
170
207
 
171
208
  public static associate(models: any) {
@@ -38,7 +38,12 @@ export class InvoiceItem extends Model<InferAttributes<InvoiceItem>, InferCreati
38
38
  declare discounts: string[];
39
39
 
40
40
  declare proration: boolean;
41
- declare proration_details: any;
41
+ declare proration_details: {
42
+ credited_items?: {
43
+ invoice_id: string;
44
+ invoice_line_items: string[];
45
+ };
46
+ };
42
47
 
43
48
  declare metadata: Record<string, any>;
44
49