Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ RESEND_FROM_EMAIL="noreply@projectamazonph.com"
# Required for file storage
BLOB_READ_WRITE_TOKEN="vercel_blob_..."

# Required for cron jobs (Vercel Cron sends this as `Authorization: Bearer <value>`)
# Used by /api/cron/expire-checkouts to authenticate scheduled invocations.
CRON_SECRET="generate-with-openssl-rand-base64-32"

# Required for error tracking
SENTRY_DSN="https://...@sentry.io/..."
NEXT_PUBLIC_SENTRY_DSN="https://...@sentry.io/..."
Expand Down
61 changes: 61 additions & 0 deletions src/app/api/cron/expire-checkouts/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

const mockSweep = vi.fn();
vi.mock('@/lib/enrollment', () => ({
sweepExpiredCheckouts: (...args: unknown[]) => mockSweep(...args),
}));
vi.mock('@/lib/logger', () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));

import { GET } from './route';

function req(auth?: string): Request {
const headers = new Headers();
if (auth !== undefined) headers.set('authorization', auth);
return new Request('http://localhost/api/cron/expire-checkouts', { headers });
}

describe('GET /api/cron/expire-checkouts', () => {
const original = process.env.CRON_SECRET;
beforeEach(() => {
vi.clearAllMocks();
process.env.CRON_SECRET = 'test-secret';
});
afterEach(() => {
process.env.CRON_SECRET = original;
});

it('runs the sweep and returns the count with a valid secret', async () => {
mockSweep.mockResolvedValue(4);

const res = await GET(req('Bearer test-secret') as never);

expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({ expired: 4 });
expect(mockSweep).toHaveBeenCalledOnce();
});

it('rejects a request with a wrong secret and does not sweep', async () => {
const res = await GET(req('Bearer nope') as never);

expect(res.status).toBe(401);
expect(mockSweep).not.toHaveBeenCalled();
});

it('rejects a request with no authorization header', async () => {
const res = await GET(req() as never);

expect(res.status).toBe(401);
expect(mockSweep).not.toHaveBeenCalled();
});

it('fails closed with 503 when CRON_SECRET is unset', async () => {
delete process.env.CRON_SECRET;

const res = await GET(req('Bearer test-secret') as never);

expect(res.status).toBe(503);
expect(mockSweep).not.toHaveBeenCalled();
});
});
38 changes: 38 additions & 0 deletions src/app/api/cron/expire-checkouts/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Cron: expire stale checkout sessions.
*
* Endpoint: GET /api/cron/expire-checkouts
*
* Driven by a Vercel Cron (see vercel.json). Marks PENDING/AWAITING_PAYMENT
* sessions past their `expiresAt` as EXPIRED, satisfying the business-layer
* guarantee that no CheckoutSession is left orphaned (business-layer.md:
* "every one ends in PAID, EXPIRED, FAILED, or ERROR").
*
* Auth: Vercel Cron attaches `Authorization: Bearer <CRON_SECRET>` when the
* CRON_SECRET env var is set. We require it — the endpoint mutates data and
* must not be publicly triggerable. If CRON_SECRET is unset we fail closed
* (503) rather than run unauthenticated.
*/

import { NextRequest, NextResponse } from 'next/server';
import { sweepExpiredCheckouts } from '@/lib/enrollment';
import { logger } from '@/lib/logger';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

export async function GET(request: NextRequest): Promise<Response> {
const secret = process.env.CRON_SECRET;
if (!secret) {
logger.error({ component: 'cron-expire-checkouts' }, 'CRON_SECRET is not set — refusing to run');
return NextResponse.json({ error: 'Not configured' }, { status: 503 });
}

if (request.headers.get('authorization') !== `Bearer ${secret}`) {
logger.warn({ component: 'cron-expire-checkouts' }, 'unauthorized cron invocation');
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const expired = await sweepExpiredCheckouts();
return NextResponse.json({ expired });
}
10 changes: 8 additions & 2 deletions src/app/api/paymongo/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - checkout_session.payment.failed (unused — see C1 note below)
* - source.chargeable (the ACTUAL completion event — checkout.ts creates a Source)
* - payment.paid (the ACTUAL completion event)
* - payment.failed
* - payment.failed (the ACTUAL failure event — marks the session FAILED, emails the buyer)
* - payment.refunded
*
* C1 (AUDIT-2026-07-17): checkout.ts creates a PayMongo Source, not a
Expand Down Expand Up @@ -48,11 +48,13 @@ import {
handlePaymentRefunded,
handleSourceChargeable,
handlePaymentPaid,
handlePaymentFailed,
type CheckoutPaidEvent,
type CheckoutFailedEvent,
type PaymentRefundedEvent,
type SourceChargeableEvent,
type PaymentPaidEvent,
type PaymentFailedEvent,
} from '@/lib/enrollment';
import { log } from '@/lib/logger';

Expand All @@ -65,7 +67,8 @@ type PayMongoEvent =
| { type: 'payment.refunded'; data: PaymentRefundedEvent }
| { type: 'source.chargeable'; data: SourceChargeableEvent }
| { type: 'payment.paid'; data: PaymentPaidEvent }
| { type: 'payment.failed' | string; data: { id?: string; attributes?: { type?: string } } };
| { type: 'payment.failed'; data: PaymentFailedEvent }
| { type: string; data: { id?: string; attributes?: { type?: string } } };

function pickEvent(body: string): PayMongoEvent | null {
try {
Expand Down Expand Up @@ -131,6 +134,9 @@ export async function POST(request: NextRequest): Promise<Response> {
case 'payment.paid':
await handlePaymentPaid(event.data as unknown as PaymentPaidEvent);
break;
case 'payment.failed':
await handlePaymentFailed(event.data as unknown as PaymentFailedEvent);
break;
default:
log.info({ component: 'paymongo-webhook', eventType: event.type }, 'unhandled event type');
}
Expand Down
133 changes: 131 additions & 2 deletions src/lib/__tests__/enrollment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,14 @@ vi.mock('@/lib/paymongo', () => ({
vi.mock('@prisma/client', () => ({ Prisma: {}, PrismaClient: class {} }));

const mockEnums = vi.hoisted(() => ({
CheckoutStatus: { PAID: 'PAID', FAILED: 'FAILED' },
CheckoutStatus: {
PENDING: 'PENDING',
AWAITING_PAYMENT: 'AWAITING_PAYMENT',
PAID: 'PAID',
EXPIRED: 'EXPIRED',
FAILED: 'FAILED',
ERROR: 'ERROR',
},
EnrollmentStatus: { ACTIVE: 'ACTIVE' },
PaymentMethod: { GCASH: 'GCASH', MAYA: 'MAYA', GRABPAY: 'GRABPAY', CREDIT_CARD: 'CREDIT_CARD', BANK_TRANSFER: 'BANK_TRANSFER', OTHER: 'OTHER' },
PaymentStatus: { COMPLETED: 'COMPLETED', REFUNDED: 'REFUNDED', PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED' },
Expand All @@ -38,6 +45,7 @@ vi.mock('@/lib/receipts', () => ({ issueInvoiceForPayment: vi.fn() }));
vi.mock('@/lib/email', () => ({
sendEnrollmentConfirmationEmail: vi.fn(() => Promise.resolve()),
sendAccountClaimEmail: vi.fn(() => Promise.resolve()),
sendPaymentFailedEmail: vi.fn(() => Promise.resolve()),
}));
vi.mock('@/lib/logger', () => ({
logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() },
Expand All @@ -52,7 +60,11 @@ vi.mock('node:crypto', () => ({
}));

import { issueInvoiceForPayment } from '@/lib/receipts';
import { sendEnrollmentConfirmationEmail, sendAccountClaimEmail } from '@/lib/email';
import {
sendEnrollmentConfirmationEmail,
sendAccountClaimEmail,
sendPaymentFailedEmail,
} from '@/lib/email';
import {
markWebhookProcessed,
findOrCreateUserByEmail,
Expand All @@ -61,11 +73,14 @@ import {
handlePaymentRefunded,
handleSourceChargeable,
handlePaymentPaid,
handlePaymentFailed,
sweepExpiredCheckouts,
type CheckoutPaidEvent,
type CheckoutFailedEvent,
type PaymentRefundedEvent,
type SourceChargeableEvent,
type PaymentPaidEvent,
type PaymentFailedEvent,
} from '@/lib/enrollment';

/** Error shaped like Prisma's unique-constraint violation. */
Expand Down Expand Up @@ -114,6 +129,31 @@ function makeFailedEvent(): CheckoutFailedEvent {
};
}

function makeFailedPaymentEvent(
attrOverrides: Record<string, unknown> = {},
): PaymentFailedEvent {
return {
data: {
id: 'evt-payfail-1',
attributes: {
type: 'payment.failed',
data: {
id: 'pm_pay_fail_1',
attributes: {
id: 'pm_pay_fail_1',
amount: 299900,
currency: 'PHP',
status: 'failed',
source: { id: 'src_abc', type: 'gcash' },
failed_message: 'card_declined',
...attrOverrides,
},
},
},
},
};
}

function makeRefundedEvent(): PaymentRefundedEvent {
return {
data: {
Expand Down Expand Up @@ -566,6 +606,95 @@ describe('enrollment.ts', () => {
});
});

describe('handlePaymentFailed', () => {
it('marks the session FAILED and emails the buyer on first event', async () => {
mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' });
mockDb.checkoutSession.findFirst.mockResolvedValue({
id: 'cs-1',
email: 'juan@example.com',
status: 'PENDING',
pricingTier: { name: 'PPC Foundations' },
});
mockDb.checkoutSession.update.mockResolvedValue({ id: 'cs-1' });

await handlePaymentFailed(makeFailedPaymentEvent());

expect(mockDb.checkoutSession.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: { paymongoSourceId: 'src_abc', deletedAt: null },
}),
);
expect(mockDb.checkoutSession.update).toHaveBeenCalledWith({
where: { id: 'cs-1' },
data: expect.objectContaining({ status: 'FAILED', failureReason: 'card_declined' }),
});
expect(sendPaymentFailedEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: 'juan@example.com', tierName: 'PPC Foundations' }),
);
});

it('skips work on duplicate event', async () => {
mockDb.processedWebhook.create.mockRejectedValue(p2002());

await handlePaymentFailed(makeFailedPaymentEvent());

expect(mockDb.checkoutSession.update).not.toHaveBeenCalled();
expect(sendPaymentFailedEmail).not.toHaveBeenCalled();
});

it('never downgrades an already-PAID session and sends no email', async () => {
mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' });
mockDb.checkoutSession.findFirst.mockResolvedValue({
id: 'cs-1',
email: 'juan@example.com',
status: 'PAID',
pricingTier: { name: 'PPC Foundations' },
});

await handlePaymentFailed(makeFailedPaymentEvent());

expect(mockDb.checkoutSession.update).not.toHaveBeenCalled();
expect(sendPaymentFailedEmail).not.toHaveBeenCalled();
});

it('is a no-op with no email when no session matches', async () => {
mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' });
mockDb.checkoutSession.findFirst.mockResolvedValue(null);

await handlePaymentFailed(makeFailedPaymentEvent());

expect(mockDb.checkoutSession.update).not.toHaveBeenCalled();
expect(sendPaymentFailedEmail).not.toHaveBeenCalled();
});
});

describe('sweepExpiredCheckouts', () => {
it('expires stale PENDING/AWAITING_PAYMENT sessions past expiresAt', async () => {
mockDb.checkoutSession.updateMany.mockResolvedValue({ count: 3 });
const now = new Date('2026-07-19T12:00:00Z');

const count = await sweepExpiredCheckouts(now);

expect(count).toBe(3);
expect(mockDb.checkoutSession.updateMany).toHaveBeenCalledWith({
where: {
status: { in: ['PENDING', 'AWAITING_PAYMENT'] },
expiresAt: { lt: now },
deletedAt: null,
},
data: { status: 'EXPIRED' },
});
});

it('returns 0 when nothing is stale', async () => {
mockDb.checkoutSession.updateMany.mockResolvedValue({ count: 0 });

const count = await sweepExpiredCheckouts();

expect(count).toBe(0);
});
});

describe('handlePaymentRefunded', () => {
it('updates payment and enrollment within the transaction', async () => {
mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' });
Expand Down
Loading