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
@@ -36,7 +36,7 @@ import { SubscriptionItem } from '../store/models/subscription-item';
36
36
  import type { EVMChainType, PaymentError, PaymentSettings } from '../store/models/types';
37
37
  import { notificationQueue } from './notification';
38
38
  import { ensureOverdraftProtectionInvoiceAndItems } from '../libs/invoice';
39
- import { Lock } from '../store/models';
39
+ import { Lock, MeterEvent } from '../store/models';
40
40
  import { ensureOverdraftProtectionPrice } from '../libs/overdraft-protection';
41
41
  import createQueue from '../libs/queue';
42
42
  import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants';
@@ -111,6 +111,200 @@ export const depositVaultQueue = createQueue<DepositVaultJob>({
111
111
  },
112
112
  });
113
113
 
114
+ export async function updateSubscriptionOnPaymentSuccess(
115
+ paymentIntent: PaymentIntent | null,
116
+ subscription: Subscription,
117
+ invoice?: Invoice | null,
118
+ triggerRenew: boolean = true
119
+ ) {
120
+ // Handle incomplete subscription activation
121
+ if (subscription.status === 'incomplete' && invoice && invoice.id === subscription.latest_invoice_id) {
122
+ const started = await subscription.start();
123
+ if (started) {
124
+ logger.info(`Subscription ${subscription.id} activated on payment done ${invoice.id}`);
125
+ }
126
+ return;
127
+ }
128
+
129
+ // Handle past_due subscription recovery
130
+ if (subscription.status === 'past_due' && subscription.cancelation_details?.reason === 'payment_failed') {
131
+ await handlePastDueSubscriptionRecovery(subscription, paymentIntent);
132
+ return;
133
+ }
134
+ if (subscription.status === 'past_due' && subscription.cancelation_details?.reason === 'insufficient_credit') {
135
+ await handlePastDueSubscriptionRecovery(subscription, paymentIntent);
136
+ return;
137
+ }
138
+
139
+ // Handle trialing subscription
140
+ if (subscription.status === 'trialing') {
141
+ const started = await subscription.start();
142
+ if (started) {
143
+ logger.info(`Subscription ${subscription.id} trialing ended on payment done ${invoice?.id || 'credit'}`);
144
+ }
145
+ return;
146
+ }
147
+
148
+ // Trigger renewal and upgrade events
149
+ if (triggerRenew && (!invoice || invoice.billing_reason !== 'subscription_update')) {
150
+ if (!invoice || invoice.billing_reason === 'subscription_cycle' || paymentIntent?.capture_method === 'manual') {
151
+ createEvent('Subscription', 'customer.subscription.renewed', subscription).catch(console.error);
152
+ }
153
+ }
154
+ if (invoice?.billing_reason === 'subscription_update') {
155
+ createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(console.error);
156
+ }
157
+ }
158
+
159
+ export async function handlePastDueSubscriptionRecovery(
160
+ subscription: Subscription,
161
+ paymentIntent: PaymentIntent | null
162
+ ) {
163
+ // For credit mode with insufficient_credit reason, check meter events instead of uncollectible invoices
164
+ if (subscription.cancelation_details?.reason === 'insufficient_credit') {
165
+ // Check if all meter events for this subscription are completed with zero pending amount
166
+ const [summary, detail] = await MeterEvent.getPendingAmounts({
167
+ subscriptionId: subscription.id,
168
+ livemode: subscription.livemode,
169
+ currencyId: subscription.currency_id,
170
+ });
171
+
172
+ if (summary[subscription.currency_id] && summary[subscription.currency_id] !== '0') {
173
+ logger.info('Subscription recovery skipped: pending meter events exist', {
174
+ subscription: subscription.id,
175
+ pendingEventsCount: detail[subscription.currency_id]?.length,
176
+ pendingEventIds: detail[subscription.currency_id],
177
+ });
178
+ return;
179
+ }
180
+ } else {
181
+ // For payment_failed reason, check uncollectible invoices
182
+ const [result] = await Invoice.getUncollectibleAmount({ subscriptionId: subscription.id });
183
+ if (!isEmpty(result)) {
184
+ return;
185
+ }
186
+ }
187
+
188
+ const now = dayjs().unix();
189
+
190
+ // Reset billing cycle anchor and cancel_* if we are recovering from payment failed or insufficient credit
191
+ if (subscription.cancel_at && subscription.cancel_at !== subscription.current_period_end) {
192
+ if (now <= subscription.current_period_end) {
193
+ // If payment succeeds before current_period_end, we should activate this subscription
194
+ await subscription.update({
195
+ status: 'active',
196
+ cancel_at: 0,
197
+ cancel_at_period_end: false,
198
+ // @ts-ignore
199
+ cancelation_details: null,
200
+ });
201
+ const recoveryReason =
202
+ subscription.cancelation_details?.reason === 'insufficient_credit' ? 'credit replenished' : 'payment done';
203
+ logger.info(
204
+ `Subscription ${subscription.id} recovered on ${recoveryReason} ${paymentIntent?.id || 'credit'}: cancel rest`
205
+ );
206
+ if (paymentIntent) {
207
+ await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
208
+ }
209
+ return;
210
+ }
211
+
212
+ // Reset billing cycle
213
+ const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
214
+ const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
215
+ const setup = getSubscriptionCreateSetup(lineItems, subscription.currency_id, 0);
216
+ await subscription.update({
217
+ status: 'active',
218
+ pending_invoice_item_interval: setup.recurring,
219
+ current_period_start: setup.period.start,
220
+ current_period_end: setup.period.end,
221
+ billing_cycle_anchor: setup.cycle.anchor,
222
+ cancel_at: 0,
223
+ cancel_at_period_end: false,
224
+ // @ts-ignore
225
+ cancelation_details: null,
226
+ });
227
+
228
+ createEvent('Subscription', 'customer.subscription.recovered', subscription).catch(console.error);
229
+ const recoveryReason =
230
+ subscription.cancelation_details?.reason === 'insufficient_credit' ? 'credit replenished' : 'payment done';
231
+ logger.info(
232
+ `Subscription ${subscription.id} recovered on ${recoveryReason} ${paymentIntent?.id || 'credit'}: cancel and billing cycle reset`
233
+ );
234
+ if (paymentIntent) {
235
+ await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
236
+ }
237
+ } else if (subscription.cancel_at_period_end) {
238
+ // Reset cancel_at_period_end if we are recovering from payment failed or insufficient credit
239
+ // @ts-ignore
240
+ await subscription.update({ status: 'active', cancel_at_period_end: false, cancelation_details: null });
241
+ const recoveryReason =
242
+ subscription.cancelation_details?.reason === 'insufficient_credit' ? 'credit replenished' : 'payment done';
243
+ logger.info(
244
+ `Subscription ${subscription.id} recovered on ${recoveryReason} ${paymentIntent?.id || 'credit'}: cancel reset`
245
+ );
246
+ if (paymentIntent) {
247
+ await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
248
+ }
249
+ } else {
250
+ await subscription.update({ status: 'active' });
251
+ const recoveryReason =
252
+ subscription.cancelation_details?.reason === 'insufficient_credit' ? 'credit replenished' : 'payment done';
253
+ logger.info(`Subscription ${subscription.id} recovered on ${recoveryReason} ${paymentIntent?.id || 'credit'}`);
254
+ }
255
+ }
256
+
257
+ async function updateCheckoutSessionOnPaymentSuccess(
258
+ paymentIntent: PaymentIntent,
259
+ checkoutSession: CheckoutSession,
260
+ invoice?: Invoice
261
+ ) {
262
+ if (checkoutSession.status !== 'open') {
263
+ return;
264
+ }
265
+
266
+ // Update quantity sold for line items
267
+ updateQuantitySold(checkoutSession).catch((err) => {
268
+ logger.error('Updating quantity_sold for line items failed', {
269
+ error: err,
270
+ checkoutSessionId: checkoutSession.id,
271
+ });
272
+ });
273
+
274
+ // Handle subscription mode checkout sessions
275
+ if (['subscription', 'setup'].includes(checkoutSession.mode) && invoice?.subscription_id) {
276
+ await checkoutSession.increment('success_subscription_count', { by: 1 });
277
+ await checkoutSession.reload();
278
+ const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
279
+
280
+ if (
281
+ checkoutSession.success_subscription_count &&
282
+ checkoutSession.success_subscription_count >= subscriptionIds.length
283
+ ) {
284
+ await checkoutSession.update({
285
+ status: 'complete',
286
+ payment_status: 'paid',
287
+ payment_details: paymentIntent?.payment_details || {},
288
+ });
289
+ logger.info('checkout session become complete on payment done', {
290
+ id: checkoutSession.id,
291
+ });
292
+ }
293
+ } else {
294
+ // Handle non-subscription mode checkout sessions
295
+ await checkoutSession.update({
296
+ status: 'complete',
297
+ payment_status: 'paid',
298
+ payment_details: paymentIntent?.payment_details || {},
299
+ });
300
+ logger.info('checkout session become complete on payment done', {
301
+ id: checkoutSession.id,
302
+ });
303
+ }
304
+
305
+ logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${paymentIntent.id}`);
306
+ }
307
+
114
308
  export const handlePaymentSucceed = async (
115
309
  paymentIntent: PaymentIntent,
116
310
  triggerRenew: boolean = true,
@@ -189,25 +383,17 @@ export const handlePaymentSucceed = async (
189
383
  if (paymentIntent.invoice_id) {
190
384
  invoice = await Invoice.findByPk(paymentIntent.invoice_id);
191
385
  }
386
+
387
+ // Handle checkout session when no invoice exists
192
388
  if (!invoice && !slashStake) {
193
389
  const checkoutSession = await CheckoutSession.findOne({ where: { payment_intent_id: paymentIntent.id } });
194
- if (checkoutSession && checkoutSession.status === 'open') {
195
- updateQuantitySold(checkoutSession).catch((err) => {
196
- logger.error('Updating quantity_sold for line items failed', {
197
- error: err,
198
- checkoutSessionId: checkoutSession.id,
199
- });
200
- });
201
- await checkoutSession.update({
202
- status: 'complete',
203
- payment_status: 'paid',
204
- payment_details: paymentIntent.payment_details,
205
- });
206
- logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${paymentIntent.id}`);
390
+ if (checkoutSession) {
391
+ await updateCheckoutSessionOnPaymentSuccess(paymentIntent, checkoutSession);
207
392
  }
208
393
  return;
209
394
  }
210
395
 
396
+ // Update invoice status
211
397
  if (invoice && invoice?.status !== 'paid') {
212
398
  await invoice.update({
213
399
  paid: true,
@@ -221,122 +407,19 @@ export const handlePaymentSucceed = async (
221
407
  logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`);
222
408
  }
223
409
 
410
+ // Update subscription status
224
411
  if (invoice && invoice.subscription_id && !slashStake) {
225
412
  const subscription = await Subscription.findByPk(invoice.subscription_id);
226
-
227
- // We only update subscription status when the invoice is the latest one
228
413
  if (subscription) {
229
- if (subscription.status === 'incomplete' && invoice.id === subscription.latest_invoice_id) {
230
- const started = await subscription.start();
231
- if (started) {
232
- logger.info(`Subscription ${subscription.id} activated on payment done ${invoice.id}`);
233
- }
234
- } else if (subscription.status === 'past_due' && subscription.cancelation_details?.reason === 'payment_failed') {
235
- // ensure no uncollectible amount before recovering from payment failed
236
- const [result] = await Invoice.getUncollectibleAmount({ subscriptionId: subscription.id });
237
- if (isEmpty(result)) {
238
- // reset billing cycle anchor and cancel_* if we are recovering from payment failed
239
- if (subscription.cancel_at && subscription.cancel_at !== subscription.current_period_end) {
240
- const now = dayjs().unix();
241
- if (now <= subscription.current_period_end) {
242
- // if payment succeeds before current_period_end, we should active this subscription
243
- await subscription.update({
244
- status: 'active',
245
- cancel_at: 0,
246
- cancel_at_period_end: false,
247
- // @ts-ignore
248
- cancelation_details: null,
249
- });
250
- logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel rest`);
251
- await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
252
- return;
253
- }
254
- const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
255
- const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
256
- const setup = getSubscriptionCreateSetup(lineItems, subscription.currency_id, 0);
257
- await subscription.update({
258
- status: 'active',
259
- pending_invoice_item_interval: setup.recurring,
260
- current_period_start: setup.period.start,
261
- current_period_end: setup.period.end,
262
- billing_cycle_anchor: setup.cycle.anchor,
263
- cancel_at: 0,
264
- cancel_at_period_end: false,
265
- // @ts-ignore
266
- cancelation_details: null,
267
- });
268
-
269
- createEvent('Subscription', 'customer.subscription.recovered', subscription).catch(console.error);
270
- logger.info(
271
- `Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel and billing cycle reset`
272
- );
273
- await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
274
- } else if (subscription.cancel_at_period_end) {
275
- // reset cancel_at_period_end if we are recovering from payment failed
276
- // @ts-ignore
277
- await subscription.update({ status: 'active', cancel_at_period_end: false, cancelation_details: null });
278
- logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}: cancel reset`);
279
- await syncStripeSubscriptionAfterRecovery(subscription, paymentIntent.id);
280
- } else {
281
- await subscription.update({ status: 'active' });
282
- logger.info(`Subscription ${subscription.id} recovered on payment done ${paymentIntent.id}`);
283
- }
284
- }
285
- } else if (subscription.status === 'trialing') {
286
- const started = await subscription.start();
287
- if (started) {
288
- logger.info(`Subscription ${subscription.id} trialing ended on payment done ${invoice.id}`);
289
- }
290
- }
291
-
292
- if (triggerRenew && invoice.billing_reason !== 'subscription_update') {
293
- if (invoice.billing_reason === 'subscription_cycle' || paymentIntent.capture_method === 'manual') {
294
- createEvent('Subscription', 'customer.subscription.renewed', subscription).catch(console.error);
295
- }
296
- }
297
- if (invoice.billing_reason === 'subscription_update') {
298
- createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(console.error);
299
- }
414
+ await updateSubscriptionOnPaymentSuccess(paymentIntent, subscription, invoice, triggerRenew);
300
415
  }
301
416
  }
302
417
 
418
+ // Update checkout session
303
419
  if (invoice && invoice.checkout_session_id && !slashStake) {
304
420
  const checkoutSession = await CheckoutSession.findByPk(invoice.checkout_session_id);
305
- if (checkoutSession && checkoutSession.status === 'open') {
306
- updateQuantitySold(checkoutSession).catch((err) => {
307
- logger.error('Updating quantity_sold for line items failed', {
308
- error: err,
309
- checkoutSessionId: checkoutSession.id,
310
- });
311
- });
312
- if (['subscription', 'setup'].includes(checkoutSession.mode) && invoice.subscription_id) {
313
- await checkoutSession.increment('success_subscription_count', { by: 1 });
314
- await checkoutSession.reload();
315
- const subscriptionIds = getCheckoutSessionSubscriptionIds(checkoutSession);
316
- if (
317
- checkoutSession.success_subscription_count &&
318
- checkoutSession.success_subscription_count >= subscriptionIds.length
319
- ) {
320
- await checkoutSession.update({
321
- status: 'complete',
322
- payment_status: 'paid',
323
- payment_details: paymentIntent.payment_details,
324
- });
325
- logger.info('checkout session become complete on payment done', {
326
- id: checkoutSession.id,
327
- });
328
- }
329
- } else {
330
- await checkoutSession.update({
331
- status: 'complete',
332
- payment_status: 'paid',
333
- payment_details: paymentIntent.payment_details,
334
- });
335
- logger.info('checkout session become complete on payment done', {
336
- id: checkoutSession.id,
337
- });
338
- }
339
- logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${paymentIntent.id}`);
421
+ if (checkoutSession) {
422
+ await updateCheckoutSessionOnPaymentSuccess(paymentIntent, checkoutSession, invoice);
340
423
  }
341
424
  }
342
425
  };
@@ -733,6 +816,11 @@ export const handlePayment = async (job: PaymentJob) => {
733
816
  return;
734
817
  }
735
818
 
819
+ if (paymentCurrency.isCredit()) {
820
+ logger.info('PaymentIntent capture skipped because paymentCurrency is credit', { id: paymentIntent.id });
821
+ return;
822
+ }
823
+
736
824
  const customer = await Customer.findByPk(paymentIntent.customer_id);
737
825
  if (!customer) {
738
826
  logger.warn('Customer not found', { id: paymentIntent.customer_id });
@@ -230,6 +230,15 @@ export async function handleSubscriptionInvoice(args: Parameters<typeof doHandle
230
230
  }
231
231
 
232
232
  const handleSubscriptionBeforeCancel = async (subscription: Subscription) => {
233
+ const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
234
+ if (!paymentCurrency) {
235
+ logger.warn('Payment currency not found for subscription', { subscription: subscription.id });
236
+ return;
237
+ }
238
+ if (paymentCurrency.isCredit()) {
239
+ logger.info('Skip invoice creation for credit subscription', { subscription: subscription.id });
240
+ return;
241
+ }
233
242
  const invoice = await handleSubscriptionInvoice({
234
243
  subscription,
235
244
  filter: (x) => x.price.recurring?.usage_type === 'metered', // include only metered items
@@ -268,6 +277,75 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
268
277
  subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
269
278
  const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
270
279
 
280
+ // Check if this is a credit subscription
281
+ const isCredit = await subscription.isConsumesCredit();
282
+ const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
283
+
284
+ if (isCredit && paymentCurrency?.isCredit()) {
285
+ // For credit subscriptions, check credit availability instead of creating invoices
286
+ const customer = await Customer.findByPk(subscription.customer_id);
287
+ const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
288
+
289
+ if (!customer || !paymentMethod || !paymentCurrency) {
290
+ logger.warn('Credit subscription cycle skipped due to missing dependencies', {
291
+ subscription: subscription.id,
292
+ customer: !!customer,
293
+ paymentMethod: !!paymentMethod,
294
+ paymentCurrency: !!paymentCurrency,
295
+ });
296
+ return;
297
+ }
298
+
299
+ if (subscription.status === 'trialing') {
300
+ const now = dayjs().unix();
301
+ if (subscription.trial_end && subscription.trial_end <= now) {
302
+ await subscription.update({ status: 'active' });
303
+ logger.info('Subscription status updated from trialing to active', {
304
+ subscription: subscription.id,
305
+ });
306
+ }
307
+ }
308
+ // Check if we need to catch up on missed periods
309
+ const now = dayjs().unix();
310
+ let nextPeriod = setup;
311
+
312
+ if (now > setup.period.end) {
313
+ // Enable catch-up for missed periods
314
+ nextPeriod = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number, {
315
+ catchUp: true,
316
+ });
317
+
318
+ if (nextPeriod.missedPeriods > 0) {
319
+ logger.warn(`Credit subscription recovered with ${nextPeriod.missedPeriods} missed periods`, {
320
+ subscription: subscription.id,
321
+ missedPeriods: nextPeriod.missedPeriods,
322
+ originalPeriodEnd: nextPeriod.recovery?.originalPeriodEnd,
323
+ currentPeriodStart: nextPeriod.period.start,
324
+ currentPeriodEnd: nextPeriod.period.end,
325
+ wasLimited: nextPeriod.recovery?.wasLimited,
326
+ });
327
+ }
328
+ }
329
+
330
+ await subscription.update({
331
+ current_period_start: nextPeriod.period.start,
332
+ current_period_end: nextPeriod.period.end,
333
+ });
334
+
335
+ if (subscription.isActive()) {
336
+ logger.info(`Credit subscription updated for billing cycle: ${subscription.id}`, {
337
+ periodStart: nextPeriod.period.start,
338
+ periodEnd: nextPeriod.period.end,
339
+ missedPeriods: nextPeriod.missedPeriods || 0,
340
+ });
341
+ await addSubscriptionJob(subscription, 'cycle', false, nextPeriod.period.end);
342
+ logger.info(`Credit subscription job scheduled for next billing cycle: ${subscription.id}`);
343
+ }
344
+
345
+ return;
346
+ }
347
+
348
+ // Original logic for non-credit subscriptions
271
349
  // set invoice status if subscription paused
272
350
  let status = 'open';
273
351
  if (subscription.pause_collection) {
@@ -1065,6 +1143,103 @@ export const subscriptionQueue = createQueue<SubscriptionJob>({
1065
1143
  },
1066
1144
  });
1067
1145
 
1146
+ /**
1147
+ * Handle credit subscription recovery after system restart
1148
+ * Checks for subscriptions that may have missed billing periods during downtime
1149
+ */
1150
+ export const handleCreditSubscriptionRecovery = async () => {
1151
+ const lock = getLock('creditSubscriptionRecovery');
1152
+ if (lock.locked) {
1153
+ return;
1154
+ }
1155
+
1156
+ logger.info('Starting credit subscription recovery check');
1157
+ try {
1158
+ await lock.acquire();
1159
+
1160
+ // Find active credit subscriptions that might need recovery
1161
+ const creditSubscriptions = await Subscription.findAll({
1162
+ where: {
1163
+ status: ['active', 'trialing'],
1164
+ },
1165
+ include: [
1166
+ {
1167
+ model: PaymentCurrency,
1168
+ as: 'paymentCurrency',
1169
+ where: {
1170
+ type: 'credit',
1171
+ },
1172
+ },
1173
+ ],
1174
+ });
1175
+
1176
+ const now = dayjs().unix();
1177
+ const recoveredSubscriptions: string[] = [];
1178
+
1179
+ const results = await Promise.allSettled(
1180
+ creditSubscriptions.map(async (subscription) => {
1181
+ // Check if subscription period has ended
1182
+ if (subscription.current_period_end && now > subscription.current_period_end) {
1183
+ const previousPeriodEnd =
1184
+ subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
1185
+
1186
+ const setup = getSubscriptionCycleSetup(
1187
+ subscription.pending_invoice_item_interval,
1188
+ previousPeriodEnd as number,
1189
+ { catchUp: true, maxMissedPeriods: 100 }
1190
+ );
1191
+
1192
+ if (setup.missedPeriods > 0) {
1193
+ logger.info('Credit subscription requires recovery', {
1194
+ subscription: subscription.id,
1195
+ missedPeriods: setup.missedPeriods,
1196
+ originalPeriodEnd: setup.recovery?.originalPeriodEnd,
1197
+ newPeriodStart: setup.period.start,
1198
+ newPeriodEnd: setup.period.end,
1199
+ });
1200
+
1201
+ // Update subscription to current period
1202
+ await subscription.update({
1203
+ current_period_start: setup.period.start,
1204
+ current_period_end: setup.period.end,
1205
+ });
1206
+
1207
+ // Schedule next billing cycle
1208
+ await addSubscriptionJob(subscription, 'cycle', true, setup.period.end);
1209
+
1210
+ // Create audit event
1211
+ await createEvent('Subscription', 'system.recovery', subscription, {
1212
+ missedPeriods: setup.missedPeriods,
1213
+ recovery: setup.recovery,
1214
+ recoveryType: 'startup',
1215
+ });
1216
+
1217
+ recoveredSubscriptions.push(subscription.id);
1218
+ }
1219
+ }
1220
+ })
1221
+ );
1222
+
1223
+ const failed = results.filter((r) => r.status === 'rejected').length;
1224
+ if (failed > 0) {
1225
+ logger.warn(`Failed to process ${failed} credit subscriptions in recovery`);
1226
+ }
1227
+
1228
+ if (recoveredSubscriptions.length > 0) {
1229
+ logger.info('Credit subscription recovery completed', {
1230
+ recoveredCount: recoveredSubscriptions.length,
1231
+ subscriptions: recoveredSubscriptions,
1232
+ });
1233
+ } else {
1234
+ logger.info('No credit subscriptions required recovery');
1235
+ }
1236
+ } catch (error) {
1237
+ logger.error('Error in credit subscription recovery:', error);
1238
+ } finally {
1239
+ lock.release();
1240
+ }
1241
+ };
1242
+
1068
1243
  export const startSubscriptionQueue = async () => {
1069
1244
  const lock = getLock('startSubscriptionQueue');
1070
1245
  if (lock.locked) {
@@ -1073,6 +1248,10 @@ export const startSubscriptionQueue = async () => {
1073
1248
  logger.info('startSubscriptionQueue');
1074
1249
  try {
1075
1250
  await lock.acquire();
1251
+
1252
+ // First handle credit subscription recovery
1253
+ await handleCreditSubscriptionRecovery();
1254
+
1076
1255
  const subscriptions = await Subscription.findAll({
1077
1256
  where: {
1078
1257
  status: EXPECTED_SUBSCRIPTION_STATUS,