payment-kit 1.19.17 → 1.19.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/index.ts +3 -1
- package/api/src/integrations/ethereum/tx.ts +11 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +26 -6
- package/api/src/integrations/stripe/handlers/setup-intent.ts +34 -2
- package/api/src/integrations/stripe/resource.ts +185 -1
- package/api/src/libs/invoice.ts +2 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +155 -0
- package/api/src/libs/session.ts +6 -1
- package/api/src/libs/ws.ts +3 -2
- package/api/src/locales/en.ts +6 -6
- package/api/src/locales/zh.ts +4 -4
- package/api/src/queues/auto-recharge.ts +343 -0
- package/api/src/queues/credit-consume.ts +51 -1
- package/api/src/queues/credit-grant.ts +15 -0
- package/api/src/queues/notification.ts +16 -13
- package/api/src/queues/payment.ts +14 -1
- package/api/src/queues/space.ts +1 -0
- package/api/src/routes/auto-recharge-configs.ts +454 -0
- package/api/src/routes/connect/auto-recharge-auth.ts +182 -0
- package/api/src/routes/connect/recharge-account.ts +72 -10
- package/api/src/routes/connect/setup.ts +5 -3
- package/api/src/routes/connect/shared.ts +45 -4
- package/api/src/routes/customers.ts +10 -6
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/invoices.ts +10 -1
- package/api/src/routes/meter-events.ts +1 -1
- package/api/src/routes/meters.ts +1 -1
- package/api/src/routes/payment-currencies.ts +129 -0
- package/api/src/store/migrate.ts +20 -0
- package/api/src/store/migrations/20250821-auto-recharge-config.ts +38 -0
- package/api/src/store/models/auto-recharge-config.ts +225 -0
- package/api/src/store/models/credit-grant.ts +2 -11
- package/api/src/store/models/customer.ts +1 -0
- package/api/src/store/models/index.ts +3 -0
- package/api/src/store/models/invoice.ts +2 -1
- package/api/src/store/models/payment-currency.ts +10 -2
- package/api/src/store/models/types.ts +12 -1
- package/blocklet.yml +3 -3
- package/package.json +18 -18
- package/src/components/currency.tsx +3 -1
- package/src/components/customer/credit-overview.tsx +103 -18
- package/src/components/customer/overdraft-protection.tsx +5 -5
- package/src/components/info-metric.tsx +11 -2
- package/src/components/invoice/recharge.tsx +8 -2
- package/src/components/metadata/form.tsx +29 -27
- package/src/components/meter/form.tsx +1 -2
- package/src/components/price/form.tsx +39 -26
- package/src/components/product/form.tsx +1 -2
- package/src/components/subscription/items/index.tsx +8 -2
- package/src/components/subscription/metrics.tsx +5 -1
- package/src/locales/en.tsx +15 -0
- package/src/locales/zh.tsx +14 -0
- package/src/pages/admin/billing/meters/detail.tsx +18 -0
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +10 -0
- package/src/pages/admin/products/prices/actions.tsx +42 -2
- package/src/pages/admin/products/products/create.tsx +1 -2
- package/src/pages/admin/settings/vault-config/edit-form.tsx +8 -8
- package/src/pages/customer/credit-grant/detail.tsx +9 -1
- package/src/pages/customer/recharge/account.tsx +14 -7
- package/src/pages/customer/recharge/subscription.tsx +4 -4
- package/src/pages/customer/subscription/detail.tsx +6 -1
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +0 -151
package/api/src/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import delegationHandlers from './routes/connect/delegation';
|
|
|
43
43
|
import overdraftProtectionHandlers from './routes/connect/overdraft-protection';
|
|
44
44
|
import rechargeAccountHandlers from './routes/connect/recharge-account';
|
|
45
45
|
import reStakeHandlers from './routes/connect/re-stake';
|
|
46
|
+
import autoRechargeAuthorizationHandlers from './routes/connect/auto-recharge-auth';
|
|
46
47
|
import { initialize } from './store/models';
|
|
47
48
|
import { sequelize } from './store/sequelize';
|
|
48
49
|
import { initUserHandler } from './integrations/blocklet/user';
|
|
@@ -84,6 +85,7 @@ handlers.attach(Object.assign({ app: router }, rechargeAccountHandlers));
|
|
|
84
85
|
handlers.attach(Object.assign({ app: router }, delegationHandlers));
|
|
85
86
|
handlers.attach(Object.assign({ app: router }, overdraftProtectionHandlers));
|
|
86
87
|
handlers.attach(Object.assign({ app: router }, reStakeHandlers));
|
|
88
|
+
handlers.attach(Object.assign({ app: router }, autoRechargeAuthorizationHandlers));
|
|
87
89
|
router.use('/api', routes);
|
|
88
90
|
|
|
89
91
|
const isProduction = process.env.BLOCKLET_MODE === 'production';
|
|
@@ -99,7 +101,7 @@ if (isProduction) {
|
|
|
99
101
|
|
|
100
102
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
101
103
|
app.use(<ErrorRequestHandler>((err, req, res, _next) => {
|
|
102
|
-
logger.error(err);
|
|
104
|
+
logger.error('handle router error', err);
|
|
103
105
|
if (err instanceof CustomError) {
|
|
104
106
|
res.status(getStatusFromError(err)).json({ error: formatError(err) });
|
|
105
107
|
return;
|
|
@@ -78,3 +78,14 @@ export function broadcastEvmTransaction(checkoutSessionId: string, status: strin
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
export function broadcastAutoRechargeEvmTransaction(autoRechargeConfigId: string, status: string, claims: any[]) {
|
|
83
|
+
const claim = claims.find((x) => x.type === 'signature');
|
|
84
|
+
if (claim?.hash) {
|
|
85
|
+
broadcast('auto_recharge.evm_transaction', {
|
|
86
|
+
id: autoRechargeConfigId,
|
|
87
|
+
txHash: claim.hash,
|
|
88
|
+
status,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -9,6 +9,7 @@ import { createEvent } from '../../../libs/audit';
|
|
|
9
9
|
import { getLock } from '../../../libs/lock';
|
|
10
10
|
import logger from '../../../libs/logger';
|
|
11
11
|
import {
|
|
12
|
+
AutoRechargeConfig,
|
|
12
13
|
CheckoutSession,
|
|
13
14
|
Customer,
|
|
14
15
|
Invoice,
|
|
@@ -110,6 +111,9 @@ export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subs
|
|
|
110
111
|
await lock.acquire();
|
|
111
112
|
|
|
112
113
|
const customer = await Customer.findByPk(subscription.customer_id);
|
|
114
|
+
if (!customer) {
|
|
115
|
+
throw new Error('Customer not found');
|
|
116
|
+
}
|
|
113
117
|
const checkoutSession = await CheckoutSession.findBySubscriptionId(subscription.id);
|
|
114
118
|
|
|
115
119
|
let invoice = await Invoice.findOne({ where: { 'metadata.stripe_id': stripeInvoice.id } });
|
|
@@ -118,10 +122,10 @@ export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subs
|
|
|
118
122
|
return invoice;
|
|
119
123
|
}
|
|
120
124
|
|
|
125
|
+
const invoiceNumber = await customer.getInvoiceNumber();
|
|
121
126
|
// @ts-ignore
|
|
122
127
|
invoice = await Invoice.create({
|
|
123
|
-
|
|
124
|
-
number: await customer.getInvoiceNumber(),
|
|
128
|
+
number: invoiceNumber,
|
|
125
129
|
...pick(stripeInvoice, [
|
|
126
130
|
'amount_due',
|
|
127
131
|
'amount_paid',
|
|
@@ -313,9 +317,12 @@ export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe)
|
|
|
313
317
|
|
|
314
318
|
// in case we missed some of the events
|
|
315
319
|
const subscriptionId = event.data.object.subscription_details?.metadata?.id;
|
|
316
|
-
const
|
|
320
|
+
const subscriptionAppPid = event.data.object.subscription_details?.metadata?.appPid;
|
|
321
|
+
const autoRechargeConfigId = event.data.object.metadata?.recharge_id;
|
|
322
|
+
|
|
317
323
|
if (!localInvoiceId) {
|
|
318
|
-
|
|
324
|
+
// Handle subscription invoices
|
|
325
|
+
if (subscriptionId && subscriptionAppPid && subscriptionAppPid === env.appPid) {
|
|
319
326
|
logger.warn('try mirror invoice from stripe', { invoiceId: event.data.object.id });
|
|
320
327
|
const subscription = await Subscription.findByPk(subscriptionId);
|
|
321
328
|
if (subscription) {
|
|
@@ -332,7 +339,7 @@ export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe)
|
|
|
332
339
|
logger.error('wait for stripe invoice mirror error', { id: event.id, type: event.type, error: err });
|
|
333
340
|
}
|
|
334
341
|
|
|
335
|
-
logger.warn('local invoice id not found in
|
|
342
|
+
logger.warn('local invoice id not found in stripe event', { id: event.id, type: event.type });
|
|
336
343
|
return;
|
|
337
344
|
}
|
|
338
345
|
}
|
|
@@ -390,12 +397,25 @@ export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe)
|
|
|
390
397
|
|
|
391
398
|
const failedEvents = ['invoice.marked_uncollectible', 'invoice.finalization_failed', 'invoice.payment_failed'];
|
|
392
399
|
if (failedEvents.includes(event.type)) {
|
|
393
|
-
logger.info('
|
|
400
|
+
logger.info('invoice finalized and failed', { invoiceId: invoice.id });
|
|
401
|
+
// Handle subscription payment failures
|
|
394
402
|
if (invoice.subscription_id) {
|
|
395
403
|
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
396
404
|
if (subscription) {
|
|
397
405
|
handleSubscriptionOnPaymentFailure(subscription, event.type, client);
|
|
398
406
|
}
|
|
399
407
|
}
|
|
408
|
+
if (autoRechargeConfigId && invoice) {
|
|
409
|
+
const autoRechargeConfig = await AutoRechargeConfig.findByPk(autoRechargeConfigId);
|
|
410
|
+
if (autoRechargeConfig) {
|
|
411
|
+
autoRechargeConfig.update({
|
|
412
|
+
enabled: false,
|
|
413
|
+
metadata: {
|
|
414
|
+
...autoRechargeConfig.metadata,
|
|
415
|
+
failReason: `Payment failed: ${event.type}`,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
400
420
|
}
|
|
401
421
|
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type Stripe from 'stripe';
|
|
2
2
|
|
|
3
3
|
import logger from '../../../libs/logger';
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
AutoRechargeConfig,
|
|
6
|
+
CheckoutSession,
|
|
7
|
+
Lock,
|
|
8
|
+
SetupIntent,
|
|
9
|
+
Subscription,
|
|
10
|
+
TEventExpanded,
|
|
11
|
+
} from '../../../store/models';
|
|
12
|
+
import { updateGroupSubscriptionsPaymentMethod, updateAutoRechargeConfigPaymentMethod } from '../resource';
|
|
6
13
|
import { getCheckoutSessionSubscriptionIds } from '../../../libs/session';
|
|
7
14
|
|
|
8
15
|
async function handleSubscriptionOnSetupSucceeded(event: TEventExpanded, stripeIntentId: string) {
|
|
@@ -119,6 +126,30 @@ async function handleCheckoutSessionOnSetupSucceeded(event: TEventExpanded, stri
|
|
|
119
126
|
}
|
|
120
127
|
}
|
|
121
128
|
|
|
129
|
+
async function handleAutoRechargeOnSetupSucceeded(event: TEventExpanded, stripeIntentId: string) {
|
|
130
|
+
const autoRechargeConfig = await AutoRechargeConfig.findOne({
|
|
131
|
+
where: { 'payment_details.stripe.setup_intent_id': stripeIntentId },
|
|
132
|
+
});
|
|
133
|
+
if (!autoRechargeConfig) {
|
|
134
|
+
logger.warn('local auto recharge config not found for setup intent', {
|
|
135
|
+
id: event.id,
|
|
136
|
+
type: event.type,
|
|
137
|
+
stripeIntentId,
|
|
138
|
+
});
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (event.type === 'setup_intent.succeeded') {
|
|
143
|
+
const stripePaymentMethod = event.data.object.payment_method;
|
|
144
|
+
if (stripePaymentMethod) {
|
|
145
|
+
await updateAutoRechargeConfigPaymentMethod({
|
|
146
|
+
stripePaymentMethodId: stripePaymentMethod,
|
|
147
|
+
autoRechargeConfig,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
122
153
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
123
154
|
export async function handleSetupIntentEvent(event: TEventExpanded, _: Stripe) {
|
|
124
155
|
const stripeIntentId = event.data.object.id;
|
|
@@ -127,4 +158,5 @@ export async function handleSetupIntentEvent(event: TEventExpanded, _: Stripe) {
|
|
|
127
158
|
await handleSubscriptionOnSetupSucceeded(event, stripeIntentId);
|
|
128
159
|
await handleSetupIntentOnSetupSucceeded(event, stripeIntentId);
|
|
129
160
|
await handleCheckoutSessionOnSetupSucceeded(event, stripeIntentId);
|
|
161
|
+
await handleAutoRechargeOnSetupSucceeded(event, stripeIntentId);
|
|
130
162
|
}
|
|
@@ -11,9 +11,11 @@ import { getPriceUintAmountByCurrency } from '../../libs/session';
|
|
|
11
11
|
import { getSubscriptionItemPrice } from '../../libs/subscription';
|
|
12
12
|
import { sleep } from '../../libs/util';
|
|
13
13
|
import {
|
|
14
|
+
AutoRechargeConfig,
|
|
14
15
|
CheckoutSession,
|
|
15
16
|
Customer,
|
|
16
17
|
Invoice,
|
|
18
|
+
InvoiceItem,
|
|
17
19
|
PaymentCurrency,
|
|
18
20
|
PaymentIntent,
|
|
19
21
|
PaymentMethod,
|
|
@@ -208,7 +210,6 @@ export async function ensureStripePaymentIntent(
|
|
|
208
210
|
let stripeIntent = null;
|
|
209
211
|
if (internal.payment_details?.stripe?.payment_intent_id) {
|
|
210
212
|
stripeIntent = await client.paymentIntents.retrieve(internal.payment_details.stripe.payment_intent_id);
|
|
211
|
-
// FIXME: update?
|
|
212
213
|
} else {
|
|
213
214
|
const customer = await ensureStripePaymentCustomer(internal, method);
|
|
214
215
|
stripeIntent = await client.paymentIntents.create({
|
|
@@ -570,6 +571,94 @@ export async function batchHandleStripePayments() {
|
|
|
570
571
|
}
|
|
571
572
|
}
|
|
572
573
|
|
|
574
|
+
export async function ensureStripeSetupIntentForAutoRecharge(
|
|
575
|
+
customer: Customer,
|
|
576
|
+
method: PaymentMethod,
|
|
577
|
+
autoRechargeConfig: AutoRechargeConfig,
|
|
578
|
+
forceReauthorize: boolean = false
|
|
579
|
+
) {
|
|
580
|
+
const client = method.getStripeClient();
|
|
581
|
+
if (autoRechargeConfig.payment_details?.stripe?.setup_intent_id && !forceReauthorize) {
|
|
582
|
+
const setupIntent = await client.setupIntents.retrieve(autoRechargeConfig.payment_details.stripe.setup_intent_id);
|
|
583
|
+
return setupIntent;
|
|
584
|
+
}
|
|
585
|
+
const stripeCustomer = await ensureStripeCustomer(customer, method);
|
|
586
|
+
|
|
587
|
+
const setupIntent = await client.setupIntents.create({
|
|
588
|
+
customer: stripeCustomer.id,
|
|
589
|
+
payment_method_types: ['card'],
|
|
590
|
+
usage: 'off_session',
|
|
591
|
+
metadata: {
|
|
592
|
+
appPid: env.appPid,
|
|
593
|
+
auto_recharge_config_id: autoRechargeConfig.id,
|
|
594
|
+
customer_id: customer.id,
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
logger.info('stripe setup intent created for auto recharge', {
|
|
599
|
+
customerId: customer.id,
|
|
600
|
+
autoRechargeConfigId: autoRechargeConfig.id,
|
|
601
|
+
setupIntentId: setupIntent.id,
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
return setupIntent;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export async function updateAutoRechargeConfigPaymentMethod(params: {
|
|
608
|
+
stripePaymentMethodId: string;
|
|
609
|
+
autoRechargeConfig: AutoRechargeConfig;
|
|
610
|
+
}) {
|
|
611
|
+
const { stripePaymentMethodId, autoRechargeConfig } = params;
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
const paymentMethod = await PaymentMethod.findByPk(autoRechargeConfig.payment_method_id);
|
|
615
|
+
if (!paymentMethod) {
|
|
616
|
+
throw new Error(`Payment method not found: ${autoRechargeConfig.payment_method_id}`);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const customer = await Customer.findByPk(autoRechargeConfig.customer_id);
|
|
620
|
+
if (!customer) {
|
|
621
|
+
throw new Error(`Customer not found: ${autoRechargeConfig.customer_id}`);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const stripeClient = paymentMethod.getStripeClient();
|
|
625
|
+
|
|
626
|
+
const stripePaymentMethod = await stripeClient.paymentMethods.retrieve(stripePaymentMethodId);
|
|
627
|
+
|
|
628
|
+
const paymentSettings = {
|
|
629
|
+
payment_method_types: ['stripe'],
|
|
630
|
+
payment_method_options: {
|
|
631
|
+
...(autoRechargeConfig.payment_settings?.payment_method_options || {}),
|
|
632
|
+
stripe: {
|
|
633
|
+
payer: stripePaymentMethodId,
|
|
634
|
+
card_last4: stripePaymentMethod.card?.last4,
|
|
635
|
+
card_brand: stripePaymentMethod.card?.brand,
|
|
636
|
+
exp_time: `${stripePaymentMethod.card?.exp_month}/${stripePaymentMethod.card?.exp_year}`,
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
await autoRechargeConfig.update({
|
|
642
|
+
payment_settings: paymentSettings,
|
|
643
|
+
payment_details: {
|
|
644
|
+
...autoRechargeConfig.payment_details,
|
|
645
|
+
stripe: {
|
|
646
|
+
...(autoRechargeConfig.payment_details?.stripe || {}),
|
|
647
|
+
payment_method_id: stripePaymentMethodId,
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
return autoRechargeConfig;
|
|
652
|
+
} catch (error: any) {
|
|
653
|
+
logger.error('Failed to update auto recharge config payment method', {
|
|
654
|
+
error: error.message,
|
|
655
|
+
configId: autoRechargeConfig.id,
|
|
656
|
+
paymentMethod: stripePaymentMethodId,
|
|
657
|
+
});
|
|
658
|
+
throw error;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
573
662
|
export async function updateGroupSubscriptionsPaymentMethod(params: {
|
|
574
663
|
stripePaymentMethodId: string;
|
|
575
664
|
subscriptionIds: string[];
|
|
@@ -623,3 +712,98 @@ export async function updateGroupSubscriptionsPaymentMethod(params: {
|
|
|
623
712
|
throw error;
|
|
624
713
|
}
|
|
625
714
|
}
|
|
715
|
+
|
|
716
|
+
export async function createStripeInvoiceForAutoRecharge(params: {
|
|
717
|
+
autoRechargeConfig: AutoRechargeConfig;
|
|
718
|
+
customer: Customer;
|
|
719
|
+
paymentMethod: PaymentMethod;
|
|
720
|
+
currency: PaymentCurrency;
|
|
721
|
+
invoice: Invoice;
|
|
722
|
+
}) {
|
|
723
|
+
const { autoRechargeConfig, customer, paymentMethod, currency, invoice } = params;
|
|
724
|
+
const client = paymentMethod.getStripeClient();
|
|
725
|
+
|
|
726
|
+
const stripePaymentMethodId = autoRechargeConfig.payment_settings?.payment_method_options?.stripe?.payer;
|
|
727
|
+
if (!stripePaymentMethodId) {
|
|
728
|
+
throw new Error('Stripe payment method not found');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
// Ensure stripe customer exists
|
|
733
|
+
const stripeCustomer = await ensureStripeCustomer(customer, paymentMethod);
|
|
734
|
+
|
|
735
|
+
const stripeInvoice = await client.invoices.create({
|
|
736
|
+
customer: stripeCustomer.id,
|
|
737
|
+
currency: currency.symbol.toLowerCase(),
|
|
738
|
+
description: invoice.description || 'Auto recharge',
|
|
739
|
+
collection_method: 'charge_automatically',
|
|
740
|
+
auto_advance: true,
|
|
741
|
+
default_payment_method: stripePaymentMethodId,
|
|
742
|
+
metadata: {
|
|
743
|
+
appPid: env.appPid,
|
|
744
|
+
recharge_id: autoRechargeConfig.id,
|
|
745
|
+
id: invoice.id,
|
|
746
|
+
},
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
logger.info('Stripe invoice created for auto recharge', {
|
|
750
|
+
stripeInvoiceId: stripeInvoice.id,
|
|
751
|
+
customerId: customer.id,
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Create invoice items from local invoice items
|
|
755
|
+
const invoiceItems = await InvoiceItem.findAll({ where: { invoice_id: invoice.id } });
|
|
756
|
+
for (const item of invoiceItems) {
|
|
757
|
+
const price = await Price.findByPk(item.price_id);
|
|
758
|
+
if (!price) {
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const stripePrice = await ensureStripePrice(price as Price, paymentMethod, currency);
|
|
762
|
+
await client.invoiceItems.create({
|
|
763
|
+
customer: stripeCustomer.id,
|
|
764
|
+
invoice: stripeInvoice.id,
|
|
765
|
+
price: stripePrice.id,
|
|
766
|
+
quantity: item.quantity,
|
|
767
|
+
description: item.description || price.nickname || '',
|
|
768
|
+
metadata: {
|
|
769
|
+
appPid: env.appPid,
|
|
770
|
+
invoice_item_id: item.id,
|
|
771
|
+
},
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
logger.info('Stripe invoice item created', {
|
|
775
|
+
localItemId: item.id,
|
|
776
|
+
stripeInvoiceId: stripeInvoice.id,
|
|
777
|
+
priceId: price.id,
|
|
778
|
+
quantity: item.quantity,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Finalize and pay the invoice automatically
|
|
783
|
+
const finalizedInvoice = await client.invoices.finalizeInvoice(stripeInvoice.id);
|
|
784
|
+
logger.info('Stripe invoice finalized', {
|
|
785
|
+
localInvoiceId: invoice.id,
|
|
786
|
+
stripeInvoiceId: stripeInvoice.id,
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
// Attempt automatic payment
|
|
790
|
+
if (finalizedInvoice.status === 'open') {
|
|
791
|
+
const paidInvoice = await client.invoices.pay(stripeInvoice.id);
|
|
792
|
+
logger.info('Stripe invoice payment attempted', {
|
|
793
|
+
localInvoiceId: invoice.id,
|
|
794
|
+
stripeInvoiceId: stripeInvoice.id,
|
|
795
|
+
status: paidInvoice.status,
|
|
796
|
+
});
|
|
797
|
+
return paidInvoice;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return finalizedInvoice;
|
|
801
|
+
} catch (error: any) {
|
|
802
|
+
logger.error('Failed to create Stripe invoice for auto recharge', {
|
|
803
|
+
localInvoiceId: invoice.id,
|
|
804
|
+
customerId: customer.id,
|
|
805
|
+
error,
|
|
806
|
+
});
|
|
807
|
+
throw error;
|
|
808
|
+
}
|
|
809
|
+
}
|
package/api/src/libs/invoice.ts
CHANGED
|
@@ -437,6 +437,7 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
|
|
|
437
437
|
...extraProps
|
|
438
438
|
} = props;
|
|
439
439
|
|
|
440
|
+
const invoiceNumber = await customer.getInvoiceNumber();
|
|
440
441
|
// create invoice
|
|
441
442
|
const invoice = await Invoice.create({
|
|
442
443
|
amount_shipping: '0',
|
|
@@ -453,7 +454,7 @@ async function createInvoiceWithItems(props: BaseInvoiceProps): Promise<{
|
|
|
453
454
|
collection_method: 'charge_automatically',
|
|
454
455
|
...extraProps,
|
|
455
456
|
livemode,
|
|
456
|
-
number:
|
|
457
|
+
number: invoiceNumber,
|
|
457
458
|
description,
|
|
458
459
|
statement_descriptor: statementDescriptor,
|
|
459
460
|
period_start: periodStart,
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/brace-style */
|
|
2
|
+
/* eslint-disable @typescript-eslint/indent */
|
|
3
|
+
import { fromUnitToToken } from '@ocap/util';
|
|
4
|
+
import { getUserLocale } from '../../../integrations/blocklet/notification';
|
|
5
|
+
import { translate } from '../../../locales';
|
|
6
|
+
import { Customer, PaymentCurrency } from '../../../store/models';
|
|
7
|
+
import type { BaseEmailTemplate, BaseEmailTemplateType } from './base';
|
|
8
|
+
import { formatNumber, getCustomerIndexUrl } from '../../util';
|
|
9
|
+
|
|
10
|
+
export interface CustomerCreditLowBalanceEmailTemplateOptions {
|
|
11
|
+
customerId: string;
|
|
12
|
+
currencyId: string;
|
|
13
|
+
availableAmount: string; // unit amount
|
|
14
|
+
totalAmount: string; // unit amount
|
|
15
|
+
percentage: string; // 0-100 number string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface CustomerCreditLowBalanceEmailTemplateContext {
|
|
19
|
+
locale: string;
|
|
20
|
+
userDid: string;
|
|
21
|
+
currencySymbol: string;
|
|
22
|
+
availableAmount: string; // formatted with symbol
|
|
23
|
+
totalAmount: string; // formatted with symbol
|
|
24
|
+
lowBalancePercentage: string; // with %
|
|
25
|
+
currencyName: string;
|
|
26
|
+
}
|
|
27
|
+
export class CustomerCreditLowBalanceEmailTemplate
|
|
28
|
+
implements BaseEmailTemplate<CustomerCreditLowBalanceEmailTemplateContext>
|
|
29
|
+
{
|
|
30
|
+
options: CustomerCreditLowBalanceEmailTemplateOptions;
|
|
31
|
+
|
|
32
|
+
constructor(options: CustomerCreditLowBalanceEmailTemplateOptions) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getContext(): Promise<CustomerCreditLowBalanceEmailTemplateContext> {
|
|
37
|
+
const { customerId, currencyId, availableAmount, totalAmount, percentage } = this.options;
|
|
38
|
+
|
|
39
|
+
const customer = await Customer.findByPk(customerId);
|
|
40
|
+
if (!customer) {
|
|
41
|
+
throw new Error(`Customer not found: ${customerId}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
|
|
45
|
+
if (!paymentCurrency) {
|
|
46
|
+
throw new Error(`PaymentCurrency not found: ${currencyId}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const userDid = customer.did;
|
|
50
|
+
const locale = await getUserLocale(userDid);
|
|
51
|
+
const currencySymbol = paymentCurrency.symbol;
|
|
52
|
+
|
|
53
|
+
const available = formatNumber(fromUnitToToken(availableAmount, paymentCurrency.decimal));
|
|
54
|
+
const total = formatNumber(fromUnitToToken(totalAmount, paymentCurrency.decimal));
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
locale,
|
|
58
|
+
userDid,
|
|
59
|
+
currencySymbol,
|
|
60
|
+
availableAmount: `${available}`,
|
|
61
|
+
totalAmount: `${total}`,
|
|
62
|
+
lowBalancePercentage: `${percentage}%`,
|
|
63
|
+
currencyName: paymentCurrency.name,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getTemplate(): Promise<BaseEmailTemplateType> {
|
|
68
|
+
const context = await this.getContext();
|
|
69
|
+
const { locale, userDid, availableAmount, totalAmount, lowBalancePercentage, currencyName, currencySymbol } =
|
|
70
|
+
context;
|
|
71
|
+
|
|
72
|
+
const fields = [
|
|
73
|
+
{
|
|
74
|
+
type: 'text',
|
|
75
|
+
data: {
|
|
76
|
+
type: 'plain',
|
|
77
|
+
color: '#9397A1',
|
|
78
|
+
text: translate('notification.common.account', locale),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
type: 'text',
|
|
83
|
+
data: {
|
|
84
|
+
type: 'plain',
|
|
85
|
+
text: userDid,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
data: {
|
|
91
|
+
type: 'plain',
|
|
92
|
+
color: '#9397A1',
|
|
93
|
+
text: translate('notification.creditInsufficient.availableCredit', locale),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
type: 'text',
|
|
98
|
+
data: {
|
|
99
|
+
type: 'plain',
|
|
100
|
+
color: '#FF6600',
|
|
101
|
+
text: `${availableAmount} ${currencySymbol} (${lowBalancePercentage})`,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
type: 'text',
|
|
106
|
+
data: {
|
|
107
|
+
type: 'plain',
|
|
108
|
+
color: '#9397A1',
|
|
109
|
+
text: translate('notification.creditLowBalance.totalAmount', locale),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'text',
|
|
114
|
+
data: {
|
|
115
|
+
type: 'plain',
|
|
116
|
+
text: `${totalAmount} ${currencySymbol}`,
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const actions = [
|
|
122
|
+
{
|
|
123
|
+
name: translate('notification.common.viewCreditGrant', locale),
|
|
124
|
+
title: translate('notification.common.viewCreditGrant', locale),
|
|
125
|
+
link: getCustomerIndexUrl({
|
|
126
|
+
locale,
|
|
127
|
+
userDid,
|
|
128
|
+
}),
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const template: BaseEmailTemplateType = {
|
|
133
|
+
title: translate('notification.creditLowBalance.title', locale, {
|
|
134
|
+
lowBalancePercentage,
|
|
135
|
+
currency: currencyName,
|
|
136
|
+
}),
|
|
137
|
+
body: translate('notification.creditLowBalance.body', locale, {
|
|
138
|
+
currency: currencyName,
|
|
139
|
+
availableAmount,
|
|
140
|
+
totalAmount,
|
|
141
|
+
lowBalancePercentage,
|
|
142
|
+
}),
|
|
143
|
+
attachments: [
|
|
144
|
+
{
|
|
145
|
+
type: 'section',
|
|
146
|
+
fields,
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
// @ts-ignore
|
|
150
|
+
actions,
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
return template;
|
|
154
|
+
}
|
|
155
|
+
}
|
package/api/src/libs/session.ts
CHANGED
|
@@ -296,7 +296,7 @@ export function getFastCheckoutAmount(
|
|
|
296
296
|
|
|
297
297
|
const { total, renew } = getCheckoutAmount(items, currencyId, trialing);
|
|
298
298
|
|
|
299
|
-
if (mode === 'payment') {
|
|
299
|
+
if (mode === 'payment' || mode === 'auto-recharge-auth') {
|
|
300
300
|
return total;
|
|
301
301
|
}
|
|
302
302
|
|
|
@@ -984,6 +984,11 @@ export function isCreditMeteredLineItems(lineItems: TLineItemExpanded[]) {
|
|
|
984
984
|
return lineItems.every((item) => item.price && isCreditMetered(item.price));
|
|
985
985
|
}
|
|
986
986
|
|
|
987
|
+
export function validateStripePaymentAmounts(amount: string, currency: PaymentCurrency) {
|
|
988
|
+
const minAmountInUnits = fromTokenToUnit(0.5, currency.decimal);
|
|
989
|
+
return new BN(amount).gte(new BN(minAmountInUnits)); // 0.5 USD
|
|
990
|
+
}
|
|
991
|
+
|
|
987
992
|
/**
|
|
988
993
|
* Validates payment amounts meet minimum requirements
|
|
989
994
|
* @param lineItems Line items to validate
|
package/api/src/libs/ws.ts
CHANGED
|
@@ -106,8 +106,9 @@ export function initEventBroadcast() {
|
|
|
106
106
|
events.on('customer.credit_grant.granted', (data: CreditGrant, extraParams?: Record<string, any>) => {
|
|
107
107
|
broadcast('customer.credit_grant.granted', data, extraParams);
|
|
108
108
|
});
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
|
|
110
|
+
events.on('customer.credit.low_balance', (data: Customer, extraParams?: Record<string, any>) => {
|
|
111
|
+
broadcast('customer.credit.low_balance', data, extraParams);
|
|
111
112
|
});
|
|
112
113
|
events.on('customer.credit_grant.depleted', (data: CreditGrant, extraParams?: Record<string, any>) => {
|
|
113
114
|
broadcast('customer.credit_grant.depleted', data, extraParams);
|
package/api/src/locales/en.ts
CHANGED
|
@@ -241,8 +241,8 @@ export default flat({
|
|
|
241
241
|
exhaustedBodyWithoutSubscription:
|
|
242
242
|
'Your credit is fully exhausted (remaining balance: 0). Please top up to ensure uninterrupted service.',
|
|
243
243
|
meterEventName: 'Service',
|
|
244
|
-
availableCredit: 'Available Credit',
|
|
245
|
-
requiredCredit: 'Required Credit',
|
|
244
|
+
availableCredit: 'Available Credit Amount',
|
|
245
|
+
requiredCredit: 'Required Credit Amount',
|
|
246
246
|
topUpNow: 'Top Up Now',
|
|
247
247
|
},
|
|
248
248
|
|
|
@@ -255,10 +255,10 @@ export default flat({
|
|
|
255
255
|
neverExpires: 'Never Expires',
|
|
256
256
|
},
|
|
257
257
|
|
|
258
|
-
|
|
259
|
-
title: '
|
|
260
|
-
body: 'Your
|
|
261
|
-
|
|
258
|
+
creditLowBalance: {
|
|
259
|
+
title: 'Your {currency} is below {lowBalancePercentage}',
|
|
260
|
+
body: 'Your {currency} available balance is below {lowBalancePercentage} of the total. Please top up to avoid service interruption.',
|
|
261
|
+
totalAmount: 'Total Credit Amount',
|
|
262
262
|
},
|
|
263
263
|
},
|
|
264
264
|
});
|
package/api/src/locales/zh.ts
CHANGED
|
@@ -247,10 +247,10 @@ export default flat({
|
|
|
247
247
|
neverExpires: '永不过期',
|
|
248
248
|
},
|
|
249
249
|
|
|
250
|
-
|
|
251
|
-
title: '
|
|
252
|
-
body: '
|
|
253
|
-
|
|
250
|
+
creditLowBalance: {
|
|
251
|
+
title: '您的{currency} 已低于 {lowBalancePercentage}',
|
|
252
|
+
body: '您的 {currency} 总可用额度已低于 {lowBalancePercentage},请及时充值以避免服务受限。',
|
|
253
|
+
totalAmount: '总额度',
|
|
254
254
|
},
|
|
255
255
|
},
|
|
256
256
|
});
|