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.
@@ -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 = {
@@ -327,7 +327,6 @@ router.post('/', auth, async (req, res) => {
327
327
  router.get('/pending-amount', authMine, async (req, res) => {
328
328
  try {
329
329
  const params: any = {
330
- status: ['requires_action', 'requires_capture'],
331
330
  livemode: !!req.livemode,
332
331
  };
333
332
  if (req.query.subscription_id) {
@@ -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
  }
@@ -72,14 +72,53 @@ const ProductAndPriceSchema = Joi.object({
72
72
  vendor_config: VendorConfigSchema,
73
73
  }).unknown(true);
74
74
 
75
+ // Schedule configuration for credit delivery
76
+ const CreditScheduleConfigSchema = Joi.object({
77
+ enabled: Joi.boolean().default(false),
78
+ delivery_mode: Joi.string().valid('invoice', 'schedule').default('invoice'),
79
+ interval_value: Joi.number().min(0.01).when('enabled', {
80
+ is: true,
81
+ then: Joi.required(),
82
+ otherwise: Joi.optional(),
83
+ }),
84
+ interval_unit: Joi.string().valid('hour', 'day', 'week', 'month').when('enabled', {
85
+ is: true,
86
+ then: Joi.required(),
87
+ otherwise: Joi.optional(),
88
+ }),
89
+ amount_per_grant: Joi.number().greater(0).when('enabled', {
90
+ is: true,
91
+ then: Joi.required(),
92
+ otherwise: Joi.optional(),
93
+ }),
94
+ first_grant_timing: Joi.string().valid('immediate', 'after_trial', 'after_first_payment').default('immediate'),
95
+ expire_with_next_grant: Joi.boolean().default(true),
96
+ max_grants_per_period: Joi.number().min(1).optional(),
97
+ });
98
+
75
99
  const CreditConfigSchema = Joi.object({
76
100
  valid_duration_value: Joi.number().default(0).optional(),
77
101
  valid_duration_unit: Joi.string().valid('hours', 'days', 'weeks', 'months', 'years').default('days').optional(),
78
102
  priority: Joi.number().min(0).max(100).default(50).optional(),
79
103
  applicable_prices: Joi.array().items(Joi.string()).default([]).optional(),
80
- credit_amount: Joi.number().greater(0).required(),
104
+ // credit_amount is required for one-time delivery, optional when schedule is enabled
105
+ credit_amount: Joi.number().greater(0).when('schedule.enabled', {
106
+ is: true,
107
+ then: Joi.optional(),
108
+ otherwise: Joi.required(),
109
+ }),
81
110
  currency_id: Joi.string().required(),
82
- });
111
+ schedule: CreditScheduleConfigSchema.optional(),
112
+ })
113
+ .custom((value, helpers) => {
114
+ if (value?.schedule?.expire_with_next_grant && (value?.valid_duration_value || 0) > 0) {
115
+ return helpers.error('any.invalid');
116
+ }
117
+ return value;
118
+ }, 'credit config validation')
119
+ .messages({
120
+ 'any.invalid': 'valid_duration_* is mutually exclusive with schedule.expire_with_next_grant',
121
+ });
83
122
 
84
123
  export async function createProductAndPrices(payload: any) {
85
124
  const raw: Partial<Product> = pick(payload, [
@@ -107,6 +107,223 @@ const schema = createListParamSchema<{
107
107
  showTotalCount: Joi.boolean().optional(),
108
108
  });
109
109
 
110
+ // Create subscription directly (for SDK use)
111
+ const createSchema = Joi.object({
112
+ customer_id: Joi.string().required(),
113
+ items: Joi.array()
114
+ .items(
115
+ Joi.object({
116
+ price_id: Joi.string().required(),
117
+ quantity: Joi.number().integer().min(1).default(1),
118
+ metadata: Joi.object().optional(),
119
+ })
120
+ )
121
+ .min(1)
122
+ .max(MAX_SUBSCRIPTION_ITEM_COUNT)
123
+ .required(),
124
+ // Optional: if not provided, subscription won't have automatic billing (for prepaid/gifted subscriptions)
125
+ default_payment_method_id: Joi.string().optional(),
126
+ currency_id: Joi.string().optional(),
127
+ trial_period_days: Joi.number().integer().min(0).optional(),
128
+ trial_end: Joi.number().integer().optional(),
129
+ description: Joi.string().optional(),
130
+ metadata: MetadataSchema,
131
+ days_until_due: Joi.number().integer().min(0).optional(),
132
+ days_until_cancel: Joi.number().integer().min(0).optional(),
133
+ billing_cycle_anchor: Joi.number().integer().optional(),
134
+ collection_method: Joi.string().valid('charge_automatically', 'send_invoice').default('charge_automatically'),
135
+ proration_behavior: Joi.string().valid('always_invoice', 'create_prorations', 'none').default('none'),
136
+ service_actions: Joi.array().items(Joi.object()).optional(),
137
+ livemode: Joi.boolean().optional(), // Required if no payment_method_id provided
138
+ });
139
+
140
+ router.post('/', auth, async (req, res) => {
141
+ try {
142
+ const value = await createSchema.validateAsync(req.body);
143
+ const {
144
+ customer_id: customerId,
145
+ items,
146
+ default_payment_method_id: paymentMethodId,
147
+ trial_period_days: trialPeriodDays = 0,
148
+ trial_end: trialEndInput = 0,
149
+ description,
150
+ metadata = {},
151
+ days_until_due: daysUntilDue,
152
+ days_until_cancel: daysUntilCancel,
153
+ billing_cycle_anchor: billingCycleAnchor,
154
+ collection_method: collectionMethod,
155
+ proration_behavior: prorationBehavior,
156
+ service_actions: serviceActions,
157
+ livemode: livemodeInput,
158
+ } = value;
159
+
160
+ // Validate customer exists
161
+ const customer = await Customer.findByPk(customerId);
162
+ if (!customer) {
163
+ return res.status(404).json({ error: `Customer ${customerId} not found` });
164
+ }
165
+
166
+ // Validate payment method if provided
167
+ let paymentMethod: PaymentMethod | null = null;
168
+ if (paymentMethodId) {
169
+ paymentMethod = await PaymentMethod.findByPk(paymentMethodId);
170
+ if (!paymentMethod) {
171
+ return res.status(404).json({ error: `Payment method ${paymentMethodId} not found` });
172
+ }
173
+ }
174
+
175
+ // If no payment method, automatic billing is disabled (for prepaid/gifted subscriptions)
176
+ const hasAutomaticBilling = !!paymentMethod;
177
+
178
+ // Determine livemode
179
+ let livemode = livemodeInput;
180
+ if (livemode === undefined) {
181
+ if (paymentMethod) {
182
+ livemode = paymentMethod.livemode;
183
+ } else {
184
+ livemode = true; // Default to livemode if not specified
185
+ }
186
+ }
187
+
188
+ // Expand and validate line items
189
+ const priceIds = items.map((item: any) => item.price_id);
190
+ const prices = await Price.findAll({
191
+ where: { id: priceIds },
192
+ include: [{ model: Product, as: 'product' }],
193
+ });
194
+
195
+ if (prices.length !== priceIds.length) {
196
+ const foundIds = prices.map((p) => p.id);
197
+ const missingIds = priceIds.filter((id: string) => !foundIds.includes(id));
198
+ return res.status(404).json({ error: `Prices not found: ${missingIds.join(', ')}` });
199
+ }
200
+
201
+ // Validate all items are recurring
202
+ const nonRecurringPrices = prices.filter((p) => p.type !== 'recurring');
203
+ if (nonRecurringPrices.length > 0) {
204
+ return res.status(400).json({
205
+ error: `Subscription only supports recurring prices. Non-recurring prices: ${nonRecurringPrices.map((p) => p.id).join(', ')}`,
206
+ });
207
+ }
208
+
209
+ // Determine currency from first price if not provided
210
+ let currencyId = value.currency_id;
211
+ const firstPrice = prices[0];
212
+ if (!currencyId && firstPrice) {
213
+ currencyId = firstPrice.currency_id;
214
+ }
215
+ if (!currencyId) {
216
+ return res.status(400).json({ error: 'currency_id is required' });
217
+ }
218
+
219
+ const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
220
+ if (!paymentCurrency) {
221
+ return res.status(404).json({ error: `Payment currency ${currencyId} not found` });
222
+ }
223
+
224
+ // Build line items
225
+ const lineItems: TLineItemExpanded[] = items.map((item: any) => {
226
+ const price = prices.find((p) => p.id === item.price_id);
227
+ return {
228
+ price_id: item.price_id,
229
+ price: price!.toJSON(),
230
+ quantity: item.quantity || 1,
231
+ metadata: item.metadata || {},
232
+ };
233
+ });
234
+
235
+ // Calculate subscription setup (periods, trial, etc.)
236
+ const setup = getSubscriptionCreateSetup(lineItems, currencyId, trialPeriodDays, trialEndInput);
237
+
238
+ // Create subscription
239
+ const subscription = await Subscription.create({
240
+ livemode,
241
+ currency_id: currencyId,
242
+ customer_id: customerId,
243
+ status: 'active', // Direct creation starts as active
244
+ current_period_start: setup.period.start,
245
+ current_period_end: setup.period.end,
246
+ billing_cycle_anchor: billingCycleAnchor || setup.cycle.anchor,
247
+ start_date: dayjs().unix(),
248
+ trial_end: setup.trial.end || undefined,
249
+ trial_start: setup.trial.start || undefined,
250
+ trial_settings: setup.trial.end
251
+ ? {
252
+ end_behavior: {
253
+ missing_payment_method: hasAutomaticBilling ? 'create_invoice' : 'cancel',
254
+ },
255
+ }
256
+ : undefined,
257
+ pending_invoice_item_interval: setup.recurring,
258
+ default_payment_method_id: paymentMethodId || '',
259
+ cancel_at_period_end: false,
260
+ collection_method: hasAutomaticBilling ? collectionMethod : 'send_invoice',
261
+ description: description || lineItems.map((item) => item.price.product?.name).join(', '),
262
+ proration_behavior: prorationBehavior,
263
+ payment_behavior: 'default_incomplete',
264
+ days_until_due: daysUntilDue,
265
+ days_until_cancel: daysUntilCancel,
266
+ metadata: formatMetadata(metadata),
267
+ service_actions: serviceActions || [],
268
+ });
269
+
270
+ // Create subscription items
271
+ await Promise.all(
272
+ lineItems.map((item) =>
273
+ SubscriptionItem.create({
274
+ livemode,
275
+ subscription_id: subscription.id,
276
+ price_id: item.price_id,
277
+ quantity: item.quantity,
278
+ metadata: item.metadata || {},
279
+ })
280
+ )
281
+ );
282
+
283
+ // If has trial period, set status to trialing
284
+ if (setup.trial.end && setup.trial.end > dayjs().unix()) {
285
+ await subscription.update({ status: 'trialing' });
286
+ createEvent('Subscription', 'customer.subscription.trial_start', subscription).catch(console.error);
287
+ } else {
288
+ createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
289
+ }
290
+
291
+ // Schedule subscription cycle job only if has automatic billing
292
+ if (hasAutomaticBilling) {
293
+ await addSubscriptionJob(subscription, 'cycle', false, setup.trial.end || setup.period.end);
294
+ }
295
+
296
+ logger.info('Subscription created via API', {
297
+ subscriptionId: subscription.id,
298
+ customerId,
299
+ status: subscription.status,
300
+ hasAutomaticBilling,
301
+ });
302
+
303
+ // Return expanded subscription
304
+ const result = await Subscription.findOne({
305
+ where: { id: subscription.id },
306
+ include: [
307
+ { model: PaymentCurrency, as: 'paymentCurrency' },
308
+ { model: PaymentMethod, as: 'paymentMethod' },
309
+ { model: SubscriptionItem, as: 'items' },
310
+ { model: Customer, as: 'customer' },
311
+ ],
312
+ });
313
+
314
+ const products = (await Product.findAll()).map((x) => x.toJSON());
315
+ const allPrices = (await Price.findAll()).map((x) => x.toJSON());
316
+ const doc = result!.toJSON();
317
+ // @ts-ignore
318
+ expandLineItems(doc.items, products, allPrices);
319
+
320
+ return res.json(doc);
321
+ } catch (err: any) {
322
+ logger.error('Failed to create subscription', { error: err.message });
323
+ return res.status(400).json({ error: err.message });
324
+ }
325
+ });
326
+
110
327
  router.get('/', authMine, async (req, res) => {
111
328
  const { page, pageSize, status, livemode, ...query } = await schema.validateAsync(req.query, {
112
329
  stripUnknown: false,
@@ -0,0 +1,33 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { Migration, safeApplyColumnChanges } from '../migrate';
3
+
4
+ /**
5
+ * Migration: Add credit_schedule_state column to subscriptions table
6
+ *
7
+ * This column stores the credit schedule state for subscriptions with
8
+ * scheduled credit delivery, enabling features like:
9
+ * - Periodic credit grants (hourly, daily, weekly, monthly)
10
+ * - Trial period credit delivery
11
+ * - Refresh-style credits that expire with next grant
12
+ */
13
+ export const up: Migration = async ({ context }) => {
14
+ await safeApplyColumnChanges(context, {
15
+ subscriptions: [
16
+ {
17
+ name: 'credit_schedule_state',
18
+ field: {
19
+ type: DataTypes.JSON,
20
+ allowNull: true,
21
+ defaultValue: null,
22
+ },
23
+ },
24
+ ],
25
+ });
26
+ };
27
+
28
+ export const down: Migration = async ({ context }) => {
29
+ const schema = await context.describeTable('subscriptions');
30
+ if (schema.credit_schedule_state) {
31
+ await context.removeColumn('subscriptions', 'credit_schedule_state');
32
+ }
33
+ };
@@ -427,7 +427,7 @@ export class MeterEvent extends Model<InferAttributes<MeterEvent>, InferCreation
427
427
  subscriptionId,
428
428
  livemode,
429
429
  currencyId,
430
- status = ['requires_action', 'requires_capture'],
430
+ status,
431
431
  customerId,
432
432
  }: {
433
433
  subscriptionId?: string;
@@ -8,6 +8,7 @@ import logger from '../../libs/logger';
8
8
  import { createIdGenerator } from '../../libs/util';
9
9
  import { Invoice } from './invoice';
10
10
  import type {
11
+ CreditScheduleState,
11
12
  PaymentDetails,
12
13
  PaymentSettings,
13
14
  PriceRecurring,
@@ -138,6 +139,9 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
138
139
  payment_details: PaymentDetails;
139
140
  };
140
141
 
142
+ // Credit schedule state for each price with schedule-based delivery
143
+ declare credit_schedule_state?: CreditScheduleState;
144
+
141
145
  public static readonly GENESIS_ATTRIBUTES = {
142
146
  id: {
143
147
  type: DataTypes.STRING(30),
@@ -326,6 +330,11 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
326
330
  payment_details: null,
327
331
  },
328
332
  },
333
+ credit_schedule_state: {
334
+ type: DataTypes.JSON,
335
+ allowNull: true,
336
+ defaultValue: null,
337
+ },
329
338
  },
330
339
  {
331
340
  sequelize,
@@ -836,6 +836,48 @@ export type StructuredSourceDataField = {
836
836
 
837
837
  export type SourceData = SimpleSourceData | StructuredSourceDataField[];
838
838
 
839
+ // Credit Schedule Configuration (embedded in Price.metadata.credit_config)
840
+ export type CreditScheduleConfig = {
841
+ enabled: boolean;
842
+ delivery_mode: 'invoice' | 'schedule';
843
+ interval_value: number;
844
+ interval_unit: 'hour' | 'day' | 'week' | 'month';
845
+ amount_per_grant?: string; // Fixed amount per grant; if not set, divide total by period
846
+ first_grant_timing?: 'immediate' | 'after_trial' | 'after_first_payment';
847
+ expire_with_next_grant?: boolean; // Whether to expire previous grant when next grant is issued (refresh style)
848
+ max_grants_per_period?: number; // Protection: max grants per billing period
849
+ };
850
+
851
+ // Credit Config for Price metadata
852
+ export type CreditConfig = {
853
+ // Existing fields
854
+ credit_amount: string;
855
+ currency_id: string;
856
+ applicable_prices?: string[];
857
+ valid_duration_value?: number;
858
+ valid_duration_unit?: 'hours' | 'days' | 'weeks' | 'months' | 'years';
859
+ priority?: number;
860
+
861
+ // New: schedule configuration
862
+ schedule?: CreditScheduleConfig;
863
+ };
864
+
865
+ // Credit Schedule State per Price (stored in Subscription.credit_schedule_state)
866
+ export type CreditSchedulePriceState = {
867
+ enabled: boolean;
868
+ schedule_anchor_at: number; // Anchor point for stable seq calculation
869
+ next_grant_at: number; // Next scheduled grant time (scheduled_at)
870
+ last_grant_seq: number; // Last executed seq (for observation/stats)
871
+ grants_in_current_period: number; // Protection counter
872
+ last_grant_id?: string;
873
+ last_error?: string;
874
+ };
875
+
876
+ // Credit Schedule State map (keyed by priceId)
877
+ export type CreditScheduleState = {
878
+ [priceId: string]: CreditSchedulePriceState;
879
+ };
880
+
839
881
  // Credit Grant on-chain operation status (combined mint, burn and transfer status)
840
882
  export type CreditGrantChainStatus =
841
883
  | 'mint_pending'