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.
- package/api/src/libs/credit-schedule.ts +866 -0
- package/api/src/libs/invoice.ts +9 -2
- package/api/src/queues/credit-consume.ts +109 -35
- 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/meter-events.ts +0 -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/meter-event.ts +1 -1
- 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
|
@@ -608,13 +608,19 @@ export async function startNotificationQueue() {
|
|
|
608
608
|
});
|
|
609
609
|
|
|
610
610
|
events.on('customer.credit_grant.granted', (creditGrant: CreditGrant) => {
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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) {
|
package/api/src/routes/prices.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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'
|