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.
@@ -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
+ };
@@ -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'