payment-kit 1.23.11 → 1.24.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.
Files changed (31) hide show
  1. package/api/src/libs/credit-schedule.ts +866 -0
  2. package/api/src/queues/credit-consume.ts +4 -2
  3. package/api/src/queues/credit-grant.ts +385 -5
  4. package/api/src/queues/notification.ts +13 -7
  5. package/api/src/queues/subscription.ts +12 -0
  6. package/api/src/routes/credit-grants.ts +18 -0
  7. package/api/src/routes/credit-transactions.ts +1 -1
  8. package/api/src/routes/prices.ts +43 -3
  9. package/api/src/routes/products.ts +41 -2
  10. package/api/src/routes/subscriptions.ts +217 -0
  11. package/api/src/store/migrations/20251225-add-credit-schedule-state.ts +33 -0
  12. package/api/src/store/models/subscription.ts +9 -0
  13. package/api/src/store/models/types.ts +42 -0
  14. package/api/tests/libs/credit-schedule.spec.ts +676 -0
  15. package/api/tests/libs/subscription.spec.ts +8 -4
  16. package/blocklet.yml +1 -1
  17. package/package.json +22 -22
  18. package/src/components/customer/credit-overview.tsx +1 -1
  19. package/src/components/price/form.tsx +376 -133
  20. package/src/components/product/edit-price.tsx +6 -0
  21. package/src/components/subscription/payment-method-info.tsx +1 -1
  22. package/src/components/subscription/portal/actions.tsx +9 -2
  23. package/src/locales/en.tsx +28 -0
  24. package/src/locales/zh.tsx +28 -0
  25. package/src/pages/admin/billing/subscriptions/detail.tsx +28 -15
  26. package/src/pages/admin/products/prices/detail.tsx +114 -0
  27. package/src/pages/admin/settings/vault-config/index.tsx +1 -1
  28. package/src/pages/customer/subscription/detail.tsx +28 -8
  29. package/src/pages/integrations/donations/edit-form.tsx +1 -1
  30. package/src/pages/integrations/donations/index.tsx +1 -1
  31. package/vite.config.ts +0 -1
@@ -397,7 +397,9 @@ async function createCreditTransaction(
397
397
  context.meter.paymentCurrency.symbol
398
398
  );
399
399
  let description = `Consume ${formattedAmount}`;
400
- if (context.meterEvent.getSubscriptionId()) {
400
+ const resolvedSubscriptionId =
401
+ context.meterEvent.getSubscriptionId() || creditGrant.metadata?.subscription_id || context.subscription?.id;
402
+ if (resolvedSubscriptionId) {
401
403
  description += ' for Subscription';
402
404
  }
403
405
  if (context.meterEvent.metadata?.description) {
@@ -409,7 +411,7 @@ async function createCreditTransaction(
409
411
  customer_id: context.meterEvent.getCustomerId(),
410
412
  credit_grant_id: creditGrant.id,
411
413
  meter_id: context.meter.id,
412
- subscription_id: context.meterEvent.getSubscriptionId(),
414
+ subscription_id: resolvedSubscriptionId,
413
415
  meter_event_name: context.meterEvent.event_name,
414
416
  meter_unit: context.meter.unit,
415
417
  quantity: consumeAmount,
@@ -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
+ });
@@ -608,13 +608,19 @@ export async function startNotificationQueue() {
608
608
  });
609
609
 
610
610
  events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
611
- addNotificationJob(
612
- 'customer.credit_grant.granted',
613
- {
614
- creditGrantId: creditGrant.id,
615
- },
616
- [creditGrant.id]
617
- );
611
+ // Only send notification for non-recurring grants or the first grant of recurring schedule
612
+ const isScheduleGrant = creditGrant.metadata?.delivery_mode === 'schedule';
613
+ const isFirstScheduleGrant = isScheduleGrant && creditGrant.metadata?.schedule_seq === 1;
614
+
615
+ if (!isScheduleGrant || isFirstScheduleGrant) {
616
+ addNotificationJob(
617
+ 'customer.credit_grant.granted',
618
+ {
619
+ creditGrantId: creditGrant.id,
620
+ },
621
+ [creditGrant.id]
622
+ );
623
+ }
618
624
  });
619
625
 
620
626
  events.on('customer.credit.low_balance', (customer: Customer, { metadata }: { metadata: any }) => {
@@ -28,6 +28,7 @@ import {
28
28
  shouldCancelSubscription,
29
29
  slashOverdraftProtectionStake,
30
30
  } from '../libs/subscription';
31
+ import { resetPeriodGrantCounter } from '../libs/credit-schedule';
31
32
  import { ensureInvoiceAndItems, migrateSubscriptionPaymentMethodInvoice } from '../libs/invoice';
32
33
  import { PaymentCurrency, PaymentIntent, PaymentMethod, Refund, SetupIntent, UsageRecord } from '../store/models';
33
34
  import { Customer } from '../store/models/customer';
@@ -368,6 +369,7 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
368
369
  // get setup for next subscription period
369
370
  const previousPeriodEnd =
370
371
  subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
372
+ const previousPeriodStart = subscription.current_period_start;
371
373
  const setup = getSubscriptionCycleSetup(subscription.pending_invoice_item_interval, previousPeriodEnd as number);
372
374
 
373
375
  // Check if this is a credit subscription
@@ -424,6 +426,9 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
424
426
  current_period_start: nextPeriod.period.start,
425
427
  current_period_end: nextPeriod.period.end,
426
428
  });
429
+ if (subscription.credit_schedule_state && previousPeriodStart !== nextPeriod.period.start) {
430
+ await resetPeriodGrantCounter(subscription);
431
+ }
427
432
 
428
433
  if (subscription.isActive()) {
429
434
  logger.info(`Credit subscription updated for billing cycle: ${subscription.id}`, {
@@ -485,6 +490,9 @@ const handleSubscriptionWhenActive = async (subscription: Subscription) => {
485
490
  current_period_start: setup.period.start,
486
491
  current_period_end: setup.period.end,
487
492
  });
493
+ if (subscription.credit_schedule_state && previousPeriodStart !== setup.period.start) {
494
+ await resetPeriodGrantCounter(subscription);
495
+ }
488
496
  logger.info(`Subscription updated for new billing cycle: ${subscription.id}`);
489
497
  }
490
498
 
@@ -1273,6 +1281,7 @@ export const handleCreditSubscriptionRecovery = async () => {
1273
1281
  creditSubscriptions.map(async (subscription) => {
1274
1282
  // Check if subscription period has ended
1275
1283
  if (subscription.current_period_end && now > subscription.current_period_end) {
1284
+ const previousPeriodStart = subscription.current_period_start;
1276
1285
  const previousPeriodEnd =
1277
1286
  subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
1278
1287
 
@@ -1296,6 +1305,9 @@ export const handleCreditSubscriptionRecovery = async () => {
1296
1305
  current_period_start: setup.period.start,
1297
1306
  current_period_end: setup.period.end,
1298
1307
  });
1308
+ if (subscription.credit_schedule_state && previousPeriodStart !== setup.period.start) {
1309
+ await resetPeriodGrantCounter(subscription);
1310
+ }
1299
1311
 
1300
1312
  // Schedule next billing cycle
1301
1313
  await addSubscriptionJob(subscription, 'cycle', true, setup.period.end);
@@ -11,6 +11,7 @@ import {
11
11
  AutoRechargeConfig,
12
12
  CreditGrant,
13
13
  Customer,
14
+ Invoice,
14
15
  MeterEvent,
15
16
  PaymentCurrency,
16
17
  PaymentMethod,
@@ -60,12 +61,14 @@ const creditGrantSchema = Joi.object({
60
61
 
61
62
  const listSchema = createListParamSchema<{
62
63
  customer_id?: string;
64
+ subscription_id?: string;
63
65
  currency_id?: string;
64
66
  status?: string;
65
67
  livemode?: boolean;
66
68
  q?: string;
67
69
  }>({
68
70
  customer_id: Joi.string().optional(),
71
+ subscription_id: Joi.string().optional(),
69
72
  currency_id: Joi.string().optional(),
70
73
  status: Joi.string().optional(),
71
74
  livemode: Joi.boolean().optional(),
@@ -95,6 +98,21 @@ router.get('/', authMine, async (req, res) => {
95
98
  }
96
99
  where.customer_id = customer.id;
97
100
  }
101
+ if (query.subscription_id) {
102
+ const invoices = await Invoice.findAll({
103
+ where: {
104
+ subscription_id: query.subscription_id,
105
+ ...(where.customer_id ? { customer_id: where.customer_id } : {}),
106
+ },
107
+ attributes: ['id'],
108
+ });
109
+ const invoiceIds = invoices.map((invoice) => invoice.id);
110
+ const subscriptionFilters = [{ 'metadata.subscription_id': query.subscription_id }];
111
+ if (invoiceIds.length > 0) {
112
+ subscriptionFilters.push({ 'metadata.invoice_id': { [Op.in]: invoiceIds } } as any);
113
+ }
114
+ where[Op.and] = [...(where[Op.and] || []), { [Op.or]: subscriptionFilters }];
115
+ }
98
116
  if (query.currency_id) {
99
117
  where.currency_id = query.currency_id;
100
118
  }
@@ -133,7 +133,7 @@ router.get('/', authMine, async (req, res) => {
133
133
  // Grant where conditions
134
134
  const grantWhere: any = {
135
135
  customer_id: query.customer_id,
136
- status: ['granted', 'depleted'],
136
+ status: ['granted', 'depleted', 'expired'],
137
137
  };
138
138
  if (query.start) {
139
139
  grantWhere.created_at = {
@@ -18,14 +18,53 @@ const router = Router();
18
18
 
19
19
  const auth = authenticate<Price>({ component: true, roles: ['owner', 'admin'] });
20
20
 
21
+ // Schedule configuration for credit delivery
22
+ const CreditScheduleConfigSchema = Joi.object({
23
+ enabled: Joi.boolean().default(false),
24
+ delivery_mode: Joi.string().valid('invoice', 'schedule').default('invoice'),
25
+ interval_value: Joi.number().min(0.01).when('enabled', {
26
+ is: true,
27
+ then: Joi.required(),
28
+ otherwise: Joi.optional(),
29
+ }),
30
+ interval_unit: Joi.string().valid('hour', 'day', 'week', 'month').when('enabled', {
31
+ is: true,
32
+ then: Joi.required(),
33
+ otherwise: Joi.optional(),
34
+ }),
35
+ amount_per_grant: Joi.number().greater(0).when('enabled', {
36
+ is: true,
37
+ then: Joi.required(),
38
+ otherwise: Joi.optional(),
39
+ }),
40
+ first_grant_timing: Joi.string().valid('immediate', 'after_trial', 'after_first_payment').default('immediate'),
41
+ expire_with_next_grant: Joi.boolean().default(true),
42
+ max_grants_per_period: Joi.number().min(1).optional(),
43
+ });
44
+
21
45
  const CreditConfigSchema = Joi.object({
22
46
  valid_duration_value: Joi.number().default(0).optional(),
23
47
  valid_duration_unit: Joi.string().valid('hours', 'days', 'weeks', 'months', 'years').default('days').optional(),
24
48
  priority: Joi.number().min(0).max(100).default(50).optional(),
25
49
  applicable_prices: Joi.array().items(Joi.string()).default([]).optional(),
26
- credit_amount: Joi.number().greater(0).required(),
50
+ // credit_amount is required for one-time delivery, optional when schedule is enabled
51
+ credit_amount: Joi.number().greater(0).when('schedule.enabled', {
52
+ is: true,
53
+ then: Joi.optional(),
54
+ otherwise: Joi.required(),
55
+ }),
27
56
  currency_id: Joi.string().required(),
28
- });
57
+ schedule: CreditScheduleConfigSchema.optional(),
58
+ })
59
+ .custom((value, helpers) => {
60
+ if (value?.schedule?.expire_with_next_grant && (value?.valid_duration_value || 0) > 0) {
61
+ return helpers.error('any.invalid');
62
+ }
63
+ return value;
64
+ }, 'credit config validation')
65
+ .messages({
66
+ 'any.invalid': 'valid_duration_* is mutually exclusive with schedule.expire_with_next_grant',
67
+ });
29
68
 
30
69
  export async function getExpandedPrice(id: string) {
31
70
  const price = await Price.findByPkOrLookupKey(id, {
@@ -361,7 +400,8 @@ router.put('/:id', auth, async (req, res) => {
361
400
  }
362
401
 
363
402
  if (product.type === 'credit' && updates.metadata) {
364
- const creditConfig = updates.metadata.credit_config;
403
+ // Merge with existing credit_config if not provided
404
+ const creditConfig = updates.metadata.credit_config || doc.metadata?.credit_config;
365
405
  if (!creditConfig) {
366
406
  return res.status(400).json({ error: 'credit_config is required' });
367
407
  }