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
|
@@ -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
|
|
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'
|