payment-kit 1.18.56 → 1.19.1
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/.eslintrc.js +6 -0
- package/api/src/crons/index.ts +8 -0
- package/api/src/index.ts +4 -0
- package/api/src/libs/credit-grant.ts +146 -0
- package/api/src/libs/env.ts +1 -0
- package/api/src/libs/invoice.ts +4 -3
- package/api/src/libs/notification/template/base.ts +388 -2
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
- package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
- package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
- package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
- package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
- package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
- package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
- package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
- package/api/src/libs/payment.ts +69 -0
- package/api/src/libs/queue/index.ts +3 -2
- package/api/src/libs/session.ts +8 -0
- package/api/src/libs/subscription.ts +74 -3
- package/api/src/libs/ws.ts +23 -1
- package/api/src/locales/en.ts +33 -0
- package/api/src/locales/zh.ts +31 -0
- package/api/src/queues/credit-consume.ts +715 -0
- package/api/src/queues/credit-grant.ts +572 -0
- package/api/src/queues/notification.ts +173 -128
- package/api/src/queues/payment.ts +210 -122
- package/api/src/queues/subscription.ts +179 -0
- package/api/src/routes/checkout-sessions.ts +157 -9
- package/api/src/routes/connect/shared.ts +3 -2
- package/api/src/routes/credit-grants.ts +241 -0
- package/api/src/routes/credit-transactions.ts +208 -0
- package/api/src/routes/index.ts +8 -0
- package/api/src/routes/meter-events.ts +347 -0
- package/api/src/routes/meters.ts +219 -0
- package/api/src/routes/payment-currencies.ts +14 -2
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +14 -2
- package/api/src/routes/prices.ts +43 -0
- package/api/src/routes/pricing-table.ts +13 -7
- package/api/src/routes/products.ts +63 -4
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/subscriptions.ts +4 -0
- package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
- package/api/src/store/models/credit-grant.ts +486 -0
- package/api/src/store/models/credit-transaction.ts +268 -0
- package/api/src/store/models/customer.ts +8 -0
- package/api/src/store/models/index.ts +52 -1
- package/api/src/store/models/meter-event.ts +423 -0
- package/api/src/store/models/meter.ts +176 -0
- package/api/src/store/models/payment-currency.ts +66 -14
- package/api/src/store/models/price.ts +6 -0
- package/api/src/store/models/product.ts +2 -2
- package/api/src/store/models/subscription.ts +24 -0
- package/api/src/store/models/types.ts +28 -2
- package/api/tests/libs/subscription.spec.ts +53 -0
- package/blocklet.yml +9 -1
- package/package.json +57 -58
- package/scripts/sdk.js +233 -1
- package/src/app.tsx +10 -0
- package/src/components/actions.tsx +22 -9
- package/src/components/balance-list.tsx +40 -12
- package/src/components/collapse.tsx +33 -15
- package/src/components/copyable.tsx +8 -7
- package/src/components/currency.tsx +15 -7
- package/src/components/customer/actions.tsx +1 -5
- package/src/components/customer/credit-grant-item-list.tsx +99 -0
- package/src/components/customer/credit-overview.tsx +233 -0
- package/src/components/customer/form.tsx +7 -2
- package/src/components/customer/link.tsx +4 -12
- package/src/components/customer/notification-preference.tsx +18 -9
- package/src/components/customer/overdraft-protection.tsx +112 -41
- package/src/components/drawer-form.tsx +42 -18
- package/src/components/error.tsx +1 -5
- package/src/components/event/list.tsx +9 -10
- package/src/components/filter-toolbar.tsx +20 -19
- package/src/components/info-card.tsx +32 -18
- package/src/components/info-metric.tsx +16 -6
- package/src/components/info-row-group.tsx +1 -7
- package/src/components/info-row.tsx +30 -24
- package/src/components/invoice/action.tsx +1 -7
- package/src/components/invoice/list.tsx +34 -26
- package/src/components/invoice/recharge.tsx +5 -7
- package/src/components/invoice/table.tsx +17 -12
- package/src/components/layout/user.tsx +1 -1
- package/src/components/metadata/form.tsx +290 -94
- package/src/components/metadata/list.tsx +11 -3
- package/src/components/meter/actions.tsx +101 -0
- package/src/components/meter/add-usage-dialog.tsx +239 -0
- package/src/components/meter/events-list.tsx +657 -0
- package/src/components/meter/form.tsx +245 -0
- package/src/components/meter/products.tsx +264 -0
- package/src/components/meter/usage-guide.tsx +174 -0
- package/src/components/passport/actions.tsx +9 -4
- package/src/components/payment-currency/add.tsx +16 -3
- package/src/components/payment-currency/form.tsx +14 -6
- package/src/components/payment-intent/actions.tsx +24 -16
- package/src/components/payment-intent/list.tsx +30 -9
- package/src/components/payment-link/actions.tsx +1 -5
- package/src/components/payment-link/after-pay.tsx +4 -2
- package/src/components/payment-link/before-pay.tsx +14 -4
- package/src/components/payment-link/item.tsx +27 -6
- package/src/components/payment-link/preview.tsx +9 -9
- package/src/components/payment-link/product-select.tsx +69 -15
- package/src/components/payment-method/arcblock.tsx +8 -1
- package/src/components/payment-method/base.tsx +8 -1
- package/src/components/payment-method/bitcoin.tsx +8 -1
- package/src/components/payment-method/ethereum.tsx +8 -1
- package/src/components/payment-method/evm-rpc-input.tsx +11 -7
- package/src/components/payment-method/form.tsx +2 -7
- package/src/components/payment-method/stripe.tsx +2 -0
- package/src/components/payouts/actions.tsx +1 -5
- package/src/components/payouts/list.tsx +30 -10
- package/src/components/payouts/portal/list.tsx +11 -9
- package/src/components/price/currency-select.tsx +63 -32
- package/src/components/price/form.tsx +895 -370
- package/src/components/price/upsell-select.tsx +10 -2
- package/src/components/price/upsell.tsx +7 -2
- package/src/components/pricing-table/actions.tsx +1 -5
- package/src/components/pricing-table/customer-settings.tsx +5 -1
- package/src/components/pricing-table/payment-settings.tsx +14 -4
- package/src/components/pricing-table/preview.tsx +9 -9
- package/src/components/pricing-table/price-item.tsx +6 -1
- package/src/components/pricing-table/product-item.tsx +6 -1
- package/src/components/pricing-table/product-settings.tsx +17 -4
- package/src/components/product/actions.tsx +1 -5
- package/src/components/product/add-price.tsx +9 -7
- package/src/components/product/create.tsx +8 -9
- package/src/components/product/cross-sell-select.tsx +5 -1
- package/src/components/product/cross-sell.tsx +7 -2
- package/src/components/product/edit-price.tsx +21 -12
- package/src/components/product/features.tsx +26 -6
- package/src/components/product/form.tsx +115 -72
- package/src/components/progress-bar.tsx +1 -1
- package/src/components/refund/actions.tsx +1 -7
- package/src/components/refund/list.tsx +31 -18
- package/src/components/section/header.tsx +12 -14
- package/src/components/subscription/actions/cancel.tsx +22 -5
- package/src/components/subscription/actions/index.tsx +9 -10
- package/src/components/subscription/actions/pause.tsx +32 -6
- package/src/components/subscription/actions/slash-stake.tsx +5 -3
- package/src/components/subscription/description.tsx +12 -8
- package/src/components/subscription/items/index.tsx +31 -16
- package/src/components/subscription/items/usage-records.tsx +19 -5
- package/src/components/subscription/list.tsx +5 -7
- package/src/components/subscription/metrics.tsx +62 -15
- package/src/components/subscription/portal/actions.tsx +78 -71
- package/src/components/subscription/portal/cancel.tsx +10 -3
- package/src/components/subscription/portal/list.tsx +48 -26
- package/src/components/uploader.tsx +5 -13
- package/src/components/webhook/attempts.tsx +51 -16
- package/src/components/webhook/request-info.tsx +8 -6
- package/src/contexts/products.tsx +27 -10
- package/src/hooks/subscription.ts +34 -0
- package/src/libs/meter-utils.ts +196 -0
- package/src/libs/util.ts +4 -0
- package/src/locales/en.tsx +385 -4
- package/src/locales/zh.tsx +364 -0
- package/src/pages/admin/billing/index.tsx +61 -33
- package/src/pages/admin/billing/invoices/detail.tsx +49 -13
- package/src/pages/admin/billing/meters/create.tsx +60 -0
- package/src/pages/admin/billing/meters/detail.tsx +435 -0
- package/src/pages/admin/billing/meters/index.tsx +210 -0
- package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +90 -25
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
- package/src/pages/admin/customers/customers/detail.tsx +67 -14
- package/src/pages/admin/customers/customers/index.tsx +6 -1
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/admin/developers/events/detail.tsx +37 -11
- package/src/pages/admin/developers/index.tsx +1 -1
- package/src/pages/admin/developers/webhooks/detail.tsx +41 -11
- package/src/pages/admin/index.tsx +15 -2
- package/src/pages/admin/overview.tsx +107 -19
- package/src/pages/admin/payments/intents/detail.tsx +58 -14
- package/src/pages/admin/payments/payouts/detail.tsx +63 -15
- package/src/pages/admin/payments/refunds/detail.tsx +58 -14
- package/src/pages/admin/products/index.tsx +11 -4
- package/src/pages/admin/products/links/create.tsx +22 -4
- package/src/pages/admin/products/links/detail.tsx +43 -14
- package/src/pages/admin/products/passports/index.tsx +23 -4
- package/src/pages/admin/products/prices/actions.tsx +16 -9
- package/src/pages/admin/products/prices/detail.tsx +73 -14
- package/src/pages/admin/products/prices/list.tsx +15 -3
- package/src/pages/admin/products/pricing-tables/create.tsx +45 -12
- package/src/pages/admin/products/pricing-tables/detail.tsx +45 -14
- package/src/pages/admin/products/products/create.tsx +233 -54
- package/src/pages/admin/products/products/detail.tsx +74 -18
- package/src/pages/admin/settings/index.tsx +8 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +87 -19
- package/src/pages/admin/settings/vault-config/edit-form.tsx +42 -28
- package/src/pages/admin/settings/vault-config/index.tsx +57 -10
- package/src/pages/customer/credit-grant/detail.tsx +308 -0
- package/src/pages/customer/index.tsx +76 -17
- package/src/pages/customer/invoice/detail.tsx +63 -14
- package/src/pages/customer/invoice/past-due.tsx +11 -3
- package/src/pages/customer/payout/detail.tsx +56 -13
- package/src/pages/customer/recharge/account.tsx +78 -18
- package/src/pages/customer/recharge/subscription.tsx +86 -25
- package/src/pages/customer/refund/list.tsx +60 -24
- package/src/pages/customer/subscription/change-payment.tsx +17 -6
- package/src/pages/customer/subscription/change-plan.tsx +34 -7
- package/src/pages/customer/subscription/detail.tsx +134 -34
- package/src/pages/customer/subscription/embed.tsx +25 -5
- package/src/pages/home.tsx +26 -4
- package/src/pages/integrations/donations/edit-form.tsx +25 -9
- package/src/pages/integrations/donations/index.tsx +26 -9
- package/src/pages/integrations/donations/preview.tsx +59 -15
- package/src/pages/integrations/index.tsx +10 -1
- package/src/pages/integrations/overview.tsx +78 -17
- package/vite.config.ts +60 -30
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
// create credit grant
|
|
2
|
+
|
|
3
|
+
import { Op } from 'sequelize';
|
|
4
|
+
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
5
|
+
import dayjs from '../libs/dayjs';
|
|
6
|
+
import createQueue from '../libs/queue';
|
|
7
|
+
import {
|
|
8
|
+
CreditGrant,
|
|
9
|
+
Customer,
|
|
10
|
+
Invoice,
|
|
11
|
+
InvoiceItem,
|
|
12
|
+
PaymentCurrency,
|
|
13
|
+
Price,
|
|
14
|
+
Product,
|
|
15
|
+
TInvoiceExpanded,
|
|
16
|
+
TPaymentCurrency,
|
|
17
|
+
TPriceExpanded,
|
|
18
|
+
} from '../store/models';
|
|
19
|
+
import { events } from '../libs/event';
|
|
20
|
+
import { calculateExpiresAt, createCreditGrant } from '../libs/credit-grant';
|
|
21
|
+
import logger from '../libs/logger';
|
|
22
|
+
|
|
23
|
+
type CreditGrantJob =
|
|
24
|
+
| {
|
|
25
|
+
creditGrantId: string;
|
|
26
|
+
action: 'activate' | 'expire';
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
invoiceId: string;
|
|
30
|
+
action: 'create_from_invoice';
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleCreditGrantJob = async (job: CreditGrantJob) => {
|
|
34
|
+
logger.info('Handling credit grant job', { job });
|
|
35
|
+
if (job.action === 'create_from_invoice') {
|
|
36
|
+
await handleInvoiceCredit(job.invoiceId);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const { creditGrantId, action } = job as { creditGrantId: string; action: 'activate' | 'expire' };
|
|
41
|
+
|
|
42
|
+
const creditGrant = await CreditGrant.findByPk(creditGrantId);
|
|
43
|
+
if (!creditGrant) {
|
|
44
|
+
logger.error('Credit grant not found', { creditGrantId });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const now = dayjs().unix();
|
|
49
|
+
|
|
50
|
+
if (action === 'activate') {
|
|
51
|
+
if (creditGrant.status !== 'pending') {
|
|
52
|
+
logger.warn('Credit grant not in pending status for activation', {
|
|
53
|
+
creditGrantId,
|
|
54
|
+
status: creditGrant.status,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (creditGrant.status === 'granted') {
|
|
59
|
+
logger.info('Credit grant already granted', {
|
|
60
|
+
creditGrantId,
|
|
61
|
+
});
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (
|
|
65
|
+
(!creditGrant.effective_at || creditGrant.effective_at <= now) &&
|
|
66
|
+
(!creditGrant.expires_at || creditGrant.expires_at > now)
|
|
67
|
+
) {
|
|
68
|
+
await creditGrant.update({
|
|
69
|
+
status: 'granted',
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
logger.info('Credit grant activated', {
|
|
73
|
+
creditGrantId,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (creditGrant.expires_at) {
|
|
77
|
+
await addCreditGrantJob(creditGrant, 'expire', creditGrant.expires_at);
|
|
78
|
+
logger.info('Credit grant expire job scheduled', {
|
|
79
|
+
creditGrantId,
|
|
80
|
+
expiresAt: creditGrant.expires_at,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (action === 'expire') {
|
|
87
|
+
// 检查是否到了过期时间
|
|
88
|
+
if (creditGrant.status === 'expired') {
|
|
89
|
+
logger.info('Credit grant already expired', {
|
|
90
|
+
creditGrantId,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (creditGrant.expires_at && creditGrant.expires_at <= now) {
|
|
95
|
+
await creditGrant.update({
|
|
96
|
+
status: 'expired',
|
|
97
|
+
});
|
|
98
|
+
logger.info('Credit grant expired', {
|
|
99
|
+
creditGrantId,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export const creditGrantQueue = createQueue<CreditGrantJob>({
|
|
106
|
+
name: 'credit-grant',
|
|
107
|
+
onJob: handleCreditGrantJob,
|
|
108
|
+
options: {
|
|
109
|
+
concurrency: 5,
|
|
110
|
+
maxRetries: 5,
|
|
111
|
+
retryDelay: 5000,
|
|
112
|
+
enableScheduledJob: true,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 添加信用授予任务
|
|
117
|
+
export async function addCreditGrantJob(creditGrant: CreditGrant, action: 'activate' | 'expire', runAt?: number) {
|
|
118
|
+
const jobId = `${action}-${creditGrant.id}`;
|
|
119
|
+
|
|
120
|
+
let scheduledTime: number | undefined;
|
|
121
|
+
if (action === 'activate') {
|
|
122
|
+
scheduledTime = runAt || creditGrant.effective_at;
|
|
123
|
+
}
|
|
124
|
+
if (action === 'expire') {
|
|
125
|
+
scheduledTime = runAt || creditGrant.expires_at;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!scheduledTime) {
|
|
129
|
+
logger.warn(`No scheduled time for credit grant ${action}`, {
|
|
130
|
+
creditGrantId: creditGrant.id,
|
|
131
|
+
action,
|
|
132
|
+
effective_at: creditGrant.effective_at,
|
|
133
|
+
expires_at: creditGrant.expires_at,
|
|
134
|
+
});
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
139
|
+
if (existingJob) {
|
|
140
|
+
await creditGrantQueue.delete(jobId);
|
|
141
|
+
logger.info(`Credit grant ${action} job replaced`, { creditGrantId: creditGrant.id });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
await creditGrantQueue.push({
|
|
145
|
+
id: jobId,
|
|
146
|
+
job: {
|
|
147
|
+
creditGrantId: creditGrant.id,
|
|
148
|
+
action,
|
|
149
|
+
},
|
|
150
|
+
runAt: scheduledTime,
|
|
151
|
+
persist: true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
logger.info(`Credit grant ${action} job scheduled`, {
|
|
155
|
+
creditGrantId: creditGrant.id,
|
|
156
|
+
action,
|
|
157
|
+
runAt: scheduledTime,
|
|
158
|
+
scheduledAt: dayjs.unix(scheduledTime).format(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function scheduleCreditGrantJobs(creditGrant: CreditGrant) {
|
|
163
|
+
const now = dayjs().unix();
|
|
164
|
+
|
|
165
|
+
if (creditGrant.status === 'pending') {
|
|
166
|
+
if (creditGrant.effective_at && creditGrant.effective_at > now) {
|
|
167
|
+
await addCreditGrantJob(creditGrant, 'activate');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!creditGrant.effective_at || creditGrant.effective_at <= now) {
|
|
171
|
+
if (!creditGrant.expires_at || creditGrant.expires_at > now) {
|
|
172
|
+
await creditGrant.update({ status: 'granted' });
|
|
173
|
+
logger.info('Credit grant immediately activated', {
|
|
174
|
+
creditGrantId: creditGrant.id,
|
|
175
|
+
customerId: creditGrant.customer_id,
|
|
176
|
+
});
|
|
177
|
+
if (creditGrant.expires_at) {
|
|
178
|
+
await addCreditGrantJob(creditGrant, 'expire');
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
await creditGrant.update({ status: 'expired' });
|
|
182
|
+
logger.info('Credit grant immediately expired', {
|
|
183
|
+
creditGrantId: creditGrant.id,
|
|
184
|
+
customerId: creditGrant.customer_id,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (creditGrant.status === 'granted' && creditGrant.expires_at) {
|
|
190
|
+
await addCreditGrantJob(creditGrant, 'expire');
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export const startCreditGrantQueue = async () => {
|
|
195
|
+
logger.info('Starting credit grant queue...');
|
|
196
|
+
|
|
197
|
+
const grantsToSchedule = await CreditGrant.findAll({
|
|
198
|
+
where: {
|
|
199
|
+
[Op.or]: [
|
|
200
|
+
{ status: 'pending' },
|
|
201
|
+
{
|
|
202
|
+
status: 'granted',
|
|
203
|
+
expires_at: { [Op.gt]: 0 },
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
},
|
|
207
|
+
order: [['created_at', 'ASC']],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const invoicesToSchedule = await Invoice.findAll({
|
|
211
|
+
where: {
|
|
212
|
+
status: 'paid',
|
|
213
|
+
metadata: {
|
|
214
|
+
credit_grant_processed: 'failed',
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
order: [['created_at', 'ASC']],
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const invoiceResults = await Promise.allSettled(
|
|
221
|
+
invoicesToSchedule.map(async (invoice) => {
|
|
222
|
+
await addInvoiceCreditJob(invoice.id);
|
|
223
|
+
return invoice.id;
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
const failedInvoices = invoiceResults.filter((r) => r.status === 'rejected').map((r) => r.reason);
|
|
228
|
+
if (failedInvoices.length > 0) {
|
|
229
|
+
logger.warn(`Failed to schedule jobs for ${failedInvoices.length} invoices`, {
|
|
230
|
+
failedInvoices,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const results = await Promise.allSettled(
|
|
235
|
+
grantsToSchedule.map(async (grant) => {
|
|
236
|
+
await scheduleCreditGrantJobs(grant);
|
|
237
|
+
return grant.id;
|
|
238
|
+
})
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
242
|
+
const succeeded = results.filter((r) => r.status === 'fulfilled').length;
|
|
243
|
+
|
|
244
|
+
if (failed > 0) {
|
|
245
|
+
logger.warn(`Failed to schedule jobs for ${failed} credit grants`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
logger.info('Credit grant queue started', {
|
|
249
|
+
totalGrants: grantsToSchedule.length,
|
|
250
|
+
succeeded,
|
|
251
|
+
failed,
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
creditGrantQueue.on('failed', ({ id, job, error }) => {
|
|
256
|
+
logger.error('Credit grant job failed', { id, job, error });
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
creditGrantQueue.on('finished', ({ id, job, result }) => {
|
|
260
|
+
logger.info('Credit grant job completed', { id, job, result });
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
async function handleInvoiceCredit(invoiceId: string) {
|
|
264
|
+
try {
|
|
265
|
+
const invoice = (await Invoice.findByPk(invoiceId, {
|
|
266
|
+
include: [
|
|
267
|
+
{
|
|
268
|
+
model: Customer,
|
|
269
|
+
as: 'customer',
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
model: InvoiceItem,
|
|
273
|
+
as: 'lines',
|
|
274
|
+
},
|
|
275
|
+
],
|
|
276
|
+
})) as TInvoiceExpanded | null;
|
|
277
|
+
|
|
278
|
+
if (!invoice) {
|
|
279
|
+
logger.info('Invoice not found', { invoiceId });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!invoice.customer) {
|
|
284
|
+
logger.warn('customer not found', { invoiceId });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const lineItems = invoice.lines || [];
|
|
289
|
+
|
|
290
|
+
if (!Array.isArray(lineItems) || lineItems.length === 0) {
|
|
291
|
+
logger.info('No line items found in invoice', { invoiceId });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const items = await Promise.all(
|
|
296
|
+
lineItems.map(async (lineItem) => {
|
|
297
|
+
const price = (await Price.findByPk(lineItem.price_id, {
|
|
298
|
+
include: [{ model: Product, as: 'product' }],
|
|
299
|
+
})) as TPriceExpanded | null;
|
|
300
|
+
|
|
301
|
+
if (!price) {
|
|
302
|
+
logger.warn('Price not found', { priceId: lineItem.price_id, invoiceId });
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!price.product || price.product.type !== 'credit') {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const metadata = price.metadata || {};
|
|
311
|
+
const creditConfig = metadata.credit_config || {};
|
|
312
|
+
const quantity = lineItem.quantity || 1;
|
|
313
|
+
const creditAmount: BN = new BN(creditConfig.credit_amount ?? 1).mul(new BN(quantity));
|
|
314
|
+
|
|
315
|
+
const currency = await PaymentCurrency.findByPk(creditConfig.currency_id);
|
|
316
|
+
|
|
317
|
+
if (!currency) {
|
|
318
|
+
logger.error('Currency not found', { currencyId: creditConfig.currency_id, invoiceId });
|
|
319
|
+
throw new Error(`Currency not found: ${creditConfig.currency_id}`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
let applicabilityConfig: any = {
|
|
323
|
+
scope: {
|
|
324
|
+
type: 'metered',
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
let applicablePrices: string[] = [];
|
|
329
|
+
if (creditConfig.applicable_prices) {
|
|
330
|
+
applicablePrices = Array.isArray(creditConfig.applicable_prices)
|
|
331
|
+
? creditConfig.applicable_prices
|
|
332
|
+
: creditConfig.applicable_prices.split(',');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (applicablePrices.length > 0) {
|
|
336
|
+
applicabilityConfig = {
|
|
337
|
+
scope: {
|
|
338
|
+
prices: applicablePrices,
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
price,
|
|
344
|
+
quantity,
|
|
345
|
+
metadata,
|
|
346
|
+
creditAmount,
|
|
347
|
+
applicablePrices,
|
|
348
|
+
expiredAt: calculateExpiresAt(
|
|
349
|
+
parseInt(String(creditConfig?.valid_duration_value || '0'), 10),
|
|
350
|
+
creditConfig?.valid_duration_unit || 'days'
|
|
351
|
+
),
|
|
352
|
+
applicabilityConfig,
|
|
353
|
+
name: price.nickname || price.product.name,
|
|
354
|
+
currency: currency as TPaymentCurrency,
|
|
355
|
+
};
|
|
356
|
+
})
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const validItems = items.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
360
|
+
|
|
361
|
+
const processedMetadata = {
|
|
362
|
+
...invoice.metadata,
|
|
363
|
+
credit_grant_processed: 'completed',
|
|
364
|
+
credit_grant_completed_at: dayjs().unix(),
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
if (validItems.length === 0) {
|
|
368
|
+
logger.info('No credit products found in invoice', { invoiceId });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const existingGrants = await CreditGrant.findAll({
|
|
373
|
+
where: {
|
|
374
|
+
customer_id: invoice.customer.id,
|
|
375
|
+
metadata: {
|
|
376
|
+
invoice_id: invoice.id,
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const existingPriceIds = new Set(existingGrants.map((grant) => grant.metadata?.price_id).filter(Boolean));
|
|
382
|
+
const grantsToCreate = validItems.filter((item) => !existingPriceIds.has(item.price.id));
|
|
383
|
+
|
|
384
|
+
if (grantsToCreate.length === 0) {
|
|
385
|
+
logger.info('All credit grants already exist for invoice', {
|
|
386
|
+
invoiceId,
|
|
387
|
+
totalItems: validItems.length,
|
|
388
|
+
existingCount: existingGrants.length,
|
|
389
|
+
});
|
|
390
|
+
// 标记为已完成
|
|
391
|
+
await Invoice.update(
|
|
392
|
+
{
|
|
393
|
+
metadata: processedMetadata,
|
|
394
|
+
},
|
|
395
|
+
{ where: { id: invoiceId } }
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 创建 Credit Grants
|
|
401
|
+
await Invoice.update(
|
|
402
|
+
{
|
|
403
|
+
metadata: {
|
|
404
|
+
...invoice.metadata,
|
|
405
|
+
credit_grant_processed: 'processing',
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
{ where: { id: invoiceId } }
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
try {
|
|
412
|
+
const createPromises = grantsToCreate.map(async (item) => {
|
|
413
|
+
const creditGrant = await createCreditGrant({
|
|
414
|
+
amount: fromTokenToUnit(item.creditAmount.toString(), item.currency.decimal).toString(),
|
|
415
|
+
currency_id: item.currency.id,
|
|
416
|
+
customer_id: invoice.customer.id,
|
|
417
|
+
name: item.price.nickname || item.price.product?.name,
|
|
418
|
+
category: 'paid',
|
|
419
|
+
priority: parseInt(String(item.metadata.credit_config?.priority || '50'), 10),
|
|
420
|
+
expires_at: item.expiredAt || undefined,
|
|
421
|
+
applicability_config: item.applicabilityConfig,
|
|
422
|
+
livemode: invoice.livemode,
|
|
423
|
+
created_via: 'invoice',
|
|
424
|
+
metadata: {
|
|
425
|
+
price_id: item.price.id,
|
|
426
|
+
invoice_id: invoice.id,
|
|
427
|
+
purchased_quantity: item.quantity,
|
|
428
|
+
},
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
logger.info('Credit Grant created from invoice', {
|
|
432
|
+
invoiceId,
|
|
433
|
+
customerId: invoice.customer.id,
|
|
434
|
+
productId: item.price.product_id,
|
|
435
|
+
priceId: item.price.id,
|
|
436
|
+
creditAmount: item.creditAmount.toString(),
|
|
437
|
+
expiresAt: item.expiredAt,
|
|
438
|
+
creditGrantId: creditGrant.id,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
return creditGrant;
|
|
442
|
+
});
|
|
443
|
+
await Promise.all(createPromises);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
logger.error('Failed to create Credit Grant from invoice', {
|
|
446
|
+
invoiceId,
|
|
447
|
+
error: error.message,
|
|
448
|
+
stack: error.stack,
|
|
449
|
+
});
|
|
450
|
+
await Invoice.update(
|
|
451
|
+
{
|
|
452
|
+
metadata: {
|
|
453
|
+
...invoice.metadata,
|
|
454
|
+
credit_grant_processed: 'failed',
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
{ where: { id: invoiceId } }
|
|
458
|
+
);
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 所有创建成功,标记为已完成
|
|
463
|
+
await Invoice.update(
|
|
464
|
+
{
|
|
465
|
+
metadata: processedMetadata,
|
|
466
|
+
},
|
|
467
|
+
{ where: { id: invoiceId } }
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
logger.info('All Credit Grants created successfully for invoice', {
|
|
471
|
+
invoiceId,
|
|
472
|
+
createdCount: grantsToCreate.length,
|
|
473
|
+
totalCreditItems: validItems.length,
|
|
474
|
+
});
|
|
475
|
+
} catch (error) {
|
|
476
|
+
logger.error('Failed to create Credit Grant from invoice', {
|
|
477
|
+
invoiceId,
|
|
478
|
+
error: error.message,
|
|
479
|
+
stack: error.stack,
|
|
480
|
+
});
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export async function addInvoiceCreditJob(invoiceId: string) {
|
|
486
|
+
const jobId = `invoice-credit-${invoiceId}`;
|
|
487
|
+
|
|
488
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
489
|
+
if (existingJob) {
|
|
490
|
+
logger.info('Invoice credit job already exists, skipping duplicate', {
|
|
491
|
+
invoiceId,
|
|
492
|
+
jobId,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
await creditGrantQueue.push({
|
|
497
|
+
id: jobId,
|
|
498
|
+
job: {
|
|
499
|
+
invoiceId,
|
|
500
|
+
action: 'create_from_invoice',
|
|
501
|
+
},
|
|
502
|
+
persist: true,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
logger.info('Invoice credit job scheduled', {
|
|
506
|
+
invoiceId,
|
|
507
|
+
jobId,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
events.on('invoice.paid', async (invoice: Invoice) => {
|
|
512
|
+
try {
|
|
513
|
+
await addInvoiceCreditJob(invoice.id);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
logger.error('Failed to schedule credit grant job for invoice', {
|
|
516
|
+
invoiceId: invoice.id,
|
|
517
|
+
error: error.message,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
events.on('customer.credit_grant.created', async (creditGrant: CreditGrant) => {
|
|
523
|
+
try {
|
|
524
|
+
await scheduleCreditGrantJobs(creditGrant);
|
|
525
|
+
logger.info('Credit grant jobs scheduled', {
|
|
526
|
+
creditGrantId: creditGrant.id,
|
|
527
|
+
customerId: creditGrant.customer_id,
|
|
528
|
+
});
|
|
529
|
+
} catch (error) {
|
|
530
|
+
logger.error('Failed to schedule credit grant jobs', {
|
|
531
|
+
creditGrantId: creditGrant.id,
|
|
532
|
+
error: error.message,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
events.on('credit-grant.queued', async (id, job, args = {}) => {
|
|
538
|
+
const { sync, ...extraArgs } = args;
|
|
539
|
+
if (sync) {
|
|
540
|
+
try {
|
|
541
|
+
await creditGrantQueue.pushAndWait({
|
|
542
|
+
id,
|
|
543
|
+
job,
|
|
544
|
+
...extraArgs,
|
|
545
|
+
});
|
|
546
|
+
events.emit('credit-grant.queued.done', { id });
|
|
547
|
+
} catch (error) {
|
|
548
|
+
logger.error('Error in credit-grant.queued', { id, job, error });
|
|
549
|
+
events.emit('credit-grant.queued.error', { id, job, error });
|
|
550
|
+
}
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
try {
|
|
554
|
+
const existJob = await creditGrantQueue.get(id);
|
|
555
|
+
if (existJob) {
|
|
556
|
+
await creditGrantQueue.delete(id);
|
|
557
|
+
logger.info('Removed existing invoice job for immediate execution', {
|
|
558
|
+
id,
|
|
559
|
+
originalRunAt: existJob.runAt,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
creditGrantQueue.push({
|
|
563
|
+
id,
|
|
564
|
+
job,
|
|
565
|
+
...extraArgs,
|
|
566
|
+
});
|
|
567
|
+
events.emit('credit-grant.queued.done', { id });
|
|
568
|
+
} catch (error) {
|
|
569
|
+
logger.error('Error in credit-grant.queued', { id, job, error });
|
|
570
|
+
events.emit('credit-grant.queued.error', { id, job, error });
|
|
571
|
+
}
|
|
572
|
+
});
|