payment-kit 1.23.11 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/src/libs/credit-schedule.ts +866 -0
- package/api/src/queues/credit-consume.ts +4 -2
- package/api/src/queues/credit-grant.ts +385 -5
- package/api/src/queues/notification.ts +13 -7
- package/api/src/queues/subscription.ts +12 -0
- package/api/src/routes/credit-grants.ts +18 -0
- package/api/src/routes/credit-transactions.ts +1 -1
- package/api/src/routes/prices.ts +43 -3
- package/api/src/routes/products.ts +41 -2
- package/api/src/routes/subscriptions.ts +217 -0
- package/api/src/store/migrations/20251225-add-credit-schedule-state.ts +33 -0
- package/api/src/store/models/subscription.ts +9 -0
- package/api/src/store/models/types.ts +42 -0
- package/api/tests/libs/credit-schedule.spec.ts +676 -0
- package/api/tests/libs/subscription.spec.ts +8 -4
- package/blocklet.yml +1 -1
- package/package.json +6 -6
- package/src/components/price/form.tsx +376 -133
- package/src/components/product/edit-price.tsx +6 -0
- package/src/components/subscription/portal/actions.tsx +9 -2
- package/src/locales/en.tsx +28 -0
- package/src/locales/zh.tsx +28 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +28 -15
- package/src/pages/admin/products/prices/detail.tsx +114 -0
- package/src/pages/customer/subscription/detail.tsx +28 -8
|
@@ -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
|
+
}
|