Skip to content

Commit d4e466b

Browse files
committed
feat(paystack): implement downgrade for expired canceled subscriptions and support variable billing intervals
1 parent 81e7638 commit d4e466b

2 files changed

Lines changed: 45 additions & 3 deletions

File tree

‎apps/cron/src/lib/paystack.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ interface Subscription {
4141
current_period_end: Date;
4242
}
4343

44+
interface ExpiredCanceledSubscription {
45+
id: string;
46+
organization_id: string;
47+
}
48+
4449
/**
4550
* Charge a stored authorization
4651
*/
@@ -128,6 +133,7 @@ async function getDueSubscriptions(): Promise<Subscription[]> {
128133
FROM subscriptions
129134
WHERE payment_provider = 'paystack'
130135
AND status = 'active'
136+
AND cancel_at_period_end = false
131137
AND paystack_authorization_code IS NOT NULL
132138
AND paystack_email IS NOT NULL
133139
AND current_period_end <= NOW()`,
@@ -138,6 +144,33 @@ async function getDueSubscriptions(): Promise<Subscription[]> {
138144
}
139145
}
140146

147+
/**
148+
* Downgrade Paystack subscriptions that were canceled and have reached period end.
149+
*
150+
* We currently model the free tier as "no subscription row", so deleting the row
151+
* keeps plan enforcement consistent across the app and prevents any future charges.
152+
*/
153+
async function downgradeExpiredCanceledSubscriptions(): Promise<void> {
154+
const client = await dbPool.connect();
155+
try {
156+
const result = await client.query<ExpiredCanceledSubscription>(
157+
`DELETE FROM subscriptions
158+
WHERE payment_provider = 'paystack'
159+
AND cancel_at_period_end = true
160+
AND current_period_end <= NOW()
161+
RETURNING id, organization_id`,
162+
);
163+
164+
if (result.rowCount) {
165+
console.log(
166+
`[Paystack] Downgraded ${result.rowCount} canceled subscription(s) to free plan`,
167+
);
168+
}
169+
} finally {
170+
client.release();
171+
}
172+
}
173+
141174
/**
142175
* Update subscription after successful charge
143176
*/
@@ -200,6 +233,8 @@ export async function chargePaystackSubscriptions(): Promise<void> {
200233
console.log("[Paystack] Starting recurring charge job...");
201234

202235
try {
236+
await downgradeExpiredCanceledSubscriptions();
237+
203238
const dueSubscriptions = await getDueSubscriptions();
204239
console.log(
205240
`[Paystack] Found ${dueSubscriptions.length} subscriptions due for renewal`,

‎apps/web/src/routes/api/checkout/paystack-verify.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export const Route = createFileRoute("/api/checkout/paystack-verify")({
7272
const metadata = verification.data.metadata as {
7373
organizationId?: string;
7474
plan?: string;
75+
interval?: "month" | "year";
7576
userId?: string;
7677
};
7778

@@ -82,7 +83,7 @@ export const Route = createFileRoute("/api/checkout/paystack-verify")({
8283
);
8384
}
8485

85-
const { organizationId, plan, userId } = metadata;
86+
const { organizationId, plan, interval = "month", userId } = metadata;
8687

8788
// Verify that the transaction was initiated by the current user
8889
if (userId !== session.user.id) {
@@ -116,9 +117,13 @@ export const Route = createFileRoute("/api/checkout/paystack-verify")({
116117
const customerEmail = verification.data.customer.email;
117118
const customerCode = verification.data.customer.customer_code;
118119

119-
// Calculate next billing date (1 month from now)
120+
// Calculate next billing date based on the checkout interval
120121
const currentPeriodEnd = new Date();
121-
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
122+
if (interval === "year") {
123+
currentPeriodEnd.setFullYear(currentPeriodEnd.getFullYear() + 1);
124+
} else {
125+
currentPeriodEnd.setMonth(currentPeriodEnd.getMonth() + 1);
126+
}
122127

123128
// Check if subscription exists
124129
const existingSubscription = await db
@@ -138,6 +143,7 @@ export const Route = createFileRoute("/api/checkout/paystack-verify")({
138143
paystackCustomerCode: customerCode,
139144
paystackAuthorizationCode: authorization.authorization_code,
140145
paystackEmail: customerEmail,
146+
billingInterval: interval,
141147
currentPeriodEnd,
142148
cancelAtPeriodEnd: false,
143149
canceledAt: null,
@@ -155,6 +161,7 @@ export const Route = createFileRoute("/api/checkout/paystack-verify")({
155161
paystackCustomerCode: customerCode,
156162
paystackAuthorizationCode: authorization.authorization_code,
157163
paystackEmail: customerEmail,
164+
billingInterval: interval,
158165
currentPeriodEnd,
159166
cancelAtPeriodEnd: false,
160167
});

0 commit comments

Comments
 (0)