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.
- package/api/src/{schedule → crons}/base.ts +1 -1
- package/api/src/index.ts +7 -7
- package/api/src/integrations/stripe/handlers/customer.ts +24 -0
- package/api/src/integrations/stripe/handlers/index.ts +4 -0
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -1
- package/api/src/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/audit.ts +34 -28
- package/api/src/libs/payment.ts +26 -0
- package/api/src/libs/queue/index.ts +18 -1
- package/api/src/libs/queue/store.ts +6 -5
- package/api/src/libs/session.ts +13 -12
- package/api/src/libs/subscription.ts +26 -0
- package/api/src/libs/util.ts +5 -1
- package/api/src/{jobs → queues}/checkout-session.ts +11 -0
- package/api/src/{jobs → queues}/invoice.ts +15 -6
- package/api/src/{jobs → queues}/payment.ts +182 -30
- package/api/src/{jobs → queues}/subscription.ts +36 -104
- package/api/src/{jobs → queues}/webhook.ts +2 -0
- package/api/src/routes/checkout-sessions.ts +68 -19
- package/api/src/routes/connect/collect.ts +2 -2
- package/api/src/routes/connect/pay.ts +1 -1
- package/api/src/routes/connect/setup.ts +2 -2
- package/api/src/routes/connect/shared.ts +94 -45
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/pricing-table.ts +2 -0
- package/api/src/routes/subscription-items.ts +1 -1
- package/api/src/routes/subscriptions.ts +434 -13
- package/api/src/store/migrate.ts +0 -1
- package/api/src/store/migrations/20231204-subupdate.ts +50 -0
- package/api/src/store/models/checkout-session.ts +4 -0
- package/api/src/store/models/customer.ts +52 -15
- package/api/src/store/models/invoice-item.ts +6 -1
- package/api/src/store/models/invoice.ts +41 -22
- package/api/src/store/models/payment-intent.ts +4 -0
- package/api/src/store/models/setup-intent.ts +4 -0
- package/api/src/store/models/subscription-item.ts +0 -4
- package/api/src/store/models/subscription.ts +77 -44
- package/api/src/store/models/types.ts +1 -0
- package/api/src/store/sequelize.ts +6 -0
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/jest.config.js +14 -0
- package/package.json +24 -19
- package/src/components/blockchain/tx.tsx +20 -11
- package/src/components/checkout/form/index.tsx +1 -1
- package/src/components/invoice/table.tsx +58 -19
- package/src/components/layout/admin.tsx +17 -5
- package/src/components/portal/invoice/list.tsx +12 -8
- package/src/components/portal/subscription/list.tsx +114 -77
- package/src/components/subscription/status.tsx +21 -19
- package/src/global.css +4 -0
- package/src/locales/en.tsx +14 -1
- package/src/locales/zh.tsx +14 -0
- package/src/pages/admin/customers/customers/detail.tsx +47 -3
- package/src/pages/admin/overview.tsx +21 -1
- package/src/pages/admin/payments/intents/detail.tsx +12 -3
- package/src/pages/customer/invoice.tsx +15 -1
- package/src/pages/customer/subscription/index.tsx +9 -2
- package/tests/api/libs/subscription.spec.ts +45 -0
- /package/api/src/{schedule → crons}/index.ts +0 -0
- /package/api/src/{schedule → crons}/interface/diff.ts +0 -0
- /package/api/src/{schedule → crons}/subscription-trail-will-end.ts +0 -0
- /package/api/src/{schedule → crons}/subscription-will-renew.ts +0 -0
- /package/api/src/{jobs → queues}/event.ts +0 -0
- /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
|
|
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 {
|
|
11
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
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
|
});
|
package/api/src/store/migrate.ts
CHANGED
|
@@ -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(
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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:
|
|
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
|
|