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.
@@ -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, delay }
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
- if (context.meterEvent.getSubscriptionId()) {
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: context.meterEvent.getSubscriptionId(),
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: creditGrant.customer_id,
761
- currencyId: creditGrant.currency_id,
762
- livemode: creditGrant.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: creditGrant.customer_id,
766
- currencyId: creditGrant.currency_id,
789
+ customerId,
790
+ currencyId,
767
791
  status: ['requires_action', 'requires_capture'],
768
- livemode: creditGrant.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: creditGrant.customer_id,
774
- currencyId: creditGrant.currency_id,
797
+ customerId,
798
+ currencyId,
775
799
  });
776
800
  return;
777
801
  }
778
802
 
779
- logger.info('Updating failed events status after credit grant', {
780
- customerId: creditGrant.customer_id,
781
- currencyId: creditGrant.currency_id,
782
- eventCount: failedEvents.length,
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
- await Promise.all(
786
- failedEvents.map((event) =>
787
- event.update({
788
- status: 'pending',
789
- attempt_count: 0,
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
- failedEvents.forEach((event, index) => {
796
- const delay = index * 1000;
797
- addCreditConsumptionJob(event.id, true, { delay });
798
- });
824
+ for (let i = 0; i < sortedEvents.length; i++) {
825
+ const event = sortedEvents[i];
826
+ if (!event) {
827
+ break;
828
+ }
799
829
 
800
- logger.info('Successfully updated failed events status', {
801
- customerId: creditGrant.customer_id,
802
- currencyId: creditGrant.currency_id,
803
- eventCount: failedEvents.length,
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 update failed events for customer', {
807
- customerId: creditGrant.customer_id,
808
- currencyId: creditGrant.currency_id,
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
- action: job.action,
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
+ });