payment-kit 1.19.0 → 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/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 +4 -4
- package/scripts/sdk.js +233 -1
- package/src/app.tsx +10 -0
- package/src/components/collapse.tsx +11 -1
- 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 +5 -2
- package/src/components/invoice/list.tsx +19 -1
- package/src/components/metadata/form.tsx +286 -90
- 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/payment-currency/form.tsx +2 -0
- package/src/components/payment-intent/list.tsx +19 -1
- package/src/components/payment-link/preview.tsx +1 -1
- package/src/components/payment-link/product-select.tsx +52 -12
- package/src/components/payment-method/arcblock.tsx +2 -0
- package/src/components/payment-method/base.tsx +2 -0
- package/src/components/payment-method/bitcoin.tsx +2 -0
- package/src/components/payment-method/ethereum.tsx +2 -0
- package/src/components/payment-method/stripe.tsx +2 -0
- package/src/components/payouts/list.tsx +19 -1
- package/src/components/price/currency-select.tsx +51 -31
- package/src/components/price/form.tsx +881 -407
- package/src/components/pricing-table/preview.tsx +1 -1
- package/src/components/product/add-price.tsx +9 -7
- package/src/components/product/create.tsx +7 -4
- package/src/components/product/edit-price.tsx +21 -12
- package/src/components/product/features.tsx +17 -7
- package/src/components/product/form.tsx +104 -89
- package/src/components/refund/list.tsx +19 -1
- package/src/components/section/header.tsx +5 -18
- package/src/components/subscription/items/index.tsx +1 -1
- package/src/components/subscription/metrics.tsx +37 -5
- package/src/components/subscription/portal/actions.tsx +2 -1
- package/src/contexts/products.tsx +26 -9
- 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 +1 -1
- 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 +47 -14
- package/src/pages/admin/customers/customers/credit-grant/detail.tsx +391 -0
- package/src/pages/admin/customers/customers/detail.tsx +22 -10
- package/src/pages/admin/customers/index.tsx +5 -0
- package/src/pages/admin/developers/events/detail.tsx +1 -1
- package/src/pages/admin/developers/index.tsx +1 -1
- package/src/pages/admin/payments/intents/detail.tsx +1 -1
- package/src/pages/admin/payments/payouts/detail.tsx +1 -1
- package/src/pages/admin/payments/refunds/detail.tsx +1 -1
- package/src/pages/admin/products/index.tsx +3 -2
- package/src/pages/admin/products/links/detail.tsx +1 -1
- package/src/pages/admin/products/prices/actions.tsx +16 -4
- package/src/pages/admin/products/prices/detail.tsx +30 -3
- package/src/pages/admin/products/prices/list.tsx +8 -1
- package/src/pages/admin/products/pricing-tables/detail.tsx +1 -1
- package/src/pages/admin/products/products/create.tsx +233 -57
- package/src/pages/admin/products/products/detail.tsx +2 -1
- package/src/pages/admin/settings/payment-methods/index.tsx +3 -0
- package/src/pages/customer/credit-grant/detail.tsx +308 -0
- package/src/pages/customer/index.tsx +35 -2
- package/src/pages/customer/recharge/account.tsx +5 -5
- package/src/pages/customer/subscription/change-payment.tsx +4 -2
- package/src/pages/customer/subscription/detail.tsx +48 -14
- 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
|
|
195
|
-
|
|
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
|
-
|
|
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
|
|
306
|
-
|
|
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,
|