payment-kit 1.23.10 → 1.24.0
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/libs/credit-schedule.ts +866 -0
- package/api/src/libs/invoice.ts +9 -2
- package/api/src/queues/credit-consume.ts +109 -35
- package/api/src/queues/credit-grant.ts +385 -5
- package/api/src/queues/notification.ts +13 -7
- package/api/src/queues/subscription.ts +12 -0
- package/api/src/routes/credit-grants.ts +18 -0
- package/api/src/routes/credit-transactions.ts +1 -1
- package/api/src/routes/meter-events.ts +0 -1
- package/api/src/routes/prices.ts +43 -3
- package/api/src/routes/products.ts +41 -2
- package/api/src/routes/subscriptions.ts +217 -0
- package/api/src/store/migrations/20251225-add-credit-schedule-state.ts +33 -0
- package/api/src/store/models/meter-event.ts +1 -1
- package/api/src/store/models/subscription.ts +9 -0
- package/api/src/store/models/types.ts +42 -0
- package/api/tests/libs/credit-schedule.spec.ts +676 -0
- package/api/tests/libs/subscription.spec.ts +8 -4
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/price/form.tsx +376 -133
- package/src/components/product/edit-price.tsx +6 -0
- package/src/components/subscription/portal/actions.tsx +9 -2
- package/src/locales/en.tsx +28 -0
- package/src/locales/zh.tsx +28 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +28 -15
- package/src/pages/admin/products/prices/detail.tsx +114 -0
- package/src/pages/customer/subscription/detail.tsx +28 -8
package/api/src/libs/invoice.ts
CHANGED
|
@@ -1121,20 +1121,27 @@ export async function retryUncollectibleInvoices(options: {
|
|
|
1121
1121
|
failed: [] as Array<{ id: string; reason: string }>,
|
|
1122
1122
|
};
|
|
1123
1123
|
|
|
1124
|
+
const now = dayjs().unix();
|
|
1125
|
+
const BATCH_SIZE = 10;
|
|
1126
|
+
const BATCH_DELAY_SECONDS = 0.1;
|
|
1127
|
+
|
|
1124
1128
|
const settledResults = await Promise.allSettled(
|
|
1125
1129
|
overdueInvoices.map(async (invoice, index) => {
|
|
1126
1130
|
const { paymentIntent } = invoice;
|
|
1127
|
-
const delay = index * 2;
|
|
1128
1131
|
if (!paymentIntent) {
|
|
1129
1132
|
throw new Error('No payment intent found');
|
|
1130
1133
|
}
|
|
1131
1134
|
|
|
1132
1135
|
await paymentIntent.update({ status: 'requires_capture' });
|
|
1136
|
+
|
|
1137
|
+
const batchIndex = Math.floor(index / BATCH_SIZE);
|
|
1138
|
+
const runAt = now + batchIndex * BATCH_DELAY_SECONDS;
|
|
1139
|
+
|
|
1133
1140
|
await emitAsync(
|
|
1134
1141
|
'payment.queued',
|
|
1135
1142
|
paymentIntent.id,
|
|
1136
1143
|
{ paymentIntentId: paymentIntent.id, retryOnError: true, ignoreMaxRetryCheck: true },
|
|
1137
|
-
{ sync: false,
|
|
1144
|
+
{ sync: false, runAt }
|
|
1138
1145
|
);
|
|
1139
1146
|
|
|
1140
1147
|
return invoice;
|
|
@@ -2,6 +2,7 @@ import { BN, fromUnitToToken } from '@ocap/util';
|
|
|
2
2
|
|
|
3
3
|
import { getLock } from '../libs/lock';
|
|
4
4
|
import logger from '../libs/logger';
|
|
5
|
+
import dayjs from '../libs/dayjs';
|
|
5
6
|
import createQueue from '../libs/queue';
|
|
6
7
|
import { createEvent } from '../libs/audit';
|
|
7
8
|
import {
|
|
@@ -396,7 +397,9 @@ async function createCreditTransaction(
|
|
|
396
397
|
context.meter.paymentCurrency.symbol
|
|
397
398
|
);
|
|
398
399
|
let description = `Consume ${formattedAmount}`;
|
|
399
|
-
|
|
400
|
+
const resolvedSubscriptionId =
|
|
401
|
+
context.meterEvent.getSubscriptionId() || creditGrant.metadata?.subscription_id || context.subscription?.id;
|
|
402
|
+
if (resolvedSubscriptionId) {
|
|
400
403
|
description += ' for Subscription';
|
|
401
404
|
}
|
|
402
405
|
if (context.meterEvent.metadata?.description) {
|
|
@@ -408,7 +411,7 @@ async function createCreditTransaction(
|
|
|
408
411
|
customer_id: context.meterEvent.getCustomerId(),
|
|
409
412
|
credit_grant_id: creditGrant.id,
|
|
410
413
|
meter_id: context.meter.id,
|
|
411
|
-
subscription_id:
|
|
414
|
+
subscription_id: resolvedSubscriptionId,
|
|
412
415
|
meter_event_name: context.meterEvent.event_name,
|
|
413
416
|
meter_unit: context.meter.unit,
|
|
414
417
|
quantity: consumeAmount,
|
|
@@ -551,7 +554,7 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
|
|
|
551
554
|
});
|
|
552
555
|
await context.meterEvent.markAsCompleted();
|
|
553
556
|
if (context.subscription && context.subscription.status === 'past_due') {
|
|
554
|
-
handlePastDueSubscriptionRecovery(context.subscription, null);
|
|
557
|
+
await handlePastDueSubscriptionRecovery(context.subscription, null);
|
|
555
558
|
}
|
|
556
559
|
} else {
|
|
557
560
|
logger.warn('Credit consumption partially completed - insufficient balance', {
|
|
@@ -622,6 +625,7 @@ export const creditQueue = createQueue<CreditConsumptionJob>({
|
|
|
622
625
|
options: {
|
|
623
626
|
concurrency: 1,
|
|
624
627
|
maxRetries: 0,
|
|
628
|
+
enableScheduledJob: true,
|
|
625
629
|
},
|
|
626
630
|
});
|
|
627
631
|
|
|
@@ -755,58 +759,128 @@ events.on('customer.credit_grant.granted', async (creditGrant: CreditGrant) => {
|
|
|
755
759
|
});
|
|
756
760
|
|
|
757
761
|
async function retryFailedEventsForCustomer(creditGrant: CreditGrant): Promise<void> {
|
|
762
|
+
const grant = await CreditGrant.findByPk(creditGrant.id);
|
|
763
|
+
if (!grant) {
|
|
764
|
+
logger.error('Credit grant not found', {
|
|
765
|
+
creditGrantId: creditGrant.id,
|
|
766
|
+
});
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const customerId = grant.customer_id;
|
|
771
|
+
const currencyId = grant.currency_id;
|
|
772
|
+
const lock = getLock(`retry-failed-events-${customerId}-${currencyId}`);
|
|
773
|
+
|
|
758
774
|
try {
|
|
775
|
+
await lock.acquire();
|
|
776
|
+
|
|
759
777
|
logger.info('Retrying failed events for customer', {
|
|
760
|
-
customerId
|
|
761
|
-
currencyId
|
|
762
|
-
livemode:
|
|
778
|
+
customerId,
|
|
779
|
+
currencyId,
|
|
780
|
+
livemode: grant.livemode,
|
|
781
|
+
grantAmount: grant.amount,
|
|
782
|
+
grantRemaining: grant.remaining_amount,
|
|
763
783
|
});
|
|
784
|
+
|
|
785
|
+
const availableGrants = await CreditGrant.getAvailableCreditsForCustomer(customerId, currencyId);
|
|
786
|
+
const totalAvailableCredit = availableGrants.reduce((sum, g) => sum.add(new BN(g.remaining_amount)), new BN(0));
|
|
787
|
+
|
|
764
788
|
const [, , failedEvents] = await MeterEvent.getPendingAmounts({
|
|
765
|
-
customerId
|
|
766
|
-
currencyId
|
|
789
|
+
customerId,
|
|
790
|
+
currencyId,
|
|
767
791
|
status: ['requires_action', 'requires_capture'],
|
|
768
|
-
livemode:
|
|
792
|
+
livemode: grant.livemode,
|
|
769
793
|
});
|
|
770
794
|
|
|
771
795
|
if (failedEvents.length === 0) {
|
|
772
796
|
logger.debug('No failed events with pending credit found', {
|
|
773
|
-
customerId
|
|
774
|
-
currencyId
|
|
797
|
+
customerId,
|
|
798
|
+
currencyId,
|
|
775
799
|
});
|
|
776
800
|
return;
|
|
777
801
|
}
|
|
778
802
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
803
|
+
if (totalAvailableCredit.lte(new BN(0))) {
|
|
804
|
+
logger.debug('No available credit to retry failed events', {
|
|
805
|
+
customerId,
|
|
806
|
+
currencyId,
|
|
807
|
+
totalAvailableCredit: totalAvailableCredit.toString(),
|
|
808
|
+
});
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const sortedEvents = failedEvents.sort((a, b) => {
|
|
813
|
+
const aPending = new BN(a.credit_pending);
|
|
814
|
+
const bPending = new BN(b.credit_pending);
|
|
815
|
+
return aPending.cmp(bPending);
|
|
783
816
|
});
|
|
784
817
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
next_attempt: undefined,
|
|
791
|
-
})
|
|
792
|
-
)
|
|
793
|
-
);
|
|
818
|
+
let cumulativePending = new BN(0);
|
|
819
|
+
const affordableEvents: MeterEvent[] = [];
|
|
820
|
+
const now = dayjs().unix();
|
|
821
|
+
const BATCH_SIZE = 10;
|
|
822
|
+
const BATCH_DELAY_SECONDS = 0.1;
|
|
794
823
|
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
824
|
+
for (let i = 0; i < sortedEvents.length; i++) {
|
|
825
|
+
const event = sortedEvents[i];
|
|
826
|
+
if (!event) {
|
|
827
|
+
break;
|
|
828
|
+
}
|
|
799
829
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
830
|
+
const eventPending = new BN(event.credit_pending);
|
|
831
|
+
const newCumulative = cumulativePending.add(eventPending);
|
|
832
|
+
|
|
833
|
+
if (newCumulative.gt(totalAvailableCredit)) {
|
|
834
|
+
if (eventPending.gt(totalAvailableCredit)) {
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// eslint-disable-next-line no-await-in-loop
|
|
840
|
+
await event.update({
|
|
841
|
+
status: 'pending',
|
|
842
|
+
attempt_count: 0,
|
|
843
|
+
next_attempt: undefined,
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
const batchIndex = Math.floor(i / BATCH_SIZE);
|
|
847
|
+
const runAt = now + batchIndex * BATCH_DELAY_SECONDS;
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
// eslint-disable-next-line no-await-in-loop
|
|
851
|
+
await addCreditConsumptionJob(event.id, true, { runAt });
|
|
852
|
+
} catch (jobError: any) {
|
|
853
|
+
logger.error('Failed to add credit consumption job after status update', {
|
|
854
|
+
eventId: event.id,
|
|
855
|
+
customerId,
|
|
856
|
+
currencyId,
|
|
857
|
+
error: jobError.message,
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
affordableEvents.push(event);
|
|
862
|
+
cumulativePending = newCumulative;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const skippedEvents = failedEvents.length - affordableEvents.length;
|
|
866
|
+
|
|
867
|
+
logger.info('Completed retrying failed events', {
|
|
868
|
+
customerId,
|
|
869
|
+
currencyId,
|
|
870
|
+
totalEvents: failedEvents.length,
|
|
871
|
+
affordableEvents: affordableEvents.length,
|
|
872
|
+
skippedEvents,
|
|
873
|
+
totalAvailableCredit: totalAvailableCredit.toString(),
|
|
874
|
+
totalAffordablePending: cumulativePending.toString(),
|
|
875
|
+
grantCount: availableGrants.length,
|
|
804
876
|
});
|
|
805
877
|
} catch (error: any) {
|
|
806
|
-
logger.error('Failed to
|
|
807
|
-
customerId:
|
|
808
|
-
currencyId:
|
|
878
|
+
logger.error('Failed to retry failed events for customer', {
|
|
879
|
+
customerId: grant.customer_id,
|
|
880
|
+
currencyId: grant.currency_id,
|
|
809
881
|
error: error.message,
|
|
810
882
|
});
|
|
883
|
+
} finally {
|
|
884
|
+
lock.release();
|
|
811
885
|
}
|
|
812
886
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Op } from 'sequelize';
|
|
4
4
|
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
5
|
+
import pAll from 'p-all';
|
|
5
6
|
import dayjs from '../libs/dayjs';
|
|
6
7
|
import createQueue from '../libs/queue';
|
|
7
8
|
import {
|
|
@@ -19,6 +20,15 @@ import {
|
|
|
19
20
|
} from '../store/models';
|
|
20
21
|
import { events } from '../libs/event';
|
|
21
22
|
import { calculateExpiresAt, createCreditGrant } from '../libs/credit-grant';
|
|
23
|
+
import {
|
|
24
|
+
CreditScheduleJob,
|
|
25
|
+
activateAfterFirstPaymentSchedules,
|
|
26
|
+
handleScheduledCredit,
|
|
27
|
+
initializeCreditSchedule,
|
|
28
|
+
stopCreditSchedule,
|
|
29
|
+
getScheduleJobId,
|
|
30
|
+
} from '../libs/credit-schedule';
|
|
31
|
+
import { Subscription } from '../store/models/subscription';
|
|
22
32
|
import { getAccountState, mintToken, transferTokenFromCustomer } from '../integrations/arcblock/token';
|
|
23
33
|
import logger from '../libs/logger';
|
|
24
34
|
|
|
@@ -30,7 +40,8 @@ type CreditGrantJob =
|
|
|
30
40
|
| {
|
|
31
41
|
invoiceId: string;
|
|
32
42
|
action: 'create_from_invoice';
|
|
33
|
-
}
|
|
43
|
+
}
|
|
44
|
+
| CreditScheduleJob;
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
47
|
* Activate credit grants and handle on-chain minting when required.
|
|
@@ -242,15 +253,31 @@ export async function expireGrant(creditGrant: CreditGrant) {
|
|
|
242
253
|
}
|
|
243
254
|
|
|
244
255
|
const handleCreditGrantJob = async (job: CreditGrantJob) => {
|
|
245
|
-
logger.info('Handling credit grant job',
|
|
246
|
-
|
|
247
|
-
...(job.action === 'create_from_invoice' ? { invoiceId: job.invoiceId } : { creditGrantId: job.creditGrantId }),
|
|
248
|
-
});
|
|
256
|
+
logger.info('Handling credit grant job', job);
|
|
257
|
+
|
|
249
258
|
if (job.action === 'create_from_invoice') {
|
|
250
259
|
await handleInvoiceCredit(job.invoiceId);
|
|
251
260
|
return;
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
if (job.action === 'create_from_schedule') {
|
|
264
|
+
const scheduleJob = job as CreditScheduleJob;
|
|
265
|
+
const nextJob = await handleScheduledCredit(scheduleJob);
|
|
266
|
+
|
|
267
|
+
logger.info('Scheduled credit grant handled', {
|
|
268
|
+
scheduleJob,
|
|
269
|
+
nextJob,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Schedule next grant if returned
|
|
273
|
+
// Use replace=true because the current job (same id) is still in the DB
|
|
274
|
+
// until onJobComplete deletes it, which would cause JOB_DUPLICATE error
|
|
275
|
+
if (nextJob) {
|
|
276
|
+
await addScheduledCreditJob(nextJob.jobId, nextJob.job, nextJob.runAt, true);
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
254
281
|
const { creditGrantId, action } = job as { creditGrantId: string; action: 'activate' | 'expire' };
|
|
255
282
|
|
|
256
283
|
const creditGrant = await CreditGrant.findByPk(creditGrantId);
|
|
@@ -435,6 +462,9 @@ export const startCreditGrantQueue = async () => {
|
|
|
435
462
|
logger.warn(`Failed to schedule jobs for ${failed} credit grants`);
|
|
436
463
|
}
|
|
437
464
|
|
|
465
|
+
// Recover credit schedule jobs for subscriptions with active schedules
|
|
466
|
+
await recoverCreditScheduleJobs();
|
|
467
|
+
|
|
438
468
|
logger.info('Credit grant queue started', {
|
|
439
469
|
totalGrants: grantsToSchedule.length,
|
|
440
470
|
succeeded,
|
|
@@ -442,6 +472,113 @@ export const startCreditGrantQueue = async () => {
|
|
|
442
472
|
});
|
|
443
473
|
};
|
|
444
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Recover credit schedule jobs after system restart
|
|
477
|
+
* Finds subscriptions with active credit schedules and ensures their jobs are scheduled
|
|
478
|
+
*/
|
|
479
|
+
async function recoverCreditScheduleJobs(): Promise<void> {
|
|
480
|
+
logger.info('Recovering credit schedule jobs...');
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
// Find subscriptions with active credit schedules
|
|
484
|
+
// We filter for non-null credit_schedule_state in application code
|
|
485
|
+
const allActiveSubscriptions = await Subscription.findAll({
|
|
486
|
+
where: {
|
|
487
|
+
status: ['active', 'trialing'],
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Filter for subscriptions with credit_schedule_state
|
|
492
|
+
const subscriptions = allActiveSubscriptions.filter(
|
|
493
|
+
(sub) => sub.credit_schedule_state && Object.keys(sub.credit_schedule_state).length > 0
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
if (subscriptions.length === 0) {
|
|
497
|
+
logger.info('No subscriptions with credit schedules to recover');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const now = dayjs().unix();
|
|
502
|
+
let recoveredCount = 0;
|
|
503
|
+
let scheduledCount = 0;
|
|
504
|
+
|
|
505
|
+
const tasks: Array<() => Promise<void>> = [];
|
|
506
|
+
const recoveryConcurrency = 5;
|
|
507
|
+
|
|
508
|
+
// Process each subscription's schedule state with bounded concurrency
|
|
509
|
+
subscriptions.forEach((subscription) => {
|
|
510
|
+
const state = subscription.credit_schedule_state;
|
|
511
|
+
if (!state) return;
|
|
512
|
+
|
|
513
|
+
Object.keys(state).forEach((priceId) => {
|
|
514
|
+
tasks.push(async () => {
|
|
515
|
+
const priceState = state[priceId];
|
|
516
|
+
if (!priceState?.enabled) return;
|
|
517
|
+
|
|
518
|
+
// Next job seq is last_grant_seq + 1
|
|
519
|
+
const nextSeq = priceState.last_grant_seq + 1;
|
|
520
|
+
const jobId = getScheduleJobId(subscription.id, priceId, nextSeq);
|
|
521
|
+
const nextGrantAt = priceState.next_grant_at;
|
|
522
|
+
|
|
523
|
+
// Skip if no next grant time scheduled
|
|
524
|
+
if (!nextGrantAt || nextGrantAt === 0) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Check if job already exists
|
|
529
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
530
|
+
|
|
531
|
+
if (nextGrantAt <= now) {
|
|
532
|
+
// Job is overdue - schedule immediately with replace=true
|
|
533
|
+
const job: CreditScheduleJob = {
|
|
534
|
+
subscriptionId: subscription.id,
|
|
535
|
+
priceId,
|
|
536
|
+
scheduledAt: nextGrantAt,
|
|
537
|
+
action: 'create_from_schedule',
|
|
538
|
+
};
|
|
539
|
+
await addScheduledCreditJob(jobId, job, now, true);
|
|
540
|
+
recoveredCount++;
|
|
541
|
+
logger.info('Recovered overdue credit schedule job', {
|
|
542
|
+
subscriptionId: subscription.id,
|
|
543
|
+
priceId,
|
|
544
|
+
originalScheduledAt: nextGrantAt,
|
|
545
|
+
overdueDays: Math.floor((now - nextGrantAt) / 86400),
|
|
546
|
+
});
|
|
547
|
+
} else if (!existingJob) {
|
|
548
|
+
// Job is in future but not scheduled - create it
|
|
549
|
+
const job: CreditScheduleJob = {
|
|
550
|
+
subscriptionId: subscription.id,
|
|
551
|
+
priceId,
|
|
552
|
+
scheduledAt: nextGrantAt,
|
|
553
|
+
action: 'create_from_schedule',
|
|
554
|
+
};
|
|
555
|
+
await addScheduledCreditJob(jobId, job, nextGrantAt, false);
|
|
556
|
+
scheduledCount++;
|
|
557
|
+
logger.debug('Scheduled missing credit schedule job', {
|
|
558
|
+
subscriptionId: subscription.id,
|
|
559
|
+
priceId,
|
|
560
|
+
scheduledAt: dayjs.unix(nextGrantAt).format(),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
await pAll(tasks, { concurrency: recoveryConcurrency, stopOnError: false });
|
|
568
|
+
|
|
569
|
+
logger.info('Credit schedule jobs recovery completed', {
|
|
570
|
+
subscriptionsChecked: subscriptions.length,
|
|
571
|
+
overdueJobsRecovered: recoveredCount,
|
|
572
|
+
missingJobsScheduled: scheduledCount,
|
|
573
|
+
});
|
|
574
|
+
} catch (error: any) {
|
|
575
|
+
logger.error('Failed to recover credit schedule jobs', {
|
|
576
|
+
error: error.message,
|
|
577
|
+
stack: error.stack,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
445
582
|
creditGrantQueue.on('failed', ({ id, job, error }) => {
|
|
446
583
|
logger.error('Credit grant job failed', { id, job, error });
|
|
447
584
|
});
|
|
@@ -513,6 +650,19 @@ async function handleInvoiceCredit(invoiceId: string) {
|
|
|
513
650
|
|
|
514
651
|
const metadata = price.metadata || {};
|
|
515
652
|
const creditConfig = metadata.credit_config || {};
|
|
653
|
+
|
|
654
|
+
// Check if this price uses schedule-based delivery
|
|
655
|
+
// If delivery_mode is 'schedule', skip invoice-based credit grant creation
|
|
656
|
+
const scheduleConfig = creditConfig.schedule;
|
|
657
|
+
if (scheduleConfig?.enabled && scheduleConfig?.delivery_mode === 'schedule') {
|
|
658
|
+
logger.info('Skipping invoice credit grant for schedule-based price', {
|
|
659
|
+
invoiceId,
|
|
660
|
+
priceId: price.id,
|
|
661
|
+
deliveryMode: scheduleConfig.delivery_mode,
|
|
662
|
+
});
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
516
666
|
const quantity = lineItem.quantity || 1;
|
|
517
667
|
const currency = await PaymentCurrency.findByPk(creditConfig.currency_id);
|
|
518
668
|
|
|
@@ -632,6 +782,7 @@ async function handleInvoiceCredit(invoiceId: string) {
|
|
|
632
782
|
price_id: item.price.id,
|
|
633
783
|
invoice_id: invoice.id,
|
|
634
784
|
purchased_quantity: item.quantity,
|
|
785
|
+
subscription_id: invoice.subscription_id,
|
|
635
786
|
},
|
|
636
787
|
});
|
|
637
788
|
|
|
@@ -716,9 +867,113 @@ export async function addInvoiceCreditJob(invoiceId: string) {
|
|
|
716
867
|
});
|
|
717
868
|
}
|
|
718
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Add a scheduled credit job to the queue
|
|
872
|
+
* @param jobId Unique job ID (typically schedule-{subscriptionId}-{priceId})
|
|
873
|
+
* @param job The credit schedule job data
|
|
874
|
+
* @param runAt Unix timestamp when the job should run
|
|
875
|
+
* @param replace If true, replace existing job; if false, skip if job exists
|
|
876
|
+
*/
|
|
877
|
+
export async function addScheduledCreditJob(
|
|
878
|
+
jobId: string,
|
|
879
|
+
job: CreditScheduleJob,
|
|
880
|
+
runAt: number,
|
|
881
|
+
replace: boolean = false
|
|
882
|
+
): Promise<boolean> {
|
|
883
|
+
// Always try to delete existing job first to avoid JOB_DUPLICATE error
|
|
884
|
+
// This is necessary because when a job is executing and creates the next job,
|
|
885
|
+
// the current job hasn't been deleted yet, causing ID conflict
|
|
886
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
887
|
+
|
|
888
|
+
if (existingJob) {
|
|
889
|
+
if (replace) {
|
|
890
|
+
await creditGrantQueue.delete(jobId);
|
|
891
|
+
logger.info('Scheduled credit job replaced', { jobId, runAt });
|
|
892
|
+
} else {
|
|
893
|
+
logger.debug('Scheduled credit job already exists, skipping', {
|
|
894
|
+
jobId,
|
|
895
|
+
existingRunAt: existingJob.runAt,
|
|
896
|
+
newRunAt: runAt,
|
|
897
|
+
});
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Use store.addJob directly to ensure proper persistence and error handling
|
|
903
|
+
// instead of relying on push() which has async issues for scheduled jobs
|
|
904
|
+
try {
|
|
905
|
+
const now = dayjs().unix();
|
|
906
|
+
const attrs: { delay?: number; will_run_at?: number } = {};
|
|
907
|
+
|
|
908
|
+
// If runAt is in the future, create as scheduled job
|
|
909
|
+
if (runAt > now) {
|
|
910
|
+
attrs.delay = 1;
|
|
911
|
+
attrs.will_run_at = runAt * 1000; // Convert to milliseconds
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
await creditGrantQueue.store.addJob(jobId, job, attrs);
|
|
915
|
+
|
|
916
|
+
logger.info('Scheduled credit job added', {
|
|
917
|
+
jobId,
|
|
918
|
+
subscriptionId: job.subscriptionId,
|
|
919
|
+
priceId: job.priceId,
|
|
920
|
+
runAt,
|
|
921
|
+
scheduledAt: dayjs.unix(runAt).format(),
|
|
922
|
+
isImmediate: runAt <= now,
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// If job should run immediately (runAt <= now), push to queue for execution
|
|
926
|
+
if (runAt <= now) {
|
|
927
|
+
creditGrantQueue.push({
|
|
928
|
+
id: jobId,
|
|
929
|
+
job,
|
|
930
|
+
persist: false, // Already persisted above
|
|
931
|
+
});
|
|
932
|
+
logger.info('Scheduled credit job pushed for immediate execution', { jobId });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return true;
|
|
936
|
+
} catch (err: any) {
|
|
937
|
+
// Handle duplicate job error gracefully
|
|
938
|
+
if (err.code === 'JOB_DUPLICATE') {
|
|
939
|
+
logger.warn('Scheduled credit job already exists (concurrent creation)', {
|
|
940
|
+
jobId,
|
|
941
|
+
error: err.message,
|
|
942
|
+
});
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
logger.error('Failed to add scheduled credit job', {
|
|
946
|
+
jobId,
|
|
947
|
+
error: err.message,
|
|
948
|
+
stack: err.stack,
|
|
949
|
+
});
|
|
950
|
+
throw err;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Delete a scheduled credit job from the queue
|
|
956
|
+
*/
|
|
957
|
+
export async function deleteScheduledCreditJob(jobId: string): Promise<boolean> {
|
|
958
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
959
|
+
if (existingJob) {
|
|
960
|
+
await creditGrantQueue.delete(jobId);
|
|
961
|
+
logger.info('Scheduled credit job deleted', { jobId });
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
|
|
719
967
|
events.on('invoice.paid', async (invoice: Invoice) => {
|
|
720
968
|
try {
|
|
721
969
|
await addInvoiceCreditJob(invoice.id);
|
|
970
|
+
if (invoice.subscription_id) {
|
|
971
|
+
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
972
|
+
if (subscription) {
|
|
973
|
+
const jobs = await activateAfterFirstPaymentSchedules(subscription, invoice.status_transitions?.paid_at);
|
|
974
|
+
await Promise.all(jobs.map((job) => addScheduledCreditJob(job.jobId, job.job, job.runAt, true)));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
722
977
|
} catch (error) {
|
|
723
978
|
logger.error('Failed to schedule credit grant job for invoice', {
|
|
724
979
|
invoiceId: invoice.id,
|
|
@@ -784,3 +1039,128 @@ events.on('credit-grant.queued', async (id, job, args = {}) => {
|
|
|
784
1039
|
events.emit('credit-grant.queued.error', { id, job, error });
|
|
785
1040
|
}
|
|
786
1041
|
});
|
|
1042
|
+
|
|
1043
|
+
// ========================================
|
|
1044
|
+
// Credit Schedule Event Handlers
|
|
1045
|
+
// ========================================
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Helper function to initialize credit schedule and create jobs
|
|
1049
|
+
* Used by both subscription.created and subscription.started events
|
|
1050
|
+
*/
|
|
1051
|
+
async function initializeAndScheduleCreditJobs(subscriptionId: string, eventName: string): Promise<void> {
|
|
1052
|
+
const sub = await Subscription.findByPk(subscriptionId);
|
|
1053
|
+
if (!sub) {
|
|
1054
|
+
logger.warn('Subscription not found for credit schedule initialization', {
|
|
1055
|
+
subscriptionId,
|
|
1056
|
+
event: eventName,
|
|
1057
|
+
});
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// initializeCreditSchedule only works for active/trialing subscriptions
|
|
1062
|
+
// and skips if already initialized
|
|
1063
|
+
const state = await initializeCreditSchedule(sub);
|
|
1064
|
+
if (!state) {
|
|
1065
|
+
logger.debug('No credit schedules to initialize for subscription', {
|
|
1066
|
+
subscriptionId,
|
|
1067
|
+
event: eventName,
|
|
1068
|
+
status: sub.status,
|
|
1069
|
+
});
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Schedule jobs for prices with valid next_grant_at
|
|
1074
|
+
const pricesToSchedule = Object.keys(state).filter((priceId) => {
|
|
1075
|
+
const priceState = state[priceId];
|
|
1076
|
+
return priceState?.enabled && priceState?.next_grant_at > 0;
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
await Promise.all(
|
|
1080
|
+
pricesToSchedule.map((priceId) => {
|
|
1081
|
+
const priceState = state[priceId]!;
|
|
1082
|
+
// First job has seq = 1 (since last_grant_seq starts at 0)
|
|
1083
|
+
const nextSeq = priceState.last_grant_seq + 1;
|
|
1084
|
+
const jobId = getScheduleJobId(subscriptionId, priceId, nextSeq);
|
|
1085
|
+
const job: CreditScheduleJob = {
|
|
1086
|
+
subscriptionId,
|
|
1087
|
+
priceId,
|
|
1088
|
+
scheduledAt: priceState.next_grant_at,
|
|
1089
|
+
action: 'create_from_schedule',
|
|
1090
|
+
};
|
|
1091
|
+
return addScheduledCreditJob(jobId, job, priceState.next_grant_at, true);
|
|
1092
|
+
})
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
logger.info('Credit schedule initialized and jobs scheduled', {
|
|
1096
|
+
subscriptionId,
|
|
1097
|
+
event: eventName,
|
|
1098
|
+
priceCount: pricesToSchedule.length,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Handle subscription started event (when subscription becomes active)
|
|
1104
|
+
*
|
|
1105
|
+
* This event fires when:
|
|
1106
|
+
* 1. Subscription payment succeeds (checkout flow completes)
|
|
1107
|
+
* 2. Trial ends and subscription becomes active
|
|
1108
|
+
* 3. Stripe SetupIntent succeeds
|
|
1109
|
+
*
|
|
1110
|
+
* For subscriptions created via checkout (status was incomplete),
|
|
1111
|
+
* this is when the credit schedule gets initialized.
|
|
1112
|
+
*/
|
|
1113
|
+
events.on('customer.subscription.started', async (subscription: Subscription) => {
|
|
1114
|
+
try {
|
|
1115
|
+
await initializeAndScheduleCreditJobs(subscription.id, 'customer.subscription.started');
|
|
1116
|
+
} catch (error: any) {
|
|
1117
|
+
logger.error('Failed to handle credit schedule on subscription started', {
|
|
1118
|
+
subscriptionId: subscription.id,
|
|
1119
|
+
error: error.message,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Handle subscription trial start event (when subscription enters trialing state)
|
|
1126
|
+
*
|
|
1127
|
+
* This event fires when subscription has a trial period and payment succeeds.
|
|
1128
|
+
* For immediate timing, credit schedule should start during trial period.
|
|
1129
|
+
*/
|
|
1130
|
+
events.on('customer.subscription.trial_start', async (subscription: Subscription) => {
|
|
1131
|
+
try {
|
|
1132
|
+
await initializeAndScheduleCreditJobs(subscription.id, 'customer.subscription.trial_start');
|
|
1133
|
+
} catch (error: any) {
|
|
1134
|
+
logger.error('Failed to handle credit schedule on subscription trial start', {
|
|
1135
|
+
subscriptionId: subscription.id,
|
|
1136
|
+
error: error.message,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Handle subscription deleted event - stop all credit schedules
|
|
1143
|
+
*/
|
|
1144
|
+
events.on('customer.subscription.deleted', async (subscription: Subscription) => {
|
|
1145
|
+
try {
|
|
1146
|
+
const sub = await Subscription.findByPk(subscription.id);
|
|
1147
|
+
if (!sub) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const jobIds = await stopCreditSchedule(sub);
|
|
1152
|
+
|
|
1153
|
+
// Delete all scheduled jobs
|
|
1154
|
+
await Promise.all(jobIds.map((id) => deleteScheduledCreditJob(id)));
|
|
1155
|
+
|
|
1156
|
+
logger.info('Credit schedules stopped on subscription deletion', {
|
|
1157
|
+
subscriptionId: subscription.id,
|
|
1158
|
+
deletedJobs: jobIds.length,
|
|
1159
|
+
});
|
|
1160
|
+
} catch (error: any) {
|
|
1161
|
+
logger.error('Failed to stop credit schedules on subscription deletion', {
|
|
1162
|
+
subscriptionId: subscription.id,
|
|
1163
|
+
error: error.message,
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
});
|