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.
@@ -0,0 +1,866 @@
1
+ /* eslint-disable no-continue */
2
+ /**
3
+ * Credit Schedule System
4
+ *
5
+ * Handles scheduled credit grant delivery, decoupling credit issuance from invoice billing cycles.
6
+ * Supports flexible delivery strategies (hourly/daily/weekly/monthly intervals, trial period grants, etc.)
7
+ */
8
+
9
+ import { fromTokenToUnit } from '@ocap/util';
10
+
11
+ import dayjs from './dayjs';
12
+ import { events } from './event';
13
+ import logger from './logger';
14
+ import { getLock } from './lock';
15
+ import { createCreditGrant, calculateExpiresAt } from './credit-grant';
16
+ import { getSubscriptionItemPrice } from './subscription';
17
+ import { CreditGrant, PaymentCurrency, Price, Product, Subscription, SubscriptionItem } from '../store/models';
18
+ import type {
19
+ CreditConfig,
20
+ CreditScheduleConfig,
21
+ CreditSchedulePriceState,
22
+ CreditScheduleState,
23
+ } from '../store/models/types';
24
+ import type { TPaymentCurrency } from '../store/models/payment-currency';
25
+
26
+ // Job types for credit schedule
27
+ export type CreditScheduleJob = {
28
+ subscriptionId: string;
29
+ priceId: string;
30
+ scheduledAt: number; // The scheduled time for this grant (used for seq calculation)
31
+ action: 'create_from_schedule';
32
+ };
33
+
34
+ /**
35
+ * Get schedule job ID for a subscription + price + seq combination
36
+ * Each scheduled grant has a unique job ID to avoid conflicts when
37
+ * creating the next job while the current one is still executing
38
+ */
39
+ export function getScheduleJobId(subscriptionId: string, priceId: string, seq: number): string {
40
+ return `schedule-${subscriptionId}-${priceId}-${seq}`;
41
+ }
42
+
43
+ /**
44
+ * Calculate schedule sequence number based on anchor and scheduled time
45
+ * seq = floor((scheduled_at - schedule_anchor_at) / interval) + 1
46
+ */
47
+ export function calculateScheduleSeq(
48
+ anchorAt: number,
49
+ scheduledAt: number,
50
+ intervalValue: number,
51
+ intervalUnit: 'hour' | 'day' | 'week' | 'month'
52
+ ): number {
53
+ if (scheduledAt < anchorAt) {
54
+ return 0;
55
+ }
56
+
57
+ if (intervalUnit === 'month') {
58
+ let seq = 1;
59
+ let cursor = dayjs.unix(anchorAt);
60
+ let next = cursor.add(intervalValue, 'month');
61
+
62
+ while (next.unix() <= scheduledAt) {
63
+ seq += 1;
64
+ cursor = next;
65
+ next = cursor.add(intervalValue, 'month');
66
+ }
67
+
68
+ return seq;
69
+ }
70
+
71
+ const intervalMs = getIntervalMs(intervalValue, intervalUnit, anchorAt);
72
+ const elapsed = scheduledAt - anchorAt;
73
+ return Math.floor(elapsed / (intervalMs / 1000)) + 1;
74
+ }
75
+
76
+ /**
77
+ * Get interval duration in milliseconds
78
+ * For month intervals, uses dayjs to handle variable month lengths
79
+ */
80
+ export function getIntervalMs(
81
+ intervalValue: number,
82
+ intervalUnit: 'hour' | 'day' | 'week' | 'month',
83
+ referenceTime?: number
84
+ ): number {
85
+ switch (intervalUnit) {
86
+ case 'hour':
87
+ return intervalValue * 60 * 60 * 1000;
88
+ case 'day':
89
+ return intervalValue * 24 * 60 * 60 * 1000;
90
+ case 'week':
91
+ return intervalValue * 7 * 24 * 60 * 60 * 1000;
92
+ case 'month': {
93
+ // For month, calculate based on reference time to handle variable month lengths
94
+ const ref = referenceTime ? dayjs.unix(referenceTime) : dayjs();
95
+ return ref.add(intervalValue, 'month').diff(ref);
96
+ }
97
+ default:
98
+ return intervalValue * 24 * 60 * 60 * 1000; // Default to days
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Calculate the next grant time based on current state and schedule config
104
+ */
105
+ export function calculateNextGrantAt(
106
+ anchorAt: number,
107
+ currentSeq: number,
108
+ intervalValue: number,
109
+ intervalUnit: 'hour' | 'day' | 'week' | 'month'
110
+ ): number {
111
+ const anchor = dayjs.unix(anchorAt);
112
+
113
+ // Use dayjs for month calculations to handle variable month lengths
114
+ if (intervalUnit === 'month') {
115
+ return anchor.add(currentSeq * intervalValue, 'month').unix();
116
+ }
117
+
118
+ const intervalMs = getIntervalMs(intervalValue, intervalUnit, anchorAt);
119
+ return anchorAt + Math.floor((currentSeq * intervalMs) / 1000);
120
+ }
121
+
122
+ /**
123
+ * Get credit config with schedule from a price
124
+ */
125
+ export function getCreditConfigWithSchedule(
126
+ price: any
127
+ ): { creditConfig: CreditConfig; scheduleConfig: CreditScheduleConfig } | null {
128
+ const metadata = price?.metadata || {};
129
+ const creditConfig = metadata.credit_config as CreditConfig | undefined;
130
+
131
+ if (!creditConfig || !creditConfig.schedule?.enabled) {
132
+ return null;
133
+ }
134
+
135
+ return {
136
+ creditConfig,
137
+ scheduleConfig: creditConfig.schedule,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Calculate the first grant time based on first_grant_timing setting
143
+ */
144
+ export function calculateFirstGrantAt(subscription: Subscription, scheduleConfig: CreditScheduleConfig): number | null {
145
+ const now = dayjs().unix();
146
+ const timing = scheduleConfig.first_grant_timing || 'immediate';
147
+
148
+ switch (timing) {
149
+ case 'immediate':
150
+ // Grant immediately (or at subscription start if in future)
151
+ return Math.max(now, subscription.start_date);
152
+
153
+ case 'after_trial':
154
+ // Grant after trial ends
155
+ if (subscription.trial_end && subscription.trial_end > now) {
156
+ return subscription.trial_end;
157
+ }
158
+ // No trial or trial ended, grant immediately
159
+ return now;
160
+
161
+ case 'after_first_payment':
162
+ // Don't schedule yet; will be activated on first invoice.paid event
163
+ return null;
164
+
165
+ default:
166
+ return now;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Calculate credit amount for a scheduled grant
172
+ * If amount_per_grant is set, use that; otherwise divide total by period count
173
+ */
174
+ export function calculateGrantAmount(
175
+ creditConfig: CreditConfig,
176
+ scheduleConfig: CreditScheduleConfig,
177
+ currency: TPaymentCurrency
178
+ ): string {
179
+ if (scheduleConfig.amount_per_grant) {
180
+ return fromTokenToUnit(scheduleConfig.amount_per_grant, currency.decimal).toString();
181
+ }
182
+
183
+ // Calculate based on billing period division
184
+ // For simplicity, use the full credit_amount as the grant amount
185
+ // In a real scenario, you might divide by expected grants per billing period
186
+ return fromTokenToUnit(creditConfig.credit_amount, currency.decimal).toString();
187
+ }
188
+
189
+ /**
190
+ * Calculate expires_at for a scheduled credit grant
191
+ * If expire_with_next_grant is true, expires at next grant time
192
+ * Otherwise uses the standard valid_duration settings
193
+ */
194
+ export function calculateScheduledGrantExpiresAt(
195
+ creditConfig: CreditConfig,
196
+ scheduleConfig: CreditScheduleConfig,
197
+ nextGrantAt: number
198
+ ): number | undefined {
199
+ if (scheduleConfig.expire_with_next_grant) {
200
+ // Expire when next grant is issued
201
+ return nextGrantAt;
202
+ }
203
+
204
+ // Use standard duration settings
205
+ if (creditConfig.valid_duration_value && creditConfig.valid_duration_unit) {
206
+ return calculateExpiresAt(creditConfig.valid_duration_value, creditConfig.valid_duration_unit);
207
+ }
208
+
209
+ return undefined;
210
+ }
211
+
212
+ /**
213
+ * Initialize credit schedule state for a subscription
214
+ *
215
+ * Only initializes when subscription is active or trialing.
216
+ * For subscriptions created via checkout flow (status: incomplete),
217
+ * this should be called after subscription.started event fires.
218
+ */
219
+ export async function initializeCreditSchedule(subscription: Subscription): Promise<CreditScheduleState | null> {
220
+ // Only initialize for active subscriptions
221
+ if (!subscription.isActive()) {
222
+ logger.debug('Subscription not active, skipping credit schedule initialization', {
223
+ subscriptionId: subscription.id,
224
+ status: subscription.status,
225
+ });
226
+ return null;
227
+ }
228
+
229
+ // Skip if already initialized
230
+ if (subscription.credit_schedule_state && Object.keys(subscription.credit_schedule_state).length > 0) {
231
+ logger.debug('Credit schedule already initialized', {
232
+ subscriptionId: subscription.id,
233
+ });
234
+ return subscription.credit_schedule_state;
235
+ }
236
+
237
+ const lock = getLock(`credit-schedule-init-${subscription.id}`);
238
+ await lock.acquire();
239
+
240
+ try {
241
+ // Get subscription items with prices
242
+ const subscriptionItems = await SubscriptionItem.findAll({
243
+ where: { subscription_id: subscription.id },
244
+ });
245
+
246
+ if (subscriptionItems.length === 0) {
247
+ logger.debug('No subscription items found for credit schedule initialization', {
248
+ subscriptionId: subscription.id,
249
+ });
250
+ return null;
251
+ }
252
+
253
+ // Load prices with products
254
+ const priceIds = subscriptionItems.map((item) => item.price_id);
255
+ const prices = await Price.findAll({
256
+ where: { id: priceIds },
257
+ include: [{ model: Product, as: 'product' }],
258
+ });
259
+
260
+ const state: CreditScheduleState = {};
261
+ const now = dayjs().unix();
262
+
263
+ for (const price of prices) {
264
+ // @ts-ignore - product is included
265
+ if (price.product?.type !== 'credit') continue;
266
+
267
+ const config = getCreditConfigWithSchedule(price);
268
+ if (!config) continue;
269
+
270
+ const { scheduleConfig } = config;
271
+
272
+ // Skip if delivery_mode is 'invoice' (old behavior)
273
+ if (scheduleConfig.delivery_mode === 'invoice') {
274
+ continue;
275
+ }
276
+
277
+ // Calculate first grant time
278
+ const firstGrantAt = calculateFirstGrantAt(subscription, scheduleConfig);
279
+
280
+ // Initialize state for this price
281
+ state[price.id] = {
282
+ enabled: true,
283
+ schedule_anchor_at: firstGrantAt || now,
284
+ next_grant_at: firstGrantAt || 0,
285
+ last_grant_seq: 0,
286
+ grants_in_current_period: 0,
287
+ };
288
+ logger.info('Credit schedule state initialized for price', {
289
+ subscriptionId: subscription.id,
290
+ priceId: price.id,
291
+ firstGrantAt,
292
+ timing: scheduleConfig.first_grant_timing,
293
+ deliveryMode: scheduleConfig.delivery_mode,
294
+ });
295
+ }
296
+
297
+ if (Object.keys(state).length === 0) {
298
+ return null;
299
+ }
300
+
301
+ // Update subscription with schedule state
302
+ await subscription.update({ credit_schedule_state: state });
303
+
304
+ logger.info('Credit schedule initialized for subscription', {
305
+ subscriptionId: subscription.id,
306
+ priceCount: Object.keys(state).length,
307
+ });
308
+
309
+ return state;
310
+ } catch (error: any) {
311
+ logger.error('Failed to initialize credit schedule', {
312
+ subscriptionId: subscription.id,
313
+ error: error.message,
314
+ stack: error.stack,
315
+ });
316
+ throw error;
317
+ } finally {
318
+ lock.release();
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Activate schedules that are waiting for the first payment
324
+ * Called on invoice.paid when delivery_mode is schedule and first_grant_timing is after_first_payment
325
+ */
326
+ export async function activateAfterFirstPaymentSchedules(
327
+ subscription: Subscription,
328
+ triggeredAt?: number
329
+ ): Promise<Array<{ jobId: string; runAt: number; job: CreditScheduleJob }>> {
330
+ if (!subscription.isActive()) {
331
+ return [];
332
+ }
333
+
334
+ const lock = getLock(`credit-schedule-first-payment-${subscription.id}`);
335
+ await lock.acquire();
336
+
337
+ try {
338
+ const subscriptionItems = await SubscriptionItem.findAll({
339
+ where: { subscription_id: subscription.id },
340
+ });
341
+
342
+ if (subscriptionItems.length === 0) {
343
+ return [];
344
+ }
345
+
346
+ const expandedItems = await Price.expand(subscriptionItems.map((item) => item.toJSON()));
347
+
348
+ const state = subscription.credit_schedule_state || {};
349
+ const updatedState: CreditScheduleState = { ...state };
350
+ const jobs: Array<{ jobId: string; runAt: number; job: CreditScheduleJob }> = [];
351
+ const now = triggeredAt || dayjs().unix();
352
+
353
+ for (const item of expandedItems) {
354
+ const price = getSubscriptionItemPrice(item);
355
+ if (!price) continue;
356
+ if (price.product?.type !== 'credit') continue;
357
+
358
+ const config = getCreditConfigWithSchedule(price);
359
+ if (!config) continue;
360
+
361
+ const { scheduleConfig } = config;
362
+ if (scheduleConfig.delivery_mode !== 'schedule') continue;
363
+ if (scheduleConfig.first_grant_timing !== 'after_first_payment') continue;
364
+
365
+ const priceState = state[price.id];
366
+ if (priceState?.next_grant_at && priceState.next_grant_at > 0) {
367
+ continue;
368
+ }
369
+
370
+ updatedState[price.id] = {
371
+ enabled: true,
372
+ schedule_anchor_at: now,
373
+ next_grant_at: now,
374
+ last_grant_seq: 0,
375
+ grants_in_current_period: 0,
376
+ last_grant_id: undefined,
377
+ last_error: undefined,
378
+ };
379
+
380
+ const jobId = getScheduleJobId(subscription.id, price.id, 1);
381
+ jobs.push({
382
+ jobId,
383
+ runAt: now,
384
+ job: {
385
+ subscriptionId: subscription.id,
386
+ priceId: price.id,
387
+ scheduledAt: now,
388
+ action: 'create_from_schedule',
389
+ },
390
+ });
391
+ }
392
+
393
+ if (jobs.length > 0) {
394
+ await subscription.update({ credit_schedule_state: updatedState });
395
+ logger.info('Activated credit schedules after first payment', {
396
+ subscriptionId: subscription.id,
397
+ jobCount: jobs.length,
398
+ });
399
+ }
400
+
401
+ return jobs;
402
+ } catch (error: any) {
403
+ logger.error('Failed to activate credit schedules after first payment', {
404
+ subscriptionId: subscription.id,
405
+ error: error.message,
406
+ stack: error.stack,
407
+ });
408
+ throw error;
409
+ } finally {
410
+ lock.release();
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Handle scheduled credit grant execution
416
+ * Called by the queue when a scheduled job triggers
417
+ * Returns the next job parameters if scheduling should continue
418
+ */
419
+ export async function handleScheduledCredit(
420
+ job: CreditScheduleJob
421
+ ): Promise<{ jobId: string; runAt: number; job: CreditScheduleJob } | null> {
422
+ const { subscriptionId, priceId, scheduledAt } = job;
423
+ const lock = getLock(`credit-schedule-exec-${subscriptionId}-${priceId}`);
424
+ await lock.acquire();
425
+
426
+ try {
427
+ // Load subscription with fresh state
428
+ const subscription = await Subscription.findByPk(subscriptionId);
429
+ if (!subscription) {
430
+ logger.warn('Subscription not found for scheduled credit', { subscriptionId, priceId });
431
+ return null;
432
+ }
433
+
434
+ // Check subscription is active
435
+ if (!subscription.isActive()) {
436
+ logger.info('Subscription not active, skipping scheduled credit', {
437
+ subscriptionId,
438
+ priceId,
439
+ status: subscription.status,
440
+ });
441
+ return null;
442
+ }
443
+
444
+ // Get state for this price
445
+ const state = subscription.credit_schedule_state?.[priceId];
446
+ if (!state?.enabled) {
447
+ logger.info('Credit schedule not enabled for price, skipping', {
448
+ subscriptionId,
449
+ priceId,
450
+ });
451
+ return null;
452
+ }
453
+
454
+ // Load price with product
455
+ const price = await Price.findByPk(priceId, {
456
+ include: [{ model: Product, as: 'product' }],
457
+ });
458
+ if (!price) {
459
+ logger.warn('Price not found for scheduled credit', { subscriptionId, priceId });
460
+ return null;
461
+ }
462
+
463
+ const config = getCreditConfigWithSchedule(price);
464
+ if (!config) {
465
+ logger.warn('Price has no schedule config', { subscriptionId, priceId });
466
+ return null;
467
+ }
468
+
469
+ const { creditConfig, scheduleConfig } = config;
470
+
471
+ // Calculate sequence for this grant
472
+ const seq = calculateScheduleSeq(
473
+ state.schedule_anchor_at,
474
+ scheduledAt,
475
+ scheduleConfig.interval_value,
476
+ scheduleConfig.interval_unit
477
+ );
478
+
479
+ // Idempotency check: look for existing grant with same seq
480
+ const existingGrant = await CreditGrant.findOne({
481
+ where: {
482
+ customer_id: subscription.customer_id,
483
+ 'metadata.subscription_id': subscriptionId,
484
+ 'metadata.price_id': priceId,
485
+ 'metadata.schedule_seq': seq,
486
+ },
487
+ });
488
+
489
+ if (existingGrant) {
490
+ logger.info('Credit grant already exists for this seq, skipping', {
491
+ subscriptionId,
492
+ priceId,
493
+ seq,
494
+ existingGrantId: existingGrant.id,
495
+ });
496
+ const now = dayjs().unix();
497
+ let nextGrantAt = calculateNextGrantAt(
498
+ state.schedule_anchor_at,
499
+ seq,
500
+ scheduleConfig.interval_value,
501
+ scheduleConfig.interval_unit
502
+ );
503
+
504
+ if (nextGrantAt <= now) {
505
+ const intervalMs = getIntervalMs(scheduleConfig.interval_value, scheduleConfig.interval_unit, now);
506
+ nextGrantAt = now + Math.ceil(intervalMs / 1000);
507
+ }
508
+
509
+ const newState: CreditSchedulePriceState = {
510
+ ...state,
511
+ last_grant_seq: seq,
512
+ last_grant_id: existingGrant.id,
513
+ next_grant_at: nextGrantAt,
514
+ last_error: undefined,
515
+ };
516
+
517
+ await updateScheduleState(subscription, priceId, newState);
518
+
519
+ // Return next job params
520
+ return getNextJobParams(subscription, priceId, newState, scheduleConfig);
521
+ }
522
+
523
+ // Check max_grants_per_period limit
524
+ if (
525
+ scheduleConfig.max_grants_per_period &&
526
+ state.grants_in_current_period >= scheduleConfig.max_grants_per_period
527
+ ) {
528
+ logger.warn('Max grants per period reached, skipping', {
529
+ subscriptionId,
530
+ priceId,
531
+ grantsInPeriod: state.grants_in_current_period,
532
+ maxGrants: scheduleConfig.max_grants_per_period,
533
+ });
534
+ const now = dayjs().unix();
535
+ const periodEnd = subscription.status === 'trialing' ? subscription.trial_end : subscription.current_period_end;
536
+ let nextGrantAt = state.next_grant_at;
537
+
538
+ if (periodEnd && periodEnd > now) {
539
+ nextGrantAt = periodEnd;
540
+ } else {
541
+ const intervalMs = getIntervalMs(scheduleConfig.interval_value, scheduleConfig.interval_unit, now);
542
+ nextGrantAt = now + Math.ceil(intervalMs / 1000);
543
+ }
544
+
545
+ const newState: CreditSchedulePriceState = {
546
+ ...state,
547
+ next_grant_at: nextGrantAt,
548
+ last_error: undefined,
549
+ };
550
+
551
+ await updateScheduleState(subscription, priceId, newState);
552
+
553
+ // Return next job params (will be checked again)
554
+ return getNextJobParams(subscription, priceId, newState, scheduleConfig);
555
+ }
556
+
557
+ // Get currency
558
+ const currency = await PaymentCurrency.findByPk(creditConfig.currency_id);
559
+ if (!currency) {
560
+ logger.error('Currency not found for scheduled credit', {
561
+ subscriptionId,
562
+ priceId,
563
+ currencyId: creditConfig.currency_id,
564
+ });
565
+ await updateScheduleState(subscription, priceId, {
566
+ ...state,
567
+ last_error: `Currency not found: ${creditConfig.currency_id}`,
568
+ });
569
+ return null;
570
+ }
571
+
572
+ // Calculate grant amount
573
+ const amount = calculateGrantAmount(creditConfig, scheduleConfig, currency);
574
+
575
+ // Calculate next grant time for expiration
576
+ const now = dayjs().unix();
577
+ let nextGrantAt = calculateNextGrantAt(
578
+ state.schedule_anchor_at,
579
+ seq,
580
+ scheduleConfig.interval_value,
581
+ scheduleConfig.interval_unit
582
+ );
583
+
584
+ // If job was delayed (e.g., after service restart), nextGrantAt might be in the past
585
+ // Recalculate based on current time if needed
586
+ if (nextGrantAt <= now) {
587
+ const intervalMs = getIntervalMs(scheduleConfig.interval_value, scheduleConfig.interval_unit, now);
588
+ nextGrantAt = now + Math.ceil(intervalMs / 1000);
589
+ logger.info('Adjusted nextGrantAt for delayed job execution', {
590
+ subscriptionId,
591
+ priceId,
592
+ originalNextGrantAt: calculateNextGrantAt(
593
+ state.schedule_anchor_at,
594
+ seq,
595
+ scheduleConfig.interval_value,
596
+ scheduleConfig.interval_unit
597
+ ),
598
+ adjustedNextGrantAt: nextGrantAt,
599
+ now,
600
+ });
601
+ }
602
+
603
+ // Calculate expiration
604
+ const expiresAt = calculateScheduledGrantExpiresAt(creditConfig, scheduleConfig, nextGrantAt);
605
+
606
+ // Handle expire_with_next_grant: expire previous grants via standard flow
607
+ if (scheduleConfig.expire_with_next_grant && state.last_grant_id) {
608
+ const previousGrant = await CreditGrant.findByPk(state.last_grant_id);
609
+ if (previousGrant && previousGrant.status === 'granted') {
610
+ await previousGrant.update({ expires_at: now });
611
+ events.emit(
612
+ 'credit-grant.queued',
613
+ `expire-${previousGrant.id}`,
614
+ { creditGrantId: previousGrant.id, action: 'expire' },
615
+ { sync: false }
616
+ );
617
+ logger.info('Scheduled previous grant expiration for refresh', {
618
+ subscriptionId,
619
+ priceId,
620
+ previousGrantId: state.last_grant_id,
621
+ expiresAt: now,
622
+ });
623
+ }
624
+ }
625
+
626
+ // Build applicability config
627
+ let applicabilityConfig: any = { scope: { type: 'metered' } };
628
+ if (creditConfig.applicable_prices && creditConfig.applicable_prices.length > 0) {
629
+ applicabilityConfig = { scope: { prices: creditConfig.applicable_prices } };
630
+ }
631
+
632
+ // Create the credit grant
633
+ const creditGrant = await createCreditGrant({
634
+ amount,
635
+ currency_id: creditConfig.currency_id,
636
+ customer_id: subscription.customer_id,
637
+ // @ts-ignore
638
+ name: price.nickname || price.product?.name,
639
+ category: 'paid',
640
+ priority: creditConfig.priority || 50,
641
+ expires_at: expiresAt,
642
+ applicability_config: applicabilityConfig,
643
+ livemode: subscription.livemode,
644
+ created_via: 'api',
645
+ metadata: {
646
+ subscription_id: subscriptionId,
647
+ price_id: priceId,
648
+ schedule_seq: seq,
649
+ scheduled_at: scheduledAt,
650
+ executed_at: dayjs().unix(),
651
+ delivery_mode: 'schedule',
652
+ },
653
+ });
654
+
655
+ logger.info('Scheduled credit grant created', {
656
+ subscriptionId,
657
+ priceId,
658
+ creditGrantId: creditGrant.id,
659
+ seq,
660
+ amount,
661
+ expiresAt,
662
+ });
663
+
664
+ // Update state
665
+ const newState: CreditSchedulePriceState = {
666
+ ...state,
667
+ last_grant_seq: seq,
668
+ last_grant_id: creditGrant.id,
669
+ grants_in_current_period: state.grants_in_current_period + 1,
670
+ next_grant_at: nextGrantAt,
671
+ last_error: undefined,
672
+ };
673
+
674
+ await updateScheduleState(subscription, priceId, newState);
675
+
676
+ // Return next job params
677
+ return getNextJobParams(subscription, priceId, newState, scheduleConfig);
678
+ } catch (error: any) {
679
+ logger.error('Failed to handle scheduled credit', {
680
+ subscriptionId,
681
+ priceId,
682
+ scheduledAt,
683
+ error: error.message,
684
+ stack: error.stack,
685
+ });
686
+
687
+ // Update state with error
688
+ const subscription = await Subscription.findByPk(subscriptionId);
689
+ if (subscription) {
690
+ const state = subscription.credit_schedule_state?.[priceId];
691
+ if (state) {
692
+ await updateScheduleState(subscription, priceId, {
693
+ ...state,
694
+ last_error: error.message,
695
+ });
696
+ }
697
+ }
698
+
699
+ throw error;
700
+ } finally {
701
+ lock.release();
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Get next job parameters for scheduling
707
+ * Returns the job parameters for the queue (synchronous)
708
+ */
709
+ function getNextJobParams(
710
+ subscription: Subscription,
711
+ priceId: string,
712
+ state: CreditSchedulePriceState,
713
+ scheduleConfig: CreditScheduleConfig
714
+ ): { jobId: string; runAt: number; job: CreditScheduleJob } | null {
715
+ if (!state.enabled || !subscription.isActive()) {
716
+ return null;
717
+ }
718
+
719
+ const nextGrantAt =
720
+ state.next_grant_at ||
721
+ calculateNextGrantAt(
722
+ state.schedule_anchor_at,
723
+ state.last_grant_seq,
724
+ scheduleConfig.interval_value,
725
+ scheduleConfig.interval_unit
726
+ );
727
+
728
+ // Check if subscription will end before next grant
729
+ if (subscription.cancel_at && subscription.cancel_at < nextGrantAt) {
730
+ logger.info('Subscription will be canceled before next grant, not scheduling', {
731
+ subscriptionId: subscription.id,
732
+ priceId,
733
+ nextGrantAt,
734
+ cancelAt: subscription.cancel_at,
735
+ });
736
+ return null;
737
+ }
738
+
739
+ // Next job seq is current seq + 1
740
+ const nextSeq = state.last_grant_seq + 1;
741
+ const jobId = getScheduleJobId(subscription.id, priceId, nextSeq);
742
+ const job: CreditScheduleJob = {
743
+ subscriptionId: subscription.id,
744
+ priceId,
745
+ scheduledAt: nextGrantAt,
746
+ action: 'create_from_schedule',
747
+ };
748
+
749
+ logger.info('Scheduling next credit grant', {
750
+ subscriptionId: subscription.id,
751
+ priceId,
752
+ jobId,
753
+ seq: nextSeq,
754
+ runAt: nextGrantAt,
755
+ scheduledAt: dayjs.unix(nextGrantAt).format(),
756
+ });
757
+
758
+ return { jobId, runAt: nextGrantAt, job };
759
+ }
760
+
761
+ /**
762
+ * Update schedule state for a specific price
763
+ */
764
+ async function updateScheduleState(
765
+ subscription: Subscription,
766
+ priceId: string,
767
+ newPriceState: CreditSchedulePriceState
768
+ ): Promise<void> {
769
+ const currentState = subscription.credit_schedule_state || {};
770
+ const updatedState: CreditScheduleState = {
771
+ ...currentState,
772
+ [priceId]: newPriceState,
773
+ };
774
+
775
+ await subscription.update({ credit_schedule_state: updatedState });
776
+ }
777
+
778
+ /**
779
+ * Stop credit schedule for a subscription
780
+ * Called when subscription is canceled/deleted
781
+ */
782
+ export async function stopCreditSchedule(subscription: Subscription): Promise<string[]> {
783
+ const state = subscription.credit_schedule_state;
784
+ if (!state || Object.keys(state).length === 0) {
785
+ return [];
786
+ }
787
+
788
+ const jobIds: string[] = [];
789
+
790
+ // Collect job IDs to be deleted (next pending job for each price)
791
+ for (const priceId of Object.keys(state)) {
792
+ const priceState = state[priceId];
793
+ if (priceState?.enabled) {
794
+ // The pending job has seq = last_grant_seq + 1
795
+ const nextSeq = (priceState.last_grant_seq || 0) + 1;
796
+ jobIds.push(getScheduleJobId(subscription.id, priceId, nextSeq));
797
+ }
798
+ }
799
+
800
+ // Clear the schedule state
801
+ await subscription.update({ credit_schedule_state: undefined });
802
+
803
+ logger.info('Credit schedule stopped for subscription', {
804
+ subscriptionId: subscription.id,
805
+ jobIds,
806
+ });
807
+
808
+ return jobIds;
809
+ }
810
+
811
+ /**
812
+ * Check if a subscription has any active credit schedules
813
+ */
814
+ export function hasActiveCreditSchedule(subscription: Subscription): boolean {
815
+ const state = subscription.credit_schedule_state;
816
+ if (!state) {
817
+ return false;
818
+ }
819
+
820
+ return Object.values(state).some((priceState) => priceState.enabled && priceState.next_grant_at > 0);
821
+ }
822
+
823
+ /**
824
+ * Get all subscriptions with active credit schedules
825
+ * Used for recovery on system restart
826
+ */
827
+ export function getSubscriptionsWithCreditSchedule(): Promise<Subscription[]> {
828
+ return Subscription.findAll({
829
+ where: {
830
+ status: ['active', 'trialing'],
831
+ credit_schedule_state: {
832
+ // SQLite/Sequelize JSON query - check that field exists and is not null
833
+ // This is a simplified check; actual implementation may need adjustment based on database
834
+ },
835
+ },
836
+ });
837
+ }
838
+
839
+ /**
840
+ * Reset grants_in_current_period counter
841
+ * Should be called at the start of each billing period
842
+ */
843
+ export async function resetPeriodGrantCounter(subscription: Subscription): Promise<void> {
844
+ const state = subscription.credit_schedule_state;
845
+ if (!state) {
846
+ return;
847
+ }
848
+
849
+ const updatedState: CreditScheduleState = {};
850
+
851
+ for (const priceId of Object.keys(state)) {
852
+ const priceState = state[priceId];
853
+ if (priceState) {
854
+ updatedState[priceId] = {
855
+ ...priceState,
856
+ grants_in_current_period: 0,
857
+ };
858
+ }
859
+ }
860
+
861
+ await subscription.update({ credit_schedule_state: updatedState });
862
+
863
+ logger.info('Reset period grant counters', {
864
+ subscriptionId: subscription.id,
865
+ });
866
+ }