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
|
@@ -397,7 +397,9 @@ async function createCreditTransaction(
|
|
|
397
397
|
context.meter.paymentCurrency.symbol
|
|
398
398
|
);
|
|
399
399
|
let description = `Consume ${formattedAmount}`;
|
|
400
|
-
|
|
400
|
+
const resolvedSubscriptionId =
|
|
401
|
+
context.meterEvent.getSubscriptionId() || creditGrant.metadata?.subscription_id || context.subscription?.id;
|
|
402
|
+
if (resolvedSubscriptionId) {
|
|
401
403
|
description += ' for Subscription';
|
|
402
404
|
}
|
|
403
405
|
if (context.meterEvent.metadata?.description) {
|
|
@@ -409,7 +411,7 @@ async function createCreditTransaction(
|
|
|
409
411
|
customer_id: context.meterEvent.getCustomerId(),
|
|
410
412
|
credit_grant_id: creditGrant.id,
|
|
411
413
|
meter_id: context.meter.id,
|
|
412
|
-
subscription_id:
|
|
414
|
+
subscription_id: resolvedSubscriptionId,
|
|
413
415
|
meter_event_name: context.meterEvent.event_name,
|
|
414
416
|
meter_unit: context.meter.unit,
|
|
415
417
|
quantity: consumeAmount,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Op } from 'sequelize';
|
|
4
4
|
import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
5
|
+
import pAll from 'p-all';
|
|
5
6
|
import dayjs from '../libs/dayjs';
|
|
6
7
|
import createQueue from '../libs/queue';
|
|
7
8
|
import {
|
|
@@ -19,6 +20,15 @@ import {
|
|
|
19
20
|
} from '../store/models';
|
|
20
21
|
import { events } from '../libs/event';
|
|
21
22
|
import { calculateExpiresAt, createCreditGrant } from '../libs/credit-grant';
|
|
23
|
+
import {
|
|
24
|
+
CreditScheduleJob,
|
|
25
|
+
activateAfterFirstPaymentSchedules,
|
|
26
|
+
handleScheduledCredit,
|
|
27
|
+
initializeCreditSchedule,
|
|
28
|
+
stopCreditSchedule,
|
|
29
|
+
getScheduleJobId,
|
|
30
|
+
} from '../libs/credit-schedule';
|
|
31
|
+
import { Subscription } from '../store/models/subscription';
|
|
22
32
|
import { getAccountState, mintToken, transferTokenFromCustomer } from '../integrations/arcblock/token';
|
|
23
33
|
import logger from '../libs/logger';
|
|
24
34
|
|
|
@@ -30,7 +40,8 @@ type CreditGrantJob =
|
|
|
30
40
|
| {
|
|
31
41
|
invoiceId: string;
|
|
32
42
|
action: 'create_from_invoice';
|
|
33
|
-
}
|
|
43
|
+
}
|
|
44
|
+
| CreditScheduleJob;
|
|
34
45
|
|
|
35
46
|
/**
|
|
36
47
|
* Activate credit grants and handle on-chain minting when required.
|
|
@@ -242,15 +253,31 @@ export async function expireGrant(creditGrant: CreditGrant) {
|
|
|
242
253
|
}
|
|
243
254
|
|
|
244
255
|
const handleCreditGrantJob = async (job: CreditGrantJob) => {
|
|
245
|
-
logger.info('Handling credit grant job',
|
|
246
|
-
|
|
247
|
-
...(job.action === 'create_from_invoice' ? { invoiceId: job.invoiceId } : { creditGrantId: job.creditGrantId }),
|
|
248
|
-
});
|
|
256
|
+
logger.info('Handling credit grant job', job);
|
|
257
|
+
|
|
249
258
|
if (job.action === 'create_from_invoice') {
|
|
250
259
|
await handleInvoiceCredit(job.invoiceId);
|
|
251
260
|
return;
|
|
252
261
|
}
|
|
253
262
|
|
|
263
|
+
if (job.action === 'create_from_schedule') {
|
|
264
|
+
const scheduleJob = job as CreditScheduleJob;
|
|
265
|
+
const nextJob = await handleScheduledCredit(scheduleJob);
|
|
266
|
+
|
|
267
|
+
logger.info('Scheduled credit grant handled', {
|
|
268
|
+
scheduleJob,
|
|
269
|
+
nextJob,
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// Schedule next grant if returned
|
|
273
|
+
// Use replace=true because the current job (same id) is still in the DB
|
|
274
|
+
// until onJobComplete deletes it, which would cause JOB_DUPLICATE error
|
|
275
|
+
if (nextJob) {
|
|
276
|
+
await addScheduledCreditJob(nextJob.jobId, nextJob.job, nextJob.runAt, true);
|
|
277
|
+
}
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
254
281
|
const { creditGrantId, action } = job as { creditGrantId: string; action: 'activate' | 'expire' };
|
|
255
282
|
|
|
256
283
|
const creditGrant = await CreditGrant.findByPk(creditGrantId);
|
|
@@ -435,6 +462,9 @@ export const startCreditGrantQueue = async () => {
|
|
|
435
462
|
logger.warn(`Failed to schedule jobs for ${failed} credit grants`);
|
|
436
463
|
}
|
|
437
464
|
|
|
465
|
+
// Recover credit schedule jobs for subscriptions with active schedules
|
|
466
|
+
await recoverCreditScheduleJobs();
|
|
467
|
+
|
|
438
468
|
logger.info('Credit grant queue started', {
|
|
439
469
|
totalGrants: grantsToSchedule.length,
|
|
440
470
|
succeeded,
|
|
@@ -442,6 +472,113 @@ export const startCreditGrantQueue = async () => {
|
|
|
442
472
|
});
|
|
443
473
|
};
|
|
444
474
|
|
|
475
|
+
/**
|
|
476
|
+
* Recover credit schedule jobs after system restart
|
|
477
|
+
* Finds subscriptions with active credit schedules and ensures their jobs are scheduled
|
|
478
|
+
*/
|
|
479
|
+
async function recoverCreditScheduleJobs(): Promise<void> {
|
|
480
|
+
logger.info('Recovering credit schedule jobs...');
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
// Find subscriptions with active credit schedules
|
|
484
|
+
// We filter for non-null credit_schedule_state in application code
|
|
485
|
+
const allActiveSubscriptions = await Subscription.findAll({
|
|
486
|
+
where: {
|
|
487
|
+
status: ['active', 'trialing'],
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Filter for subscriptions with credit_schedule_state
|
|
492
|
+
const subscriptions = allActiveSubscriptions.filter(
|
|
493
|
+
(sub) => sub.credit_schedule_state && Object.keys(sub.credit_schedule_state).length > 0
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
if (subscriptions.length === 0) {
|
|
497
|
+
logger.info('No subscriptions with credit schedules to recover');
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const now = dayjs().unix();
|
|
502
|
+
let recoveredCount = 0;
|
|
503
|
+
let scheduledCount = 0;
|
|
504
|
+
|
|
505
|
+
const tasks: Array<() => Promise<void>> = [];
|
|
506
|
+
const recoveryConcurrency = 5;
|
|
507
|
+
|
|
508
|
+
// Process each subscription's schedule state with bounded concurrency
|
|
509
|
+
subscriptions.forEach((subscription) => {
|
|
510
|
+
const state = subscription.credit_schedule_state;
|
|
511
|
+
if (!state) return;
|
|
512
|
+
|
|
513
|
+
Object.keys(state).forEach((priceId) => {
|
|
514
|
+
tasks.push(async () => {
|
|
515
|
+
const priceState = state[priceId];
|
|
516
|
+
if (!priceState?.enabled) return;
|
|
517
|
+
|
|
518
|
+
// Next job seq is last_grant_seq + 1
|
|
519
|
+
const nextSeq = priceState.last_grant_seq + 1;
|
|
520
|
+
const jobId = getScheduleJobId(subscription.id, priceId, nextSeq);
|
|
521
|
+
const nextGrantAt = priceState.next_grant_at;
|
|
522
|
+
|
|
523
|
+
// Skip if no next grant time scheduled
|
|
524
|
+
if (!nextGrantAt || nextGrantAt === 0) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Check if job already exists
|
|
529
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
530
|
+
|
|
531
|
+
if (nextGrantAt <= now) {
|
|
532
|
+
// Job is overdue - schedule immediately with replace=true
|
|
533
|
+
const job: CreditScheduleJob = {
|
|
534
|
+
subscriptionId: subscription.id,
|
|
535
|
+
priceId,
|
|
536
|
+
scheduledAt: nextGrantAt,
|
|
537
|
+
action: 'create_from_schedule',
|
|
538
|
+
};
|
|
539
|
+
await addScheduledCreditJob(jobId, job, now, true);
|
|
540
|
+
recoveredCount++;
|
|
541
|
+
logger.info('Recovered overdue credit schedule job', {
|
|
542
|
+
subscriptionId: subscription.id,
|
|
543
|
+
priceId,
|
|
544
|
+
originalScheduledAt: nextGrantAt,
|
|
545
|
+
overdueDays: Math.floor((now - nextGrantAt) / 86400),
|
|
546
|
+
});
|
|
547
|
+
} else if (!existingJob) {
|
|
548
|
+
// Job is in future but not scheduled - create it
|
|
549
|
+
const job: CreditScheduleJob = {
|
|
550
|
+
subscriptionId: subscription.id,
|
|
551
|
+
priceId,
|
|
552
|
+
scheduledAt: nextGrantAt,
|
|
553
|
+
action: 'create_from_schedule',
|
|
554
|
+
};
|
|
555
|
+
await addScheduledCreditJob(jobId, job, nextGrantAt, false);
|
|
556
|
+
scheduledCount++;
|
|
557
|
+
logger.debug('Scheduled missing credit schedule job', {
|
|
558
|
+
subscriptionId: subscription.id,
|
|
559
|
+
priceId,
|
|
560
|
+
scheduledAt: dayjs.unix(nextGrantAt).format(),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
await pAll(tasks, { concurrency: recoveryConcurrency, stopOnError: false });
|
|
568
|
+
|
|
569
|
+
logger.info('Credit schedule jobs recovery completed', {
|
|
570
|
+
subscriptionsChecked: subscriptions.length,
|
|
571
|
+
overdueJobsRecovered: recoveredCount,
|
|
572
|
+
missingJobsScheduled: scheduledCount,
|
|
573
|
+
});
|
|
574
|
+
} catch (error: any) {
|
|
575
|
+
logger.error('Failed to recover credit schedule jobs', {
|
|
576
|
+
error: error.message,
|
|
577
|
+
stack: error.stack,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
445
582
|
creditGrantQueue.on('failed', ({ id, job, error }) => {
|
|
446
583
|
logger.error('Credit grant job failed', { id, job, error });
|
|
447
584
|
});
|
|
@@ -513,6 +650,19 @@ async function handleInvoiceCredit(invoiceId: string) {
|
|
|
513
650
|
|
|
514
651
|
const metadata = price.metadata || {};
|
|
515
652
|
const creditConfig = metadata.credit_config || {};
|
|
653
|
+
|
|
654
|
+
// Check if this price uses schedule-based delivery
|
|
655
|
+
// If delivery_mode is 'schedule', skip invoice-based credit grant creation
|
|
656
|
+
const scheduleConfig = creditConfig.schedule;
|
|
657
|
+
if (scheduleConfig?.enabled && scheduleConfig?.delivery_mode === 'schedule') {
|
|
658
|
+
logger.info('Skipping invoice credit grant for schedule-based price', {
|
|
659
|
+
invoiceId,
|
|
660
|
+
priceId: price.id,
|
|
661
|
+
deliveryMode: scheduleConfig.delivery_mode,
|
|
662
|
+
});
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
516
666
|
const quantity = lineItem.quantity || 1;
|
|
517
667
|
const currency = await PaymentCurrency.findByPk(creditConfig.currency_id);
|
|
518
668
|
|
|
@@ -632,6 +782,7 @@ async function handleInvoiceCredit(invoiceId: string) {
|
|
|
632
782
|
price_id: item.price.id,
|
|
633
783
|
invoice_id: invoice.id,
|
|
634
784
|
purchased_quantity: item.quantity,
|
|
785
|
+
subscription_id: invoice.subscription_id,
|
|
635
786
|
},
|
|
636
787
|
});
|
|
637
788
|
|
|
@@ -716,9 +867,113 @@ export async function addInvoiceCreditJob(invoiceId: string) {
|
|
|
716
867
|
});
|
|
717
868
|
}
|
|
718
869
|
|
|
870
|
+
/**
|
|
871
|
+
* Add a scheduled credit job to the queue
|
|
872
|
+
* @param jobId Unique job ID (typically schedule-{subscriptionId}-{priceId})
|
|
873
|
+
* @param job The credit schedule job data
|
|
874
|
+
* @param runAt Unix timestamp when the job should run
|
|
875
|
+
* @param replace If true, replace existing job; if false, skip if job exists
|
|
876
|
+
*/
|
|
877
|
+
export async function addScheduledCreditJob(
|
|
878
|
+
jobId: string,
|
|
879
|
+
job: CreditScheduleJob,
|
|
880
|
+
runAt: number,
|
|
881
|
+
replace: boolean = false
|
|
882
|
+
): Promise<boolean> {
|
|
883
|
+
// Always try to delete existing job first to avoid JOB_DUPLICATE error
|
|
884
|
+
// This is necessary because when a job is executing and creates the next job,
|
|
885
|
+
// the current job hasn't been deleted yet, causing ID conflict
|
|
886
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
887
|
+
|
|
888
|
+
if (existingJob) {
|
|
889
|
+
if (replace) {
|
|
890
|
+
await creditGrantQueue.delete(jobId);
|
|
891
|
+
logger.info('Scheduled credit job replaced', { jobId, runAt });
|
|
892
|
+
} else {
|
|
893
|
+
logger.debug('Scheduled credit job already exists, skipping', {
|
|
894
|
+
jobId,
|
|
895
|
+
existingRunAt: existingJob.runAt,
|
|
896
|
+
newRunAt: runAt,
|
|
897
|
+
});
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// Use store.addJob directly to ensure proper persistence and error handling
|
|
903
|
+
// instead of relying on push() which has async issues for scheduled jobs
|
|
904
|
+
try {
|
|
905
|
+
const now = dayjs().unix();
|
|
906
|
+
const attrs: { delay?: number; will_run_at?: number } = {};
|
|
907
|
+
|
|
908
|
+
// If runAt is in the future, create as scheduled job
|
|
909
|
+
if (runAt > now) {
|
|
910
|
+
attrs.delay = 1;
|
|
911
|
+
attrs.will_run_at = runAt * 1000; // Convert to milliseconds
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
await creditGrantQueue.store.addJob(jobId, job, attrs);
|
|
915
|
+
|
|
916
|
+
logger.info('Scheduled credit job added', {
|
|
917
|
+
jobId,
|
|
918
|
+
subscriptionId: job.subscriptionId,
|
|
919
|
+
priceId: job.priceId,
|
|
920
|
+
runAt,
|
|
921
|
+
scheduledAt: dayjs.unix(runAt).format(),
|
|
922
|
+
isImmediate: runAt <= now,
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
// If job should run immediately (runAt <= now), push to queue for execution
|
|
926
|
+
if (runAt <= now) {
|
|
927
|
+
creditGrantQueue.push({
|
|
928
|
+
id: jobId,
|
|
929
|
+
job,
|
|
930
|
+
persist: false, // Already persisted above
|
|
931
|
+
});
|
|
932
|
+
logger.info('Scheduled credit job pushed for immediate execution', { jobId });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return true;
|
|
936
|
+
} catch (err: any) {
|
|
937
|
+
// Handle duplicate job error gracefully
|
|
938
|
+
if (err.code === 'JOB_DUPLICATE') {
|
|
939
|
+
logger.warn('Scheduled credit job already exists (concurrent creation)', {
|
|
940
|
+
jobId,
|
|
941
|
+
error: err.message,
|
|
942
|
+
});
|
|
943
|
+
return false;
|
|
944
|
+
}
|
|
945
|
+
logger.error('Failed to add scheduled credit job', {
|
|
946
|
+
jobId,
|
|
947
|
+
error: err.message,
|
|
948
|
+
stack: err.stack,
|
|
949
|
+
});
|
|
950
|
+
throw err;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Delete a scheduled credit job from the queue
|
|
956
|
+
*/
|
|
957
|
+
export async function deleteScheduledCreditJob(jobId: string): Promise<boolean> {
|
|
958
|
+
const existingJob = await creditGrantQueue.get(jobId);
|
|
959
|
+
if (existingJob) {
|
|
960
|
+
await creditGrantQueue.delete(jobId);
|
|
961
|
+
logger.info('Scheduled credit job deleted', { jobId });
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
|
|
719
967
|
events.on('invoice.paid', async (invoice: Invoice) => {
|
|
720
968
|
try {
|
|
721
969
|
await addInvoiceCreditJob(invoice.id);
|
|
970
|
+
if (invoice.subscription_id) {
|
|
971
|
+
const subscription = await Subscription.findByPk(invoice.subscription_id);
|
|
972
|
+
if (subscription) {
|
|
973
|
+
const jobs = await activateAfterFirstPaymentSchedules(subscription, invoice.status_transitions?.paid_at);
|
|
974
|
+
await Promise.all(jobs.map((job) => addScheduledCreditJob(job.jobId, job.job, job.runAt, true)));
|
|
975
|
+
}
|
|
976
|
+
}
|
|
722
977
|
} catch (error) {
|
|
723
978
|
logger.error('Failed to schedule credit grant job for invoice', {
|
|
724
979
|
invoiceId: invoice.id,
|
|
@@ -784,3 +1039,128 @@ events.on('credit-grant.queued', async (id, job, args = {}) => {
|
|
|
784
1039
|
events.emit('credit-grant.queued.error', { id, job, error });
|
|
785
1040
|
}
|
|
786
1041
|
});
|
|
1042
|
+
|
|
1043
|
+
// ========================================
|
|
1044
|
+
// Credit Schedule Event Handlers
|
|
1045
|
+
// ========================================
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Helper function to initialize credit schedule and create jobs
|
|
1049
|
+
* Used by both subscription.created and subscription.started events
|
|
1050
|
+
*/
|
|
1051
|
+
async function initializeAndScheduleCreditJobs(subscriptionId: string, eventName: string): Promise<void> {
|
|
1052
|
+
const sub = await Subscription.findByPk(subscriptionId);
|
|
1053
|
+
if (!sub) {
|
|
1054
|
+
logger.warn('Subscription not found for credit schedule initialization', {
|
|
1055
|
+
subscriptionId,
|
|
1056
|
+
event: eventName,
|
|
1057
|
+
});
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// initializeCreditSchedule only works for active/trialing subscriptions
|
|
1062
|
+
// and skips if already initialized
|
|
1063
|
+
const state = await initializeCreditSchedule(sub);
|
|
1064
|
+
if (!state) {
|
|
1065
|
+
logger.debug('No credit schedules to initialize for subscription', {
|
|
1066
|
+
subscriptionId,
|
|
1067
|
+
event: eventName,
|
|
1068
|
+
status: sub.status,
|
|
1069
|
+
});
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Schedule jobs for prices with valid next_grant_at
|
|
1074
|
+
const pricesToSchedule = Object.keys(state).filter((priceId) => {
|
|
1075
|
+
const priceState = state[priceId];
|
|
1076
|
+
return priceState?.enabled && priceState?.next_grant_at > 0;
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
await Promise.all(
|
|
1080
|
+
pricesToSchedule.map((priceId) => {
|
|
1081
|
+
const priceState = state[priceId]!;
|
|
1082
|
+
// First job has seq = 1 (since last_grant_seq starts at 0)
|
|
1083
|
+
const nextSeq = priceState.last_grant_seq + 1;
|
|
1084
|
+
const jobId = getScheduleJobId(subscriptionId, priceId, nextSeq);
|
|
1085
|
+
const job: CreditScheduleJob = {
|
|
1086
|
+
subscriptionId,
|
|
1087
|
+
priceId,
|
|
1088
|
+
scheduledAt: priceState.next_grant_at,
|
|
1089
|
+
action: 'create_from_schedule',
|
|
1090
|
+
};
|
|
1091
|
+
return addScheduledCreditJob(jobId, job, priceState.next_grant_at, true);
|
|
1092
|
+
})
|
|
1093
|
+
);
|
|
1094
|
+
|
|
1095
|
+
logger.info('Credit schedule initialized and jobs scheduled', {
|
|
1096
|
+
subscriptionId,
|
|
1097
|
+
event: eventName,
|
|
1098
|
+
priceCount: pricesToSchedule.length,
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* Handle subscription started event (when subscription becomes active)
|
|
1104
|
+
*
|
|
1105
|
+
* This event fires when:
|
|
1106
|
+
* 1. Subscription payment succeeds (checkout flow completes)
|
|
1107
|
+
* 2. Trial ends and subscription becomes active
|
|
1108
|
+
* 3. Stripe SetupIntent succeeds
|
|
1109
|
+
*
|
|
1110
|
+
* For subscriptions created via checkout (status was incomplete),
|
|
1111
|
+
* this is when the credit schedule gets initialized.
|
|
1112
|
+
*/
|
|
1113
|
+
events.on('customer.subscription.started', async (subscription: Subscription) => {
|
|
1114
|
+
try {
|
|
1115
|
+
await initializeAndScheduleCreditJobs(subscription.id, 'customer.subscription.started');
|
|
1116
|
+
} catch (error: any) {
|
|
1117
|
+
logger.error('Failed to handle credit schedule on subscription started', {
|
|
1118
|
+
subscriptionId: subscription.id,
|
|
1119
|
+
error: error.message,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Handle subscription trial start event (when subscription enters trialing state)
|
|
1126
|
+
*
|
|
1127
|
+
* This event fires when subscription has a trial period and payment succeeds.
|
|
1128
|
+
* For immediate timing, credit schedule should start during trial period.
|
|
1129
|
+
*/
|
|
1130
|
+
events.on('customer.subscription.trial_start', async (subscription: Subscription) => {
|
|
1131
|
+
try {
|
|
1132
|
+
await initializeAndScheduleCreditJobs(subscription.id, 'customer.subscription.trial_start');
|
|
1133
|
+
} catch (error: any) {
|
|
1134
|
+
logger.error('Failed to handle credit schedule on subscription trial start', {
|
|
1135
|
+
subscriptionId: subscription.id,
|
|
1136
|
+
error: error.message,
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
/**
|
|
1142
|
+
* Handle subscription deleted event - stop all credit schedules
|
|
1143
|
+
*/
|
|
1144
|
+
events.on('customer.subscription.deleted', async (subscription: Subscription) => {
|
|
1145
|
+
try {
|
|
1146
|
+
const sub = await Subscription.findByPk(subscription.id);
|
|
1147
|
+
if (!sub) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
const jobIds = await stopCreditSchedule(sub);
|
|
1152
|
+
|
|
1153
|
+
// Delete all scheduled jobs
|
|
1154
|
+
await Promise.all(jobIds.map((id) => deleteScheduledCreditJob(id)));
|
|
1155
|
+
|
|
1156
|
+
logger.info('Credit schedules stopped on subscription deletion', {
|
|
1157
|
+
subscriptionId: subscription.id,
|
|
1158
|
+
deletedJobs: jobIds.length,
|
|
1159
|
+
});
|
|
1160
|
+
} catch (error: any) {
|
|
1161
|
+
logger.error('Failed to stop credit schedules on subscription deletion', {
|
|
1162
|
+
subscriptionId: subscription.id,
|
|
1163
|
+
error: error.message,
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
@@ -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 = {
|
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
|
}
|