plans
2026-05-11
2026 05 11 Payment Expiry Cron

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 mutation

Modify 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:

  1. Create a reservation with bookingExpiresAt set to past timestamp
  2. Run the cron
  3. 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 dev starts without errors
  • npx convex run internal.functions.scheduled.expireStaleReservations --json executes
  • grep -n "expireStaleReservations" packages/backend/convex/crons.ts shows registration
  • grep -n "expireStaleReservations" packages/backend/convex/functions/scheduled.ts shows mutation
  • TypeScript passes: cd packages/backend && npx tsc --noEmit

Self-Review

  1. Spec coverage: Does this implement the payment expiry cron from the audit? Yes — every minute cron + mutation that calls releaseExpired logic.

  2. Placeholder scan: No placeholders — all code is complete.

  3. Type consistency: expireStaleReservations uses internalMutation (correct for cron callers), accesses reservations table with same fields as existing releaseExpired mutation.


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