payment-kit 1.16.17 → 1.16.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/crons/index.ts +1 -1
- package/api/src/hooks/pre-start.ts +2 -0
- package/api/src/index.ts +2 -0
- package/api/src/integrations/arcblock/stake.ts +7 -1
- package/api/src/integrations/stripe/resource.ts +1 -1
- package/api/src/libs/env.ts +12 -0
- package/api/src/libs/event.ts +8 -0
- package/api/src/libs/invoice.ts +585 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -2
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +2 -2
- package/api/src/libs/notification/template/subscription-will-renew.ts +6 -2
- package/api/src/libs/notification/template/subscription.overdraft-protection.exhausted.ts +139 -0
- package/api/src/libs/overdraft-protection.ts +86 -0
- package/api/src/libs/payment.ts +1 -65
- package/api/src/libs/queue/index.ts +0 -1
- package/api/src/libs/subscription.ts +532 -2
- package/api/src/libs/util.ts +4 -0
- package/api/src/locales/en.ts +5 -0
- package/api/src/locales/zh.ts +5 -0
- package/api/src/queues/event.ts +3 -2
- package/api/src/queues/invoice.ts +28 -3
- package/api/src/queues/notification.ts +25 -3
- package/api/src/queues/payment.ts +154 -3
- package/api/src/queues/refund.ts +2 -2
- package/api/src/queues/subscription.ts +215 -4
- package/api/src/queues/webhook.ts +1 -0
- package/api/src/routes/connect/change-payment.ts +1 -1
- package/api/src/routes/connect/change-plan.ts +1 -1
- package/api/src/routes/connect/overdraft-protection.ts +120 -0
- package/api/src/routes/connect/recharge.ts +2 -1
- package/api/src/routes/connect/setup.ts +1 -1
- package/api/src/routes/connect/shared.ts +117 -350
- package/api/src/routes/connect/subscribe.ts +1 -1
- package/api/src/routes/customers.ts +2 -2
- package/api/src/routes/invoices.ts +9 -4
- package/api/src/routes/subscriptions.ts +172 -2
- package/api/src/store/migrate.ts +9 -10
- package/api/src/store/migrations/20240905-index.ts +95 -60
- package/api/src/store/migrations/20241203-overdraft-protection.ts +25 -0
- package/api/src/store/migrations/20241216-update-overdraft-protection.ts +30 -0
- package/api/src/store/models/customer.ts +2 -2
- package/api/src/store/models/invoice.ts +7 -0
- package/api/src/store/models/lock.ts +7 -0
- package/api/src/store/models/subscription.ts +15 -0
- package/api/src/store/sequelize.ts +6 -1
- package/blocklet.yml +1 -1
- package/package.json +23 -23
- package/src/components/customer/overdraft-protection.tsx +367 -0
- package/src/components/event/list.tsx +3 -4
- package/src/components/product/edit-price.tsx +2 -2
- package/src/components/subscription/actions/cancel.tsx +3 -0
- package/src/components/subscription/portal/actions.tsx +324 -77
- package/src/components/uploader.tsx +31 -26
- package/src/env.d.ts +1 -0
- package/src/hooks/subscription.ts +30 -0
- package/src/libs/env.ts +4 -0
- package/src/locales/en.tsx +41 -0
- package/src/locales/zh.tsx +37 -0
- package/src/pages/admin/billing/invoices/detail.tsx +16 -15
- package/src/pages/admin/index.tsx +3 -1
- package/src/pages/admin/products/prices/detail.tsx +1 -1
- package/src/pages/admin/products/products/detail.tsx +6 -2
- package/src/pages/customer/index.tsx +7 -2
- package/src/pages/customer/invoice/detail.tsx +29 -5
- package/src/pages/customer/invoice/past-due.tsx +18 -4
- package/src/pages/customer/recharge.tsx +2 -4
- package/src/pages/customer/subscription/change-payment.tsx +7 -1
- package/src/pages/customer/subscription/detail.tsx +69 -51
- package/tsconfig.json +0 -5
- package/api/tests/libs/payment.spec.ts +0 -168
package/api/src/libs/invoice.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { component } from '@blocklet/sdk';
|
|
|
2
2
|
import type { LiteralUnion } from 'type-fest';
|
|
3
3
|
import { withQuery } from 'ufo';
|
|
4
4
|
|
|
5
|
-
import { fromUnitToToken } from '@ocap/util';
|
|
5
|
+
import { BN, fromUnitToToken } from '@ocap/util';
|
|
6
6
|
import { Op } from 'sequelize';
|
|
7
7
|
import { cloneDeep, pick } from 'lodash';
|
|
8
8
|
import {
|
|
@@ -10,18 +10,32 @@ import {
|
|
|
10
10
|
Invoice,
|
|
11
11
|
InvoiceItem,
|
|
12
12
|
PaymentCurrency,
|
|
13
|
+
PaymentIntent,
|
|
13
14
|
PaymentMethod,
|
|
15
|
+
PaymentSettings,
|
|
14
16
|
Price,
|
|
15
17
|
Product,
|
|
16
18
|
Refund,
|
|
17
19
|
SetupIntent,
|
|
20
|
+
SimpleCustomField,
|
|
18
21
|
Subscription,
|
|
19
22
|
SubscriptionItem,
|
|
23
|
+
TInvoice,
|
|
24
|
+
TLineItemExpanded,
|
|
20
25
|
UsageRecord,
|
|
21
26
|
} from '../store/models';
|
|
22
27
|
import { getConnectQueryParam } from './util';
|
|
23
|
-
import { expandLineItems } from './session';
|
|
24
|
-
import
|
|
28
|
+
import { expandLineItems, getPriceUintAmountByCurrency } from './session';
|
|
29
|
+
import dayjs from './dayjs';
|
|
30
|
+
import {
|
|
31
|
+
getSubscriptionCycleAmount,
|
|
32
|
+
getSubscriptionCycleSetup,
|
|
33
|
+
getSubscriptionItemPrice,
|
|
34
|
+
getSubscriptionStakeAmountSetup,
|
|
35
|
+
isSubscriptionOverdraftProtectionEnabled,
|
|
36
|
+
} from './subscription';
|
|
37
|
+
import logger from './logger';
|
|
38
|
+
import { ensureOverdraftProtectionPrice } from './overdraft-protection';
|
|
25
39
|
|
|
26
40
|
export function getCustomerInvoicePageUrl({
|
|
27
41
|
invoiceId,
|
|
@@ -345,3 +359,571 @@ export async function getStakingInvoices(subscription: Subscription): Promise<In
|
|
|
345
359
|
);
|
|
346
360
|
return invoices;
|
|
347
361
|
}
|
|
362
|
+
|
|
363
|
+
type BaseInvoiceProps = {
|
|
364
|
+
customer: Customer;
|
|
365
|
+
subscription?: Subscription;
|
|
366
|
+
currency_id: string;
|
|
367
|
+
livemode: boolean;
|
|
368
|
+
period_start: number;
|
|
369
|
+
period_end: number;
|
|
370
|
+
status?: string;
|
|
371
|
+
billing_reason: string;
|
|
372
|
+
description: string;
|
|
373
|
+
statement_descriptor?: string;
|
|
374
|
+
total: string;
|
|
375
|
+
amount_due?: string;
|
|
376
|
+
amount_paid?: string;
|
|
377
|
+
amount_remaining?: string;
|
|
378
|
+
default_payment_method_id: string;
|
|
379
|
+
payment_intent_id?: string;
|
|
380
|
+
checkout_session_id?: string;
|
|
381
|
+
metadata?: Record<string, any>;
|
|
382
|
+
items?: Array<{
|
|
383
|
+
price_id: string;
|
|
384
|
+
amount: string;
|
|
385
|
+
quantity: number;
|
|
386
|
+
description: string;
|
|
387
|
+
period?: {
|
|
388
|
+
start: number;
|
|
389
|
+
end: number;
|
|
390
|
+
};
|
|
391
|
+
metadata?: Record<string, any>;
|
|
392
|
+
subscription_item_id?: string;
|
|
393
|
+
}>;
|
|
394
|
+
auto_advance?: boolean;
|
|
395
|
+
paid?: boolean;
|
|
396
|
+
paid_out_of_band?: boolean;
|
|
397
|
+
payment_settings?: PaymentSettings;
|
|
398
|
+
footer?: string;
|
|
399
|
+
custom_fields?: SimpleCustomField[];
|
|
400
|
+
starting_token_balance?: Record<string, string>;
|
|
401
|
+
ending_token_balance?: Record<string, string>;
|
|
402
|
+
subtotal_excluding_tax?: string;
|
|
403
|
+
collection_method?: 'charge_automatically' | 'send_invoice';
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
|
|
407
|
+
invoice: Invoice;
|
|
408
|
+
items: InvoiceItem[];
|
|
409
|
+
}> {
|
|
410
|
+
const {
|
|
411
|
+
customer,
|
|
412
|
+
subscription,
|
|
413
|
+
currency_id: currencyId,
|
|
414
|
+
livemode,
|
|
415
|
+
period_start: periodStart,
|
|
416
|
+
period_end: periodEnd,
|
|
417
|
+
status = 'open',
|
|
418
|
+
billing_reason: billingReason,
|
|
419
|
+
description,
|
|
420
|
+
statement_descriptor: statementDescriptor = '',
|
|
421
|
+
total,
|
|
422
|
+
amount_due: amountDue = total,
|
|
423
|
+
amount_paid: amountPaid = '0',
|
|
424
|
+
amount_remaining: amountRemaining = total,
|
|
425
|
+
default_payment_method_id: paymentMethodId,
|
|
426
|
+
payment_intent_id: paymentIntentId = '',
|
|
427
|
+
checkout_session_id: checkoutSessionId = '',
|
|
428
|
+
items: itemsData,
|
|
429
|
+
metadata = {},
|
|
430
|
+
paid = false,
|
|
431
|
+
auto_advance: autoAdvance = true,
|
|
432
|
+
paid_out_of_band: paidOutOfBand = false,
|
|
433
|
+
payment_settings: paymentSettings,
|
|
434
|
+
...extraProps
|
|
435
|
+
} = props;
|
|
436
|
+
|
|
437
|
+
// create invoice
|
|
438
|
+
const invoice = await Invoice.create({
|
|
439
|
+
amount_shipping: '0',
|
|
440
|
+
starting_balance: '0',
|
|
441
|
+
ending_balance: '0',
|
|
442
|
+
subtotal_excluding_tax: '0',
|
|
443
|
+
starting_token_balance: {},
|
|
444
|
+
ending_token_balance: {},
|
|
445
|
+
attempt_count: 0,
|
|
446
|
+
attempted: false,
|
|
447
|
+
tax: '0',
|
|
448
|
+
discounts: [],
|
|
449
|
+
total_discount_amounts: [],
|
|
450
|
+
collection_method: 'charge_automatically',
|
|
451
|
+
...extraProps,
|
|
452
|
+
livemode,
|
|
453
|
+
number: await customer.getInvoiceNumber(),
|
|
454
|
+
description,
|
|
455
|
+
statement_descriptor: statementDescriptor,
|
|
456
|
+
period_start: periodStart,
|
|
457
|
+
period_end: periodEnd,
|
|
458
|
+
auto_advance: autoAdvance,
|
|
459
|
+
paid,
|
|
460
|
+
paid_out_of_band: paidOutOfBand,
|
|
461
|
+
status,
|
|
462
|
+
billing_reason: billingReason,
|
|
463
|
+
currency_id: currencyId,
|
|
464
|
+
customer_id: customer.id,
|
|
465
|
+
default_payment_method_id: paymentMethodId,
|
|
466
|
+
payment_intent_id: paymentIntentId,
|
|
467
|
+
subscription_id: subscription?.id,
|
|
468
|
+
checkout_session_id: checkoutSessionId,
|
|
469
|
+
total,
|
|
470
|
+
subtotal: total,
|
|
471
|
+
amount_due: amountDue,
|
|
472
|
+
amount_paid: amountPaid,
|
|
473
|
+
amount_remaining: amountRemaining,
|
|
474
|
+
|
|
475
|
+
customer_address: customer.address,
|
|
476
|
+
customer_email: customer.email,
|
|
477
|
+
customer_name: customer.name,
|
|
478
|
+
customer_phone: customer.phone,
|
|
479
|
+
|
|
480
|
+
effective_at: dayjs().unix(),
|
|
481
|
+
status_transitions: {
|
|
482
|
+
finalized_at: dayjs().unix(),
|
|
483
|
+
},
|
|
484
|
+
payment_settings: paymentSettings || subscription?.payment_settings,
|
|
485
|
+
metadata,
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
if (!itemsData) {
|
|
489
|
+
return { invoice, items: [] };
|
|
490
|
+
}
|
|
491
|
+
// create invoice items
|
|
492
|
+
const items = await Promise.all(
|
|
493
|
+
itemsData.map((item) =>
|
|
494
|
+
InvoiceItem.create({
|
|
495
|
+
livemode,
|
|
496
|
+
amount: item.amount,
|
|
497
|
+
quantity: item.quantity,
|
|
498
|
+
description: item.description,
|
|
499
|
+
period: item.period,
|
|
500
|
+
currency_id: currencyId,
|
|
501
|
+
customer_id: customer.id,
|
|
502
|
+
price_id: item.price_id,
|
|
503
|
+
invoice_id: invoice.id,
|
|
504
|
+
subscription_id: subscription?.id,
|
|
505
|
+
subscription_item_id: item.subscription_item_id,
|
|
506
|
+
discountable: false,
|
|
507
|
+
discounts: [],
|
|
508
|
+
discount_amounts: [],
|
|
509
|
+
proration: false,
|
|
510
|
+
proration_details: {},
|
|
511
|
+
metadata: item.metadata || {},
|
|
512
|
+
})
|
|
513
|
+
)
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
return { invoice, items };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function ensureInvoiceAndItems({
|
|
520
|
+
customer,
|
|
521
|
+
currency,
|
|
522
|
+
subscription,
|
|
523
|
+
props,
|
|
524
|
+
lineItems,
|
|
525
|
+
trialing,
|
|
526
|
+
metered,
|
|
527
|
+
applyCredit = true,
|
|
528
|
+
}: {
|
|
529
|
+
customer: Customer;
|
|
530
|
+
currency: PaymentCurrency;
|
|
531
|
+
subscription?: Subscription;
|
|
532
|
+
props: TInvoice;
|
|
533
|
+
lineItems: TLineItemExpanded[];
|
|
534
|
+
trialing: boolean; // do we have trialing
|
|
535
|
+
metered: boolean; // is the quantity metered
|
|
536
|
+
applyCredit?: boolean; // should we apply customer credit?
|
|
537
|
+
}): Promise<{ invoice: Invoice; items: InvoiceItem[] }> {
|
|
538
|
+
// apply possible balance to invoice
|
|
539
|
+
let remaining = props.total;
|
|
540
|
+
let result = { starting: {}, ending: {} };
|
|
541
|
+
if (applyCredit && props.total > '0') {
|
|
542
|
+
const balance = customer.getBalanceToApply(currency.id, props.total);
|
|
543
|
+
result = await customer.decreaseTokenBalance(currency.id, balance);
|
|
544
|
+
remaining = new BN(props.total).sub(new BN(balance)).toString();
|
|
545
|
+
logger.info('Invoice will use customer credit', { result, remaining, total: props.total });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// get subscription items
|
|
549
|
+
const subscriptionItems = subscription
|
|
550
|
+
? await SubscriptionItem.findAll({ where: { subscription_id: subscription?.id } })
|
|
551
|
+
: [];
|
|
552
|
+
|
|
553
|
+
function getLineSetup(x: TLineItemExpanded) {
|
|
554
|
+
const price = getSubscriptionItemPrice(x);
|
|
555
|
+
if (price.type === 'recurring' && trialing) {
|
|
556
|
+
return {
|
|
557
|
+
price,
|
|
558
|
+
amount: '0',
|
|
559
|
+
// @ts-ignore
|
|
560
|
+
description: trialing ? `${price.product.name} (trialing)` : price.product.name,
|
|
561
|
+
period: {
|
|
562
|
+
start: props.period_start,
|
|
563
|
+
end: props.period_end,
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
price,
|
|
570
|
+
amount:
|
|
571
|
+
x.custom_amount ||
|
|
572
|
+
new BN(getPriceUintAmountByCurrency(price, props.currency_id)).mul(new BN(x.quantity)).toString(),
|
|
573
|
+
description: price.product.name,
|
|
574
|
+
period: undefined,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
// process line items
|
|
578
|
+
const processedItems = lineItems.map((x: TLineItemExpanded) => {
|
|
579
|
+
const setup = getLineSetup(x);
|
|
580
|
+
const { price } = setup;
|
|
581
|
+
let { quantity } = x;
|
|
582
|
+
if (price.type === 'recurring') {
|
|
583
|
+
if (price.recurring?.usage_type === 'metered' && !metered) {
|
|
584
|
+
quantity = 0;
|
|
585
|
+
}
|
|
586
|
+
if (trialing) {
|
|
587
|
+
quantity = 0;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
price_id: price.id,
|
|
593
|
+
amount: quantity > 0 ? setup.amount : '0',
|
|
594
|
+
quantity,
|
|
595
|
+
description: setup.description,
|
|
596
|
+
period: setup.period,
|
|
597
|
+
metadata: x.metadata || {},
|
|
598
|
+
subscription_item_id: subscriptionItems.find((si) => si.price_id === price.id)?.id,
|
|
599
|
+
};
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
return createInvoiceWithItems({
|
|
603
|
+
customer,
|
|
604
|
+
subscription,
|
|
605
|
+
items: processedItems,
|
|
606
|
+
livemode: props.livemode,
|
|
607
|
+
status: props.status || 'open',
|
|
608
|
+
billing_reason: props.billing_reason,
|
|
609
|
+
description: props.description || '',
|
|
610
|
+
statement_descriptor: props.statement_descriptor,
|
|
611
|
+
|
|
612
|
+
currency_id: props.currency_id,
|
|
613
|
+
payment_intent_id: props.payment_intent_id,
|
|
614
|
+
checkout_session_id: props.checkout_session_id,
|
|
615
|
+
default_payment_method_id: props.default_payment_method_id,
|
|
616
|
+
|
|
617
|
+
period_start: props.period_start,
|
|
618
|
+
period_end: props.period_end,
|
|
619
|
+
auto_advance: props.auto_advance,
|
|
620
|
+
|
|
621
|
+
total: props.total,
|
|
622
|
+
amount_remaining: props.amount_remaining || remaining,
|
|
623
|
+
amount_paid: props.amount_paid,
|
|
624
|
+
amount_due: props.amount_due || remaining,
|
|
625
|
+
subtotal_excluding_tax: props.total || '0',
|
|
626
|
+
starting_token_balance: result.starting,
|
|
627
|
+
ending_token_balance: result.ending,
|
|
628
|
+
|
|
629
|
+
footer: props.footer,
|
|
630
|
+
custom_fields: props.custom_fields,
|
|
631
|
+
payment_settings: props.payment_settings || subscription?.payment_settings,
|
|
632
|
+
metadata: {
|
|
633
|
+
...props.metadata,
|
|
634
|
+
starting_token_balance: result.starting,
|
|
635
|
+
ending_token_balance: result.ending,
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
export async function cleanupInvoiceAndItems(invoiceId: string) {
|
|
641
|
+
const invoice = await Invoice.findByPk(invoiceId);
|
|
642
|
+
if (!invoice) {
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
if (invoice.isImmutable()) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const removedItem = await InvoiceItem.destroy({ where: { invoice_id: invoiceId } });
|
|
650
|
+
const removedInvoice = await Invoice.destroy({ where: { id: invoiceId } });
|
|
651
|
+
logger.info('cleanup invoice and items', { invoiceId, removedItem, removedInvoice });
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export async function ensureRechargeInvoice(
|
|
655
|
+
invoiceProps: {
|
|
656
|
+
total: string;
|
|
657
|
+
description?: string;
|
|
658
|
+
checkout_session_id?: string;
|
|
659
|
+
currency_id: string;
|
|
660
|
+
metadata?: any;
|
|
661
|
+
payment_settings?: any;
|
|
662
|
+
},
|
|
663
|
+
subscription: Subscription,
|
|
664
|
+
paymentMethod: PaymentMethod,
|
|
665
|
+
customer: Customer
|
|
666
|
+
) {
|
|
667
|
+
try {
|
|
668
|
+
const { invoice } = await createInvoiceWithItems({
|
|
669
|
+
customer,
|
|
670
|
+
subscription,
|
|
671
|
+
currency_id: invoiceProps.currency_id,
|
|
672
|
+
livemode: subscription.livemode,
|
|
673
|
+
period_start: dayjs().unix(),
|
|
674
|
+
period_end: dayjs().unix(),
|
|
675
|
+
status: 'paid',
|
|
676
|
+
billing_reason: 'recharge',
|
|
677
|
+
description: invoiceProps?.description || 'Add funds for subscription',
|
|
678
|
+
total: invoiceProps.total || '0',
|
|
679
|
+
amount_due: '0',
|
|
680
|
+
amount_paid: invoiceProps.total || '0',
|
|
681
|
+
amount_remaining: '0',
|
|
682
|
+
default_payment_method_id: paymentMethod.id,
|
|
683
|
+
checkout_session_id: invoiceProps?.checkout_session_id || '',
|
|
684
|
+
metadata: invoiceProps.metadata || {},
|
|
685
|
+
auto_advance: false,
|
|
686
|
+
paid: true,
|
|
687
|
+
paid_out_of_band: false,
|
|
688
|
+
payment_settings: invoiceProps?.payment_settings || subscription?.payment_settings,
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
logger.info('create recharge invoice success', {
|
|
692
|
+
invoice,
|
|
693
|
+
subscriptionId: subscription?.id,
|
|
694
|
+
paymentMethod: paymentMethod.id,
|
|
695
|
+
});
|
|
696
|
+
} catch (error) {
|
|
697
|
+
logger.error('ensureRechargeInvoice: create invoice failed', {
|
|
698
|
+
error,
|
|
699
|
+
subscriptionId: subscription?.id,
|
|
700
|
+
paymentMethod: paymentMethod.id,
|
|
701
|
+
});
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
export async function ensureOverdraftProtectionInvoiceAndItems({
|
|
707
|
+
customer,
|
|
708
|
+
subscription,
|
|
709
|
+
paymentIntent,
|
|
710
|
+
props,
|
|
711
|
+
}: {
|
|
712
|
+
customer: Customer;
|
|
713
|
+
subscription: Subscription;
|
|
714
|
+
paymentIntent: PaymentIntent;
|
|
715
|
+
props: Partial<TInvoice> & { period_start: number; period_end: number };
|
|
716
|
+
}): Promise<{ invoice: Invoice; items: InvoiceItem[] }> {
|
|
717
|
+
const { price, product } = await ensureOverdraftProtectionPrice(subscription.livemode);
|
|
718
|
+
const invoicePrice = price.currency_options.find((x: any) => x.currency_id === paymentIntent?.currency_id);
|
|
719
|
+
|
|
720
|
+
if (!subscription.overdraft_protection?.enabled) {
|
|
721
|
+
throw new Error('create overdraft protection invoice skipped due to overdraft protection not enabled');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (!invoicePrice) {
|
|
725
|
+
throw new Error('overdraft protection invoice price not found');
|
|
726
|
+
}
|
|
727
|
+
const currency = await PaymentCurrency.findByPk(invoicePrice.currency_id);
|
|
728
|
+
if (!currency) {
|
|
729
|
+
throw new Error('overdraft protection invoice currency not found');
|
|
730
|
+
}
|
|
731
|
+
const paymentMethod = await PaymentMethod.findByPk(currency?.payment_method_id);
|
|
732
|
+
if (!paymentMethod) {
|
|
733
|
+
throw new Error('overdraft protection invoice payment method not found');
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (paymentMethod.type !== 'arcblock') {
|
|
737
|
+
throw new Error(`Payment method ${paymentMethod.type} not supported for overdraft protection`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const { unused } = await isSubscriptionOverdraftProtectionEnabled(subscription, paymentIntent?.currency_id);
|
|
741
|
+
if (new BN(unused).lt(new BN(invoicePrice.unit_amount))) {
|
|
742
|
+
throw new Error('create overdraft protection invoice skipped due to insufficient overdraft protection');
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const result = await createInvoiceWithItems({
|
|
746
|
+
customer,
|
|
747
|
+
subscription,
|
|
748
|
+
currency_id: invoicePrice.currency_id,
|
|
749
|
+
livemode: subscription.livemode,
|
|
750
|
+
period_start: props.period_start || 0,
|
|
751
|
+
period_end: props.period_end || 0,
|
|
752
|
+
status: props.status,
|
|
753
|
+
billing_reason: 'overdraft_protection',
|
|
754
|
+
description: 'Overdraft protection',
|
|
755
|
+
collection_method: 'send_invoice',
|
|
756
|
+
default_payment_method_id: paymentMethod.id,
|
|
757
|
+
total: invoicePrice.unit_amount || '0',
|
|
758
|
+
items: [
|
|
759
|
+
{
|
|
760
|
+
price_id: price.id,
|
|
761
|
+
amount: invoicePrice.unit_amount || '0',
|
|
762
|
+
quantity: 1,
|
|
763
|
+
description: product?.name || '',
|
|
764
|
+
period: {
|
|
765
|
+
start: props.period_start || 0,
|
|
766
|
+
end: props.period_end || 0,
|
|
767
|
+
},
|
|
768
|
+
},
|
|
769
|
+
],
|
|
770
|
+
metadata: {
|
|
771
|
+
...(props.metadata || {}),
|
|
772
|
+
payment_intent_id: paymentIntent.id,
|
|
773
|
+
},
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
await Price.increment({ quantity_sold: 1 }, { where: { id: price.id } });
|
|
777
|
+
return result;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export async function ensureStakeInvoice(
|
|
781
|
+
invoiceProps: {
|
|
782
|
+
total: string;
|
|
783
|
+
description?: string;
|
|
784
|
+
checkout_session_id?: string;
|
|
785
|
+
currency_id: string;
|
|
786
|
+
billing_reason?: string;
|
|
787
|
+
metadata?: any;
|
|
788
|
+
payment_settings?: any;
|
|
789
|
+
},
|
|
790
|
+
subscription: Subscription,
|
|
791
|
+
paymentMethod: PaymentMethod,
|
|
792
|
+
customer: Customer
|
|
793
|
+
) {
|
|
794
|
+
if (paymentMethod.type !== 'arcblock') {
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
try {
|
|
799
|
+
const { invoice } = await createInvoiceWithItems({
|
|
800
|
+
customer,
|
|
801
|
+
subscription,
|
|
802
|
+
currency_id: invoiceProps.currency_id,
|
|
803
|
+
livemode: subscription.livemode,
|
|
804
|
+
period_start: dayjs().unix(),
|
|
805
|
+
period_end: subscription.current_period_end,
|
|
806
|
+
status: 'paid',
|
|
807
|
+
billing_reason: invoiceProps?.billing_reason || 'stake',
|
|
808
|
+
description: invoiceProps?.description || 'Stake for subscription',
|
|
809
|
+
total: invoiceProps.total || '0',
|
|
810
|
+
amount_due: '0',
|
|
811
|
+
amount_paid: invoiceProps.total || '0',
|
|
812
|
+
amount_remaining: '0',
|
|
813
|
+
default_payment_method_id: paymentMethod.id,
|
|
814
|
+
checkout_session_id: invoiceProps?.checkout_session_id || '',
|
|
815
|
+
metadata: invoiceProps.metadata || {},
|
|
816
|
+
auto_advance: false,
|
|
817
|
+
paid: true,
|
|
818
|
+
paid_out_of_band: false,
|
|
819
|
+
payment_settings: invoiceProps?.payment_settings || subscription?.payment_settings,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
logger.info('create staking invoice success', {
|
|
823
|
+
invoice,
|
|
824
|
+
subscriptionId: subscription?.id,
|
|
825
|
+
paymentMethod: paymentMethod.id,
|
|
826
|
+
});
|
|
827
|
+
} catch (error) {
|
|
828
|
+
logger.error('ensureStake: create invoice failed', {
|
|
829
|
+
error,
|
|
830
|
+
subscriptionId: subscription?.id,
|
|
831
|
+
paymentMethod: paymentMethod.id,
|
|
832
|
+
});
|
|
833
|
+
throw error;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// mark overdraft protection invoice as void after payment
|
|
838
|
+
export async function handleOverdraftProtectionInvoiceAfterPayment(invoice: Invoice) {
|
|
839
|
+
try {
|
|
840
|
+
if (['stake', 'overdraft_protection', 'stake_overdraft_protection'].includes(invoice.billing_reason)) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
844
|
+
if (!subscription) {
|
|
845
|
+
logger.info('handle overdraft protection invoice skipped due to subscription not found', {
|
|
846
|
+
invoiceId: invoice.id,
|
|
847
|
+
});
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
if (!subscription.overdraft_protection?.enabled) {
|
|
851
|
+
logger.info('handle overdraft protection invoice skipped due to overdraft protection not enabled', {
|
|
852
|
+
subscriptionId: subscription.id,
|
|
853
|
+
});
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
const paymentMethod = await PaymentMethod.findByPk(invoice.default_payment_method_id);
|
|
857
|
+
if (!paymentMethod || paymentMethod?.type !== 'arcblock') {
|
|
858
|
+
logger.info('handle overdraft protection invoice skipped due to payment method not supported', {
|
|
859
|
+
invoiceId: invoice.id,
|
|
860
|
+
paymentMethodId: paymentMethod?.id,
|
|
861
|
+
});
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
|
|
865
|
+
if (!paymentIntent) {
|
|
866
|
+
logger.info('handle overdraft protection invoice skipped due to payment intent not found', {
|
|
867
|
+
invoiceId: invoice.id,
|
|
868
|
+
});
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (paymentIntent.status !== 'succeeded') {
|
|
872
|
+
logger.info('handle overdraft protection invoice skipped due to payment intent not succeeded', {
|
|
873
|
+
invoiceId: invoice.id,
|
|
874
|
+
});
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (paymentIntent.payment_details?.arcblock?.type !== 'transfer') {
|
|
878
|
+
logger.info('handle overdraft protection invoice skipped due to payment type not transfer', {
|
|
879
|
+
invoiceId: invoice.id,
|
|
880
|
+
paymentIntentId: paymentIntent.id,
|
|
881
|
+
});
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
const overdraftProtectionInvoice = await Invoice.findOne({
|
|
885
|
+
where: {
|
|
886
|
+
subscription_id: subscription.id,
|
|
887
|
+
billing_reason: 'overdraft_protection',
|
|
888
|
+
'metadata.invoice_id': invoice.id,
|
|
889
|
+
status: {
|
|
890
|
+
[Op.notIn]: ['paid', 'void'],
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
if (!overdraftProtectionInvoice) {
|
|
895
|
+
logger.info('handle overdraft protection invoice skipped due to overdraft protection invoice not found', {
|
|
896
|
+
invoiceId: invoice.id,
|
|
897
|
+
});
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
if (overdraftProtectionInvoice.payment_intent_id) {
|
|
902
|
+
const overdraftProtectionPaymentIntent = await PaymentIntent.findOne({
|
|
903
|
+
where: {
|
|
904
|
+
id: overdraftProtectionInvoice.payment_intent_id,
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
if (overdraftProtectionPaymentIntent) {
|
|
908
|
+
await overdraftProtectionPaymentIntent.update({
|
|
909
|
+
status: 'canceled',
|
|
910
|
+
canceled_at: dayjs().unix(),
|
|
911
|
+
cancellation_reason: 'void_invoice',
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
await overdraftProtectionInvoice.update({
|
|
917
|
+
status: 'void',
|
|
918
|
+
status_transitions: {
|
|
919
|
+
...(overdraftProtectionInvoice.status_transitions || {}),
|
|
920
|
+
voided_at: dayjs().unix(),
|
|
921
|
+
},
|
|
922
|
+
});
|
|
923
|
+
} catch (error) {
|
|
924
|
+
logger.error('handle overdraft protection invoice failed', {
|
|
925
|
+
error,
|
|
926
|
+
invoiceId: invoice.id,
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
}
|
|
@@ -4,7 +4,7 @@ import pWaitFor from 'p-wait-for';
|
|
|
4
4
|
import prettyMsI18n from 'pretty-ms-i18n';
|
|
5
5
|
import isEmpty from 'lodash/isEmpty';
|
|
6
6
|
|
|
7
|
-
import { getPaymentAmountForCycleSubscription } from '../../
|
|
7
|
+
import { getPaymentAmountForCycleSubscription, getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
8
8
|
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
9
9
|
import { translate } from '../../../locales';
|
|
10
10
|
import {
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
} from '../../../store/models';
|
|
20
20
|
import { getCustomerInvoicePageUrl, getOneTimeProductInfo } from '../../invoice';
|
|
21
21
|
import { getMainProductName } from '../../product';
|
|
22
|
-
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
23
22
|
import { formatTime, getPrettyMsI18nLocale } from '../../time';
|
|
24
23
|
import { getExplorerLink, getSubscriptionNotificationCustomActions } from '../../util';
|
|
25
24
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
@@ -8,9 +8,9 @@ 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';
|
|
11
|
-
import { PaymentDetail
|
|
11
|
+
import { PaymentDetail } from '../../payment';
|
|
12
12
|
import { getMainProductName } from '../../product';
|
|
13
|
-
import { getCustomerSubscriptionPageUrl } from '../../subscription';
|
|
13
|
+
import { getCustomerSubscriptionPageUrl, getPaymentAmountForCycleSubscription } from '../../subscription';
|
|
14
14
|
import { formatTime, getPrettyMsI18nLocale, getSimplifyDuration } from '../../time';
|
|
15
15
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
16
16
|
import dayjs from '../../dayjs';
|
|
@@ -18,9 +18,13 @@ import {
|
|
|
18
18
|
SubscriptionItem,
|
|
19
19
|
PaymentCurrency,
|
|
20
20
|
} from '../../../store/models';
|
|
21
|
-
import {
|
|
21
|
+
import type { PaymentDetail } from '../../payment';
|
|
22
22
|
import { getMainProductName } from '../../product';
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
getCustomerSubscriptionPageUrl,
|
|
25
|
+
getSubscriptionPaymentAddress,
|
|
26
|
+
getPaymentAmountForCycleSubscription,
|
|
27
|
+
} from '../../subscription';
|
|
24
28
|
import { formatTime, getPrettyMsI18nLocale, getSimplifyDuration } from '../../time';
|
|
25
29
|
import { getCustomerRechargeLink, getSubscriptionNotificationCustomActions } from '../../util';
|
|
26
30
|
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|