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,676 @@
1
+ import dayjs from '../../src/libs/dayjs';
2
+ import {
3
+ activateAfterFirstPaymentSchedules,
4
+ calculateFirstGrantAt,
5
+ calculateGrantAmount,
6
+ calculateNextGrantAt,
7
+ calculateScheduleSeq,
8
+ calculateScheduledGrantExpiresAt,
9
+ getCreditConfigWithSchedule,
10
+ getIntervalMs,
11
+ getScheduleJobId,
12
+ handleScheduledCredit,
13
+ hasActiveCreditSchedule,
14
+ initializeCreditSchedule,
15
+ resetPeriodGrantCounter,
16
+ stopCreditSchedule,
17
+ } from '../../src/libs/credit-schedule';
18
+ import { calculateExpiresAt } from '../../src/libs/credit-grant';
19
+ import { getLock } from '../../src/libs/lock';
20
+ import { getSubscriptionItemPrice } from '../../src/libs/subscription';
21
+ import { CreditGrant, PaymentCurrency, Price, Subscription, SubscriptionItem } from '../../src/store/models';
22
+
23
+ jest.mock('../../src/libs/logger', () => ({
24
+ __esModule: true,
25
+ default: {
26
+ info: jest.fn(),
27
+ warn: jest.fn(),
28
+ error: jest.fn(),
29
+ debug: jest.fn(),
30
+ },
31
+ }));
32
+
33
+ jest.mock('../../src/libs/event', () => ({
34
+ events: {
35
+ emit: jest.fn(),
36
+ },
37
+ }));
38
+
39
+ jest.mock('../../src/libs/lock', () => ({
40
+ getLock: jest.fn(),
41
+ }));
42
+
43
+ jest.mock('../../src/libs/credit-grant', () => ({
44
+ createCreditGrant: jest.fn(),
45
+ calculateExpiresAt: jest.fn(),
46
+ }));
47
+
48
+ jest.mock('../../src/libs/subscription', () => ({
49
+ getSubscriptionItemPrice: jest.fn(),
50
+ }));
51
+
52
+
53
+ jest.mock('../../src/store/models', () => ({
54
+ CreditGrant: {
55
+ findOne: jest.fn(),
56
+ findByPk: jest.fn(),
57
+ },
58
+ PaymentCurrency: {
59
+ findByPk: jest.fn(),
60
+ },
61
+ Price: {
62
+ findAll: jest.fn(),
63
+ findByPk: jest.fn(),
64
+ expand: jest.fn(),
65
+ },
66
+ Product: {},
67
+ Subscription: {
68
+ findByPk: jest.fn(),
69
+ findAll: jest.fn(),
70
+ },
71
+ SubscriptionItem: {
72
+ findAll: jest.fn(),
73
+ },
74
+ }));
75
+
76
+ describe('libs/credit-schedule.ts', () => {
77
+ beforeEach(() => {
78
+ jest.clearAllMocks();
79
+ jest.restoreAllMocks();
80
+ });
81
+
82
+ describe('basic helpers', () => {
83
+ it('builds schedule job id with seq', () => {
84
+ expect(getScheduleJobId('sub_1', 'price_1', 3)).toBe('schedule-sub_1-price_1-3');
85
+ });
86
+
87
+ it('calculates schedule seq for non-month intervals', () => {
88
+ const anchorAt = 1000;
89
+ const intervalValue = 1;
90
+ expect(calculateScheduleSeq(anchorAt, anchorAt - 1, intervalValue, 'hour')).toBe(0);
91
+ expect(calculateScheduleSeq(anchorAt, anchorAt, intervalValue, 'hour')).toBe(1);
92
+ expect(calculateScheduleSeq(anchorAt, anchorAt + 3600, intervalValue, 'hour')).toBe(2);
93
+ });
94
+
95
+ it('calculates schedule seq for month intervals', () => {
96
+ const anchorAt = 1704067200; // 2024-01-01T00:00:00Z
97
+ const firstMonth = dayjs.unix(anchorAt).add(1, 'month').unix();
98
+ expect(calculateScheduleSeq(anchorAt, anchorAt, 1, 'month')).toBe(1);
99
+ expect(calculateScheduleSeq(anchorAt, firstMonth, 1, 'month')).toBe(2);
100
+ });
101
+
102
+ it('calculates schedule seq around leap day month ends', () => {
103
+ const jan31 = dayjs.utc('2024-01-31T00:00:00Z').unix();
104
+ const feb29 = dayjs.utc('2024-02-29T00:00:00Z').unix();
105
+ const mar29 = dayjs.utc('2024-03-29T00:00:00Z').unix();
106
+
107
+ expect(calculateScheduleSeq(jan31, jan31, 1, 'month')).toBe(1);
108
+ expect(calculateScheduleSeq(jan31, feb29, 1, 'month')).toBe(2);
109
+ expect(calculateScheduleSeq(feb29, mar29, 1, 'month')).toBe(2);
110
+ });
111
+
112
+ it('returns interval ms for day and month', () => {
113
+ expect(getIntervalMs(2, 'day')).toBe(2 * 24 * 60 * 60 * 1000);
114
+ expect(getIntervalMs(1, 'week')).toBe(7 * 24 * 60 * 60 * 1000);
115
+ const anchorAt = 1704067200;
116
+ const ref = dayjs.unix(anchorAt);
117
+ expect(getIntervalMs(1, 'month', anchorAt)).toBe(ref.add(1, 'month').diff(ref));
118
+ });
119
+
120
+ it('calculates next grant time', () => {
121
+ const anchorAt = 1000;
122
+ expect(calculateNextGrantAt(anchorAt, 2, 1, 'hour')).toBe(8200);
123
+
124
+ const monthAnchor = 1704067200;
125
+ const expected = dayjs.unix(monthAnchor).add(2, 'month').unix();
126
+ expect(calculateNextGrantAt(monthAnchor, 2, 1, 'month')).toBe(expected);
127
+ });
128
+ });
129
+
130
+ describe('config and amount helpers', () => {
131
+ it('extracts credit config with schedule', () => {
132
+ const price = {
133
+ metadata: {
134
+ credit_config: {
135
+ schedule: {
136
+ enabled: true,
137
+ delivery_mode: 'schedule',
138
+ },
139
+ },
140
+ },
141
+ };
142
+
143
+ expect(getCreditConfigWithSchedule(price)).toEqual({
144
+ creditConfig: price.metadata.credit_config,
145
+ scheduleConfig: price.metadata.credit_config.schedule,
146
+ });
147
+
148
+ const disabled = { metadata: { credit_config: { schedule: { enabled: false } } } };
149
+ expect(getCreditConfigWithSchedule(disabled)).toBeNull();
150
+ });
151
+
152
+ it('calculates first grant time based on timing', () => {
153
+ jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z'));
154
+ const now = dayjs().unix();
155
+ const subscription = {
156
+ start_date: now + 600,
157
+ trial_end: now + 1200,
158
+ } as any;
159
+
160
+ expect(calculateFirstGrantAt(subscription, { first_grant_timing: 'immediate' } as any)).toBe(
161
+ subscription.start_date
162
+ );
163
+ expect(calculateFirstGrantAt(subscription, { first_grant_timing: 'after_trial' } as any)).toBe(
164
+ subscription.trial_end
165
+ );
166
+ expect(calculateFirstGrantAt(subscription, { first_grant_timing: 'after_first_payment' } as any)).toBeNull();
167
+ jest.useRealTimers();
168
+ });
169
+
170
+ it('calculates grant amount using amount_per_grant or credit_amount', () => {
171
+ const currency = { decimal: 2 } as any;
172
+ const creditConfig = { credit_amount: '1000' } as any;
173
+
174
+ expect(
175
+ calculateGrantAmount(creditConfig, { amount_per_grant: '200' } as any, currency)
176
+ ).toBe('20000');
177
+ expect(calculateGrantAmount(creditConfig, {} as any, currency)).toBe('100000');
178
+ });
179
+
180
+ it('calculates scheduled grant expiration', () => {
181
+ const expiresAt = calculateExpiresAt as jest.Mock;
182
+ expiresAt.mockReturnValue(999);
183
+
184
+ const creditConfig = { valid_duration_value: 3, valid_duration_unit: 'day' } as any;
185
+ const scheduleConfig = { expire_with_next_grant: false } as any;
186
+
187
+ expect(calculateScheduledGrantExpiresAt(creditConfig, scheduleConfig, 1234)).toBe(999);
188
+
189
+ expect(
190
+ calculateScheduledGrantExpiresAt(
191
+ creditConfig,
192
+ { expire_with_next_grant: true } as any,
193
+ 5678
194
+ )
195
+ ).toBe(5678);
196
+ });
197
+ });
198
+
199
+ describe('initializeCreditSchedule', () => {
200
+ it('initializes schedule state for credit prices', async () => {
201
+ jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z'));
202
+ const now = dayjs().unix();
203
+
204
+ const subscription = {
205
+ id: 'sub_1',
206
+ status: 'active',
207
+ start_date: now - 60,
208
+ trial_end: null,
209
+ credit_schedule_state: undefined,
210
+ isActive: jest.fn().mockReturnValue(true),
211
+ update: jest.fn().mockResolvedValue(undefined),
212
+ } as any;
213
+
214
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
215
+ (getLock as jest.Mock).mockReturnValue(lock);
216
+
217
+ const items = [{ price_id: 'price_1' }, { price_id: 'price_2' }];
218
+ (SubscriptionItem.findAll as jest.Mock).mockResolvedValue(items);
219
+
220
+ const prices = [
221
+ {
222
+ id: 'price_1',
223
+ product: { type: 'credit' },
224
+ metadata: {
225
+ credit_config: {
226
+ credit_amount: '1000',
227
+ currency_id: 'usd',
228
+ schedule: {
229
+ enabled: true,
230
+ delivery_mode: 'schedule',
231
+ interval_value: 1,
232
+ interval_unit: 'day',
233
+ first_grant_timing: 'immediate',
234
+ },
235
+ },
236
+ },
237
+ },
238
+ {
239
+ id: 'price_2',
240
+ product: { type: 'subscription' },
241
+ },
242
+ ];
243
+ (Price.findAll as jest.Mock).mockResolvedValue(prices);
244
+
245
+ const state = await initializeCreditSchedule(subscription);
246
+
247
+ expect(state).toEqual(
248
+ expect.objectContaining({
249
+ price_1: expect.objectContaining({
250
+ enabled: true,
251
+ schedule_anchor_at: now,
252
+ next_grant_at: now,
253
+ last_grant_seq: 0,
254
+ grants_in_current_period: 0,
255
+ }),
256
+ })
257
+ );
258
+ expect(subscription.update).toHaveBeenCalled();
259
+ expect(lock.release).toHaveBeenCalled();
260
+ jest.useRealTimers();
261
+ });
262
+
263
+ it('returns null when subscription is not active', async () => {
264
+ const subscription = {
265
+ id: 'sub_1',
266
+ status: 'canceled',
267
+ isActive: jest.fn().mockReturnValue(false),
268
+ } as any;
269
+
270
+ const state = await initializeCreditSchedule(subscription);
271
+
272
+ expect(state).toBeNull();
273
+ expect(getLock).not.toHaveBeenCalled();
274
+ });
275
+
276
+ it('returns null when no subscription items found', async () => {
277
+ const subscription = {
278
+ id: 'sub_1',
279
+ status: 'active',
280
+ credit_schedule_state: undefined,
281
+ isActive: jest.fn().mockReturnValue(true),
282
+ update: jest.fn().mockResolvedValue(undefined),
283
+ } as any;
284
+
285
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
286
+ (getLock as jest.Mock).mockReturnValue(lock);
287
+ (SubscriptionItem.findAll as jest.Mock).mockResolvedValue([]);
288
+
289
+ const state = await initializeCreditSchedule(subscription);
290
+
291
+ expect(state).toBeNull();
292
+ expect(subscription.update).not.toHaveBeenCalled();
293
+ expect(lock.release).toHaveBeenCalled();
294
+ });
295
+ });
296
+
297
+ describe('activateAfterFirstPaymentSchedules', () => {
298
+ it('activates after_first_payment schedules and returns jobs', async () => {
299
+ const subscription = {
300
+ id: 'sub_1',
301
+ status: 'active',
302
+ isActive: jest.fn().mockReturnValue(true),
303
+ credit_schedule_state: {},
304
+ update: jest.fn().mockResolvedValue(undefined),
305
+ } as any;
306
+
307
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
308
+ (getLock as jest.Mock).mockReturnValue(lock);
309
+
310
+ const items = [{ toJSON: () => ({ id: 'item_1' }) }];
311
+ (SubscriptionItem.findAll as jest.Mock).mockResolvedValue(items);
312
+ (Price.expand as jest.Mock).mockResolvedValue([{ id: 'expanded_1' }]);
313
+
314
+ (getSubscriptionItemPrice as jest.Mock).mockReturnValue({
315
+ id: 'price_1',
316
+ product: { type: 'credit' },
317
+ metadata: {
318
+ credit_config: {
319
+ schedule: {
320
+ enabled: true,
321
+ delivery_mode: 'schedule',
322
+ first_grant_timing: 'after_first_payment',
323
+ interval_value: 1,
324
+ interval_unit: 'day',
325
+ },
326
+ },
327
+ },
328
+ });
329
+
330
+ const jobs = await activateAfterFirstPaymentSchedules(subscription, 1234);
331
+
332
+ expect(jobs).toHaveLength(1);
333
+ expect(jobs[0]!.jobId).toBe('schedule-sub_1-price_1-1');
334
+ expect(jobs[0]!.runAt).toBe(1234);
335
+ expect(subscription.update).toHaveBeenCalled();
336
+ expect(lock.release).toHaveBeenCalled();
337
+ });
338
+
339
+ it('returns empty when subscription is not active', async () => {
340
+ const subscription = {
341
+ id: 'sub_1',
342
+ status: 'canceled',
343
+ isActive: jest.fn().mockReturnValue(false),
344
+ } as any;
345
+
346
+ const jobs = await activateAfterFirstPaymentSchedules(subscription, 1234);
347
+
348
+ expect(jobs).toEqual([]);
349
+ expect(getLock).not.toHaveBeenCalled();
350
+ });
351
+ });
352
+
353
+ describe('handleScheduledCredit (idempotent path)', () => {
354
+ it('updates state and schedules next job when grant already exists', async () => {
355
+ jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z'));
356
+ const now = dayjs().unix();
357
+ const subscription = {
358
+ id: 'sub_1',
359
+ customer_id: 'cus_1',
360
+ status: 'active',
361
+ isActive: jest.fn().mockReturnValue(true),
362
+ credit_schedule_state: {
363
+ price_1: {
364
+ enabled: true,
365
+ schedule_anchor_at: now,
366
+ next_grant_at: now,
367
+ last_grant_seq: 0,
368
+ grants_in_current_period: 0,
369
+ },
370
+ },
371
+ update: jest.fn().mockResolvedValue(undefined),
372
+ } as any;
373
+
374
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
375
+ (getLock as jest.Mock).mockReturnValue(lock);
376
+ (Subscription.findByPk as jest.Mock).mockResolvedValue(subscription);
377
+
378
+ (Price.findByPk as jest.Mock).mockResolvedValue({
379
+ id: 'price_1',
380
+ product: { type: 'credit' },
381
+ metadata: {
382
+ credit_config: {
383
+ credit_amount: '1000',
384
+ currency_id: 'usd',
385
+ schedule: {
386
+ enabled: true,
387
+ delivery_mode: 'schedule',
388
+ interval_value: 1,
389
+ interval_unit: 'hour',
390
+ },
391
+ },
392
+ },
393
+ });
394
+
395
+ (CreditGrant.findOne as jest.Mock).mockResolvedValue({ id: 'cg_1' });
396
+
397
+ const result = await handleScheduledCredit({
398
+ subscriptionId: 'sub_1',
399
+ priceId: 'price_1',
400
+ scheduledAt: now,
401
+ action: 'create_from_schedule',
402
+ });
403
+
404
+ expect(result?.jobId).toBe('schedule-sub_1-price_1-2');
405
+ expect(result?.runAt).toBe(now + 3600);
406
+ expect(subscription.update).toHaveBeenCalled();
407
+ expect(lock.release).toHaveBeenCalled();
408
+ jest.useRealTimers();
409
+ });
410
+ });
411
+
412
+ describe('handleScheduledCredit (defensive paths)', () => {
413
+ const job = {
414
+ subscriptionId: 'sub_1',
415
+ priceId: 'price_1',
416
+ scheduledAt: 1000,
417
+ action: 'create_from_schedule' as const,
418
+ };
419
+
420
+ it('returns null when subscription not found', async () => {
421
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
422
+ (getLock as jest.Mock).mockReturnValue(lock);
423
+ (Subscription.findByPk as jest.Mock).mockResolvedValue(null);
424
+
425
+ const result = await handleScheduledCredit(job);
426
+
427
+ expect(result).toBeNull();
428
+ expect(lock.release).toHaveBeenCalled();
429
+ });
430
+
431
+ it('returns null when subscription not active', async () => {
432
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
433
+ (getLock as jest.Mock).mockReturnValue(lock);
434
+ (Subscription.findByPk as jest.Mock).mockResolvedValue({
435
+ id: 'sub_1',
436
+ status: 'canceled',
437
+ isActive: jest.fn().mockReturnValue(false),
438
+ } as any);
439
+
440
+ const result = await handleScheduledCredit(job);
441
+
442
+ expect(result).toBeNull();
443
+ expect(lock.release).toHaveBeenCalled();
444
+ });
445
+
446
+ it('returns null when schedule not enabled for price', async () => {
447
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
448
+ (getLock as jest.Mock).mockReturnValue(lock);
449
+ (Subscription.findByPk as jest.Mock).mockResolvedValue({
450
+ id: 'sub_1',
451
+ status: 'active',
452
+ isActive: jest.fn().mockReturnValue(true),
453
+ credit_schedule_state: {
454
+ price_1: { enabled: false },
455
+ },
456
+ } as any);
457
+
458
+ const result = await handleScheduledCredit(job);
459
+
460
+ expect(result).toBeNull();
461
+ expect(Price.findByPk).not.toHaveBeenCalled();
462
+ expect(lock.release).toHaveBeenCalled();
463
+ });
464
+
465
+ it('returns null when price not found', async () => {
466
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
467
+ (getLock as jest.Mock).mockReturnValue(lock);
468
+ (Subscription.findByPk as jest.Mock).mockResolvedValue({
469
+ id: 'sub_1',
470
+ status: 'active',
471
+ isActive: jest.fn().mockReturnValue(true),
472
+ credit_schedule_state: {
473
+ price_1: { enabled: true },
474
+ },
475
+ } as any);
476
+ (Price.findByPk as jest.Mock).mockResolvedValue(null);
477
+
478
+ const result = await handleScheduledCredit(job);
479
+
480
+ expect(result).toBeNull();
481
+ expect(lock.release).toHaveBeenCalled();
482
+ });
483
+
484
+ it('returns null when price has no schedule config', async () => {
485
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
486
+ (getLock as jest.Mock).mockReturnValue(lock);
487
+ (Subscription.findByPk as jest.Mock).mockResolvedValue({
488
+ id: 'sub_1',
489
+ status: 'active',
490
+ isActive: jest.fn().mockReturnValue(true),
491
+ credit_schedule_state: {
492
+ price_1: { enabled: true },
493
+ },
494
+ } as any);
495
+ (Price.findByPk as jest.Mock).mockResolvedValue({
496
+ id: 'price_1',
497
+ product: { type: 'credit' },
498
+ metadata: {},
499
+ });
500
+
501
+ const result = await handleScheduledCredit(job);
502
+
503
+ expect(result).toBeNull();
504
+ expect(lock.release).toHaveBeenCalled();
505
+ });
506
+
507
+ it('returns null when currency not found', async () => {
508
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
509
+ (getLock as jest.Mock).mockReturnValue(lock);
510
+ const subscription = {
511
+ id: 'sub_1',
512
+ customer_id: 'cus_1',
513
+ status: 'active',
514
+ isActive: jest.fn().mockReturnValue(true),
515
+ credit_schedule_state: {
516
+ price_1: {
517
+ enabled: true,
518
+ schedule_anchor_at: 1000,
519
+ next_grant_at: 1000,
520
+ last_grant_seq: 0,
521
+ grants_in_current_period: 0,
522
+ },
523
+ },
524
+ update: jest.fn().mockResolvedValue(undefined),
525
+ } as any;
526
+ (Subscription.findByPk as jest.Mock).mockResolvedValue(subscription);
527
+ (Price.findByPk as jest.Mock).mockResolvedValue({
528
+ id: 'price_1',
529
+ product: { type: 'credit' },
530
+ metadata: {
531
+ credit_config: {
532
+ credit_amount: '1000',
533
+ currency_id: 'usd',
534
+ schedule: {
535
+ enabled: true,
536
+ delivery_mode: 'schedule',
537
+ interval_value: 1,
538
+ interval_unit: 'day',
539
+ },
540
+ },
541
+ },
542
+ });
543
+ (CreditGrant.findOne as jest.Mock).mockResolvedValue(null);
544
+ (PaymentCurrency.findByPk as jest.Mock).mockResolvedValue(null);
545
+
546
+ const result = await handleScheduledCredit(job);
547
+
548
+ expect(result).toBeNull();
549
+ expect(subscription.update).toHaveBeenCalledWith({
550
+ credit_schedule_state: expect.objectContaining({
551
+ price_1: expect.objectContaining({
552
+ last_error: 'Currency not found: usd',
553
+ }),
554
+ }),
555
+ });
556
+ expect(lock.release).toHaveBeenCalled();
557
+ });
558
+ });
559
+
560
+ describe('handleScheduledCredit (max grants per period)', () => {
561
+ it('recalculates nextGrantAt when max_grants_per_period reached', async () => {
562
+ jest.useFakeTimers().setSystemTime(new Date('2024-01-01T00:00:00Z'));
563
+ const now = dayjs().unix();
564
+ const lock = { acquire: jest.fn().mockResolvedValue(undefined), release: jest.fn() };
565
+ (getLock as jest.Mock).mockReturnValue(lock);
566
+
567
+ const subscription = {
568
+ id: 'sub_1',
569
+ customer_id: 'cus_1',
570
+ status: 'active',
571
+ current_period_end: now - 10,
572
+ isActive: jest.fn().mockReturnValue(true),
573
+ credit_schedule_state: {
574
+ price_1: {
575
+ enabled: true,
576
+ schedule_anchor_at: now - 1000,
577
+ next_grant_at: now - 100,
578
+ last_grant_seq: 1,
579
+ grants_in_current_period: 2,
580
+ },
581
+ },
582
+ update: jest.fn().mockResolvedValue(undefined),
583
+ } as any;
584
+ (Subscription.findByPk as jest.Mock).mockResolvedValue(subscription);
585
+
586
+ (Price.findByPk as jest.Mock).mockResolvedValue({
587
+ id: 'price_1',
588
+ product: { type: 'credit' },
589
+ metadata: {
590
+ credit_config: {
591
+ credit_amount: '1000',
592
+ currency_id: 'usd',
593
+ schedule: {
594
+ enabled: true,
595
+ delivery_mode: 'schedule',
596
+ interval_value: 1,
597
+ interval_unit: 'week',
598
+ max_grants_per_period: 2,
599
+ },
600
+ },
601
+ },
602
+ });
603
+ (CreditGrant.findOne as jest.Mock).mockResolvedValue(null);
604
+
605
+ const result = await handleScheduledCredit({
606
+ subscriptionId: 'sub_1',
607
+ priceId: 'price_1',
608
+ scheduledAt: now,
609
+ action: 'create_from_schedule',
610
+ });
611
+
612
+ const expectedNextGrantAt = now + 7 * 24 * 60 * 60;
613
+ expect(result?.jobId).toBe('schedule-sub_1-price_1-2');
614
+ expect(result?.runAt).toBe(expectedNextGrantAt);
615
+ expect(subscription.update).toHaveBeenCalledWith({
616
+ credit_schedule_state: expect.objectContaining({
617
+ price_1: expect.objectContaining({
618
+ next_grant_at: expectedNextGrantAt,
619
+ last_error: undefined,
620
+ }),
621
+ }),
622
+ });
623
+ expect(lock.release).toHaveBeenCalled();
624
+ jest.useRealTimers();
625
+ });
626
+ });
627
+
628
+ describe('stopCreditSchedule & helpers', () => {
629
+ it('stops schedule and returns pending job ids', async () => {
630
+ const subscription = {
631
+ id: 'sub_1',
632
+ credit_schedule_state: {
633
+ price_1: { enabled: true, last_grant_seq: 2, next_grant_at: 100 },
634
+ price_2: { enabled: true, last_grant_seq: 0, next_grant_at: 200 },
635
+ },
636
+ update: jest.fn().mockResolvedValue(undefined),
637
+ } as any;
638
+
639
+ const jobIds = await stopCreditSchedule(subscription);
640
+
641
+ expect(jobIds).toEqual(['schedule-sub_1-price_1-3', 'schedule-sub_1-price_2-1']);
642
+ expect(subscription.update).toHaveBeenCalledWith({ credit_schedule_state: undefined });
643
+ });
644
+
645
+ it('checks for active schedule entries', () => {
646
+ const subscription = {
647
+ credit_schedule_state: {
648
+ price_1: { enabled: true, next_grant_at: 100 },
649
+ price_2: { enabled: false, next_grant_at: 200 },
650
+ },
651
+ } as any;
652
+
653
+ expect(hasActiveCreditSchedule(subscription)).toBe(true);
654
+ expect(hasActiveCreditSchedule({ credit_schedule_state: undefined } as any)).toBe(false);
655
+ });
656
+
657
+ it('resets period grant counters', async () => {
658
+ const subscription = {
659
+ id: 'sub_1',
660
+ credit_schedule_state: {
661
+ price_1: { enabled: true, grants_in_current_period: 3 },
662
+ price_2: { enabled: true, grants_in_current_period: 1 },
663
+ },
664
+ update: jest.fn().mockResolvedValue(undefined),
665
+ } as any;
666
+
667
+ await resetPeriodGrantCounter(subscription);
668
+ expect(subscription.update).toHaveBeenCalledWith({
669
+ credit_schedule_state: {
670
+ price_1: { enabled: true, grants_in_current_period: 0 },
671
+ price_2: { enabled: true, grants_in_current_period: 0 },
672
+ },
673
+ });
674
+ });
675
+ });
676
+ });
@@ -152,7 +152,11 @@ describe('getDaysUntilCancel', () => {
152
152
  });
153
153
 
154
154
  describe('shouldCancelSubscription', () => {
155
- const now = dayjs().unix();
155
+ let now: number;
156
+
157
+ beforeEach(() => {
158
+ now = dayjs().unix();
159
+ });
156
160
 
157
161
  it('should return true when status is "past_due" and cancel_at is less than or equal to now', () => {
158
162
  const subscription = { status: 'past_due', cancel_at: now - 1 };
@@ -185,19 +189,19 @@ describe('shouldCancelSubscription', () => {
185
189
  });
186
190
 
187
191
  it('should return false when cancel_at is greater than now', () => {
188
- const subscription = { status: 'active', cancel_at: now + 1 };
192
+ const subscription = { status: 'active', cancel_at: now + 60 };
189
193
  const result = shouldCancelSubscription(subscription as any);
190
194
  expect(result).toBe(false);
191
195
  });
192
196
 
193
197
  it('should return false when current_period_end is greater than now #1', () => {
194
- const subscription = { status: 'active', current_period_end: now + 1 };
198
+ const subscription = { status: 'active', current_period_end: now + 60 };
195
199
  const result = shouldCancelSubscription(subscription as any);
196
200
  expect(result).toBe(false);
197
201
  });
198
202
 
199
203
  it('should return false when current_period_end is greater than now #2', () => {
200
- const subscription = { status: 'active', current_period_end: now + 1, cancel_at_period_end: false };
204
+ const subscription = { status: 'active', current_period_end: now + 60, cancel_at_period_end: false };
201
205
  const result = shouldCancelSubscription(subscription as any);
202
206
  expect(result).toBe(false);
203
207
  });