diff --git a/.env.example b/.env.example index 88f7154..1891fca 100644 --- a/.env.example +++ b/.env.example @@ -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 `) +# 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/..." diff --git a/src/app/api/cron/expire-checkouts/route.test.ts b/src/app/api/cron/expire-checkouts/route.test.ts new file mode 100644 index 0000000..bc862d0 --- /dev/null +++ b/src/app/api/cron/expire-checkouts/route.test.ts @@ -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(); + }); +}); diff --git a/src/app/api/cron/expire-checkouts/route.ts b/src/app/api/cron/expire-checkouts/route.ts new file mode 100644 index 0000000..1809bc6 --- /dev/null +++ b/src/app/api/cron/expire-checkouts/route.ts @@ -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 ` 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 { + 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 }); +} diff --git a/src/app/api/paymongo/webhook/route.ts b/src/app/api/paymongo/webhook/route.ts index ef15056..517a375 100644 --- a/src/app/api/paymongo/webhook/route.ts +++ b/src/app/api/paymongo/webhook/route.ts @@ -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 @@ -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'; @@ -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 { @@ -131,6 +134,9 @@ export async function POST(request: NextRequest): Promise { 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'); } diff --git a/src/lib/__tests__/enrollment.test.ts b/src/lib/__tests__/enrollment.test.ts index 8d547ff..a95dc72 100644 --- a/src/lib/__tests__/enrollment.test.ts +++ b/src/lib/__tests__/enrollment.test.ts @@ -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' }, @@ -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() }, @@ -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, @@ -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. */ @@ -114,6 +129,31 @@ function makeFailedEvent(): CheckoutFailedEvent { }; } +function makeFailedPaymentEvent( + attrOverrides: Record = {}, +): 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: { @@ -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' }); diff --git a/src/lib/email.tsx b/src/lib/email.tsx index be238f0..68db89c 100644 --- a/src/lib/email.tsx +++ b/src/lib/email.tsx @@ -667,3 +667,119 @@ function LiveClassReminderEmail({ ); } + +// --------------------------------------------------------------------------- +// Payment-failed email +// --------------------------------------------------------------------------- + +interface PaymentFailedEmailProps { + to: string; + studentName: string; + tierName: string; + /** Where the buyer can retry checkout. Defaults to the pricing page. */ + retryUrl?: string; +} + +/** + * Notify a buyer that their payment did not go through, with a link back to + * retry. Fired from the `payment.failed` / `checkout_session.payment.failed` + * webhook handlers. Best-effort like every other send here. + */ +export async function sendPaymentFailedEmail({ + to, + studentName, + tierName, + retryUrl, +}: PaymentFailedEmailProps): Promise { + await sendEmail({ + to, + subject: `Your payment didn't go through — ${tierName}`, + react: ( + + ), + }); +} + +function PaymentFailedEmail({ + studentName, + tierName, + retryUrl, +}: { + studentName: string; + tierName: string; + retryUrl: string; +}) { + return ( +
+
+

+ {BRAND_NAME} +

+

+ Your payment didn't go through +

+

+ Hi {studentName}, we couldn't complete your payment for {tierName}. + No charge was made. You can try again below — if a payment method was + declined, use a different one. +

+ + Try again → + +
+

+ {`${BRAND_NAME} · projectamazonph.com`} +

+
+ ); +} diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts index 989b477..6e85ff1 100644 --- a/src/lib/enrollment.ts +++ b/src/lib/enrollment.ts @@ -30,7 +30,11 @@ import { db } from './db'; import { CheckoutStatus, EnrollmentStatus, PaymentMethod, PaymentStatus } from './enums'; import { alreadyRefundedAmountPhp } from './refunds'; import { issueInvoiceForPayment } from './receipts'; -import { sendEnrollmentConfirmationEmail, sendAccountClaimEmail } from './email'; +import { + sendEnrollmentConfirmationEmail, + sendAccountClaimEmail, + sendPaymentFailedEmail, +} from './email'; import { logger } from './logger'; import { generateClaimToken, PLACEHOLDER_PASSWORD_PREFIX } from './claim-token'; import { createPaymentFromSource, type PayMongoPayment } from './paymongo'; @@ -142,12 +146,38 @@ export interface PaymentPaidEvent { }; } +export interface PaymentFailedEvent { + data: { + id: string; + attributes: { + type: 'payment.failed'; + data: { + id: string; + attributes: { + id: string; + amount: number; + currency: string; + status: string; + source?: { id: string; type: string }; + // PayMongo surfaces the decline reason under a few shapes depending + // on the flow; all optional and used only for logging/copy. + last_payment_error?: string; + failed_code?: string; + failed_message?: string; + metadata?: Record; + }; + }; + }; + }; +} + export type PayMongoWebhookEvent = | { type: 'checkout_session.payment.paid'; payload: CheckoutPaidEvent } | { type: 'checkout_session.payment.failed'; payload: CheckoutFailedEvent } | { type: 'payment.refunded'; payload: PaymentRefundedEvent } | { type: 'source.chargeable'; payload: SourceChargeableEvent } | { type: 'payment.paid'; payload: PaymentPaidEvent } + | { type: 'payment.failed'; payload: PaymentFailedEvent } | { type: string; payload: unknown }; /** @@ -489,6 +519,89 @@ export async function handleCheckoutFailed( }); } +/** + * Handle `payment.failed` webhook. + * + * This is the failure counterpart to `payment.paid` for the Source-based flow + * this codebase uses (checkout.ts creates a Source, not a hosted Checkout + * Session), so PayMongo fires `payment.failed` — not + * `checkout_session.payment.failed` — when a charge is declined. Previously + * this event fell through to the webhook route's `default` case and was + * dropped: the CheckoutSession stayed PENDING and the buyer was never told. + * + * Marks the matching CheckoutSession FAILED and best-effort emails the buyer a + * retry link. + */ +export async function handlePaymentFailed( + event: PaymentFailedEvent, +): Promise { + const eventId = event.data.id; + const attrs = event.data.attributes.data.attributes; + const paymentIdPm = attrs.id; + const sourceId = attrs.source?.id; + const reason = + attrs.failed_message ?? attrs.last_payment_error ?? attrs.failed_code ?? 'unknown'; + + const notify = await db.$transaction(async (tx) => { + const firstTime = await markWebhookProcessed( + eventId, + 'payment.failed', + 'payment', + paymentIdPm, + `reason=${reason}`, + 200, + tx, + ); + if (!firstTime) return null; + + // Find the session via source id (set at checkout creation) or, failing + // that, a payment id we may have recorded on a prior event. + const checkout = + (sourceId + ? await tx.checkoutSession.findFirst({ + where: { paymongoSourceId: sourceId, deletedAt: null }, + select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } }, + }) + : null) ?? + (await tx.checkoutSession.findFirst({ + where: { paymongoPaymentId: paymentIdPm, deletedAt: null }, + select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } }, + })); + + if (!checkout) { + logger.warn({ paymentId: paymentIdPm, sourceId }, 'payment.failed: no checkout session found'); + return null; + } + + // Never downgrade a session that already succeeded — a late/duplicate + // failure event must not revoke a paid enrollment. + if (checkout.status === CheckoutStatus.PAID) { + logger.warn({ checkoutId: checkout.id }, 'payment.failed: session already PAID, ignoring'); + return null; + } + + await tx.checkoutSession.update({ + where: { id: checkout.id }, + data: { + status: CheckoutStatus.FAILED, + failedAt: new Date(), + failureReason: reason, + }, + }); + + return { email: checkout.email, tierName: checkout.pricingTier.name }; + }); + + if (notify) { + // Best-effort — a failed email must not fail the webhook. + sendPaymentFailedEmail({ + to: notify.email, + studentName: notify.email.split('@')[0] ?? 'there', + tierName: notify.tierName, + }).catch((err: Error) => logger.error({ err }, 'payment.failed: retry email send failed')); + } +} + /** * Handle `payment.refunded` webhook. */ @@ -556,6 +669,37 @@ export async function handlePaymentRefunded( }); } +/** + * Sweep stale checkout sessions to EXPIRED. + * + * A session is created PENDING with a 24h `expiresAt` (see checkout.ts). If the + * buyer abandons the flow, no webhook ever arrives, so nothing transitions it + * out of PENDING/AWAITING_PAYMENT. This leaves "orphan" sessions that the + * business-layer spec explicitly forbids ("every one ends in PAID, EXPIRED, + * FAILED, or ERROR"). This function — driven by an hourly Vercel cron at + * /api/cron/expire-checkouts — closes them out. + * + * Only unresolved statuses are touched; PAID/FAILED/ERROR/EXPIRED are left + * alone, so a race with an in-flight webhook can't clobber a real outcome. + * Soft-deleted rows are ignored. + * + * @returns the number of sessions expired. + */ +export async function sweepExpiredCheckouts(now: Date = new Date()): Promise { + const { count } = await db.checkoutSession.updateMany({ + where: { + status: { in: [CheckoutStatus.PENDING, CheckoutStatus.AWAITING_PAYMENT] }, + expiresAt: { lt: now }, + deletedAt: null, + }, + data: { status: CheckoutStatus.EXPIRED }, + }); + if (count > 0) { + logger.info({ count }, 'sweepExpiredCheckouts: expired stale checkout sessions'); + } + return count; +} + // --------------------------------------------------------------------------- // Source-based flow handlers (C1 / AUDIT-2026-07-17) // diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..2b0f141 --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "crons": [ + { + "path": "/api/cron/expire-checkouts", + "schedule": "0 * * * *" + } + ] +}