payment-kit 1.19.0 → 1.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/api/src/crons/index.ts +8 -0
  2. package/api/src/index.ts +4 -0
  3. package/api/src/libs/credit-grant.ts +146 -0
  4. package/api/src/libs/env.ts +1 -0
  5. package/api/src/libs/invoice.ts +4 -3
  6. package/api/src/libs/notification/template/base.ts +388 -2
  7. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +149 -0
  8. package/api/src/libs/notification/template/customer-credit-grant-low-balance.ts +151 -0
  9. package/api/src/libs/notification/template/customer-credit-insufficient.ts +254 -0
  10. package/api/src/libs/notification/template/subscription-canceled.ts +193 -202
  11. package/api/src/libs/notification/template/subscription-refund-succeeded.ts +215 -237
  12. package/api/src/libs/notification/template/subscription-renewed.ts +130 -200
  13. package/api/src/libs/notification/template/subscription-succeeded.ts +100 -202
  14. package/api/src/libs/notification/template/subscription-trial-start.ts +142 -188
  15. package/api/src/libs/notification/template/subscription-trial-will-end.ts +146 -174
  16. package/api/src/libs/notification/template/subscription-upgraded.ts +96 -192
  17. package/api/src/libs/notification/template/subscription-will-canceled.ts +94 -135
  18. package/api/src/libs/notification/template/subscription-will-renew.ts +220 -245
  19. package/api/src/libs/payment.ts +69 -0
  20. package/api/src/libs/queue/index.ts +3 -2
  21. package/api/src/libs/session.ts +8 -0
  22. package/api/src/libs/subscription.ts +74 -3
  23. package/api/src/libs/util.ts +3 -1
  24. package/api/src/libs/ws.ts +23 -1
  25. package/api/src/locales/en.ts +33 -0
  26. package/api/src/locales/zh.ts +31 -0
  27. package/api/src/queues/credit-consume.ts +728 -0
  28. package/api/src/queues/credit-grant.ts +572 -0
  29. package/api/src/queues/notification.ts +173 -128
  30. package/api/src/queues/payment.ts +210 -122
  31. package/api/src/queues/subscription.ts +179 -0
  32. package/api/src/routes/checkout-sessions.ts +157 -9
  33. package/api/src/routes/connect/shared.ts +3 -2
  34. package/api/src/routes/credit-grants.ts +241 -0
  35. package/api/src/routes/credit-transactions.ts +208 -0
  36. package/api/src/routes/customers.ts +34 -5
  37. package/api/src/routes/index.ts +8 -0
  38. package/api/src/routes/meter-events.ts +347 -0
  39. package/api/src/routes/meters.ts +219 -0
  40. package/api/src/routes/payment-currencies.ts +20 -2
  41. package/api/src/routes/payment-links.ts +1 -1
  42. package/api/src/routes/payment-methods.ts +14 -2
  43. package/api/src/routes/prices.ts +43 -0
  44. package/api/src/routes/pricing-table.ts +13 -7
  45. package/api/src/routes/products.ts +63 -4
  46. package/api/src/routes/settings.ts +1 -1
  47. package/api/src/routes/subscriptions.ts +4 -0
  48. package/api/src/routes/webhook-endpoints.ts +0 -3
  49. package/api/src/store/migrations/20250610-billing-credit.ts +43 -0
  50. package/api/src/store/models/credit-grant.ts +486 -0
  51. package/api/src/store/models/credit-transaction.ts +268 -0
  52. package/api/src/store/models/customer.ts +8 -0
  53. package/api/src/store/models/index.ts +52 -1
  54. package/api/src/store/models/meter-event.ts +423 -0
  55. package/api/src/store/models/meter.ts +176 -0
  56. package/api/src/store/models/payment-currency.ts +66 -14
  57. package/api/src/store/models/price.ts +6 -0
  58. package/api/src/store/models/product.ts +2 -2
  59. package/api/src/store/models/subscription.ts +24 -0
  60. package/api/src/store/models/types.ts +28 -2
  61. package/api/tests/libs/subscription.spec.ts +53 -0
  62. package/blocklet.yml +9 -1
  63. package/package.json +4 -4
  64. package/scripts/sdk.js +233 -1
  65. package/src/app.tsx +10 -0
  66. package/src/components/collapse.tsx +11 -1
  67. package/src/components/conditional-section.tsx +87 -0
  68. package/src/components/customer/credit-grant-item-list.tsx +99 -0
  69. package/src/components/customer/credit-overview.tsx +246 -0
  70. package/src/components/customer/form.tsx +7 -3
  71. package/src/components/invoice/list.tsx +19 -1
  72. package/src/components/metadata/form.tsx +287 -91
  73. package/src/components/meter/actions.tsx +101 -0
  74. package/src/components/meter/add-usage-dialog.tsx +239 -0
  75. package/src/components/meter/events-list.tsx +657 -0
  76. package/src/components/meter/form.tsx +245 -0
  77. package/src/components/meter/products.tsx +264 -0
  78. package/src/components/meter/usage-guide.tsx +174 -0
  79. package/src/components/payment-currency/form.tsx +2 -0
  80. package/src/components/payment-intent/list.tsx +19 -1
  81. package/src/components/payment-link/item.tsx +2 -2
  82. package/src/components/payment-link/preview.tsx +1 -1
  83. package/src/components/payment-link/product-select.tsx +52 -12
  84. package/src/components/payment-method/arcblock.tsx +2 -0
  85. package/src/components/payment-method/base.tsx +2 -0
  86. package/src/components/payment-method/bitcoin.tsx +2 -0
  87. package/src/components/payment-method/ethereum.tsx +2 -0
  88. package/src/components/payment-method/stripe.tsx +2 -0
  89. package/src/components/payouts/list.tsx +19 -1
  90. package/src/components/payouts/portal/list.tsx +6 -11
  91. package/src/components/price/currency-select.tsx +56 -32
  92. package/src/components/price/form.tsx +912 -407
  93. package/src/components/pricing-table/preview.tsx +1 -1
  94. package/src/components/product/add-price.tsx +9 -7
  95. package/src/components/product/create.tsx +7 -4
  96. package/src/components/product/edit-price.tsx +21 -12
  97. package/src/components/product/features.tsx +17 -7
  98. package/src/components/product/form.tsx +100 -90
  99. package/src/components/refund/list.tsx +19 -1
  100. package/src/components/section/header.tsx +5 -18
  101. package/src/components/subscription/items/index.tsx +1 -1
  102. package/src/components/subscription/metrics.tsx +37 -5
  103. package/src/components/subscription/portal/actions.tsx +2 -1
  104. package/src/contexts/products.tsx +26 -9
  105. package/src/hooks/subscription.ts +34 -0
  106. package/src/libs/meter-utils.ts +196 -0
  107. package/src/libs/util.ts +4 -0
  108. package/src/locales/en.tsx +389 -5
  109. package/src/locales/zh.tsx +368 -1
  110. package/src/pages/admin/billing/index.tsx +61 -33
  111. package/src/pages/admin/billing/invoices/detail.tsx +1 -1
  112. package/src/pages/admin/billing/meters/create.tsx +60 -0
  113. package/src/pages/admin/billing/meters/detail.tsx +435 -0
  114. package/src/pages/admin/billing/meters/index.tsx +210 -0
  115. package/src/pages/admin/billing/meters/meter-event.tsx +346 -0
  116. package/src/pages/admin/billing/subscriptions/detail.tsx +47 -14
  117. package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
  118. package/src/pages/admin/customers/customers/detail.tsx +14 -10
  119. package/src/pages/admin/customers/index.tsx +5 -0
  120. package/src/pages/admin/developers/events/detail.tsx +1 -1
  121. package/src/pages/admin/developers/index.tsx +1 -1
  122. package/src/pages/admin/payments/intents/detail.tsx +1 -1
  123. package/src/pages/admin/payments/payouts/detail.tsx +1 -1
  124. package/src/pages/admin/payments/refunds/detail.tsx +1 -1
  125. package/src/pages/admin/products/index.tsx +3 -2
  126. package/src/pages/admin/products/links/detail.tsx +1 -1
  127. package/src/pages/admin/products/prices/actions.tsx +16 -4
  128. package/src/pages/admin/products/prices/detail.tsx +30 -3
  129. package/src/pages/admin/products/prices/list.tsx +8 -1
  130. package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
  131. package/src/pages/admin/products/products/create.tsx +233 -57
  132. package/src/pages/admin/products/products/detail.tsx +2 -1
  133. package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
  134. package/src/pages/customer/credit-grant/detail.tsx +308 -0
  135. package/src/pages/customer/index.tsx +44 -9
  136. package/src/pages/customer/recharge/account.tsx +5 -5
  137. package/src/pages/customer/subscription/change-payment.tsx +4 -2
  138. package/src/pages/customer/subscription/detail.tsx +48 -14
  139. package/src/pages/customer/subscription/embed.tsx +1 -1
@@ -0,0 +1,728 @@
1
+ import { BN, fromUnitToToken } from '@ocap/util';
2
+
3
+ import { getLock } from '../libs/lock';
4
+ import logger from '../libs/logger';
5
+ import createQueue from '../libs/queue';
6
+ import { createEvent } from '../libs/audit';
7
+ import {
8
+ MeterEvent,
9
+ CreditGrant,
10
+ CreditTransaction,
11
+ Meter,
12
+ Customer,
13
+ Subscription,
14
+ PaymentCurrency,
15
+ TMeterExpanded,
16
+ } from '../store/models';
17
+
18
+ import { MAX_RETRY_COUNT, getNextRetry } from '../libs/util';
19
+ import { getDaysUntilCancel, getDueUnit, getMeterPriceIdsFromSubscription } from '../libs/subscription';
20
+ import { events } from '../libs/event';
21
+ import { handlePastDueSubscriptionRecovery } from './payment';
22
+
23
+ type CreditConsumptionJob = {
24
+ meterEventId: string;
25
+ };
26
+
27
+ type CreditConsumptionContext = {
28
+ meterEvent: MeterEvent;
29
+ meter: TMeterExpanded;
30
+ customer: Customer;
31
+ subscription?: Subscription;
32
+ priceIds?: string[];
33
+ transactions: string[];
34
+ warnings: string[];
35
+ };
36
+
37
+ type CreditConsumptionResult = {
38
+ consumed: string;
39
+ pending: string;
40
+ available_balance: string;
41
+ fully_consumed: boolean;
42
+ };
43
+
44
+ async function validateAndLoadData(meterEventId: string): Promise<CreditConsumptionContext | null> {
45
+ const meterEvent = await MeterEvent.findByPk(meterEventId);
46
+ if (!meterEvent) {
47
+ logger.warn('Skipping credit consumption job: MeterEvent not found', { meterEventId });
48
+ return null;
49
+ }
50
+
51
+ if (meterEvent.status === 'completed' || meterEvent.status === 'canceled') {
52
+ logger.info('Skipping credit consumption job: MeterEvent already processed', {
53
+ meterEventId,
54
+ status: meterEvent.status,
55
+ });
56
+ return null;
57
+ }
58
+
59
+ if (meterEvent.status === 'requires_action') {
60
+ logger.warn('Skipping credit consumption job: MeterEvent requires manual intervention', {
61
+ meterEventId,
62
+ attemptCount: meterEvent.attempt_count,
63
+ });
64
+ return null;
65
+ }
66
+
67
+ const meter = (await Meter.findOne({
68
+ where: { event_name: meterEvent.event_name },
69
+ include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
70
+ })) as TMeterExpanded | null;
71
+
72
+ if (!meter) {
73
+ logger.warn('Skipping credit consumption job: Meter not found', {
74
+ meterEventId,
75
+ eventName: meterEvent.event_name,
76
+ });
77
+ return null;
78
+ }
79
+
80
+ if (!meter.currency_id) {
81
+ logger.warn('Skipping credit consumption job: Meter missing currency_id', {
82
+ meterEventId,
83
+ meterId: meter.id,
84
+ });
85
+ return null;
86
+ }
87
+
88
+ const customerId = meterEvent.getCustomerId();
89
+ if (!customerId) {
90
+ logger.warn('Skipping credit consumption job: MeterEvent missing customer_id', { meterEventId });
91
+ return null;
92
+ }
93
+
94
+ const customer = await Customer.findByPk(customerId);
95
+ if (!customer) {
96
+ logger.warn('Skipping credit consumption job: Customer not found', {
97
+ meterEventId,
98
+ customerId,
99
+ });
100
+ return null;
101
+ }
102
+
103
+ const subscriptionId = meterEvent.getSubscriptionId();
104
+ if (subscriptionId) {
105
+ const subscription = await Subscription.findByPk(subscriptionId);
106
+ if (!subscription) {
107
+ logger.warn('Skipping credit consumption job: Subscription not found', {
108
+ meterEventId,
109
+ subscriptionId,
110
+ });
111
+ return null;
112
+ }
113
+
114
+ const priceIds = await getMeterPriceIdsFromSubscription(subscription);
115
+
116
+ return {
117
+ meterEvent,
118
+ meter,
119
+ customer,
120
+ subscription: subscription!,
121
+ transactions: [],
122
+ warnings: [],
123
+ priceIds,
124
+ };
125
+ }
126
+
127
+ return {
128
+ meterEvent,
129
+ meter,
130
+ customer,
131
+ transactions: [],
132
+ warnings: [],
133
+ };
134
+ }
135
+
136
+ async function consumeAvailableCredits(
137
+ context: CreditConsumptionContext,
138
+ totalRequiredAmount: string
139
+ ): Promise<CreditConsumptionResult> {
140
+ const customerId = context.meterEvent.getCustomerId();
141
+ const currencyId = context.meter.currency_id!;
142
+ const meterEventId = context.meterEvent.id;
143
+
144
+ const existingTransactions = await CreditTransaction.findAll({
145
+ where: {
146
+ source: meterEventId,
147
+ },
148
+ });
149
+
150
+ const alreadyConsumed = existingTransactions.reduce((sum, tx) => sum.add(new BN(tx.credit_amount)), new BN(0));
151
+ const remainingToConsume = new BN(totalRequiredAmount).sub(alreadyConsumed);
152
+
153
+ // 如果已经完全消费,直接返回结果
154
+ if (remainingToConsume.lte(new BN(0))) {
155
+ logger.info('Credit consumption already completed', {
156
+ meterEventId,
157
+ totalRequiredAmount,
158
+ alreadyConsumed: alreadyConsumed.toString(),
159
+ });
160
+
161
+ context.transactions.push(...existingTransactions.map((tx) => tx.id));
162
+
163
+ return {
164
+ consumed: alreadyConsumed.toString(),
165
+ pending: '0',
166
+ available_balance: '0',
167
+ fully_consumed: true,
168
+ };
169
+ }
170
+
171
+ // Get all available grants sorted by priority
172
+ const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId, context.priceIds);
173
+
174
+ // Calculate total available balance
175
+ const totalAvailable = availableGrants.reduce((sum, grant) => sum.add(new BN(grant.remaining_amount)), new BN(0));
176
+ logger.debug('Total available credits calculated', { totalAvailable: totalAvailable.toString() });
177
+
178
+ let remaining = remainingToConsume;
179
+ let consumed = new BN(0);
180
+
181
+ // Consume credits from grants in priority order
182
+ for (const grant of availableGrants) {
183
+ if (remaining.lte(new BN(0))) break;
184
+
185
+ const availableInGrant = new BN(grant.remaining_amount);
186
+
187
+ // if remaining is less than availableInGrant, consume remaining
188
+ // otherwise, consume availableInGrant
189
+ const consumeAmount = remaining.lte(availableInGrant) ? remaining : availableInGrant;
190
+
191
+ // if consumeAmount is greater than 0, process grant consumption
192
+ if (consumeAmount.gt(new BN(0))) {
193
+ await processGrantConsumption(context, grant, consumeAmount.toString()); // eslint-disable-line no-await-in-loop
194
+ consumed = consumed.add(consumeAmount);
195
+ remaining = remaining.sub(consumeAmount);
196
+ }
197
+ }
198
+
199
+ const totalConsumed = alreadyConsumed.add(consumed);
200
+ const finalPending = new BN(totalRequiredAmount).sub(totalConsumed);
201
+
202
+ logger.info('Consumed credits', {
203
+ customerId,
204
+ currencyId,
205
+ totalRequiredAmount,
206
+ alreadyConsumed: alreadyConsumed.toString(),
207
+ newlyConsumed: consumed.toString(),
208
+ totalConsumed: totalConsumed.toString(),
209
+ finalPending: finalPending.toString(),
210
+ });
211
+
212
+ const pendingAmount = Math.max(0, finalPending.toNumber()).toString();
213
+ const remainingBalance = totalAvailable.sub(consumed).toString();
214
+
215
+ // 如果无法完全消费所需额度,记录不足事件
216
+ if (finalPending.gt(new BN(0))) {
217
+ logger.warn('Insufficient credit balance', {
218
+ customerId,
219
+ currencyId,
220
+ totalRequiredAmount,
221
+ availableAmount: totalAvailable.toString(),
222
+ totalConsumed: totalConsumed.toString(),
223
+ pendingAmount,
224
+ });
225
+ if ((context.subscription && context.subscription.isActive()) || !context.subscription) {
226
+ await createEvent('Customer', 'customer.credit.insufficient', context.customer, {
227
+ metadata: {
228
+ meter_event_id: context.meterEvent.id,
229
+ meter_event_name: context.meterEvent.event_name,
230
+ required_amount: remainingToConsume.toString(),
231
+ available_amount: totalAvailable.toString(),
232
+ consumed_amount: consumed.toString(),
233
+ pending_amount: pendingAmount,
234
+ currency_id: currencyId,
235
+ subscription_id: context.subscription?.id,
236
+ },
237
+ }).catch(console.error);
238
+ }
239
+
240
+ // 如果有关联订阅且订阅活跃,将其标记为逾期
241
+ if (context.subscription && context.subscription.isActive()) {
242
+ const cancelUpdates: { [key: string]: any } = {};
243
+ const { interval } = context.subscription.pending_invoice_item_interval;
244
+ const dueUnit = getDueUnit(interval);
245
+ const daysUntilCancel = getDaysUntilCancel(context.subscription);
246
+ if (daysUntilCancel > 0) {
247
+ cancelUpdates.cancel_at = context.subscription.current_period_start + daysUntilCancel * dueUnit;
248
+ } else {
249
+ cancelUpdates.cancel_at_period_end = true;
250
+ }
251
+ await context.subscription.update({
252
+ status: 'past_due',
253
+ ...cancelUpdates,
254
+ cancelation_details: {
255
+ comment: 'past_due',
256
+ feedback: 'other',
257
+ reason: 'insufficient_credit',
258
+ },
259
+ });
260
+ }
261
+ } else if (remainingBalance === '0') {
262
+ await createEvent('Customer', 'customer.credit.insufficient', context.customer, {
263
+ metadata: {
264
+ meter_event_id: context.meterEvent.id,
265
+ meter_event_name: context.meterEvent.event_name,
266
+ required_amount: remainingToConsume.toString(),
267
+ available_amount: '0',
268
+ consumed_amount: consumed.toString(),
269
+ pending_amount: pendingAmount,
270
+ currency_id: currencyId,
271
+ subscription_id: context.subscription?.id,
272
+ },
273
+ }).catch(console.error);
274
+ }
275
+
276
+ return {
277
+ consumed: totalConsumed.toString(),
278
+ pending: pendingAmount,
279
+ available_balance: remainingBalance,
280
+ fully_consumed: finalPending.lte(new BN(0)),
281
+ };
282
+ }
283
+
284
+ async function processGrantConsumption(
285
+ context: CreditConsumptionContext,
286
+ creditGrant: CreditGrant,
287
+ consumeAmount: string
288
+ ): Promise<void> {
289
+ logger.info('Processing grant consumption', {
290
+ creditGrantId: creditGrant.id,
291
+ consumeAmount,
292
+ });
293
+ const result = await creditGrant.consumeCredit(consumeAmount, {
294
+ subscription_id: context.subscription?.id,
295
+ meter_event_id: context.meterEvent.id,
296
+ });
297
+ await createCreditTransaction(context, creditGrant, consumeAmount, result);
298
+ }
299
+
300
+ async function createCreditTransaction(
301
+ context: CreditConsumptionContext,
302
+ creditGrant: CreditGrant,
303
+ consumeAmount: string,
304
+ consumeResult: any
305
+ ): Promise<void> {
306
+ const meterEventId = context.meterEvent.id;
307
+ const creditGrantId = creditGrant.id;
308
+
309
+ // 检查是否已经为这个 MeterEvent 和 CreditGrant 创建过 transaction
310
+ const existingTransaction = await CreditTransaction.findOne({
311
+ where: {
312
+ source: meterEventId,
313
+ credit_grant_id: creditGrantId,
314
+ },
315
+ });
316
+
317
+ if (existingTransaction) {
318
+ logger.warn('CreditTransaction already exists for this MeterEvent and CreditGrant', {
319
+ meterEventId,
320
+ creditGrantId,
321
+ existingTransactionId: existingTransaction.id,
322
+ });
323
+ context.transactions.push(existingTransaction.id);
324
+ return;
325
+ }
326
+
327
+ logger.debug('start to create credit transaction', {
328
+ customerId: context.meterEvent.getCustomerId(),
329
+ creditGrantId: creditGrant.id,
330
+ meterId: context.meter.id,
331
+ subscriptionId: context.meterEvent.getSubscriptionId(),
332
+ meterEventId: context.meterEvent.id,
333
+ });
334
+
335
+ let description = `Consume ${fromUnitToToken(consumeAmount, context.meter.paymentCurrency.decimal)}${context.meter.paymentCurrency.symbol}`;
336
+ if (context.meterEvent.getSubscriptionId()) {
337
+ description += 'for Subscription';
338
+ }
339
+
340
+ try {
341
+ const transaction = await CreditTransaction.create({
342
+ customer_id: context.meterEvent.getCustomerId(),
343
+ credit_grant_id: creditGrant.id,
344
+ meter_id: context.meter.id,
345
+ subscription_id: context.meterEvent.getSubscriptionId(),
346
+ meter_event_name: context.meterEvent.event_name,
347
+ meter_unit: context.meter.unit,
348
+ quantity: consumeAmount,
349
+ credit_amount: consumeResult.consumed,
350
+ remaining_balance: consumeResult.remaining,
351
+ source: context.meterEvent.id,
352
+ description,
353
+ metadata: {
354
+ ...(context.meterEvent.metadata || {}),
355
+ meter_event_id: context.meterEvent.id,
356
+ grant_depleted: consumeResult.depleted,
357
+ },
358
+ });
359
+
360
+ logger.info('Created credit transaction', {
361
+ transactionId: transaction.id,
362
+ creditGrantId: creditGrant.id,
363
+ meterId: context.meter.id,
364
+ subscriptionId: context.meterEvent.getSubscriptionId(),
365
+ meterEventId: context.meterEvent.id,
366
+ });
367
+ context.transactions.push(transaction.id);
368
+ } catch (error: any) {
369
+ // 处理唯一约束违反错误
370
+ if (error.name === 'SequelizeUniqueConstraintError' || error.message.includes('UNIQUE constraint failed')) {
371
+ logger.warn('CreditTransaction creation failed due to unique constraint - transaction already exists', {
372
+ meterEventId,
373
+ creditGrantId,
374
+ error: error.message,
375
+ });
376
+
377
+ // 重新查询已存在的 transaction
378
+ const duplicateTransaction = await CreditTransaction.findOne({
379
+ where: {
380
+ source: meterEventId,
381
+ credit_grant_id: creditGrantId,
382
+ },
383
+ });
384
+
385
+ if (duplicateTransaction) {
386
+ context.transactions.push(duplicateTransaction.id);
387
+ }
388
+ return;
389
+ }
390
+
391
+ // 其他错误重新抛出
392
+ throw error;
393
+ }
394
+ }
395
+
396
+ export async function handleCreditConsumption(job: CreditConsumptionJob) {
397
+ const { meterEventId } = job;
398
+
399
+ logger.info('Starting credit consumption job', { meterEventId });
400
+
401
+ // Load and validate data first to get customer_id
402
+ const context: CreditConsumptionContext | null = await validateAndLoadData(meterEventId);
403
+ if (!context) {
404
+ logger.warn('Credit consumption context not found, skipping job', { meterEventId });
405
+ return;
406
+ }
407
+
408
+ const customerId = context.customer.id;
409
+ const lock = getLock(`credit-consumption-customer-${customerId}`);
410
+
411
+ try {
412
+ await lock.acquire();
413
+
414
+ // 重新加载MeterEvent以获取最新状态
415
+ await context.meterEvent.reload();
416
+ if (context.meterEvent.status === 'completed' || context.meterEvent.status === 'canceled') {
417
+ logger.info('MeterEvent already processed, skipping', {
418
+ meterEventId,
419
+ status: context.meterEvent.status,
420
+ });
421
+ return;
422
+ }
423
+
424
+ // 检查是否正在处理中,避免重复处理
425
+ if (context.meterEvent.status === 'processing') {
426
+ logger.warn('MeterEvent is already being processed, skipping duplicate job', {
427
+ meterEventId,
428
+ status: context.meterEvent.status,
429
+ });
430
+ return;
431
+ }
432
+
433
+ // Mark as processing to prevent duplicate processing
434
+ await context.meterEvent.markAsProcessing();
435
+
436
+ const totalRequiredAmount = context.meterEvent.getValue();
437
+
438
+ logger.info('Attempting to consume credits', {
439
+ meterEventId,
440
+ customerId,
441
+ totalRequiredAmount,
442
+ currencyId: context.meter.currency_id,
443
+ });
444
+
445
+ // Consume available credits (handles existing transactions internally)
446
+ const consumptionResult = await consumeAvailableCredits(context, totalRequiredAmount);
447
+
448
+ // Update MeterEvent with consumption details
449
+ await context.meterEvent.update({
450
+ credit_consumed: consumptionResult.consumed,
451
+ credit_pending: consumptionResult.pending,
452
+ metadata: {
453
+ ...context.meterEvent.metadata,
454
+ consumption_result: consumptionResult,
455
+ processed_at: new Date().toISOString(),
456
+ },
457
+ });
458
+
459
+ if (consumptionResult.fully_consumed) {
460
+ logger.info('Credit consumption completed successfully', {
461
+ meterEventId,
462
+ customerId,
463
+ totalRequired: totalRequiredAmount,
464
+ consumed: consumptionResult.consumed,
465
+ pending: consumptionResult.pending,
466
+ transactionCount: context.transactions.length,
467
+ });
468
+ await context.meterEvent.markAsCompleted();
469
+ if (context.subscription && context.subscription.status === 'past_due') {
470
+ handlePastDueSubscriptionRecovery(context.subscription, null);
471
+ }
472
+ } else {
473
+ logger.warn('Credit consumption partially completed - insufficient balance', {
474
+ meterEventId,
475
+ customerId,
476
+ totalRequired: totalRequiredAmount,
477
+ finalConsumed: consumptionResult.consumed,
478
+ finalPending: consumptionResult.pending,
479
+ availableBalance: consumptionResult.available_balance,
480
+ });
481
+ throw new Error(
482
+ `Insufficient credit balance: required ${totalRequiredAmount}, consumed ${consumptionResult.consumed}, pending ${consumptionResult.pending}`
483
+ );
484
+ }
485
+ } catch (error: any) {
486
+ logger.error('Credit consumption failed', {
487
+ meterEventId,
488
+ customerId,
489
+ error: error.message,
490
+ });
491
+
492
+ // Handle retry logic with more robust error handling
493
+ try {
494
+ const meterEvent = await MeterEvent.findByPk(meterEventId);
495
+ if (meterEvent && !['completed', 'canceled', 'requires_action'].includes(meterEvent.status)) {
496
+ const attemptCount = meterEvent.attempt_count + 1;
497
+
498
+ if (attemptCount >= MAX_RETRY_COUNT) {
499
+ await meterEvent.markAsRequiresAction(error.message);
500
+ logger.warn('MeterEvent marked as requires_action', {
501
+ meterEventId,
502
+ attemptCount,
503
+ reason: attemptCount >= MAX_RETRY_COUNT ? 'max_retries_exceeded' : 'non_retryable_error',
504
+ });
505
+ } else {
506
+ const nextAttemptTime = getNextRetry(attemptCount);
507
+ await meterEvent.markAsRequiresCapture(error.message, nextAttemptTime);
508
+
509
+ // Reschedule the job with exponential backoff
510
+ addCreditConsumptionJob(meterEventId, true, { runAt: nextAttemptTime });
511
+
512
+ logger.warn('Credit consumption retry scheduled', {
513
+ meterEventId,
514
+ attemptCount,
515
+ nextAttemptTime,
516
+ error: error.message,
517
+ });
518
+ }
519
+ }
520
+ } catch (updateError: any) {
521
+ logger.error('Failed to update meter event failure status', {
522
+ meterEventId,
523
+ originalError: error.message,
524
+ updateError: updateError.message,
525
+ });
526
+ }
527
+
528
+ // 重新抛出错误,让队列知道任务失败
529
+ throw error;
530
+ } finally {
531
+ lock.release();
532
+ }
533
+ }
534
+
535
+ export const creditQueue = createQueue<CreditConsumptionJob>({
536
+ name: 'credit-consumption',
537
+ onJob: handleCreditConsumption,
538
+ options: {
539
+ concurrency: 1,
540
+ maxRetries: 0,
541
+ },
542
+ });
543
+
544
+ creditQueue.on('finished', ({ id, job }) => {
545
+ logger.info('Credit consumption job finished', { id, job });
546
+ });
547
+
548
+ creditQueue.on('failed', ({ id, job, error }) => {
549
+ logger.error('Credit consumption job failed', { id, job, error });
550
+
551
+ // Error handling is now done in the main handler function
552
+ // following payment.ts pattern with proper retry logic
553
+ });
554
+
555
+ const addCreditConsumptionJob = async (
556
+ meterEventId: string,
557
+ replace: boolean = false,
558
+ options: { delay?: number; runAt?: number } = {}
559
+ ) => {
560
+ const jobId = `meter-event-${meterEventId}`;
561
+
562
+ try {
563
+ const existingJob = await creditQueue.get(jobId);
564
+ if (existingJob && !replace) {
565
+ logger.debug('Credit consumption job already exists, skipping duplicate', {
566
+ meterEventId,
567
+ jobId,
568
+ existingJobRunAt: existingJob.runAt,
569
+ });
570
+ return;
571
+ }
572
+
573
+ if (existingJob && replace) {
574
+ await creditQueue.delete(jobId);
575
+ logger.info('Replaced existing credit consumption job', {
576
+ meterEventId,
577
+ jobId,
578
+ previousRunAt: existingJob.runAt,
579
+ });
580
+ }
581
+
582
+ // 检查 MeterEvent 状态,避免为已完成的事件添加任务
583
+ const meterEvent = await MeterEvent.findByPk(meterEventId);
584
+ if (!meterEvent) {
585
+ logger.warn('Cannot add credit consumption job: MeterEvent not found', { meterEventId });
586
+ return;
587
+ }
588
+
589
+ if (meterEvent.status === 'completed' || meterEvent.status === 'canceled') {
590
+ logger.debug('Skipping credit consumption job: MeterEvent already processed', {
591
+ meterEventId,
592
+ status: meterEvent.status,
593
+ });
594
+ return;
595
+ }
596
+
597
+ creditQueue.push({ id: jobId, job: { meterEventId }, ...options });
598
+ logger.info('Credit consumption job added to queue', {
599
+ meterEventId,
600
+ jobId,
601
+ meterEventStatus: meterEvent.status,
602
+ });
603
+ } catch (error: any) {
604
+ logger.error('Failed to add credit consumption job', {
605
+ meterEventId,
606
+ jobId,
607
+ error: error.message,
608
+ });
609
+ }
610
+ };
611
+
612
+ creditQueue.on('retry', ({ id, job }) => {
613
+ logger.info('Retrying credit consumption job', { id, job });
614
+ });
615
+
616
+ export async function startCreditConsumeQueue(): Promise<void> {
617
+ const lock = getLock('startCreditConsumeQueue');
618
+ if (lock.locked) {
619
+ return;
620
+ }
621
+
622
+ try {
623
+ await lock.acquire();
624
+ logger.info('Starting credit queue and processing pending meter events');
625
+
626
+ // Find pending and requires_capture events that need processing
627
+ const pendingEvents = await MeterEvent.findAll({
628
+ where: {
629
+ status: ['pending', 'requires_capture'],
630
+ },
631
+ limit: 50,
632
+ });
633
+
634
+ const results = await Promise.allSettled(
635
+ pendingEvents.map(async (event) => {
636
+ const jobId = `meter-event-${event.id}`;
637
+ const existingJob = await creditQueue.get(jobId);
638
+ if (!existingJob) {
639
+ addCreditConsumptionJob(event.id, true);
640
+ }
641
+ })
642
+ );
643
+
644
+ const failed = results.filter((r) => r.status === 'rejected').length;
645
+ if (failed > 0) {
646
+ logger.warn(`Failed to queue ${failed} pending meter events`);
647
+ }
648
+
649
+ logger.info(`Credit queue started, processed ${pendingEvents.length} pending events`);
650
+ } catch (error: any) {
651
+ logger.error('Failed to start credit queue', { error: error.message });
652
+ } finally {
653
+ lock.release();
654
+ }
655
+ }
656
+
657
+ events.on('billing.meter_event.created', (meterEvent) => {
658
+ addCreditConsumptionJob(meterEvent.id, true);
659
+ });
660
+
661
+ events.on('customer.credit_grant.granted', async (creditGrant: CreditGrant) => {
662
+ try {
663
+ await retryFailedEventsForCustomer(creditGrant);
664
+ } catch (error: any) {
665
+ logger.error('Failed to retry failed events for customer', {
666
+ creditGrantId: creditGrant.id,
667
+ customerId: creditGrant.customer_id,
668
+ error: error.message,
669
+ });
670
+ }
671
+ });
672
+
673
+ async function retryFailedEventsForCustomer(creditGrant: CreditGrant): Promise<void> {
674
+ try {
675
+ logger.info('Retrying failed events for customer', {
676
+ customerId: creditGrant.customer_id,
677
+ currencyId: creditGrant.currency_id,
678
+ livemode: creditGrant.livemode,
679
+ });
680
+ const [, , failedEvents] = await MeterEvent.getPendingAmounts({
681
+ customerId: creditGrant.customer_id,
682
+ currencyId: creditGrant.currency_id,
683
+ status: ['requires_action', 'requires_capture'],
684
+ livemode: creditGrant.livemode,
685
+ });
686
+
687
+ if (failedEvents.length === 0) {
688
+ logger.debug('No failed events with pending credit found', {
689
+ customerId: creditGrant.customer_id,
690
+ currencyId: creditGrant.currency_id,
691
+ });
692
+ return;
693
+ }
694
+
695
+ logger.info('Updating failed events status after credit grant', {
696
+ customerId: creditGrant.customer_id,
697
+ currencyId: creditGrant.currency_id,
698
+ eventCount: failedEvents.length,
699
+ });
700
+
701
+ await Promise.all(
702
+ failedEvents.map((event) =>
703
+ event.update({
704
+ status: 'pending',
705
+ attempt_count: 0,
706
+ next_attempt: undefined,
707
+ })
708
+ )
709
+ );
710
+
711
+ failedEvents.forEach((event, index) => {
712
+ const delay = index * 1000;
713
+ addCreditConsumptionJob(event.id, true, { delay });
714
+ });
715
+
716
+ logger.info('Successfully updated failed events status', {
717
+ customerId: creditGrant.customer_id,
718
+ currencyId: creditGrant.currency_id,
719
+ eventCount: failedEvents.length,
720
+ });
721
+ } catch (error: any) {
722
+ logger.error('Failed to update failed events for customer', {
723
+ customerId: creditGrant.customer_id,
724
+ currencyId: creditGrant.currency_id,
725
+ error: error.message,
726
+ });
727
+ }
728
+ }