Payment Expiry Cron Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Auto-release seats when payment window expires (runs every minute)
Architecture: Convex cron job calls internal mutation that runs releaseExpired logic. Uses existing releaseSeatsForReservation and markReservationAsFailed helpers already extracted in reservations.ts.
Tech Stack: Convex cron jobs, internalMutation, existing releaseExpired mutation pattern
File Map
packages/backend/convex/
├── crons.ts # Register cron jobs
└── functions/scheduled.ts # Add expireStaleReservations mutationModify only: crons.ts:11-20, functions/scheduled.ts:1-91 (append new mutation)
Task 1: Add expireStaleReservations internalMutation
Files:
-
Modify:
packages/backend/convex/functions/scheduled.ts:93-140 -
Step 1: Read existing scheduled.ts to understand line count
Run: wc -l packages/backend/convex/functions/scheduled.ts
Expected: ~91 lines
- Step 2: Append expireStaleReservations mutation
/**
* expireStaleReservations — release seats for expired pending reservations
*
* Runs every minute via Convex cron. Finds all PENDING reservations where
* bookingExpiresAt < now and:
* - Releases seats back to event capacity
* - Marks reservation as FAILED
* - Clears payment-related fields
*
* Idempotent: only processes PENDING reservations, skips PAID/REFUNDED
*/
export const expireStaleReservations = internalMutation({
args: v.object({}),
handler: async (ctx) => {
const now = Date.now();
const expired = await ctx.db
.query("reservations")
.withIndex("by_expires", (q) => q.lt("bookingExpiresAt", now))
.collect();
let released = 0;
for (const reservation of expired) {
// Only process PENDING reservations
if (reservation.paymentStatus !== "PENDING") continue;
// Release seats back to event
const event = await ctx.db.get(reservation.eventId);
if (event) {
await ctx.db.patch(event._id, {
bookedCount: Math.max(0, event.bookedCount - reservation.quantity),
updatedAt: now,
});
}
// Mark reservation as failed and clear payment fields
await ctx.db.patch(reservation._id, {
paymentStatus: "FAILED",
bookingExpiresAt: undefined,
paymentExpiresAt: undefined,
onePayOrderId: undefined,
vaNumber: undefined,
qrCode: undefined,
qrCodeUrl: undefined,
updatedAt: now,
});
released++;
console.log(`Released expired reservation ${reservation._id}`);
}
return { released, checked: expired.length };
},
});- Step 3: Verify file has new mutation
Run: grep -n "expireStaleReservations" packages/backend/convex/functions/scheduled.ts
Expected: Line number where function was added
Task 2: Register cron job in crons.ts
Files:
-
Modify:
packages/backend/convex/crons.ts:11-20 -
Step 1: Read crons.ts to see current structure
Run: cat packages/backend/convex/crons.ts
Expected: Shows existing cronJobs() setup with d1LowOccupancyCheck
- Step 2: Add expireStaleReservations cron (runs every minute)
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
// D-1 low occupancy check: daily at 09:00 Vietnam time = 02:00 UTC
crons.daily(
"d1LowOccupancyCheck",
{ hourUTC: 2, minuteUTC: 0 },
internal.functions.scheduled.d1LowOccupancyCheck,
);
// Payment expiry: every minute to release stale reservations
crons.interval(
"expireStaleReservations",
{ minutes: 1 },
internal.functions.scheduled.expireStaleReservations,
);
export default crons;- Step 3: Verify cron registered
Run: grep -n "expireStaleReservations" packages/backend/convex/crons.ts
Expected: Shows the new cron registration
Task 3: Test locally with Convex CLI
- Step 1: Run Convex dev server
Run: cd packages/backend/convex && npx convex dev --once 2>&1 | head -50
Expected: Shows "Started Convex dev server" and runs pending cron jobs
- Step 2: Trigger cron manually to test
Run: cd packages/backend/convex && npx convex run internal.functions.scheduled.expireStaleReservations --json 2>&1
Expected: Returns {"released": 0, "checked": 0} (no expired reservations in test)
- Step 3: Create a test expired reservation and verify it releases
This requires manual testing via:
- Create a reservation with
bookingExpiresAtset to past timestamp - Run the cron
- Verify reservation status changed to FAILED and seats released
Task 4: Commit
- Step 1: Stage and commit
cd /Users/curlyz/usr/hol
git add packages/backend/convex/functions/scheduled.ts packages/backend/convex/crons.ts
git commit -m "feat(payments): add payment expiry cron job
Runs every minute to release seats for expired PENDING reservations.
Uses existing releaseSeatsForReservation and markReservationAsFailed helpers.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"Verification Checklist
-
npx convex devstarts without errors -
npx convex run internal.functions.scheduled.expireStaleReservations --jsonexecutes -
grep -n "expireStaleReservations" packages/backend/convex/crons.tsshows registration -
grep -n "expireStaleReservations" packages/backend/convex/functions/scheduled.tsshows mutation - TypeScript passes:
cd packages/backend && npx tsc --noEmit
Self-Review
-
Spec coverage: Does this implement the payment expiry cron from the audit? Yes — every minute cron + mutation that calls releaseExpired logic.
-
Placeholder scan: No placeholders — all code is complete.
-
Type consistency:
expireStaleReservationsusesinternalMutation(correct for cron callers), accessesreservationstable with same fields as existingreleaseExpiredmutation.
What's Next (Not in This Plan)
- Email/WhatsApp notifications on payment success (backend mutations already exist)
- Payment status polling on confirmation page
- Payment retry if expired