diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47397cf..5ad45b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -125,6 +125,10 @@ jobs: env: DATABASE_URL: postgresql://test:test@localhost:5432/amph_v2_e2e JWT_SECRET: test-secret-min-32-characters-long-aaaaaaaa + # The seed fails loudly if these are unset (C5 hardening). CI-only test + # credentials, never used outside this ephemeral Postgres. + ADMIN_EMAIL: admin@ci.local + ADMIN_PASSWORD: ci-test-password-not-secret steps: - uses: actions/checkout@v7 @@ -183,6 +187,10 @@ jobs: env: DATABASE_URL: postgresql://test:test@localhost:5432/amph_v2_lhci JWT_SECRET: test-secret-min-32-characters-long-aaaaaaaa + # The seed fails loudly if these are unset (C5 hardening). CI-only test + # credentials, never used outside this ephemeral Postgres. + ADMIN_EMAIL: admin@ci.local + ADMIN_PASSWORD: ci-test-password-not-secret steps: - uses: actions/checkout@v7 diff --git a/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql new file mode 100644 index 0000000..a0c1e67 --- /dev/null +++ b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql @@ -0,0 +1,107 @@ +-- Audit remediation (2026-07-17): integrity + security fixes on main. +-- Covers: XP ledger (C10), guest-claim token (C5/C6), unique refund id (C8), +-- one-active-refund-per-payment (C7), one-active-certificate-per-course (H7), +-- and canonical (lowercase) email backfill (H6). +-- +-- This is a single hand-assembled migration reconciling PR #33's +-- 20260717000001_claim_token and PR #34's 20260717000000_audit_integrity_fixes +-- migrations, which both independently added `User.claimTokenHash` / +-- `User.claimTokenExpiresAt` and would collide (duplicate-column error) if +-- stacked directly on top of one another. + +-- ---------------------------------------------------------------------------- +-- C5/C6: guest-checkout account-claim token columns on User. +-- ---------------------------------------------------------------------------- +ALTER TABLE "User" ADD COLUMN "claimTokenHash" TEXT; +ALTER TABLE "User" ADD COLUMN "claimTokenExpiresAt" TIMESTAMP(3); + +CREATE INDEX "User_claimTokenHash_idx" ON "User"("claimTokenHash"); + +-- ---------------------------------------------------------------------------- +-- C10: XP ledger. Unique (userId, eventKey) makes every award exactly-once. +-- ---------------------------------------------------------------------------- +CREATE TABLE "XpLedger" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventKey" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "XpLedger_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "XpLedger_userId_eventKey_key" ON "XpLedger" ("userId", "eventKey"); +CREATE INDEX "XpLedger_userId_idx" ON "XpLedger" ("userId"); +CREATE INDEX "XpLedger_createdAt_idx" ON "XpLedger" ("createdAt"); + +ALTER TABLE "XpLedger" ADD CONSTRAINT "XpLedger_userId_fkey" + FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ---------------------------------------------------------------------------- +-- C8: a PayMongo refund id may be recorded at most once. +-- ---------------------------------------------------------------------------- +CREATE UNIQUE INDEX "RefundRequest_paymongoRefundId_key" + ON "RefundRequest" ("paymongoRefundId"); + +-- ---------------------------------------------------------------------------- +-- C7: at most one active (PENDING or APPROVED) refund request per payment. +-- Partial unique index — cannot be expressed in schema.prisma, so it is +-- managed by raw SQL here. Concurrent duplicate requests now fail with P2002. +-- ---------------------------------------------------------------------------- +CREATE UNIQUE INDEX "RefundRequest_active_per_payment_key" + ON "RefundRequest" ("paymentId") + WHERE "deletedAt" IS NULL AND "status" IN ('PENDING', 'APPROVED'); + +-- ---------------------------------------------------------------------------- +-- H7: at most one active certificate per (user, course). +-- ---------------------------------------------------------------------------- +CREATE UNIQUE INDEX "Certificate_active_per_user_course_key" + ON "Certificate" ("userId", "courseId") + WHERE "deletedAt" IS NULL AND "status" = 'ACTIVE'; + +-- ---------------------------------------------------------------------------- +-- H6: backfill existing emails to canonical (trimmed, lowercase) form. +-- Rows whose canonical form would collide with another account are left +-- untouched, and need manual reconciliation before a case-insensitive +-- uniqueness constraint (citext / normalized column) can be enforced. +-- +-- The collision guard compares canonical-to-CANONICAL, not canonical-to-exact. +-- Two rows that are both non-canonical but share a canonical form (e.g. +-- 'Foo@Bar.com' and 'FOO@bar.com') would otherwise BOTH satisfy an exact-match +-- guard and both UPDATE to the same value in one statement (WHERE is evaluated +-- against the MVCC snapshot), violating the case-sensitive unique email index +-- and failing the migration. Canonical-to-canonical skips every member of a +-- colliding group instead. +-- ---------------------------------------------------------------------------- +UPDATE "User" u +SET email = lower(btrim(u.email)) +WHERE u.email <> lower(btrim(u.email)) + AND NOT EXISTS ( + SELECT 1 FROM "User" o + WHERE o.id <> u.id + AND lower(btrim(o.email)) = lower(btrim(u.email)) + ); + +-- Surface the rows we deliberately skipped (their canonical form collides with +-- another account) so they don't vanish silently: canonicalized lookups +-- (findOrCreateUserByEmail, signUpAction) query by lowercase and can't find a +-- still-mixed-case row. These need manual reconciliation. Emitted as a WARNING +-- so it shows in migration/deploy logs; run scripts/report-email-collisions.ts +-- afterwards to list them again on demand. +DO $$ +DECLARE + skipped_count INTEGER; +BEGIN + SELECT count(*) INTO skipped_count + FROM "User" u + WHERE u.email <> lower(btrim(u.email)) + AND EXISTS ( + SELECT 1 FROM "User" o + WHERE o.id <> u.id + AND lower(btrim(o.email)) = lower(btrim(u.email)) + ); + IF skipped_count > 0 THEN + RAISE WARNING 'H6 email canonicalization: % account(s) left un-normalized due to a case-insensitive collision. Run scripts/report-email-collisions.ts and reconcile manually.', skipped_count; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51c6e30..6932c34 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,12 @@ model User { image String? passwordHash String? // null for OAuth-only + // Guest-checkout account claiming. A guest purchase creates a placeholder + // user; the buyer claims it by presenting the raw claim token (only its + // hash is stored). Consumed atomically at signup, then both fields null. + claimTokenHash String? + claimTokenExpiresAt DateTime? + // Gamification role String @default("STUDENT") xp Int @default(0) @@ -59,6 +65,7 @@ model User { reviewedRefunds RefundRequest[] @relation("RefundReviewer") payments Payment[] checkoutSessions CheckoutSession[] + xpLedger XpLedger[] authoredCourses Course[] @relation("CourseAuthor") authoredLessons Lesson[] @relation("LessonAuthor") authoredBadges Badge[] @relation("BadgeAuthor") @@ -71,6 +78,7 @@ model User { @@index([status]) @@index([role]) @@index([lastActiveAt]) + @@index([claimTokenHash]) } model Account { @@ -736,7 +744,7 @@ model RefundRequest { reviewedById String? reviewedAt DateTime? reviewerNotes String? - paymongoRefundId String? + paymongoRefundId String? @unique processedAt DateTime? failedAt DateTime? failureReason String? @@ -777,6 +785,33 @@ model ProcessedWebhook { @@index([processedAt]) } +// ============================================================================ +// XP LEDGER (idempotent gamification awards) +// ============================================================================ + +// Every XP award is recorded here with a unique (userId, eventKey). The +// User.xp increment and the ledger insert happen in ONE transaction: the +// insert's unique constraint is what makes an award exactly-once under +// concurrency. A duplicate completion (two racing requests, a webhook replay) +// hits P2002 on insert, the transaction rolls back, and no XP is granted twice. +// +// eventKey convention: ":" — e.g. +// lesson-complete:, quiz-pass:, tool-pass:. +model XpLedger { + id String @id @default(cuid()) + userId String + eventKey String + amount Int + reason String + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([userId, eventKey]) + @@index([userId]) + @@index([createdAt]) +} + // ============================================================================ // INVOICES (BIR-compliant sequential numbering for receipts) // ============================================================================ diff --git a/prisma/seed.ts b/prisma/seed.ts index 69316fc..89f13ab 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -34,8 +34,29 @@ function hashPassword(password: string): string { } async function upsertAdminUser(): Promise { - const email = process.env.ADMIN_EMAIL ?? '[email protected]'; - const password = process.env.ADMIN_PASSWORD ?? 'ChangeMe123!'; + // C5: fail loudly when ADMIN_EMAIL or ADMIN_PASSWORD is missing instead of + // silently seeding a well-known ([email protected] / ChangeMe123!) admin + // account — that fallback shipping to production would be a standing + // account-takeover hole. + const isProduction = process.env.NODE_ENV === 'production'; + // Canonicalize to match the app's trim().toLowerCase() contract, so a + // mixed-case ADMIN_EMAIL still matches canonicalized sign-in lookups. + const rawEmail = (process.env.ADMIN_EMAIL ?? '').trim().toLowerCase(); + const rawPassword = process.env.ADMIN_PASSWORD ?? ''; + if (isProduction && (!rawEmail || !rawPassword.trim())) { + throw new Error( + 'ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required in production. ' + + 'Set them in .env.local or your deployment environment.', + ); + } + if (!rawEmail) { + throw new Error('ADMIN_EMAIL must not be empty.'); + } + if (!rawPassword.trim()) { + throw new Error('ADMIN_PASSWORD must not be empty or whitespace-only.'); + } + const email = rawEmail; + const password = rawPassword; await prisma.user.upsert({ where: { email }, @@ -382,9 +403,8 @@ async function main(): Promise { await grandfatherFreeEnrollment(); console.log('\nSeed complete.'); - console.log(`\nSign in with: ${process.env.ADMIN_EMAIL ?? '[email protected]'}`); - console.log(`Default password: ${process.env.ADMIN_PASSWORD ?? 'ChangeMe123!'}`); - console.log('(Change the password after first sign-in.)'); + console.log(`\nSign in with the admin email you configured (ADMIN_EMAIL): ${process.env.ADMIN_EMAIL ?? '(unset)'}`); + console.log('Use the ADMIN_PASSWORD you set. Change it after first sign-in.'); } main() diff --git a/scripts/report-email-collisions.ts b/scripts/report-email-collisions.ts new file mode 100644 index 0000000..0ba315d --- /dev/null +++ b/scripts/report-email-collisions.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env tsx +/** + * Report User rows whose email is not yet canonical (trimmed + lowercase) + * because canonicalizing it would collide with another account's email. + * + * The H6 backfill migration (20260718000000_audit_integrity_fixes) skips these + * rows on purpose - normalizing them would violate the case-sensitive unique + * email constraint. Canonicalized lookups (findOrCreateUserByEmail, + * signUpAction) query by lowercase, so a still-mixed-case row is effectively + * unreachable until an operator reconciles the duplicate accounts by hand. + * + * This script lists them so the reconciliation can actually happen. + * + * Usage: + * DATABASE_URL="postgres://..." tsx scripts/report-email-collisions.ts + */ +import { PrismaClient } from '@prisma/client'; +import { PrismaPg } from '@prisma/adapter-pg'; + +if (!process.env.DATABASE_URL) { + throw new Error('DATABASE_URL environment variable is not set.'); +} +const prisma = new PrismaClient({ adapter: new PrismaPg(process.env.DATABASE_URL) }); + +// Raw email addresses are PII; a plain run may land in CI/cron/aggregated logs +// (CWE-532). Mask by default and require an explicit opt-in to print full +// addresses for the actual reconciliation. The opaque user id is always shown +// (it's the key an operator acts on) and is not itself PII. +const SHOW_RAW_EMAILS = process.env.SHOW_EMAILS === '1' || process.env.SHOW_EMAILS === 'true'; + +/** Mask the local part, keep the domain: 'Maria@Example.com' -> 'M***@example.com'. */ +function maskEmail(email: string): string { + const at = email.lastIndexOf('@'); + if (at <= 0) return '***'; + const first = email[0] ?? ''; + return `${first}***${email.slice(at)}`; +} + +async function main() { + // Group by canonical (lowercased) email and report every member of any group + // with more than one row. This catches collisions where BOTH rows are + // non-canonical (e.g. 'Foo@Bar.com' + 'FOO@bar.com') and share a canonical + // form, which an exact-value EXISTS lookup would miss - exactly the rows the + // H6 migration leaves un-normalized. + const rows = await prisma.$queryRaw< + Array<{ id: string; email: string; canonical: string }> + >` + SELECT u.id, u.email, lower(btrim(u.email)) AS canonical + FROM "User" u + WHERE lower(btrim(u.email)) IN ( + SELECT lower(btrim(email)) + FROM "User" + GROUP BY lower(btrim(email)) + HAVING COUNT(*) > 1 + ) + ORDER BY canonical, u.email + `; + + if (rows.length === 0) { + console.log('No email-canonicalization collisions - every canonical email is unique.'); + return; + } + + console.log(`Found ${rows.length} account(s) in ${new Set(rows.map((r) => r.canonical)).size} colliding group(s), needing manual reconciliation:\n`); + for (const r of rows) { + const email = SHOW_RAW_EMAILS ? r.email : maskEmail(r.email); + const canonical = SHOW_RAW_EMAILS ? r.canonical : maskEmail(r.canonical); + console.log(` ${r.id} ${email} -> canonical "${canonical}"`); + } + if (!SHOW_RAW_EMAILS) { + console.log('\n(Emails masked. Re-run with SHOW_EMAILS=1 to print full addresses for reconciliation.)'); + } + console.log( + '\nResolve each group by merging or renaming the duplicate accounts, then re-run the H6 UPDATE.', + ); +} + +main() + .catch((err) => { + console.error(err); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx b/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx index 807f117..3640169 100644 --- a/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx +++ b/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx @@ -9,6 +9,7 @@ import { evaluateCourseCompletion, issueCertificate, } from '@/lib/certificates'; +import { logger } from '@/lib/logger'; import { IssueButton } from './IssueButton'; import styles from './certificate.module.css'; @@ -70,10 +71,21 @@ export default async function CertificatePage({ params }: PageProps) { // Auto-issue on page visit if eligible. This way the user sees their existing // cert immediately on the final lesson completion without a manual click. + // Issuance is best-effort here: a transient failure (or a lost issuance race) + // must not crash the whole page to the error boundary. We fall back to showing + // progress, and the manual "Issue" button remains as a retry. let verificationHash: string | null = activeCert?.verificationHash ?? null; if (!verificationHash && completion.isComplete) { - const issued = await issueCertificate(user.id, course.id); - verificationHash = issued?.verificationHash ?? null; + try { + const issued = await issueCertificate(user.id, course.id); + verificationHash = issued?.verificationHash ?? null; + } catch (err) { + logger.error( + { err, userId: user.id, courseId: course.id }, + 'Certificate auto-issue failed; showing progress fallback', + ); + verificationHash = null; + } } const issuedDateFmt = new Intl.DateTimeFormat('en-PH', { diff --git a/src/app/(public)/auth/signin/SignInForm.tsx b/src/app/(public)/auth/signin/SignInForm.tsx index bc9af56..17c3165 100644 --- a/src/app/(public)/auth/signin/SignInForm.tsx +++ b/src/app/(public)/auth/signin/SignInForm.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { Button, Input } from '@/components/ui'; import { Icon } from '@/components/ui/Icon'; import { signInAction, signUpAction } from '@/app/actions/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { Toast } from '@/components/ui/Toast'; import styles from './auth.module.css'; @@ -27,7 +28,7 @@ export function SignInForm({ password: formData.get('password'), }); if (result.success) { - const target = result.data.role === 'ADMIN' ? '/admin' : (redirectTo || '/'); + const target = result.data.role === 'ADMIN' ? '/admin' : validateRedirectUrl(redirectTo); router.push(target); router.refresh(); toast('Signed in', 'success'); diff --git a/src/app/(public)/auth/signin/page.tsx b/src/app/(public)/auth/signin/page.tsx index 64894ce..6a5f74f 100644 --- a/src/app/(public)/auth/signin/page.tsx +++ b/src/app/(public)/auth/signin/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { ToastProvider } from '@/components/ui/Toast'; import { SignInForm } from './SignInForm'; import styles from './auth.module.css'; @@ -23,6 +24,8 @@ export default async function SignInPage({ searchParams }: PageProps) { const params = await searchParams; const error = params.error ? decodeURIComponent(params.error) : null; + // C3: validate the redirect target before it can ever reach router.push(). + const safeRedirect = validateRedirectUrl(params.redirect); return (
@@ -31,7 +34,7 @@ export default async function SignInPage({ searchParams }: PageProps) {

Sign in

Welcome back. Sign in to continue learning.

- +

New here? Create an account diff --git a/src/app/(public)/auth/signup/SignUpForm.tsx b/src/app/(public)/auth/signup/SignUpForm.tsx index d99e2a1..d72a23c 100644 --- a/src/app/(public)/auth/signup/SignUpForm.tsx +++ b/src/app/(public)/auth/signup/SignUpForm.tsx @@ -11,9 +11,15 @@ interface SignUpFormProps { error: string | null; prefilledEmail?: string; nextUrl?: string; + claimToken?: string; } -export function SignUpForm({ error: initialError, prefilledEmail = '', nextUrl = '/' }: SignUpFormProps) { +export function SignUpForm({ + error: initialError, + prefilledEmail = '', + nextUrl = '/', + claimToken, +}: SignUpFormProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(initialError); @@ -32,6 +38,7 @@ export function SignUpForm({ error: initialError, prefilledEmail = '', nextUrl = password, confirmPassword, name: (formData.get('name') as string) || undefined, + claimToken: (formData.get('claimToken') as string) || undefined, }); if (result.success) { // STORY-027: respect the `next` param so guest checkout returns to @@ -47,6 +54,7 @@ export function SignUpForm({ error: initialError, prefilledEmail = '', nextUrl = return (

startTransition(() => handleSubmit(formData))} className={styles.form}> {error &&
{error}
} + {claimToken && } diff --git a/src/app/(public)/auth/signup/page.tsx b/src/app/(public)/auth/signup/page.tsx index 612421d..f27611c 100644 --- a/src/app/(public)/auth/signup/page.tsx +++ b/src/app/(public)/auth/signup/page.tsx @@ -1,6 +1,7 @@ import Link from 'next/link'; import { redirect } from 'next/navigation'; import { getSession } from '@/lib/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { ToastProvider } from '@/components/ui/Toast'; import { SignUpForm } from './SignUpForm'; import styles from '../signin/auth.module.css'; @@ -12,7 +13,10 @@ export const metadata = { interface PageProps { // STORY-027: guest checkout completion forwards the payer's email and a // `next` return URL so the form prefills and redirects back to checkout. - searchParams: Promise<{ error?: string; email?: string; next?: string }>; + // `claim` carries the single-use account-claim token minted for a guest + // purchase (see sendAccountClaimEmail) — required to upgrade a placeholder + // account instead of hitting "email already exists" (C5/C6). + searchParams: Promise<{ error?: string; email?: string; next?: string; claim?: string }>; } export default async function SignUpPage({ searchParams }: PageProps) { @@ -25,7 +29,10 @@ export default async function SignUpPage({ searchParams }: PageProps) { const params = await searchParams; const error = params.error ? decodeURIComponent(params.error) : null; const prefilledEmail = params.email ?? ''; - const nextUrl = params.next ?? '/'; + // C3: `next` is attacker-controlled query input — validate before it can + // ever reach a redirect() or router.push(). + const nextUrl = validateRedirectUrl(params.next); + const claimToken = params.claim ?? undefined; return (
@@ -36,7 +43,12 @@ export default async function SignUpPage({ searchParams }: PageProps) { Start with the free tools. Upgrade when you're ready for the full curriculum.

- +

Already have an account? Sign in diff --git a/src/app/(public)/checkout/complete/page.tsx b/src/app/(public)/checkout/complete/page.tsx index a5b1548..20a9f76 100644 --- a/src/app/(public)/checkout/complete/page.tsx +++ b/src/app/(public)/checkout/complete/page.tsx @@ -11,17 +11,17 @@ */ import Link from 'next/link'; -import { redirect } from 'next/navigation'; import { Card, CardHeader, CardTitle, CardDescription, Button, Badge } from '@/components/ui'; import { Icon } from '@/components/ui/Icon'; import { db } from '@/lib/db'; import { getSession } from '@/lib/auth'; +import { validateRedirectUrl } from '@/lib/redirect-url'; import { CheckoutStatus } from '@/lib/enums'; import { BRAND_NAME } from '@/lib/brand'; import styles from './complete.module.css'; interface PageProps { - searchParams: Promise<{ status?: string; returnUrl?: string; id?: string }>; + searchParams: Promise<{ status?: string; returnUrl?: string; id?: string; checkout_id?: string }>; } export const metadata = { @@ -29,18 +29,13 @@ export const metadata = { robots: { index: false }, }; -async function resolveCheckout( - checkoutSessionId: string | undefined, - paymongoReference: string | undefined, -) { - if (!checkoutSessionId && !paymongoReference) return null; +async function resolveCheckout(checkoutSessionId: string | undefined) { + // Look up strictly by the CheckoutSession id carried on the redirect URL + // (`checkout_id`/`id`). PayMongo doesn't hand this page a source reference, + // so there is no source-id fallback to attempt. + if (!checkoutSessionId) return null; return db.checkoutSession.findFirst({ - where: { - OR: [ - checkoutSessionId ? { id: checkoutSessionId } : { id: '__never__' }, - paymongoReference ? { paymongoSourceId: paymongoReference } : { paymongoSourceId: '__never__' }, - ], - }, + where: { id: checkoutSessionId }, include: { pricingTier: { select: { name: true, slug: true } }, }, @@ -48,12 +43,18 @@ async function resolveCheckout( } export default async function CheckoutCompletePage({ searchParams }: PageProps) { - const { status, returnUrl: rawReturnUrl, id } = await searchParams; - // Defense in depth against open redirects: only same-app relative paths - // survive ("/foo" yes, "//evil.example" and "https://…" no). - const returnUrl = - rawReturnUrl?.startsWith('/') && !rawReturnUrl.startsWith('//') ? rawReturnUrl : undefined; - const checkout = await resolveCheckout(id, returnUrl); + const { status, returnUrl: rawReturnUrl, id, checkout_id } = await searchParams; + // H1: support the explicit checkout_id query param (set by + // createCheckoutSessionAction on the PayMongo redirect URL) in addition to + // the legacy `id` param, so this page reliably finds the session instead of + // depending solely on webhook timing. + const checkoutSessionId = checkout_id || id || undefined; + // C3: validateRedirectUrl replaces the ad hoc same-origin check. It rejects + // '//evil.example', 'javascript:', encoded schemes, etc., not just a bare + // '//' prefix. Use an empty-string fallback so missing/invalid input becomes + // undefined (not the default '/'), letting FailedCard fall back to /pricing. + const returnUrl = validateRedirectUrl(rawReturnUrl, '') || undefined; + const checkout = await resolveCheckout(checkoutSessionId); if (status === 'failed') { return ; @@ -64,17 +65,13 @@ export default async function CheckoutCompletePage({ searchParams }: PageProps) } if (checkout.status === CheckoutStatus.PAID) { - // STORY-027: guest checkout completion. - // If the payer isn't signed in, force them through /auth/signup to - // claim their placeholder account. Email is forwarded so the form - // prefills. After signup, they're redirected back here and signed in, - // so the next render falls through to SuccessCard below. + // Guest checkout completion. The account can only be claimed with the + // single-use token we email to the buyer (C5/C6) — the raw token is never + // available to this page — so we point guests at their inbox rather than + // a tokenless signup that would be rejected. const session = await getSession(); if (!session) { - const params = new URLSearchParams({ email: checkout.email }); - if (returnUrl) params.set('next', `/checkout/complete?returnUrl=${encodeURIComponent(returnUrl)}`); - else params.set('next', '/checkout/complete'); - redirect(`/auth/signup?${params.toString()}`); + return ; } return ; } @@ -112,6 +109,42 @@ function SuccessCard({ tierName }: { tierName: string }) { ); } +// Show only enough of the address to recognize it. This page is reachable +// with just a checkout id in the URL, so it must not print the full email. +function maskEmail(email: string): string { + const at = email.lastIndexOf('@'); + if (at <= 0) return '***'; + const local = email.slice(0, at); + const domain = email.slice(at); + const first = local[0] ?? ''; + return `${first}***${domain}`; +} + +function ClaimCard({ email, tierName }: { email: string; tierName: string }) { + return ( +

+ + + + Payment received + + Check your email to finish. + + Your {tierName} enrollment is active. We sent a secure link to{' '} + {maskEmail(email)}. Use it to set a password and + access your account. The link expires in 7 days. + + +
+ + + +
+
+
+ ); +} + function PendingCard({ checkoutId }: { checkoutId?: string } = {}) { return (
diff --git a/src/app/actions/__tests__/auth-actions.test.ts b/src/app/actions/__tests__/auth-actions.test.ts index 7ddc87b..766ee9b 100644 --- a/src/app/actions/__tests__/auth-actions.test.ts +++ b/src/app/actions/__tests__/auth-actions.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { signInAction, signUpAction, signOutAction } from '@/app/actions/auth'; +import { PLACEHOLDER_PASSWORD_PREFIX } from '@/lib/claim-token'; const mockSignToken = vi.fn(); const mockSetAuthCookie = vi.fn(); @@ -18,7 +19,7 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/db', () => ({ db: { - user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() }, + user: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, })); @@ -77,6 +78,62 @@ describe('auth actions', () => { expect((result as any).data.userId).toBe('u2'); }); + // C6: guest-checkout placeholder accounts can only be claimed with the + // single-use token emailed to the buyer. + const placeholderUser = { + id: 'u-guest', + email: 'guest@b.com', + name: 'Guest', + role: 'STUDENT', + status: 'ACTIVE', + passwordHash: 'placeholder_abc123', + emailVerified: null, + lastActiveAt: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + }; + + it('signUpAction refuses to claim a placeholder without a token', async () => { + (db.user.findUnique as unknown as ReturnType).mockResolvedValue(placeholderUser); + const result = await signUpAction({ email: 'guest@b.com', password: 'pass1234', confirmPassword: 'pass1234' }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toMatch(/claim link/i); + expect(db.user.updateMany).not.toHaveBeenCalled(); + }); + + it('signUpAction rejects an invalid or expired claim token', async () => { + (db.user.findUnique as unknown as ReturnType).mockResolvedValue(placeholderUser); + // Guarded update matches zero rows → token invalid/expired/already used. + (db.user.updateMany as unknown as ReturnType).mockResolvedValue({ count: 0 }); + const result = await signUpAction({ email: 'guest@b.com', password: 'pass1234', confirmPassword: 'pass1234', claimToken: 'wrong-token' }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toMatch(/invalid or has expired/i); + }); + + it('signUpAction claims a placeholder with a valid token (atomic consume)', async () => { + (db.user.findUnique as unknown as ReturnType).mockResolvedValue(placeholderUser); + (db.user.updateMany as unknown as ReturnType).mockResolvedValue({ count: 1 }); + const result = await signUpAction({ email: 'guest@b.com', password: 'pass1234', confirmPassword: 'pass1234', claimToken: 'right-token' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.userId).toBe('u-guest'); + // The consume is a guarded updateMany, not a blind update. Every predicate + // that protects against takeover/replay must be asserted so removing any of + // them fails this test: id, placeholder marker, token hash, and expiry. + expect(db.user.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: 'u-guest', + passwordHash: { startsWith: PLACEHOLDER_PASSWORD_PREFIX }, + claimTokenHash: expect.any(String), + claimTokenExpiresAt: expect.objectContaining({ gt: expect.any(Date) }), + }), + data: expect.objectContaining({ claimTokenHash: null, claimTokenExpiresAt: null }), + }), + ); + expect(db.user.update).not.toHaveBeenCalled(); + }); + it('signOutAction clears cookie and returns ok', async () => { const result = await signOutAction(); expect(result.success).toBe(true); diff --git a/src/app/actions/__tests__/tool-actions.test.ts b/src/app/actions/__tests__/tool-actions.test.ts index 9e1e4a2..5a60674 100644 --- a/src/app/actions/__tests__/tool-actions.test.ts +++ b/src/app/actions/__tests__/tool-actions.test.ts @@ -25,7 +25,7 @@ vi.mock('@/engine/keyword-research/scenarios', () => ({ vi.mock('@/lib/db', () => ({ db: { user: { findUnique: vi.fn(), update: vi.fn() }, - toolSession: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn() }, + toolSession: { create: vi.fn(), findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() }, }, })) @@ -72,6 +72,30 @@ describe('tool session actions', () => { if (!result.success) expect(result.error).toBe('Forbidden.'); }); + it('saveToolSession freezes edits once the session leaves IN_PROGRESS (atomic guard)', async () => { + (db.toolSession.findUnique as unknown as ReturnType).mockResolvedValue({ + id: 's1', userId: 'u1', status: 'IN_PROGRESS', scenarioId: 's1', toolType: 'CAMPAIGN_BUILDER', state: '{}', timeSpentSeconds: 0, createdAt: new Date(), updatedAt: new Date(), + }); + // A concurrent submit transitioned the session between the read and the + // guarded update, so the status-guarded updateMany matches zero rows. + (db.toolSession.updateMany as unknown as ReturnType).mockResolvedValue({ count: 0 }); + const result = await saveToolSession({ sessionId: 's1', state: { a: 1 } }); + expect(result.success).toBe(false); + if (!result.success) expect(result.error).toBe('This session has been submitted and can no longer be edited.'); + }); + + it('saveToolSession writes through the IN_PROGRESS-guarded update', async () => { + (db.toolSession.findUnique as unknown as ReturnType).mockResolvedValue({ + id: 's1', userId: 'u1', status: 'IN_PROGRESS', scenarioId: 's1', toolType: 'CAMPAIGN_BUILDER', state: '{}', timeSpentSeconds: 0, createdAt: new Date(), updatedAt: new Date(), + }); + (db.toolSession.updateMany as unknown as ReturnType).mockResolvedValue({ count: 1 }); + const result = await saveToolSession({ sessionId: 's1', state: { a: 1 } }); + expect(result.success).toBe(true); + expect(db.toolSession.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ status: 'IN_PROGRESS' }) }), + ); + }); + it('loadToolSession returns null for missing session', async () => { (db.toolSession.findUnique as unknown as ReturnType).mockResolvedValue(null); expect(await loadToolSession('missing')).toBeNull(); diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index 3d49c2e..2f10075 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -16,6 +16,10 @@ import { } from '@/lib/auth'; import { logger } from '@/lib/logger'; import { rateLimit } from '@/lib/rate-limit'; +import { + hashClaimToken, + PLACEHOLDER_PASSWORD_PREFIX, +} from '@/lib/claim-token'; import { createSafeAction, signUpSchema, @@ -37,32 +41,59 @@ export const signUpAction = createSafeAction(signUpSchema, async (data) => { // Two sign-up cases: // 1. New email — create a fresh STUDENT user. - // 2. Existing email with `placeholder_` passwordHash — guest checkout - // created this account; the user is now claiming it by setting a real - // password. Upgrade in place: replace hash, set name, mark verified. + // 2. Existing email with a `placeholder_` passwordHash — guest checkout + // created this account. The buyer may claim it ONLY by presenting the + // single-use claim token that was emailed to that address (C5/C6). + // Merely knowing the email is not enough — that was an account-takeover + // hole. The token is consumed atomically. // 3. Existing email with a real passwordHash — email is taken. if (existing) { const isPlaceholder = - existing.passwordHash && existing.passwordHash.startsWith('placeholder_'); + existing.passwordHash?.startsWith(PLACEHOLDER_PASSWORD_PREFIX) ?? false; if (!isPlaceholder) { throw new Error('An account with that email already exists.'); } - const upgraded = await db.user.update({ - where: { id: existing.id }, + if (!data.claimToken) { + throw new Error( + 'To finish your account, use the claim link we emailed you after checkout.', + ); + } + + const newHash = await hashPassword(data.password); + // Atomic consume: the update only matches while the account is still an + // unclaimed placeholder AND the token hash + expiry are valid. Two + // concurrent claims both compute the same guard, but only one UPDATE + // matches a still-placeholder row — the other affects zero rows. This + // closes the "both requests set a different password" race (C6). + const consumed = await db.user.updateMany({ + where: { + id: existing.id, + passwordHash: { startsWith: PLACEHOLDER_PASSWORD_PREFIX }, + claimTokenHash: hashClaimToken(data.claimToken), + claimTokenExpiresAt: { gt: new Date() }, + }, data: { name: data.name ?? existing.name, - passwordHash: await hashPassword(data.password), + passwordHash: newHash, emailVerified: new Date(), + claimTokenHash: null, + claimTokenExpiresAt: null, }, }); + if (consumed.count !== 1) { + throw new Error( + 'This claim link is invalid or has expired. Request a new one from support.', + ); + } + const token = await signToken({ - sub: upgraded.id, - email: upgraded.email, - role: upgraded.role, - name: upgraded.name, + sub: existing.id, + email: existing.email, + role: existing.role, + name: data.name ?? existing.name, }); await setAuthCookie(token); - return { userId: upgraded.id }; + return { userId: existing.id }; } const user = await db.user.create({ @@ -156,7 +187,9 @@ export async function signUpFormAction(formData: FormData): Promise { const result = await signUpAction({ email: formData.get('email'), password: formData.get('password'), + confirmPassword: formData.get('confirmPassword') || formData.get('password'), name: formData.get('name') || undefined, + claimToken: formData.get('claimToken') || undefined, }); if (result.success) { diff --git a/src/app/actions/checkout.ts b/src/app/actions/checkout.ts index c8063e8..e00343f 100644 --- a/src/app/actions/checkout.ts +++ b/src/app/actions/checkout.ts @@ -17,6 +17,12 @@ * - Success/failure pages at /checkout/success and /checkout/failed * * Sprint 8 adds the refund action here. + * + * H1 (AUDIT-2026-07-17): the CheckoutSession is created FIRST (before the + * PayMongo Source) so its id can be embedded as a `checkout_id` query param + * on the PayMongo redirect URL. /checkout/complete uses that param to + * reliably locate the session when the user returns from PayMongo, instead + * of relying solely on webhook timing. */ 'use server'; @@ -37,6 +43,8 @@ import { import { getSession } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; import { createSafeAction, type ActionResult } from '@/lib/validation'; +import { CheckoutStatus } from '@/lib/enums'; +import { logger } from '@/lib/logger'; import { z } from 'zod'; // --------------------------------------------------------------------------- @@ -45,7 +53,9 @@ import { z } from 'zod'; const checkoutSchema = z.object({ pricingTierId: z.string().min(1), - email: z.string().email(), + // H6: canonicalize the buyer's email so the placeholder user, checkout row, + // and later sign-in all key off the same lowercase value. + email: z.string().trim().toLowerCase().email(), name: z.string().max(100).optional(), discountCode: z.string().max(50).optional(), // Relative in-app paths only — an absolute URL here is an open-redirect @@ -150,22 +160,46 @@ export async function createCheckoutSessionAction( const finalAmountCentavos = amountCentavos - discountAmountCentavos; const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours - // Create source with PayMongo + // Base redirect URL the user lands on after PayMongo. Built before we know + // the source id — the checkout_id param is added once we have it below. const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3000'; - const redirectUrl = new URL('/checkout/complete', appUrl); - if (returnUrl) redirectUrl.searchParams.set('returnUrl', returnUrl); + const baseRedirectUrl = new URL('/checkout/complete', appUrl); + if (returnUrl) baseRedirectUrl.searchParams.set('returnUrl', returnUrl); + + // Step 1: create the CheckoutSession FIRST (H1), with no PayMongo source id + // yet (the column is nullable — leaving it unset avoids two concurrent + // checkouts colliding on a shared placeholder value under its @unique + // constraint). This gives us checkoutSessionId for the redirect URL. + const { checkoutSessionId } = await createCheckoutSessionAtomic({ + userId: session?.id ?? null, + email, + pricingTierId, + amountCentavos, + discountCodeId, + discountAmountCentavos, + finalAmountCentavos, + redirectUrl: baseRedirectUrl.toString(), + expiresAt, + }); + + // Step 2: embed the checkout session id in the PayMongo redirect URL so + // /checkout/complete can reliably locate the session (H1). + const paymongoRedirectUrl = new URL(baseRedirectUrl.toString()); + paymongoRedirectUrl.searchParams.set('checkout_id', checkoutSessionId); + // Step 3: create the PayMongo Source with the enriched redirect URL. const sourceInput: CreateSourceInput = { amountCentavos: finalAmountCentavos, type: 'gcash', // default; PayMongo page lets user choose GCash/Maya/Card/etc email, - redirectUrl: redirectUrl.toString(), + redirectUrl: paymongoRedirectUrl.toString(), metadata: { pricingTierId, tierSlug: tier.slug, tierName: tier.name, userId: session?.id ?? 'guest', userName: formName ?? session?.name ?? '', + checkoutSessionId, }, }; @@ -173,24 +207,29 @@ export async function createCheckoutSessionAction( try { source = await createSource(sourceInput); } catch (err) { + // The CheckoutSession row was created first (H1) but its PayMongo Source + // never came into being - mark it ERROR so it isn't left dangling as a + // PENDING row forever (there is no cron/TTL sweep to reclaim it). + await db.checkoutSession + .update({ + where: { id: checkoutSessionId }, + data: { status: CheckoutStatus.ERROR, failedAt: new Date(), failureReason: 'source-creation-failed' }, + }) + .catch((cleanupErr) => + logger.error({ cleanupErr, checkoutSessionId }, 'Failed to mark checkout session ERROR after source failure'), + ); if (err instanceof PayMongoError) { return { success: false as const, error: `Payment error: ${err.message}` }; } return { success: false as const, error: 'Unable to initiate payment. Please try again.' }; } - // Atomically create CheckoutSession + increment discount usage if any - const { checkoutSessionId } = await createCheckoutSessionAtomic({ - userId: session?.id ?? null, - email, - pricingTierId, - amountCentavos, - discountCodeId, - discountAmountCentavos, - finalAmountCentavos, - paymongoSourceId: source.id, - redirectUrl: redirectUrl.toString(), - expiresAt, + // Step 4: attach the real PayMongo source id to the CheckoutSession now + // that we have it — this is what the source.chargeable/payment.paid + // webhook handlers look the session up by. + await db.checkoutSession.update({ + where: { id: checkoutSessionId }, + data: { paymongoSourceId: source.id }, }); return { diff --git a/src/app/actions/progress.ts b/src/app/actions/progress.ts index 422be1f..8863df1 100644 --- a/src/app/actions/progress.ts +++ b/src/app/actions/progress.ts @@ -15,6 +15,11 @@ import { createSafeAction, type ActionResult } from '@/lib/validation'; import { ProgressStatus, AttemptStatus } from '@/lib/enums'; import { evaluateCourseAccess } from '@/lib/tier-gate'; import { evaluateBadges } from '@/lib/badges'; +import { awardXpOnce } from '@/lib/xp'; +import { isUniqueConstraintError } from '@/lib/prisma-errors'; + +/** Bonus XP granted for passing a quiz, on top of the lesson's completion XP. */ +const QUIZ_PASS_BONUS_XP = 50; const slugsSchema = z.object({ courseSlug: z.string().min(1), @@ -73,7 +78,10 @@ export const markLessonCompleteAction = createSafeAction(slugsSchema, async (dat const lesson = await db.lesson.findUnique({ where: { slug: data.lessonSlug }, - include: { module: { include: { course: true } } }, + include: { + module: { include: { course: true } }, + quiz: { select: { isPublished: true } }, + }, }); if (!lesson || lesson.module.course.slug !== data.courseSlug) { @@ -86,33 +94,44 @@ export const markLessonCompleteAction = createSafeAction(slugsSchema, async (dat throw new Error('Your current tier does not include this lesson. Upgrade to continue.'); } - // Mark lesson complete + award XP - await db.$transaction(async (tx) => { - await tx.lessonProgress.upsert({ - where: { - userId_lessonId: { userId: user.id, lessonId: lesson.id }, - }, - update: { - status: ProgressStatus.COMPLETED, - completedAt: new Date(), - xpEarned: lesson.xpReward, - }, - create: { - userId: user.id, - lessonId: lesson.id, - status: ProgressStatus.COMPLETED, - completedAt: new Date(), - xpEarned: lesson.xpReward, - }, - }); + // H1: a lesson with a published quiz can only be completed by passing that + // quiz (submitQuizAction). Manual completion here would let students earn + // completion — and certificate eligibility — without demonstrating mastery. + if (lesson.quiz?.isPublished) { + throw new Error('Complete this lesson by passing its quiz.'); + } - await tx.user.update({ - where: { id: user.id }, - data: { - xp: { increment: lesson.xpReward }, - lastActiveAt: new Date(), - }, - }); + // Award XP exactly once (C10), THEN mark the lesson complete. These can't + // share one transaction: awardXpOnce relies on its ledger's unique-index + // insert failing (P2002) to enforce exactly-once, and a P2002 inside an outer + // transaction would abort the whole thing - including the progress write - + // breaking idempotent re-completion. Awarding first means any XP failure + // leaves the lesson un-completed and cleanly retryable, rather than + // "completed but no XP." A crash in the gap self-heals: the lesson still + // shows incomplete, and re-completing no-ops the (already-granted) XP. + await awardXpOnce( + user.id, + `lesson-complete:${lesson.id}`, + lesson.xpReward, + 'Lesson completed', + ); + + await db.lessonProgress.upsert({ + where: { + userId_lessonId: { userId: user.id, lessonId: lesson.id }, + }, + update: { + status: ProgressStatus.COMPLETED, + completedAt: new Date(), + xpEarned: lesson.xpReward, + }, + create: { + userId: user.id, + lessonId: lesson.id, + status: ProgressStatus.COMPLETED, + completedAt: new Date(), + xpEarned: lesson.xpReward, + }, }); revalidatePath(`/dashboard/courses/${data.courseSlug}`); @@ -149,6 +168,11 @@ export const submitQuizAction = createSafeAction(submitQuizSchema, async (data) if (!lesson || !lesson.quiz || lesson.module.course.slug !== data.courseSlug) { throw new Error('Quiz not found.'); } + // A draft (unpublished) quiz must not grant completion or XP even if a + // direct action call reaches here. Treat it as not found. + if (!lesson.quiz.isPublished) { + throw new Error('Quiz not found.'); + } // Tier gate — deny if user lacks the required enrollment. const gate = await evaluateCourseAccess(user.id, data.courseSlug); @@ -166,56 +190,58 @@ export const submitQuizAction = createSafeAction(submitQuizSchema, async (data) const score = Math.round((correctCount / totalQuestions) * 100); const passed = score >= lesson.quiz.passThreshold; - // Get the next attempt number - const priorAttempts = await db.quizAttempt.count({ - where: { userId: user.id, quizId: lesson.quiz.id }, - }); - - // Persist attempt - await db.quizAttempt.create({ - data: { - userId: user.id, - quizId: lesson.quiz.id, - attemptNumber: priorAttempts + 1, - status: passed ? AttemptStatus.GRADED : AttemptStatus.SUBMITTED, - answers: JSON.stringify(data.answers), - score, - correctCount, - totalQuestions, - xpEarned: passed ? 50 : 0, // bonus XP for passing - timeSpentSeconds: data.timeSpentSeconds ?? 0, - completedAt: new Date(), - }, + // Persist attempt with a concurrency-safe attempt number. `count + 1` + // collides under concurrent submissions (both read the same count); the + // unique index on (userId, quizId, attemptNumber) rejects the loser, so we + // retry with the next number instead of 500-ing (C10). + await createQuizAttempt({ + userId: user.id, + quizId: lesson.quiz.id, + status: passed ? AttemptStatus.GRADED : AttemptStatus.SUBMITTED, + answers: JSON.stringify(data.answers), + score, + correctCount, + totalQuestions, + xpEarned: passed ? QUIZ_PASS_BONUS_XP : 0, + timeSpentSeconds: data.timeSpentSeconds ?? 0, }); - // If passed, mark lesson complete and award XP + // If passed, award XP exactly once THEN mark the lesson complete. Completion + // XP and the pass bonus are separate ledger events so neither can be + // double-granted by a re-submit or a concurrent pass (C10). XP is awarded + // before the progress write for the same reason as markLessonCompleteAction: + // a shared transaction is incompatible with the ledger's P2002-based + // exactly-once gate, and awarding first keeps any failure cleanly retryable. if (passed) { - await db.$transaction(async (tx) => { - await tx.lessonProgress.upsert({ - where: { - userId_lessonId: { userId: user.id, lessonId: lesson.id }, - }, - update: { - status: ProgressStatus.COMPLETED, - completedAt: new Date(), - xpEarned: lesson.xpReward + 50, - }, - create: { - userId: user.id, - lessonId: lesson.id, - status: ProgressStatus.COMPLETED, - completedAt: new Date(), - xpEarned: lesson.xpReward + 50, - }, - }); - - await tx.user.update({ - where: { id: user.id }, - data: { - xp: { increment: lesson.xpReward + 50 }, - lastActiveAt: new Date(), - }, - }); + await awardXpOnce( + user.id, + `lesson-complete:${lesson.id}`, + lesson.xpReward, + 'Lesson completed', + ); + await awardXpOnce( + user.id, + `quiz-pass:${lesson.quiz.id}`, + QUIZ_PASS_BONUS_XP, + 'Quiz passed', + ); + + await db.lessonProgress.upsert({ + where: { + userId_lessonId: { userId: user.id, lessonId: lesson.id }, + }, + update: { + status: ProgressStatus.COMPLETED, + completedAt: new Date(), + xpEarned: lesson.xpReward + QUIZ_PASS_BONUS_XP, + }, + create: { + userId: user.id, + lessonId: lesson.id, + status: ProgressStatus.COMPLETED, + completedAt: new Date(), + xpEarned: lesson.xpReward + QUIZ_PASS_BONUS_XP, + }, }); } @@ -235,4 +261,61 @@ export const submitQuizAction = createSafeAction(submitQuizSchema, async (data) totalQuestions, passThreshold: lesson.quiz.passThreshold, }; -}); \ No newline at end of file +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface QuizAttemptInput { + userId: string; + quizId: string; + status: string; + answers: string; + score: number; + correctCount: number; + totalQuestions: number; + xpEarned: number; + timeSpentSeconds: number; +} + +/** + * Insert a QuizAttempt, allocating the next attempt number safely under + * concurrency. Two simultaneous submissions both compute the same + * `count + 1`; the unique (userId, quizId, attemptNumber) index rejects the + * loser with P2002, and we retry with the next number rather than failing. + */ +async function createQuizAttempt(input: QuizAttemptInput): Promise { + const priorAttempts = await db.quizAttempt.count({ + where: { userId: input.userId, quizId: input.quizId }, + }); + + let attemptNumber = priorAttempts + 1; + for (let tries = 0; tries < 8; tries++) { + try { + await db.quizAttempt.create({ + data: { + userId: input.userId, + quizId: input.quizId, + attemptNumber, + status: input.status, + answers: input.answers, + score: input.score, + correctCount: input.correctCount, + totalQuestions: input.totalQuestions, + xpEarned: input.xpEarned, + timeSpentSeconds: input.timeSpentSeconds, + completedAt: new Date(), + }, + }); + return; + } catch (e) { + if (isUniqueConstraintError(e)) { + attemptNumber++; + continue; + } + throw e; + } + } + throw new Error('Could not record quiz attempt. Please try again.'); +} \ No newline at end of file diff --git a/src/app/actions/refunds.ts b/src/app/actions/refunds.ts index 68b7458..cb2bced 100644 --- a/src/app/actions/refunds.ts +++ b/src/app/actions/refunds.ts @@ -34,6 +34,27 @@ import { PAYMONGO_REFUND_REASON, } from '@/lib/refunds'; import { sendRefundStatusEmail } from '@/lib/email'; +import { logger } from '@/lib/logger'; +import { isUniqueConstraintError } from '@/lib/prisma-errors'; +import { auditLog } from '@/lib/admin-audit'; + +/** + * Best-effort AuditLog for a refund admin action. The state transition has + * already been committed by the time we log it, so a logging failure must not + * break the action or misreport a refund that actually moved money - we log + * the failure and move on (AGENTS.md: every admin action logs to AuditLog). + */ +async function auditRefundAction( + action: string, + requestId: string, + metadata: Record, +): Promise { + try { + await auditLog({ action, entityType: 'RefundRequest', entityId: requestId, metadata }); + } catch (err) { + logger.error({ err, action, requestId }, 'Failed to write refund AuditLog entry'); + } +} // --------------------------------------------------------------------------- // Student: create refund request @@ -92,16 +113,27 @@ export const createRefundRequestAction = createSafeAction< throw new Error('This payment has already been fully refunded.'); } - const request = await db.refundRequest.create({ - data: { - userId: user.id, - paymentId: payment.id, - reason: data.reason.trim(), - amountPhp: refundAmountPhp, - status: RefundStatus.PENDING, - }, - select: { id: true }, - }); + // The pre-check above catches the common case; the partial unique index + // (one active request per payment) is the concurrency backstop — two + // simultaneous requests both pass the read, but only one insert survives. + let request: { id: string }; + try { + request = await db.refundRequest.create({ + data: { + userId: user.id, + paymentId: payment.id, + reason: data.reason.trim(), + amountPhp: refundAmountPhp, + status: RefundStatus.PENDING, + }, + select: { id: true }, + }); + } catch (e) { + if (isUniqueConstraintError(e)) { + throw new Error('A refund request for this payment is already in progress.'); + } + throw e; + } revalidatePath('/dashboard/payments'); revalidatePath('/admin/refunds'); @@ -208,8 +240,11 @@ export async function approveRefundAction( return { success: false, error: 'Request is no longer pending.' }; } - // Call PayMongo. If this throws, mark the request FAILED. - let paymongoRefundId: string | null = null; + // State 1 — provider call. A throw HERE means PayMongo did not accept the + // refund, so it is safe to mark the request FAILED. This is the only place + // FAILED is set; a DB failure after provider success must never land here + // (audit C9). + let paymongoRefundId: string; try { const refund = await refundPayment({ paymentId: request.payment.paymongoPaymentId, @@ -218,34 +253,6 @@ export async function approveRefundAction( metadata: { refundRequestId: request.id }, }); paymongoRefundId = refund.id; - - // Mark PROCESSED. The webhook will update Payment + Enrollment; this - // is a best-effort synchronous update so the admin sees immediate - // feedback. Webhook will idempotently fill in the rest. - await db.refundRequest.updateMany({ - where: { id: request.id, status: RefundStatus.APPROVED }, - data: { - status: RefundStatus.PROCESSED, - paymongoRefundId: refund.id, - processedAt: new Date(), - }, - }); - - // Also update Payment.status optimistically. If webhook beats us, - // it will see status=REFUNDED and skip its own update. - const newPaymentStatus = - request.amountPhp >= request.payment.amountPhp - ? PaymentStatus.REFUNDED - : PaymentStatus.PARTIALLY_REFUNDED; - await db.payment.update({ - where: { id: request.payment.id }, - data: { - status: newPaymentStatus, - refundedAt: new Date(), - refundAmountPhp: request.amountPhp, - refundReason: PAYMONGO_REFUND_REASON, - }, - }); } catch (err: unknown) { const message = err instanceof PayMongoError @@ -253,19 +260,110 @@ export async function approveRefundAction( : err instanceof Error ? err.message : 'Unknown error'; - await db.refundRequest.update({ - where: { id: request.id }, - data: { - status: RefundStatus.FAILED, - failedAt: new Date(), - failureReason: message.slice(0, 500), - }, + const statusCode = err instanceof PayMongoError ? err.statusCode : null; + // Only an explicit 4xx rejection means no money moved — safe to mark + // FAILED. A timeout, connection reset, or 5xx is ambiguous: the refund may + // have succeeded, so marking it FAILED would misreport a possible refund. + const isExplicitRejection = statusCode !== null && statusCode >= 400 && statusCode < 500; + + if (isExplicitRejection) { + await db.refundRequest.updateMany({ + where: { id: request.id, status: RefundStatus.APPROVED }, + data: { + status: RefundStatus.FAILED, + failedAt: new Date(), + failureReason: message.slice(0, 500), + }, + }); + await auditRefundAction('APPROVE_REFUND_FAILED', request.id, { + amountPhp: request.amountPhp, + statusCode, + reason: message.slice(0, 200), + }); + revalidatePath('/admin/refunds'); + revalidatePath(`/admin/refunds/${request.id}`); + return { success: false, error: `Refund API call failed: ${message}` }; + } + + // Ambiguous outcome: leave the request APPROVED (not FAILED, not + // re-approvable) so it can't be re-issued, and let the payment.refunded + // webhook or a human reconcile it (C9). + logger.error( + { err, requestId: request.id, statusCode }, + 'Refund outcome unknown; left APPROVED for reconciliation', + ); + await auditRefundAction('APPROVE_REFUND_AMBIGUOUS', request.id, { + amountPhp: request.amountPhp, + statusCode, }); revalidatePath('/admin/refunds'); revalidatePath(`/admin/refunds/${request.id}`); - return { success: false, error: `Refund API call failed: ${message}` }; + return { + success: false, + error: + 'Refund status is unknown (network error). It has been left for automatic reconciliation. Do not retry.', + }; } + // State 2 — provider refund ACCEPTED. Reconcile local records. A failure + // here does NOT undo the refund and must NOT be reported as a provider + // failure or re-trigger a refund (the request is already APPROVED, so a + // retry can't re-call PayMongo). The webhook is the backstop that finishes + // reconciliation. + try { + await db.$transaction(async (tx) => { + // Mark this request PROCESSED with its provider refund id (unique — + // C8). Once marked, it counts toward the cumulative refunded amount. + await tx.refundRequest.updateMany({ + where: { id: request.id, status: RefundStatus.APPROVED }, + data: { + status: RefundStatus.PROCESSED, + paymongoRefundId, + processedAt: new Date(), + }, + }); + + // Single-writer cumulative amount: sum of PROCESSED requests (includes + // the one just marked, read within this transaction). The webhook + // derives the same value, so the refund is counted exactly once. + const refundedTotal = await alreadyRefundedAmountPhp(request.payment.id, tx); + const fullyRefunded = refundedTotal >= request.payment.amountPhp; + await tx.payment.update({ + where: { id: request.payment.id }, + data: { + status: fullyRefunded + ? PaymentStatus.REFUNDED + : PaymentStatus.PARTIALLY_REFUNDED, + refundedAt: new Date(), + refundAmountPhp: refundedTotal, + refundReason: PAYMONGO_REFUND_REASON, + }, + }); + }); + } catch (err) { + logger.error( + { err, requestId: request.id, paymongoRefundId }, + 'Refund succeeded at PayMongo but local reconciliation failed; webhook will reconcile', + ); + await auditRefundAction('APPROVE_REFUND_RECONCILING', request.id, { + amountPhp: request.amountPhp, + paymongoRefundId, + }); + revalidatePath('/admin/refunds'); + revalidatePath(`/admin/refunds/${request.id}`); + // State 2 is NOT a failure — the money left PayMongo. Report it honestly. + return { + success: true, + data: { requestId: request.id, status: 'PROCESSING', paymongoRefundId }, + }; + } + + await auditRefundAction('APPROVE_REFUND', request.id, { + amountPhp: request.amountPhp, + paymongoRefundId, + status: 'PROCESSED', + }); + revalidatePath('/admin/refunds'); revalidatePath(`/admin/refunds/${request.id}`); revalidatePath('/dashboard/payments'); @@ -324,6 +422,10 @@ export async function rejectRefundAction( return { success: false, error: 'Request is no longer pending.' }; } + await auditRefundAction('REJECT_REFUND', requestId, { + reviewerNotes: reviewerNotes.trim().slice(0, 200), + }); + const refundRequest = await db.refundRequest.findUnique({ where: { id: requestId }, select: { diff --git a/src/app/actions/tools.ts b/src/app/actions/tools.ts index b6ec73f..6a3b024 100644 --- a/src/app/actions/tools.ts +++ b/src/app/actions/tools.ts @@ -18,6 +18,18 @@ import { requireAuth } from '@/lib/auth'; import { createSafeAction } from '@/lib/validation'; import { type ToolType } from '@/lib/enums'; import { evaluateBadges } from '@/lib/badges'; +import { awardXpOnce } from '@/lib/xp'; +import { isUniqueConstraintError } from '@/lib/prisma-errors'; + +/** XP granted for a passing tool submission. */ +const TOOL_PASS_XP = 30; + +interface ToolGrade { + totalScore: number; + passed: boolean; + criteriaResults: unknown[]; + overallFeedback: string; +} import { gradeCampaignDraft } from '@/engine/campaign-builder/engine'; import { getScenarioById as getCbScenario } from '@/engine/campaign-builder/scenarios'; @@ -81,13 +93,21 @@ export const saveToolSession = createSafeAction(saveSessionSchema, async (data) if (!session) throw new Error('Session not found.'); if (session.userId !== user.id) throw new Error('Forbidden.'); - await db.toolSession.update({ - where: { id: data.sessionId }, + // H3: once a session is submitted/graded its stored state is frozen, so it + // stays consistent with the recorded grade. The status guard lives in the + // updateMany where-clause so the check and the write are one atomic + // operation: a submit that lands between a plain read and update can no + // longer let a late save overwrite the graded state. + const saved = await db.toolSession.updateMany({ + where: { id: data.sessionId, userId: user.id, status: 'IN_PROGRESS' }, data: { state: JSON.stringify(data.state), timeSpentSeconds: data.timeSpentSeconds ?? session.timeSpentSeconds, }, }); + if (saved.count !== 1) { + throw new Error('This session has been submitted and can no longer be edited.'); + } return { savedAt: new Date().toISOString() }; }); @@ -107,69 +127,78 @@ export const submitToolSession = createSafeAction(submitSessionSchema, async (da const session = await db.toolSession.findUnique({ where: { id: data.sessionId } }); if (!session) throw new Error('Session not found.'); if (session.userId !== user.id) throw new Error('Forbidden.'); - if (session.status !== 'IN_PROGRESS') throw new Error('Session already submitted.'); const scenarioId = session.scenarioId; if (!scenarioId) throw new Error('No scenario associated with this session.'); - let grade: { totalScore: number; passed: boolean; criteriaResults: unknown[]; overallFeedback: string }; + // The request that transitions the session out of IN_PROGRESS is the single + // writer of its terminal status, state, score, and (on a pass) XP. Any other + // request reconciles from the recorded terminal state instead. This keeps + // the grade, the stored state, and the XP consistent under concurrency and + // makes submit safely retriable after a mid-grade crash (H3). + let grade: ToolGrade; + let xpAwarded = 0; - if (session.toolType === 'CAMPAIGN_BUILDER') { - const scenario = getCbScenario(scenarioId); - if (!scenario) throw new Error('Scenario not found.'); - const state = data.state as CampaignBuilderSessionState; - grade = gradeCampaignDraft(state.draft, scenario); - } else if (session.toolType === 'BID_ELEVATOR') { - const scenario = getBeScenario(scenarioId); - if (!scenario) throw new Error('Scenario not found.'); - const state = data.state as BidElevatorSessionState; - grade = gradeBidDecisions(scenario, state.decisions); - } else if (session.toolType === 'STR_TRIAGE') { - const scenario = getStrScenario(scenarioId); - if (!scenario) throw new Error('Scenario not found.'); - const state = data.state as StrTriageSessionState; - grade = gradeStrDecisions(scenario, state.decisions); - } else if (session.toolType === 'LISTING_AUDIT') { - const scenario = getLaScenario(scenarioId); - if (!scenario) throw new Error('Scenario not found.'); - const state = data.state as ListingAuditSessionState; - grade = gradeListingAudit(scenario, state.findings, state.revisedListing); - } else if (session.toolType === 'KEYWORD_RESEARCH') { - const scenario = getKrScenario(scenarioId); - if (!scenario) throw new Error('Scenario not found.'); - const state = data.state as KeywordResearchSessionState; - grade = gradeKeywordResearch(scenario, state.decisions, state.negatives); - } else { - throw new Error(`Unknown tool type: ${session.toolType}`); - } + if (session.status === 'IN_PROGRESS') { + // Grade BEFORE any write — a bad scenario or malformed state throws here + // and leaves the session IN_PROGRESS and retriable. + grade = gradeToolSession(session.toolType, scenarioId, data.state); + const passed = grade.passed; - const updated = await db.toolSession.update({ - where: { id: data.sessionId }, - data: { - state: JSON.stringify(data.state), - status: grade.passed ? 'GRADED' : 'SUBMITTED', - score: grade.totalScore, - submittedAt: new Date(), - timeSpentSeconds: data.timeSpentSeconds ?? session.timeSpentSeconds, - }, - }); + let wonTransition = false; + try { + wonTransition = await db.$transaction(async (tx) => { + const claim = await tx.toolSession.updateMany({ + where: { id: data.sessionId, userId: user.id, status: 'IN_PROGRESS' }, + data: { + state: JSON.stringify(data.state), + status: passed ? 'GRADED' : 'SUBMITTED', + score: grade.totalScore, + submittedAt: new Date(), + timeSpentSeconds: data.timeSpentSeconds ?? session.timeSpentSeconds, + }, + }); + // Lost the race: a concurrent submit already made the session + // terminal. Award nothing and reconcile from the recorded state below. + if (claim.count !== 1) return false; + + if (passed) { + // Only the winner writes XP, and only for a recorded pass. The + // ledger's unique key keeps it exactly-once even against a replay. + await tx.xpLedger.create({ + data: { + userId: user.id, + eventKey: `tool-pass:${data.sessionId}`, + amount: TOOL_PASS_XP, + reason: 'Tool practice passed', + }, + }); + await tx.user.update({ + where: { id: user.id }, + data: { xp: { increment: TOOL_PASS_XP }, lastActiveAt: new Date() }, + }); + } else { + await tx.user.update({ + where: { id: user.id }, + data: { lastActiveAt: new Date() }, + }); + } + return true; + }); + } catch (e) { + if (!isUniqueConstraintError(e)) throw e; + wonTransition = false; + } - // Award XP only on passed submissions — failing a tool is still practice, not - // progress. Bump lastActiveAt so the streak counter can move forward on the - // next login attempt. - if (grade.passed) { - await db.user.update({ - where: { id: user.id }, - data: { - xp: { increment: 30 }, - lastActiveAt: new Date(), - }, - }); + if (wonTransition) { + xpAwarded = passed ? TOOL_PASS_XP : 0; + } else { + // Reconcile from whatever the winner recorded. + ({ grade, xpAwarded } = await reconcileTerminalSession(data.sessionId, user.id, scenarioId)); + } } else { - await db.user.update({ - where: { id: user.id }, - data: { lastActiveAt: new Date() }, - }); + // Session was already terminal on read — reconcile, never re-mutate. + ({ grade, xpAwarded } = await reconcileTerminalSession(data.sessionId, user.id, scenarioId)); } // Badge trigger fires only on a passing submission — the only criteria that @@ -183,16 +212,101 @@ export const submitToolSession = createSafeAction(submitSessionSchema, async (da : { awarded: [], totalXpGained: 0 }; return { - sessionId: updated.id, + sessionId: data.sessionId, totalScore: grade.totalScore, passed: grade.passed, criteriaResults: grade.criteriaResults as Array<{ criterionId: string; passed: boolean; score: number; feedback: string }>, overallFeedback: grade.overallFeedback, newlyAwardedBadges: badgeResult.awarded, - xpAwarded: grade.passed ? 30 : 0, + xpAwarded, }; }); +/** + * Grade a session from its RECORDED terminal state and reconcile XP. Used when + * this request did not win the status transition (session already terminal, or + * a concurrent submit won the race). The response reflects what was stored, and + * XP is (idempotently) ensured only when the recorded status is GRADED. + */ +async function reconcileTerminalSession( + sessionId: string, + userId: string, + scenarioId: string, +): Promise<{ grade: ToolGrade; xpAwarded: number }> { + const terminal = await db.toolSession.findUnique({ where: { id: sessionId } }); + if (!terminal) throw new Error('Session not found.'); + const grade = gradeToolSession(terminal.toolType, scenarioId, parseState(terminal.state)); + + let xpAwarded = 0; + if (terminal.status === 'GRADED') { + // awardXpOnce is idempotent: it grants only if the winner hasn't already. + const granted = await awardXpOnce( + userId, + `tool-pass:${sessionId}`, + TOOL_PASS_XP, + 'Tool practice passed', + ); + xpAwarded = granted ? TOOL_PASS_XP : 0; + } + return { grade, xpAwarded }; +} + +/** + * Parse a stored session-state JSON blob. Returns `{}` on malformed JSON so + * grading fails closed (zero score) rather than throwing on a corrupt row. + */ +function parseState(raw: string): Record { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +/** + * Pure grading dispatch. Selects the scenario + engine for the tool type and + * returns the grade. No database writes — callers own persistence so grading + * can be repeated safely. + */ +function gradeToolSession( + toolType: string, + scenarioId: string, + state: Record, +): ToolGrade { + if (toolType === 'CAMPAIGN_BUILDER') { + const scenario = getCbScenario(scenarioId); + if (!scenario) throw new Error('Scenario not found.'); + const s = state as unknown as CampaignBuilderSessionState; + return gradeCampaignDraft(s.draft, scenario); + } + if (toolType === 'BID_ELEVATOR') { + const scenario = getBeScenario(scenarioId); + if (!scenario) throw new Error('Scenario not found.'); + const s = state as unknown as BidElevatorSessionState; + return gradeBidDecisions(scenario, s.decisions); + } + if (toolType === 'STR_TRIAGE') { + const scenario = getStrScenario(scenarioId); + if (!scenario) throw new Error('Scenario not found.'); + const s = state as unknown as StrTriageSessionState; + return gradeStrDecisions(scenario, s.decisions); + } + if (toolType === 'LISTING_AUDIT') { + const scenario = getLaScenario(scenarioId); + if (!scenario) throw new Error('Scenario not found.'); + const s = state as unknown as ListingAuditSessionState; + return gradeListingAudit(scenario, s.findings, s.revisedListing); + } + if (toolType === 'KEYWORD_RESEARCH') { + const scenario = getKrScenario(scenarioId); + if (!scenario) throw new Error('Scenario not found.'); + const s = state as unknown as KeywordResearchSessionState; + return gradeKeywordResearch(scenario, s.decisions, s.negatives); + } + throw new Error(`Unknown tool type: ${toolType}`); +} + // --------------------------------------------------------------------------- // Helper: load a session's state for resume // --------------------------------------------------------------------------- diff --git a/src/app/api/paymongo/webhook/route.ts b/src/app/api/paymongo/webhook/route.ts index 8a8c62a..ef15056 100644 --- a/src/app/api/paymongo/webhook/route.ts +++ b/src/app/api/paymongo/webhook/route.ts @@ -7,21 +7,30 @@ * Sprint 11 — STORY-049: structured logger replaces console.* * * PayMongo sends six event types we care about: - * - checkout_session.payment.paid - * - checkout_session.payment.failed - * - source.chargeable (used when we create a Source manually) - * - payment.paid + * - checkout_session.payment.paid (unused — see C1 note below) + * - 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.refunded * + * C1 (AUDIT-2026-07-17): checkout.ts creates a PayMongo Source, not a + * Checkout Session (the PayMongo SDK has no `checkoutSessions` resource — + * see src/lib/paymongo.ts). A Source-based purchase never fires + * `checkout_session.payment.*` — it fires `source.chargeable` then + * `payment.paid`. Previously those two events were logged and dropped here, + * which meant no real purchase could ever complete. They are now wired to + * handleSourceChargeable/handlePaymentPaid in src/lib/enrollment.ts. + * * Flow: * 1. Read raw body BEFORE anything else — JSON parsing breaks HMAC. * 2. Verify signature with HMAC-SHA256(`t=${ts}.${body}`, webhook_secret). * 3. Branch on event type, defer to handler in src/lib/enrollment.ts. * 4. Each handler does its own idempotency check via ProcessedWebhook. - * 5. Always ACK 200 — even on handler error, after logging. PayMongo - * retries on non-2xx, and we don't want a partially-handled event - * to multiply. + * 5. Return 500 on handler errors so PayMongo retries. The ProcessedWebhook + * idempotency row is created within the handler's transaction, so a + * failed handler rolls back cleanly and the retry starts fresh. + * 6. Return 200 only for successfully processed or already-processed events. * * Public surface (no auth): PayMongo needs to reach this URL without our * cookies. The signature header is the gate. @@ -37,9 +46,13 @@ import { handleCheckoutPaid, handleCheckoutFailed, handlePaymentRefunded, + handleSourceChargeable, + handlePaymentPaid, type CheckoutPaidEvent, type CheckoutFailedEvent, type PaymentRefundedEvent, + type SourceChargeableEvent, + type PaymentPaidEvent, } from '@/lib/enrollment'; import { log } from '@/lib/logger'; @@ -50,7 +63,9 @@ type PayMongoEvent = | { type: 'checkout_session.payment.paid'; data: CheckoutPaidEvent } | { type: 'checkout_session.payment.failed'; data: CheckoutFailedEvent } | { type: 'payment.refunded'; data: PaymentRefundedEvent } - | { type: 'source.chargeable' | 'payment.paid' | 'payment.failed' | string; data: { id?: string; attributes?: { type?: string } } }; + | { type: 'source.chargeable'; data: SourceChargeableEvent } + | { type: 'payment.paid'; data: PaymentPaidEvent } + | { type: 'payment.failed' | string; data: { id?: string; attributes?: { type?: string } } }; function pickEvent(body: string): PayMongoEvent | null { try { @@ -110,18 +125,24 @@ export async function POST(request: NextRequest): Promise { case 'payment.refunded': await handlePaymentRefunded(event.data as unknown as PaymentRefundedEvent); break; - // source.chargeable + payment.paid + payment.failed are reserved for - // the Source-based flow (legacy). Handled in STORY-027/Sprint 8 if we - // ship that path; for now we acknowledge and skip. + case 'source.chargeable': + await handleSourceChargeable(event.data as unknown as SourceChargeableEvent); + break; + case 'payment.paid': + await handlePaymentPaid(event.data as unknown as PaymentPaidEvent); + break; default: log.info({ component: 'paymongo-webhook', eventType: event.type }, 'unhandled event type'); } } catch (err) { - // Log but ack 200 — idempotent retry by PayMongo would otherwise - // 10x our handler error rate. Real alert comes from the ProcessedWebhook - // log + the absence of side-effects. + // Return 500 only for handler errors so PayMongo retries. The + // ProcessedWebhook idempotency key is created inside the handler's + // transaction — a failed handler rolls back cleanly, so retry is safe. + // If the event was already processed (idempotency check returned false), + // the handler returned early without error, so we never reach here. log.error({ component: 'paymongo-webhook', err, eventType: event.type }, 'handler error'); Sentry.captureException(err, { tags: { source: 'paymongo-webhook', eventType: event.type } }); + return new Response('Internal error', { status: 500 }); } return new Response('OK', { status: 200 }); diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts index a40de07..a16263a 100644 --- a/src/lib/__tests__/auth.test.ts +++ b/src/lib/__tests__/auth.test.ts @@ -226,7 +226,11 @@ describe('auth.ts', () => { sub: 'u1', email: 'admin@b.com', role: 'ADMIN', name: 'Admin', }); mockCookieStore.get.mockReturnValue({ name: 'amph_auth', value: token }); - mockDb.user.findUnique.mockResolvedValue({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }); + // H3: requireAdmin queries the DB twice — once inside getSession(), once + // to load the authoritative role (the JWT claim can be stale). + mockDb.user.findUnique + .mockResolvedValueOnce({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }) + .mockResolvedValueOnce({ role: 'ADMIN' }); const user = await requireAdmin(); expect(user.role).toBe('ADMIN'); @@ -237,7 +241,38 @@ describe('auth.ts', () => { sub: 'u1', email: 'a@b.com', role: 'STUDENT', name: 'A', }); mockCookieStore.get.mockReturnValue({ name: 'amph_auth', value: token }); - mockDb.user.findUnique.mockResolvedValue({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }); + mockDb.user.findUnique + .mockResolvedValueOnce({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }) + .mockResolvedValueOnce({ role: 'STUDENT' }); + + await expect(requireAdmin()).rejects.toThrow('NEXT_REDIRECT'); + }); + + it('requireAdmin redirects a STUDENT even when the stale JWT claims ADMIN (H3)', async () => { + // The JWT was issued while the user was an admin; they've since been + // demoted in the DB. requireAdmin must not trust the stale claim. + const token = await signToken({ + sub: 'u1', email: 'demoted@b.com', role: 'ADMIN', name: 'Demoted', + }); + mockCookieStore.get.mockReturnValue({ name: 'amph_auth', value: token }); + mockDb.user.findUnique + .mockResolvedValueOnce({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }) + .mockResolvedValueOnce({ role: 'STUDENT' }); + + await expect(requireAdmin()).rejects.toThrow('NEXT_REDIRECT'); + }); + + it('requireAdmin fails closed when the authoritative role lookup returns null', async () => { + // The row is gone from requireAdmin's own lookup (e.g. soft-deleted between + // requireAuth's read and this one - a narrow TOCTOU window). We must deny, + // not fall back to trusting the JWT's possibly-stale ADMIN claim. + const token = await signToken({ + sub: 'u1', email: 'ghost@b.com', role: 'ADMIN', name: 'Ghost', + }); + mockCookieStore.get.mockReturnValue({ name: 'amph_auth', value: token }); + mockDb.user.findUnique + .mockResolvedValueOnce({ xp: 0, level: 1, streakDays: 0, status: 'ACTIVE', deletedAt: null }) + .mockResolvedValueOnce(null); await expect(requireAdmin()).rejects.toThrow('NEXT_REDIRECT'); }); diff --git a/src/lib/__tests__/certificates.test.ts b/src/lib/__tests__/certificates.test.ts index 3c68150..d12f721 100644 --- a/src/lib/__tests__/certificates.test.ts +++ b/src/lib/__tests__/certificates.test.ts @@ -156,6 +156,30 @@ describe('certificates.ts', () => { }), ); }); + + it('returns the winner cert (not a crash) when the create loses the unique-index race (H7 P2002)', async () => { + mockCount.mockResolvedValue(10); + const { db } = await import('@/lib/db'); + (db.lessonProgress.count as unknown as ReturnType).mockResolvedValue(10); + // First findFirst: no active cert (pre-check). Second findFirst (after + // P2002): the row the racing writer committed. + mockFindFirst + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: 'cert-winner', + verificationHash: 'winner-hash', + courseId: 'c-1', + userId: 'u-1', + issuedAt: new Date('2026-07-15'), + }); + const p2002 = Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }); + mockCreate.mockRejectedValue(p2002); + + const result = await issueCertificate('u-1', 'c-1'); + expect(result).not.toBeNull(); + expect(result!.id).toBe('cert-winner'); + expect(result!.alreadyExisted).toBe(true); + }); }); describe('getCertificateByVerificationHash', () => { diff --git a/src/lib/__tests__/enrollment.test.ts b/src/lib/__tests__/enrollment.test.ts index 238961b..8d547ff 100644 --- a/src/lib/__tests__/enrollment.test.ts +++ b/src/lib/__tests__/enrollment.test.ts @@ -3,19 +3,25 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; const mockDb = vi.hoisted(() => { const fn = () => vi.fn(); return { - processedWebhook: { create: fn() }, + processedWebhook: { create: fn(), deleteMany: fn() }, user: { findUnique: fn(), create: fn() }, - checkoutSession: { findUnique: fn(), update: fn(), updateMany: fn() }, + checkoutSession: { findUnique: fn(), findFirst: fn(), update: fn(), updateMany: fn() }, payment: { create: fn(), findUnique: fn(), findFirst: fn(), update: fn() }, course: { findFirst: fn() }, enrollment: { create: fn(), findUnique: fn(), findFirst: fn(), update: fn() }, - discountCode: { update: fn() }, + discountCode: { update: fn(), updateMany: fn() }, + refundRequest: { findMany: fn() }, $transaction: vi.fn(), }; }); vi.mock('@/lib/db', () => ({ db: mockDb })); +const mockCreatePaymentFromSource = vi.fn(); +vi.mock('@/lib/paymongo', () => ({ + createPaymentFromSource: (...args: unknown[]) => mockCreatePaymentFromSource(...args), +})); + // Prisma is imported for the TransactionClient type only; stub the value import. vi.mock('@prisma/client', () => ({ Prisma: {}, PrismaClient: class {} })); @@ -23,29 +29,43 @@ const mockEnums = vi.hoisted(() => ({ CheckoutStatus: { PAID: 'PAID', FAILED: 'FAILED' }, 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' }, + PaymentStatus: { COMPLETED: 'COMPLETED', REFUNDED: 'REFUNDED', PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED' }, + RefundStatus: { PENDING: 'PENDING', APPROVED: 'APPROVED', REJECTED: 'REJECTED', PROCESSED: 'PROCESSED', FAILED: 'FAILED' }, })); vi.mock('@/lib/enums', () => mockEnums); vi.mock('@/lib/receipts', () => ({ issueInvoiceForPayment: vi.fn() })); -vi.mock('@/lib/email', () => ({ sendEnrollmentConfirmationEmail: vi.fn(() => Promise.resolve()) })); +vi.mock('@/lib/email', () => ({ + sendEnrollmentConfirmationEmail: vi.fn(() => Promise.resolve()), + sendAccountClaimEmail: vi.fn(() => Promise.resolve()), +})); vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, })); -vi.mock('node:crypto', () => ({ randomUUID: () => 'mock-uuid' })); +// claim-token.ts calls these directly (not mocked itself) — mocking node:crypto +// gives deterministic raw tokens/hashes without stubbing claim-token.ts's logic. +vi.mock('node:crypto', () => ({ + randomUUID: () => 'mock-uuid', + randomBytes: (n: number) => Buffer.alloc(n, 1), + createHash: () => ({ update: () => ({ digest: () => 'mock-hash' }) }), +})); import { issueInvoiceForPayment } from '@/lib/receipts'; -import { sendEnrollmentConfirmationEmail } from '@/lib/email'; +import { sendEnrollmentConfirmationEmail, sendAccountClaimEmail } from '@/lib/email'; import { markWebhookProcessed, findOrCreateUserByEmail, handleCheckoutPaid, handleCheckoutFailed, handlePaymentRefunded, + handleSourceChargeable, + handlePaymentPaid, type CheckoutPaidEvent, type CheckoutFailedEvent, type PaymentRefundedEvent, + type SourceChargeableEvent, + type PaymentPaidEvent, } from '@/lib/enrollment'; /** Error shaped like Prisma's unique-constraint violation. */ @@ -112,6 +132,53 @@ function makeRefundedEvent(): PaymentRefundedEvent { }; } +function makeSourceChargeableEvent(overrides: Record = {}): SourceChargeableEvent { + return { + data: { + id: 'evt-src-1', + attributes: { + type: 'source.chargeable', + data: { + id: 'src_test_001', + attributes: { + id: 'src_test_001', + amount: 299900, + currency: 'PHP', + status: 'chargeable', + type: 'gcash', + metadata: {}, + ...overrides, + }, + }, + }, + }, + }; +} + +function makePaymentPaidEvent(overrides: Record = {}): PaymentPaidEvent { + return { + data: { + id: 'evt-pp-1', + attributes: { + type: 'payment.paid', + data: { + id: 'pay_test_001', + attributes: { + id: 'pay_test_001', + amount: 299900, + currency: 'PHP', + status: 'paid', + paid_at: '2026-07-17T00:00:00.000Z', + source: { id: 'src_test_001', type: 'source' }, + metadata: {}, + ...overrides, + }, + }, + }, + }, + }; +} + const checkoutRow = { id: 'cs-1', email: 'juan@example.com', @@ -122,6 +189,18 @@ const checkoutRow = { pricingTier: { tier: 'PREMIUM', name: 'PPC Pro' }, }; +// Source-flow checkout rows carry `discountCode` (maxUses) alongside +// `discountCodeId`, per the SourceFlowCheckout select shape in enrollment.ts. +const sourceCheckoutRow = { + id: 'cs-src-1', + email: 'maria@example.com', + finalAmountPhp: 299900, + discountCodeId: null, + discountCode: null, + pricingTierId: 'pt-1', + pricingTier: { name: 'PPC Pro', tier: 'PREMIUM', slug: 'ppc-pro' }, +}; + /** Wire the happy-path mocks for handleCheckoutPaid (guest, new user). */ function wireHappyPath() { mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); @@ -139,6 +218,28 @@ function wireHappyPath() { mockDb.checkoutSession.update.mockResolvedValue({}); } +/** Wire the happy-path mocks for the Source-flow handlers (guest, new user). */ +function wireSourceHappyPath() { + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-src' }); + mockDb.checkoutSession.findFirst.mockResolvedValue(sourceCheckoutRow); + mockDb.payment.findUnique.mockResolvedValue(null); // not already processed + mockDb.user.create.mockResolvedValue({ id: 'u-guest' }); + mockDb.payment.create.mockResolvedValue({ id: 'pay-src-1' }); + mockDb.course.findFirst.mockResolvedValue({ id: 'c-1' }); + mockDb.enrollment.findUnique.mockResolvedValue(null); + mockDb.enrollment.create.mockResolvedValue({ id: 'enr-src-1' }); + mockDb.payment.findFirst.mockResolvedValue(null); + mockDb.payment.update.mockResolvedValue({}); + mockDb.checkoutSession.update.mockResolvedValue({}); + // First call (inside findOrCreateUserByEmail) misses -> placeholder created. + // Second call (post-commit, inside sendPostPurchaseEmails) returns that user. + mockDb.user.findUnique.mockResolvedValueOnce(null).mockResolvedValue({ + id: 'u-guest', + email: 'maria@example.com', + name: 'maria', + }); +} + describe('enrollment.ts', () => { beforeEach(() => { vi.clearAllMocks(); @@ -191,13 +292,13 @@ describe('enrollment.ts', () => { expect(result).toEqual({ id: 'u-1', isNew: false }); }); - it('creates placeholder user when not found', async () => { + it('creates placeholder user with claim token when not found', async () => { mockDb.user.findUnique.mockResolvedValue(null); mockDb.user.create.mockResolvedValue({ id: 'u-new' }); const result = await findOrCreateUserByEmail('new@example.com', 'New User'); - expect(result).toEqual({ id: 'u-new', isNew: true }); + expect(result).toEqual({ id: 'u-new', isNew: true, rawClaimToken: expect.any(String) }); expect(mockDb.user.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ @@ -205,6 +306,9 @@ describe('enrollment.ts', () => { name: 'New User', role: 'STUDENT', status: 'ACTIVE', + passwordHash: expect.stringMatching(/^placeholder_/), + claimTokenHash: expect.any(String), + claimTokenExpiresAt: expect.any(Date), }), }), ); @@ -222,6 +326,22 @@ describe('enrollment.ts', () => { }), ); }); + + it('canonicalizes (trims + lowercases) the email before lookup and create', async () => { + mockDb.user.findUnique.mockResolvedValue(null); + mockDb.user.create.mockResolvedValue({ id: 'u-new' }); + + await findOrCreateUserByEmail(' Student@Example.COM '); + + expect(mockDb.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { email: 'student@example.com' } }), + ); + expect(mockDb.user.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ email: 'student@example.com' }), + }), + ); + }); }); describe('handleCheckoutPaid', () => { @@ -254,6 +374,41 @@ describe('enrollment.ts', () => { }); }); + it('sends the account-claim email for a new guest user, with the raw token', async () => { + wireHappyPath(); + + await handleCheckoutPaid(makePaidEvent()); + + // Regression guard for the dead-code bug this task fixes: the raw claim + // token minted in findOrCreateUserByEmail must actually reach + // sendAccountClaimEmail, not just be generated and dropped. + expect(sendAccountClaimEmail).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'juan@example.com', + rawClaimToken: expect.any(String), + }), + ); + }); + + it('does not send a claim email for an existing (non-placeholder) user', async () => { + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); + mockDb.checkoutSession.findUnique.mockResolvedValue(checkoutRow); + mockDb.course.findFirst.mockResolvedValue({ id: 'c-1' }); + mockDb.user.findUnique + .mockResolvedValueOnce({ id: 'u-existing' }) // findOrCreateUserByEmail: found + .mockResolvedValueOnce({ id: 'u-existing', email: 'juan@example.com', name: 'Juan' }); + mockDb.payment.create.mockResolvedValue({ id: 'pay-1' }); + mockDb.enrollment.findUnique.mockResolvedValue(null); + mockDb.enrollment.create.mockResolvedValue({ id: 'enr-1' }); + mockDb.payment.findFirst.mockResolvedValue(null); + mockDb.payment.update.mockResolvedValue({}); + mockDb.checkoutSession.update.mockResolvedValue({}); + + await handleCheckoutPaid(makePaidEvent()); + + expect(sendAccountClaimEmail).not.toHaveBeenCalled(); + }); + it('returns null on duplicate webhook event', async () => { mockDb.processedWebhook.create.mockRejectedValue(p2002()); @@ -307,17 +462,45 @@ describe('enrollment.ts', () => { mockDb.checkoutSession.findUnique.mockResolvedValue({ ...checkoutRow, discountCodeId: 'dc-1', + discountCode: { maxUses: null }, }); - mockDb.discountCode.update.mockResolvedValue({}); + mockDb.discountCode.updateMany.mockResolvedValue({ count: 1 }); await handleCheckoutPaid(makePaidEvent()); - expect(mockDb.discountCode.update).toHaveBeenCalledWith({ + // H4: conditional atomic increment, not a blind update. + expect(mockDb.discountCode.updateMany).toHaveBeenCalledWith({ where: { id: 'dc-1' }, data: { currentUses: { increment: 1 } }, }); }); + it('throws (rolling back the transaction) when a maxUses-limited code has hit its limit', async () => { + // Set up mocks directly (not via wireHappyPath()) — this flow throws + // mid-transaction, before the post-commit db.user.findUnique lookup, so + // wireHappyPath()'s second queued mockResolvedValueOnce would never be + // consumed here and would leak into (and corrupt) a later test. + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); + mockDb.checkoutSession.findUnique.mockResolvedValue({ + ...checkoutRow, + discountCodeId: 'dc-1', + discountCode: { maxUses: 10 }, + }); + mockDb.course.findFirst.mockResolvedValue({ id: 'c-1' }); + mockDb.user.findUnique.mockResolvedValue(null); // no existing user -> placeholder path + mockDb.user.create.mockResolvedValue({ id: 'u-new' }); + // H4: updateMany's `currentUses < maxUses` guard matched zero rows. + mockDb.discountCode.updateMany.mockResolvedValue({ count: 0 }); + + await expect(handleCheckoutPaid(makePaidEvent())).rejects.toThrow( + 'Discount code usage limit reached.', + ); + expect(mockDb.discountCode.updateMany).toHaveBeenCalledWith({ + where: { id: 'dc-1', currentUses: { lt: 10 } }, + data: { currentUses: { increment: 1 } }, + }); + }); + it('does not fail when invoice issuance throws', async () => { (issueInvoiceForPayment as unknown as ReturnType).mockRejectedValue(new Error('Invoice failed')); wireHappyPath(); @@ -386,7 +569,10 @@ describe('enrollment.ts', () => { describe('handlePaymentRefunded', () => { it('updates payment and enrollment within the transaction', async () => { mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); - mockDb.payment.findUnique.mockResolvedValue({ id: 'pay-1' }); + // Full refund (amount === payment amount) with no prior PROCESSED + // requests → cumulative falls back to the event amount → fully refunded. + mockDb.payment.findUnique.mockResolvedValue({ id: 'pay-1', amountPhp: 299900 }); + mockDb.refundRequest.findMany.mockResolvedValue([]); mockDb.enrollment.findFirst.mockResolvedValue({ id: 'enr-1' }); mockDb.payment.update.mockResolvedValue({}); mockDb.enrollment.update.mockResolvedValue({}); @@ -396,7 +582,7 @@ describe('enrollment.ts', () => { expect(mockDb.payment.update).toHaveBeenCalledWith( expect.objectContaining({ where: { id: 'pay-1' }, - data: expect.objectContaining({ status: 'REFUNDED' }), + data: expect.objectContaining({ status: 'REFUNDED', refundAmountPhp: 299900 }), }), ); expect(mockDb.enrollment.update).toHaveBeenCalledWith( @@ -407,6 +593,36 @@ describe('enrollment.ts', () => { ); }); + it('marks PARTIALLY_REFUNDED and keeps enrollment active on a partial refund', async () => { + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); + mockDb.payment.findUnique.mockResolvedValue({ id: 'pay-1', amountPhp: 299900 }); + // No PROCESSED requests recorded yet — falls back to the webhook's + // (partial) amount, which is less than the payment total. + mockDb.refundRequest.findMany.mockResolvedValue([]); + mockDb.payment.update.mockResolvedValue({}); + + const partialEvent: PaymentRefundedEvent = { + data: { + id: 'evt-ref-2', + attributes: { + type: 'payment.refunded', + data: { + id: 'ref_002', + attributes: { id: 'ref_002', amount: 100000, payment_id: 'pm_pay_456', metadata: {} }, + }, + }, + }, + }; + await handlePaymentRefunded(partialEvent); + + expect(mockDb.payment.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: 'PARTIALLY_REFUNDED', refundAmountPhp: 100000 }), + }), + ); + expect(mockDb.enrollment.update).not.toHaveBeenCalled(); + }); + it('skips on duplicate event', async () => { mockDb.processedWebhook.create.mockRejectedValue(p2002()); @@ -424,4 +640,179 @@ describe('enrollment.ts', () => { expect(mockDb.payment.update).not.toHaveBeenCalled(); }); }); + + // --------------------------------------------------------------------------- + // Source-based flow (C1 / AUDIT-2026-07-17): checkout.ts creates a PayMongo + // Source, not a Checkout Session, so THESE handlers — not handleCheckoutPaid + // above — are what complete a real purchase. + // --------------------------------------------------------------------------- + + describe('handleSourceChargeable', () => { + it('creates a payment from the source and, once paid, fulfils the order', async () => { + wireSourceHappyPath(); + mockCreatePaymentFromSource.mockResolvedValue({ + id: 'pm_from_src_1', + status: 'paid', + amount: 299900, + paidAt: '2026-07-17T00:00:00.000Z', + }); + + const result = await handleSourceChargeable(makeSourceChargeableEvent()); + + expect(result).toEqual( + expect.objectContaining({ id: 'pm_from_src_1', status: 'paid' }), + ); + expect(mockCreatePaymentFromSource).toHaveBeenCalledWith( + expect.objectContaining({ amountCentavos: 299900, sourceId: 'src_test_001' }), + ); + expect(mockDb.payment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 'u-guest', paymongoPaymentId: 'pm_from_src_1' }), + }), + ); + expect(mockDb.enrollment.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ userId: 'u-guest', courseId: 'c-1', status: 'ACTIVE' }), + }), + ); + // Post-purchase emails fire AFTER the transaction commits (best-effort, + // not awaited inside it) - let the pending microtasks drain before + // asserting on them. + await new Promise((resolve) => setImmediate(resolve)); + // The dead-code bug this task fixes: a NEW guest user must get the + // account-claim email with the raw token, not just a log line. + expect(sendAccountClaimEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: 'maria@example.com', rawClaimToken: expect.any(String) }), + ); + expect(sendEnrollmentConfirmationEmail).toHaveBeenCalled(); + }); + + it('does not fulfil the order when the source is not yet paid', async () => { + wireSourceHappyPath(); + mockCreatePaymentFromSource.mockResolvedValue({ + id: 'pm_from_src_1', + status: 'pending', + amount: 299900, + paidAt: null, + }); + + await handleSourceChargeable(makeSourceChargeableEvent()); + + expect(mockDb.payment.create).not.toHaveBeenCalled(); + expect(sendAccountClaimEmail).not.toHaveBeenCalled(); + }); + + it('returns null when already processed (duplicate webhook)', async () => { + mockDb.processedWebhook.create.mockRejectedValue(p2002()); + + const result = await handleSourceChargeable(makeSourceChargeableEvent()); + + expect(result).toBeNull(); + expect(mockCreatePaymentFromSource).not.toHaveBeenCalled(); + }); + + it('returns null when no checkout session matches the source', async () => { + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); + mockDb.checkoutSession.findFirst.mockResolvedValue(null); + + const result = await handleSourceChargeable(makeSourceChargeableEvent()); + + expect(result).toBeNull(); + expect(mockCreatePaymentFromSource).not.toHaveBeenCalled(); + }); + + it('rejects (throws) on a non-PHP currency instead of silently creating a payment', async () => { + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); + mockDb.checkoutSession.findFirst.mockResolvedValue(sourceCheckoutRow); + + await expect( + handleSourceChargeable(makeSourceChargeableEvent({ currency: 'USD' })), + ).rejects.toThrow('Unexpected currency: USD'); + expect(mockCreatePaymentFromSource).not.toHaveBeenCalled(); + }); + + it('rejects (throws) an underpayment instead of granting full access (H2)', async () => { + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); + mockDb.checkoutSession.findFirst.mockResolvedValue(sourceCheckoutRow); // finalAmountPhp 299900 + + await expect( + handleSourceChargeable(makeSourceChargeableEvent({ amount: 100000 })), + ).rejects.toThrow('less than the expected checkout amount'); + expect(mockCreatePaymentFromSource).not.toHaveBeenCalled(); + }); + }); + + describe('handlePaymentPaid', () => { + it('fulfils the order by looking up the checkout session via source id', async () => { + wireSourceHappyPath(); + + const result = await handlePaymentPaid(makePaymentPaidEvent()); + + expect(result).toEqual( + expect.objectContaining({ enrollmentId: 'enr-src-1', paymentId: 'pay-src-1' }), + ); + expect(mockDb.checkoutSession.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ paymongoSourceId: 'src_test_001' }) }), + ); + expect(sendAccountClaimEmail).toHaveBeenCalledWith( + expect.objectContaining({ rawClaimToken: expect.any(String) }), + ); + }); + + it('falls back to paymongoPaymentId lookup when the source-id lookup misses', async () => { + wireSourceHappyPath(); + mockDb.checkoutSession.findFirst + .mockResolvedValueOnce(null) // source-id lookup misses + .mockResolvedValueOnce(sourceCheckoutRow); // paymongoPaymentId lookup hits + + const result = await handlePaymentPaid(makePaymentPaidEvent()); + + expect(result).toEqual( + expect.objectContaining({ enrollmentId: 'enr-src-1', paymentId: 'pay-src-1' }), + ); + expect(mockDb.checkoutSession.findFirst).toHaveBeenCalledTimes(2); + }); + + it('skips the source-id lookup entirely when the event carries no source', async () => { + wireSourceHappyPath(); + + const result = await handlePaymentPaid(makePaymentPaidEvent({ source: undefined })); + + expect(result).toEqual( + expect.objectContaining({ enrollmentId: 'enr-src-1', paymentId: 'pay-src-1' }), + ); + expect(mockDb.checkoutSession.findFirst).toHaveBeenCalledTimes(1); + expect(mockDb.checkoutSession.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ paymongoPaymentId: 'pay_test_001' }) }), + ); + }); + + it('returns null when no checkout session matches by source or payment id', async () => { + mockDb.processedWebhook.create.mockResolvedValue({ id: 'pw-1' }); + mockDb.checkoutSession.findFirst.mockResolvedValue(null); + + const result = await handlePaymentPaid(makePaymentPaidEvent()); + + expect(result).toBeNull(); + }); + + it('returns null when already processed (duplicate webhook)', async () => { + mockDb.processedWebhook.create.mockRejectedValue(p2002()); + + const result = await handlePaymentPaid(makePaymentPaidEvent()); + + expect(result).toBeNull(); + expect(mockDb.checkoutSession.findFirst).not.toHaveBeenCalled(); + }); + + it('does not double-process a payment id that already has a local Payment row', async () => { + wireSourceHappyPath(); + mockDb.payment.findUnique.mockResolvedValue({ id: 'already-there' }); + + const result = await handlePaymentPaid(makePaymentPaidEvent()); + + expect(result).toBeNull(); + expect(mockDb.payment.create).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/__tests__/validation.test.ts b/src/lib/__tests__/validation.test.ts index 31a535b..1d30c25 100644 --- a/src/lib/__tests__/validation.test.ts +++ b/src/lib/__tests__/validation.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { signUpSchema, signInSchema, createSafeAction } from '@/lib/validation'; +import { validateRedirectUrl } from '@/lib/redirect-url'; describe('validation.ts', () => { it('signUpSchema accepts valid input', () => { @@ -68,4 +69,52 @@ describe('validation.ts', () => { expect(result.error).not.toContain('prisma'); } }); + + // ── validateRedirectUrl (C3 / XSS defence) ────────────────────────────── + + it('validateRedirectUrl allows valid internal path', () => { + expect(validateRedirectUrl('/dashboard')).toBe('/dashboard'); + expect(validateRedirectUrl('/auth/signin')).toBe('/auth/signin'); + expect(validateRedirectUrl('/checkout/complete?status=success')).toBe('/checkout/complete?status=success'); + }); + + it('validateRedirectUrl rejects external URLs with scheme', () => { + expect(validateRedirectUrl('https://evil.com')).toBe('/'); + expect(validateRedirectUrl('javascript:alert(1)')).toBe('/'); + expect(validateRedirectUrl('data:text/html,')).toBe('/'); + expect(validateRedirectUrl('file:///etc/passwd')).toBe('/'); + }); + + it('validateRedirectUrl rejects double-slash paths', () => { + expect(validateRedirectUrl('//evil.com')).toBe('/'); + expect(validateRedirectUrl('//google.com')).toBe('/'); + }); + + it('validateRedirectUrl rejects paths with control characters', () => { + // Use actual control characters via String.fromCharCode + expect(validateRedirectUrl('/path' + String.fromCharCode(10))).toBe('/'); + expect(validateRedirectUrl('/path' + String.fromCharCode(13))).toBe('/'); + expect(validateRedirectUrl('/path' + String.fromCharCode(0))).toBe('/'); + }); + + it('validateRedirectUrl returns fallback for null/undefined/empty', () => { + expect(validateRedirectUrl(null)).toBe('/'); + expect(validateRedirectUrl(undefined)).toBe('/'); + expect(validateRedirectUrl('')).toBe('/'); + expect(validateRedirectUrl(' ')).toBe('/'); + }); + + it('validateRedirectUrl uses custom fallback', () => { + expect(validateRedirectUrl('https://evil.com', '/fallback')).toBe('/fallback'); + expect(validateRedirectUrl(null, '/custom')).toBe('/custom'); + }); + + it('validateRedirectUrl rejects encoded scheme attacks', () => { + expect(validateRedirectUrl('/%6A%61%76%61%73%63%72%69%70%74:alert(1)')).toBe('/'); + }); + + it('validateRedirectUrl rejects paths starting with scheme prefix', () => { + expect(validateRedirectUrl('/https://evil.com')).toBe('/'); + expect(validateRedirectUrl('/http://phishing.com')).toBe('/'); + }); }); diff --git a/src/lib/__tests__/xp.test.ts b/src/lib/__tests__/xp.test.ts new file mode 100644 index 0000000..dfe722c --- /dev/null +++ b/src/lib/__tests__/xp.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockDb = vi.hoisted(() => ({ + xpLedger: { create: vi.fn() }, + user: { update: vi.fn() }, + $transaction: vi.fn(), +})); + +vi.mock('@/lib/db', () => ({ db: mockDb })); +vi.mock('server-only', () => ({})); + +import { awardXpOnce } from '@/lib/xp'; + +/** Error shaped like Prisma's unique-constraint violation. */ +function p2002(): Error { + const err = new Error('Unique constraint failed') as Error & { code: string }; + err.code = 'P2002'; + return err; +} + +describe('awardXpOnce', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDb.$transaction.mockImplementation( + async (cb: (tx: typeof mockDb) => Promise) => cb(mockDb), + ); + }); + + it('inserts the ledger row then increments XP, returning true', async () => { + mockDb.xpLedger.create.mockResolvedValue({ id: 'x1' }); + mockDb.user.update.mockResolvedValue({}); + + const awarded = await awardXpOnce('u1', 'lesson-complete:l1', 50, 'Lesson completed'); + + expect(awarded).toBe(true); + expect(mockDb.xpLedger.create).toHaveBeenCalledWith({ + data: { userId: 'u1', eventKey: 'lesson-complete:l1', amount: 50, reason: 'Lesson completed' }, + }); + expect(mockDb.user.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'u1' }, + data: expect.objectContaining({ xp: { increment: 50 } }), + }), + ); + }); + + it('returns false without double-incrementing on a duplicate event (P2002)', async () => { + // Ledger insert is first — a duplicate event key aborts the transaction. + mockDb.xpLedger.create.mockRejectedValue(p2002()); + + const awarded = await awardXpOnce('u1', 'lesson-complete:l1', 50, 'Lesson completed'); + + expect(awarded).toBe(false); + expect(mockDb.user.update).not.toHaveBeenCalled(); + }); + + it('rethrows non-unique errors', async () => { + mockDb.xpLedger.create.mockRejectedValue(new Error('connection lost')); + + await expect( + awardXpOnce('u1', 'lesson-complete:l1', 50, 'Lesson completed'), + ).rejects.toThrow('connection lost'); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 36ab98a..c992050 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -198,8 +198,23 @@ export async function requireAuth(): Promise { export async function requireAdmin(): Promise { const user = await requireAuth(); - if (user.role !== 'ADMIN') { - log.warn({ component: 'auth', userId: user.id, role: user.role }, 'non-admin → redirect /'); + + // H3: Load the authoritative role from the database. The JWT claim can be + // stale (e.g. an admin was demoted after the token was issued, but the + // cookie is still valid for the rest of its lifetime). + const dbUser = await db.user.findUnique({ + where: { id: user.id }, + select: { role: true }, + }); + + // Fail closed: if the row is missing (soft-deleted between requireAuth's + // lookup and this one, a narrow TOCTOU window), deny rather than fall back + // to the JWT's possibly-stale role claim, which would defeat this check. + if (!dbUser || dbUser.role !== 'ADMIN') { + log.warn( + { component: 'auth', userId: user.id, role: dbUser?.role ?? 'missing' }, + 'non-admin → redirect /', + ); redirect('/'); } log.debug({ component: 'auth', userId: user.id }, 'requireAdmin ok'); diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts index ed2f173..c274b51 100644 --- a/src/lib/certificates.ts +++ b/src/lib/certificates.ts @@ -10,6 +10,7 @@ import 'server-only'; import { randomUUID } from 'node:crypto'; import { db } from './db'; +import { isUniqueConstraintError } from './prisma-errors'; /** * Total lesson count for a course, filtered to published modules and @@ -106,47 +107,66 @@ export async function issueCertificate( const completion = await evaluateCourseCompletion(userId, courseId); if (!completion.isComplete) return null; - const existing = await db.certificate.findFirst({ - where: { - userId, - courseId, - status: 'ACTIVE', - deletedAt: null, - }, - orderBy: { issuedAt: 'desc' }, - }); + const existing = await findActiveCertificate(userId, courseId); + if (existing) return existing; + + // The read-then-create above is a TOCTOU race: two concurrent requests (the + // manual "issue" action and the auto-issue page render) can both pass the + // findFirst and both create. The partial unique index + // `Certificate_active_per_user_course_key` (one ACTIVE cert per user+course) + // makes the loser fail with P2002 - we treat that as "already issued" and + // return the winner's row, exactly like every other create-under-race in the + // audit remediation (refunds, XP ledger, quiz attempts). + try { + const created = await db.certificate.create({ + data: { + userId, + courseId, + status: 'ACTIVE', + verificationHash: randomUUID(), + metadata: JSON.stringify({ + completedLessons: completion.completedLessons, + totalLessons: completion.totalLessons, + }), + }, + }); - if (existing) { return { - id: existing.id, - verificationHash: existing.verificationHash, - courseId: existing.courseId, - userId: existing.userId, - issuedAt: existing.issuedAt, - alreadyExisted: true, + id: created.id, + verificationHash: created.verificationHash, + courseId: created.courseId, + userId: created.userId, + issuedAt: created.issuedAt, + alreadyExisted: false, }; + } catch (e) { + if (isUniqueConstraintError(e)) { + const winner = await findActiveCertificate(userId, courseId); + if (winner) return winner; + } + throw e; } +} - const created = await db.certificate.create({ - data: { - userId, - courseId, - status: 'ACTIVE', - verificationHash: randomUUID(), - metadata: JSON.stringify({ - completedLessons: completion.completedLessons, - totalLessons: completion.totalLessons, - }), - }, +/** + * Return the user's active certificate for a course, or null. + */ +async function findActiveCertificate( + userId: string, + courseId: string, +): Promise { + const existing = await db.certificate.findFirst({ + where: { userId, courseId, status: 'ACTIVE', deletedAt: null }, + orderBy: { issuedAt: 'desc' }, }); - + if (!existing) return null; return { - id: created.id, - verificationHash: created.verificationHash, - courseId: created.courseId, - userId: created.userId, - issuedAt: created.issuedAt, - alreadyExisted: false, + id: existing.id, + verificationHash: existing.verificationHash, + courseId: existing.courseId, + userId: existing.userId, + issuedAt: existing.issuedAt, + alreadyExisted: true, }; } diff --git a/src/lib/claim-token.ts b/src/lib/claim-token.ts new file mode 100644 index 0000000..8ee2133 --- /dev/null +++ b/src/lib/claim-token.ts @@ -0,0 +1,38 @@ +/** + * Guest-checkout account-claim tokens (audit C5/C6). + * + * A guest purchase creates a placeholder User. To become usable, the buyer + * must PROVE they own the email by presenting a single-use claim token that + * is delivered only to that address. We store the SHA-256 hash of the token, + * never the token itself, and consume it atomically at signup so it can't be + * claimed twice or by anyone who merely knows the email. + */ + +import 'server-only'; + +import { randomBytes, createHash } from 'node:crypto'; + +/** Claim links expire after this window. */ +export const CLAIM_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +/** Prefix marking a placeholder (guest) account's password hash. */ +export const PLACEHOLDER_PASSWORD_PREFIX = 'placeholder_'; + +/** SHA-256 hex of a raw claim token. Deterministic — used for lookup + compare. */ +export function hashClaimToken(raw: string): string { + return createHash('sha256').update(raw).digest('hex'); +} + +/** Mint a fresh claim token: strong raw value, its hash, and an expiry. */ +export function generateClaimToken(now: Date = new Date()): { + raw: string; + hash: string; + expiresAt: Date; +} { + const raw = randomBytes(32).toString('base64url'); + return { + raw, + hash: hashClaimToken(raw), + expiresAt: new Date(now.getTime() + CLAIM_TOKEN_TTL_MS), + }; +} diff --git a/src/lib/db.ts b/src/lib/db.ts index 81ac017..533d941 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -43,7 +43,9 @@ const SOFT_DELETE_MODELS = new Set([ 'Payment', 'DiscountCode', 'RefundRequest', - 'ProcessedWebhook', + // H4 (AUDIT-2026-07-17): ProcessedWebhook has no deletedAt column — listing + // it here made every read on that model inject a filter on a nonexistent + // field, which Prisma would reject at runtime. 'Invoice', 'TeamMember', ]); diff --git a/src/lib/email.tsx b/src/lib/email.tsx index ab48318..be238f0 100644 --- a/src/lib/email.tsx +++ b/src/lib/email.tsx @@ -162,6 +162,116 @@ function EnrollmentConfirmationEmail({ ); } +// --------------------------------------------------------------------------- +// Account-claim email (guest checkout) +// --------------------------------------------------------------------------- + +interface AccountClaimEmailProps { + to: string; + rawClaimToken: string; + tierName: string; +} + +/** + * Deliver the single-use link a guest buyer uses to set a password and claim + * their account. This is the ONLY place the raw claim token is sent — it is + * never logged. The link expires (see CLAIM_TOKEN_TTL_MS). + */ +export async function sendAccountClaimEmail({ + to, + rawClaimToken, + tierName, +}: AccountClaimEmailProps): Promise { + const claimUrl = new URL('/auth/signup', APP_URL); + claimUrl.searchParams.set('claim', rawClaimToken); + claimUrl.searchParams.set('email', to); + claimUrl.searchParams.set('next', '/dashboard'); + + await sendEmail({ + to, + subject: `Claim your ${BRAND_NAME} account`, + react: ( + + ), + }); +} + +function AccountClaimEmail({ claimUrl, tierName }: { claimUrl: string; tierName: string }) { + return ( +
+
+

+ {BRAND_NAME} +

+

+ Set your password to finish +

+

+ Your payment for {tierName} went through and your enrollment is active. + To access your account, set a password using the secure link below. + It expires in 7 days. +

+ + Claim your account → + +

+ If you didn't make this purchase, you can ignore this email — no + account can be accessed without this link. +

+
+

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

+
+ ); +} + // --------------------------------------------------------------------------- // Refund status email // --------------------------------------------------------------------------- diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts index f7ff1cb..989b477 100644 --- a/src/lib/enrollment.ts +++ b/src/lib/enrollment.ts @@ -13,15 +13,27 @@ * transaction as the handler's writes. If the handler fails, the row rolls * back too, so PayMongo's retry gets a clean slate — an event can never be * consumed without its side effects being committed. + * + * AUDIT-2026-07-17 (C1): checkout.ts creates a PayMongo Source, not a + * Checkout Session (the SDK has no `checkoutSessions` resource — see + * paymongo.ts). A Source-based purchase fires `source.chargeable` then + * `payment.paid`, NOT `checkout_session.payment.paid` — so + * handleSourceChargeable/handlePaymentPaid below are what actually complete + * a real purchase. handleCheckoutPaid/handleCheckoutFailed are kept for a + * future PayMongo Checkout Session integration and exercised by tests, but + * are not reachable from the current Source-based checkout flow. */ import 'server-only'; import { db } from './db'; import { CheckoutStatus, EnrollmentStatus, PaymentMethod, PaymentStatus } from './enums'; +import { alreadyRefundedAmountPhp } from './refunds'; import { issueInvoiceForPayment } from './receipts'; -import { sendEnrollmentConfirmationEmail } from './email'; +import { sendEnrollmentConfirmationEmail, sendAccountClaimEmail } from './email'; import { logger } from './logger'; +import { generateClaimToken, PLACEHOLDER_PASSWORD_PREFIX } from './claim-token'; +import { createPaymentFromSource, type PayMongoPayment } from './paymongo'; import { randomUUID } from 'node:crypto'; /** @@ -89,10 +101,53 @@ export interface PaymentRefundedEvent { }; } +export interface SourceChargeableEvent { + data: { + id: string; + attributes: { + type: 'source.chargeable'; + data: { + id: string; + attributes: { + id: string; + amount: number; + currency: string; + status: string; + metadata?: Record; + type: string; + }; + }; + }; + }; +} + +export interface PaymentPaidEvent { + data: { + id: string; + attributes: { + type: 'payment.paid'; + data: { + id: string; + attributes: { + id: string; + amount: number; + currency: string; + status: string; + paid_at: string | null; + source?: { id: string; type: 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: string; payload: unknown }; /** @@ -131,34 +186,45 @@ export async function markWebhookProcessed( /** * Find an existing user by email, or create a minimal placeholder user - * for guest checkout. The placeholder gets a random password hash and - * must complete signup on first dashboard visit. + * for guest checkout. The placeholder gets a single-use claim token; the + * buyer claims it via /auth/signup by presenting the raw token (delivered + * by email — see sendAccountClaimEmail). Only the token's hash is stored. + * + * Returns the raw claim token so the caller can email it to the user. The + * raw token is a one-time secret — the caller MUST send it via email and + * MUST NOT log it, store it, or expose it in client-side code. */ export async function findOrCreateUserByEmail( email: string, name?: string | null, client: DbClient = db, -): Promise<{ id: string; isNew: boolean }> { +): Promise<{ id: string; isNew: boolean; rawClaimToken?: string }> { + const canonicalEmail = email.trim().toLowerCase(); const existing = await client.user.findUnique({ - where: { email }, + where: { email: canonicalEmail }, select: { id: true }, }); if (existing) return { id: existing.id, isNew: false }; - // Create placeholder user. They'll complete signup via /auth/signup - // which will update passwordHash + name + emailVerified. + // Create a placeholder user with a single-use claim token. The buyer claims + // it via /auth/signup by presenting the raw token (delivered by email); only + // the token's hash is stored. `placeholder_` marks the account as unclaimed + // (see PLACEHOLDER_PASSWORD_PREFIX). + const claim = generateClaimToken(); const placeholder = await client.user.create({ data: { - email, - name: name ?? email.split('@')[0], + email: canonicalEmail, + name: name ?? canonicalEmail.split('@')[0], emailVerified: null, - passwordHash: `placeholder_${randomUUID()}`, + passwordHash: `${PLACEHOLDER_PASSWORD_PREFIX}${randomUUID()}`, + claimTokenHash: claim.hash, + claimTokenExpiresAt: claim.expiresAt, role: 'STUDENT', status: 'ACTIVE', }, select: { id: true }, }); - return { id: placeholder.id, isNew: true }; + return { id: placeholder.id, isNew: true, rawClaimToken: claim.raw }; } function mapPaymentMethod(pm: string): PaymentMethod { @@ -171,6 +237,33 @@ function mapPaymentMethod(pm: string): PaymentMethod { return PaymentMethod.OTHER; } +/** + * Atomically bump a DiscountCode's currentUses, guarding against exceeding + * maxUses under concurrency (H4). `updateMany` with a `currentUses < maxUses` + * predicate is a single atomic UPDATE — Postgres re-evaluates the predicate + * against the row's committed value under the row lock, so two concurrent + * bumps of the last remaining use can't both succeed. A null maxUses means + * unlimited (no guard). Throws if the code has hit its limit — callers run + * this inside the fulfillment transaction so the whole thing rolls back + * rather than granting access on a discount that can no longer be honored. + */ +async function bumpDiscountUsageOrThrow( + tx: DbClient, + discountCodeId: string, + maxUses: number | null, +): Promise { + const bumped = await tx.discountCode.updateMany({ + where: + maxUses === null + ? { id: discountCodeId } + : { id: discountCodeId, currentUses: { lt: maxUses } }, + data: { currentUses: { increment: 1 } }, + }); + if (bumped.count === 0) { + throw new Error('Discount code usage limit reached.'); + } +} + /** * Handle `checkout_session.payment.paid` webhook. * @@ -211,7 +304,7 @@ export async function handleCheckoutPaid( const checkout = await tx.checkoutSession.findUnique({ where: { paymongoSourceId: csId }, - include: { pricingTier: true }, + include: { pricingTier: true, discountCode: { select: { maxUses: true } } }, }); if (!checkout) { // Consume the event — a session that doesn't exist won't appear on retry. @@ -232,7 +325,7 @@ export async function handleCheckoutPaid( // Resolve the user FIRST (guest checkout support) so the Payment is // never created with a dangling FK. Uses the tx client so a rollback // doesn't leave an orphaned placeholder user. - const { id: userId } = await findOrCreateUserByEmail( + const { id: userId, rawClaimToken } = await findOrCreateUserByEmail( checkout.email, metadata?.name ?? null, tx, @@ -258,12 +351,10 @@ export async function handleCheckoutPaid( // A limited-use discount only counts once the payment completes — // abandoned checkouts no longer burn uses (moved from - // createCheckoutSessionAtomic). + // createCheckoutSessionAtomic). H4: atomic guard against exceeding + // maxUses under concurrency. if (checkout.discountCodeId) { - await tx.discountCode.update({ - where: { id: checkout.discountCodeId }, - data: { currentUses: { increment: 1 } }, - }); + await bumpDiscountUsageOrThrow(tx, checkout.discountCodeId, checkout.discountCode?.maxUses ?? null); } // Repeat purchase of the same course reactivates the enrollment @@ -323,11 +414,24 @@ export async function handleCheckoutPaid( paymentId: payment.id, userId, tierName: checkout.pricingTier?.name ?? 'your course', + email: checkout.email, + rawClaimToken: rawClaimToken ?? null, }; }); if (!result) return null; + // Guest purchase — deliver the account-claim link AFTER the transaction has + // committed, so we never email a token for a fulfillment that rolled back + // (audit C5). The claim email is the ONLY delivery of the raw token. + if (result.rawClaimToken) { + sendAccountClaimEmail({ + to: result.email, + rawClaimToken: result.rawClaimToken, + tierName: result.tierName, + }).catch((err) => logger.error({ err }, 'Account claim email failed')); + } + // Invoice issuance — non-durable, best-effort, outside transaction try { await issueInvoiceForPayment(result.paymentId); @@ -409,31 +513,501 @@ export async function handlePaymentRefunded( const payment = await tx.payment.findUnique({ where: { paymongoPaymentId: paymentIdPm }, - select: { id: true }, + select: { id: true, amountPhp: true }, }); if (!payment) return; + // C8: cumulative refunded amount is derived from the PROCESSED refund + // requests — the single source of truth. If the admin path has already + // recorded this refund, that sum already includes it and we must NOT add + // the webhook `amount` on top. When no processed request exists yet (a + // refund initiated outside our flow, or the admin DB write is still + // pending), fall back to the event amount without double-adding. + const processedSum = await alreadyRefundedAmountPhp(payment.id, tx); + const refundedTotal = processedSum > 0 ? processedSum : amount; + const fullyRefunded = refundedTotal >= payment.amountPhp; + await tx.payment.update({ where: { id: payment.id }, data: { - status: PaymentStatus.REFUNDED, + status: fullyRefunded ? PaymentStatus.REFUNDED : PaymentStatus.PARTIALLY_REFUNDED, refundedAt: new Date(), - refundAmountPhp: amount, + refundAmountPhp: refundedTotal, }, }); - // Access enrollment via relation - const enrollment = await tx.enrollment.findFirst({ - where: { payment: { id: payment.id } }, + + // Only revoke access on a FULL refund. A partial refund leaves the + // enrollment active. + if (fullyRefunded) { + const enrollment = await tx.enrollment.findFirst({ + where: { payment: { id: payment.id } }, + }); + if (enrollment) { + await tx.enrollment.update({ + where: { id: enrollment.id }, + data: { + status: 'REFUNDED', + cancelledAt: new Date(), + cancellationReason: 'Refund processed', + }, + }); + } + } + }); +} + +// --------------------------------------------------------------------------- +// Source-based flow handlers (C1 / AUDIT-2026-07-17) +// +// checkout.ts creates a PayMongo Source, not a Checkout Session. PayMongo +// fires `source.chargeable` when the user authorizes payment on the hosted +// page, then `payment.paid` once the resulting Payment settles. These two +// handlers are what actually complete a real purchase on this codebase — +// handleCheckoutPaid above is dead code until/unless a Checkout-Session-based +// flow is wired up. +// --------------------------------------------------------------------------- + +type SourceFlowCheckout = { + id: string; + email: string; + finalAmountPhp: number; + pricingTierId: string; + discountCodeId: string | null; + pricingTier: { name: string; tier: string; slug: string }; + discountCode: { maxUses: number | null } | null; +}; + +/** + * Reject a webhook-reported payment whose currency is not PHP or whose amount + * is less than the checkout's expected amount (H2). Underpayment must never + * grant full tier access; a non-PHP currency (including an empty string) is + * likewise refused. Overpayment only warns - it isn't a security issue. + * + * A `currency` of `undefined` skips the currency assertion (some events, e.g. + * `payment.paid`, don't carry it); the amount assertion always runs. + */ +function assertPaymentMatchesCheckout( + expectedAmountCentavos: number, + actualAmountCentavos: number, + currency: string | undefined, + context: Record, +): void { + if (currency !== undefined && currency !== 'PHP') { + logger.error({ ...context, currency }, 'payment currency is not PHP - rejecting'); + throw new Error(`Unexpected currency: ${currency || '(empty)'}. Expected PHP.`); + } + if (actualAmountCentavos < expectedAmountCentavos) { + logger.error( + { ...context, expectedAmountCentavos, actualAmountCentavos }, + 'payment amount is less than the expected checkout amount - rejecting', + ); + throw new Error('Payment amount is less than the expected checkout amount.'); + } + if (actualAmountCentavos > expectedAmountCentavos) { + logger.warn( + { ...context, expectedAmountCentavos, actualAmountCentavos }, + 'payment amount exceeds expected - proceeding (overpayment)', + ); + } +} + +/** + * Handle `source.chargeable` webhook. + * + * This fires when a Source (GCash, Maya, GrabPay) becomes chargeable after + * the user authorizes the payment on PayMongo's hosted page. We call + * `createPaymentFromSource` to convert the source into an actual Payment. + * + * Ordering (why it's shaped this way): + * - Idempotency FIRST: `markWebhookProcessed` commits the event id before any + * external call. Its unique index makes concurrent redeliveries race-safe - + * exactly one caller proceeds to charge; duplicates return null. A source is + * charged at most once per event. + * - State 1 - the external PayMongo charge runs OUTSIDE any DB transaction, so + * we never hold row locks or a pooled connection across an HTTP round-trip. + * If the charge itself fails, we RELEASE the idempotency claim so PayMongo's + * retry can re-attempt (a transient failure must not silently lose the sale). + * - State 2 - durable local fulfillment runs in its own short transaction + * (DB writes only). If it fails, the idempotency claim stays (the money was + * charged) and the `payment.paid` webhook - a separate event id - is the + * backstop that creates the enrollment. + * - Post-purchase emails are best-effort, fired AFTER the transaction commits + * (never awaited inside it - they read via the top-level `db` client, which + * cannot see a transaction's uncommitted rows). + */ +export async function handleSourceChargeable( + event: SourceChargeableEvent, +): Promise { + const eventId = event.data.id; + const sourceId = event.data.attributes.data.id; + const amountCentavos = event.data.attributes.data.attributes.amount; + const metadata = event.data.attributes.data.attributes.metadata ?? {}; + const currency = event.data.attributes.data.attributes.currency; + + // Idempotency claim FIRST - never charge a source twice for the same event. + // Concurrent redeliveries race on the unique index; only the winner proceeds. + const firstTime = await markWebhookProcessed( + eventId, + 'source.chargeable', + 'source', + sourceId, + `amount=${amountCentavos}`, + 200, + ); + if (!firstTime) return null; + + // Read-only lookup + validation before charging. + const checkout = (await db.checkoutSession.findFirst({ + where: { paymongoSourceId: sourceId, deletedAt: null }, + select: { + id: true, + email: true, + finalAmountPhp: true, + pricingTierId: true, + discountCodeId: true, + pricingTier: { select: { name: true, tier: true, slug: true } }, + discountCode: { select: { maxUses: true } }, + }, + })) as SourceFlowCheckout | null; + if (!checkout) { + // Unknown source - leave the event marked (retrying won't conjure a + // session) and don't charge. + logger.warn({ sourceId }, 'source.chargeable: no checkout session found for source'); + return null; + } + + // H2: reject underpayment / non-PHP currency BEFORE charging the source. + // Throwing returns 500 for this delivery, but the event is already marked + // processed above, so PayMongo's retry short-circuits (never charges, no + // retry storm) rather than looping on the same mismatch. + assertPaymentMatchesCheckout(checkout.finalAmountPhp, amountCentavos, currency, { sourceId }); + + // State 1 - external provider call, OUTSIDE any DB transaction. + let payment: PayMongoPayment; + try { + payment = await createPaymentFromSource({ + amountCentavos, + sourceId, + description: `Checkout ${checkout.id} - Amazon PH Academy`, + metadata: { checkoutId: checkout.id, ...metadata }, }); - if (enrollment) { - await tx.enrollment.update({ - where: { id: enrollment.id }, + } catch (err) { + // Release the idempotency claim so PayMongo's retry can re-attempt the + // charge - a transient failure must not silently lose the sale. + await db.processedWebhook + .deleteMany({ where: { paymongoEventId: eventId } }) + .catch((cleanupErr) => + logger.error({ cleanupErr, eventId }, 'Failed to release webhook idempotency claim'), + ); + logger.error({ err, sourceId }, 'source.chargeable: charge failed; released claim for retry'); + throw err; // surfaced to the webhook route → 500 → PayMongo retries + } + + logger.info( + { sourceId, paymentId: payment.id, status: payment.status }, + 'source.chargeable: payment created from source', + ); + + // Only fulfil when the payment already settled; otherwise the payment.paid + // webhook completes it. + if (payment.status === 'paid') { + // State 2 - durable local fulfillment (DB writes only). A failure here does + // NOT undo the PayMongo charge and must not re-run State 1; payment.paid + // reconciles. + let result: PostPurchaseResult | null = null; + try { + result = await db.$transaction((tx) => + processPaymentPaidInTransaction( + tx, + payment, + checkout, + event.data.attributes.data.attributes.type, + ), + ); + } catch (err) { + logger.error( + { err, sourceId, paymentId: payment.id }, + 'source.chargeable: local fulfillment failed; payment.paid webhook will reconcile', + ); + } + if (result) { + // Best-effort emails, AFTER commit - never awaited inside the transaction. + sendPostPurchaseEmails(result).catch((err: Error) => + logger.error({ err }, 'Failed to send post-purchase emails'), + ); + } + } + + return payment; +} + +/** + * Handle `payment.paid` webhook. + * + * Fires when a Payment (created from a Source or Payment Intent) reaches + * `paid` status. We reconcile with the CheckoutSession and create the + * local Payment + Enrollment. + */ +export async function handlePaymentPaid( + event: PaymentPaidEvent, +): Promise<{ enrollmentId: string; paymentId: string } | null> { + const eventId = event.data.id; + const paymentIdPm = event.data.attributes.data.attributes.id; + const amountCentavos = event.data.attributes.data.attributes.amount; + const currency = event.data.attributes.data.attributes.currency; + const sourceId = event.data.attributes.data.attributes.source?.id; + // Forward the real payment method so non-GCash payments aren't mis-recorded. + const sourceType = event.data.attributes.data.attributes.source?.type; + + const checkoutSelect = { + id: true, + email: true, + finalAmountPhp: true, + pricingTierId: true, + discountCodeId: true, + pricingTier: { select: { name: true, tier: true, slug: true } }, + discountCode: { select: { maxUses: true } }, + } as const; + + return db.$transaction(async (tx) => { + const firstTime = await markWebhookProcessed( + eventId, + 'payment.paid', + 'payment', + paymentIdPm, + `amount=${amountCentavos}`, + 200, + tx, + ); + if (!firstTime) return null; + + // Find the CheckoutSession via source ID or payment ID + const checkout = sourceId + ? ((await tx.checkoutSession.findFirst({ + where: { paymongoSourceId: sourceId, deletedAt: null }, + select: checkoutSelect, + })) as SourceFlowCheckout | null) + : null; + + if (!checkout) { + // Check via paymongoPaymentId + const checkoutByPayment = (await tx.checkoutSession.findFirst({ + where: { paymongoPaymentId: paymentIdPm, deletedAt: null }, + select: checkoutSelect, + })) as SourceFlowCheckout | null; + if (!checkoutByPayment) { + logger.warn({ paymentId: paymentIdPm }, 'payment.paid: no checkout session found'); + return null; + } + return processPaymentInCheckout(tx, paymentIdPm, amountCentavos, checkoutByPayment, sourceType, currency); + } + + return processPaymentInCheckout(tx, paymentIdPm, amountCentavos, checkout, sourceType, currency); + }); +} + +/** + * Internal: process a paid payment within a checkout session transaction. + */ +async function processPaymentInCheckout( + tx: DbClient, + paymentIdPm: string, + amountCentavos: number, + checkout: SourceFlowCheckout, + paymentMethod?: string, + currency?: string, +): Promise<{ enrollmentId: string; paymentId: string } | null> { + // H2: reject underpayment / non-PHP currency rather than granting access on + // a webhook amount below the listed price. This event has already been marked + // processed by the caller, so returning null consumes it without granting an + // enrollment (no retry storm), matching the "refuse, don't retry" posture. + try { + assertPaymentMatchesCheckout(checkout.finalAmountPhp, amountCentavos, currency, { + checkoutId: checkout.id, + paymentId: paymentIdPm, + }); + } catch (err) { + logger.error({ err, checkoutId: checkout.id }, 'payment.paid: amount/currency check failed - not granting access'); + return null; + } + + const result = await processPaymentPaidInTransaction( + tx, + { id: paymentIdPm, status: 'paid', amount: amountCentavos, paidAt: new Date().toISOString() }, + checkout, + paymentMethod, + ); + + // Send post-purchase emails (outside transaction — best-effort) + if (result) { + sendPostPurchaseEmails(result).catch((err: Error) => + logger.error({ err }, 'Failed to send post-purchase emails'), + ); + } + + return result ? { enrollmentId: result.enrollmentId, paymentId: result.paymentId } : null; +} + +interface PostPurchaseResult { + enrollmentId: string; + paymentId: string; + userId: string; + tierName: string; + email: string; + rawClaimToken: string | null; +} + +// Re-use the existing processPayment workflow but adapted for Source flow +async function processPaymentPaidInTransaction( + tx: DbClient, + payment: { id: string; status: string; amount: number; paidAt: string | null }, + checkout: SourceFlowCheckout, + paymentMethod?: string, +): Promise { + const paymentIdPm = payment.id; + + // Check if Payment already exists locally + const existingPayment = await tx.payment.findUnique({ + where: { paymongoPaymentId: paymentIdPm }, + select: { id: true }, + }); + if (existingPayment) return null; // Already processed + + // Create or find user. `rawClaimToken` is set ONLY when a new placeholder + // (guest checkout) user was just created — see findOrCreateUserByEmail. + const { id: userId, rawClaimToken } = await findOrCreateUserByEmail(checkout.email, null, tx); + + // Create local Payment record + const localPayment = await tx.payment.create({ + data: { + userId, + checkoutSessionId: checkout.id, + pricingTierId: checkout.pricingTierId, + amountPhp: payment.amount, + feePhp: 0, + netAmountPhp: checkout.finalAmountPhp, + currency: 'PHP', + paymongoPaymentId: paymentIdPm, + // Record the real method from the source type; only fall back to OTHER + // when it's genuinely unavailable - never silently assume GCash. + method: paymentMethod ? mapPaymentMethod(paymentMethod) : PaymentMethod.OTHER, + status: PaymentStatus.COMPLETED, + paidAt: payment.paidAt ? new Date(payment.paidAt) : new Date(), + }, + select: { id: true }, + }); + + // Find the course bound to the pricing tier + const course = await tx.course.findFirst({ + where: { pricingTierId: checkout.pricingTierId, deletedAt: null }, + select: { id: true }, + }); + if (!course) { + logger.warn({ pricingTierId: checkout.pricingTierId }, 'no course for pricing tier'); + return null; + } + + // H4: Atomic discount usage with maxUses guard — same protection as the + // checkout_session flow above, so the Source flow can't over-redeem a + // limited-use code under concurrency either. + if (checkout.discountCodeId) { + await bumpDiscountUsageOrThrow(tx, checkout.discountCodeId, checkout.discountCode?.maxUses ?? null); + } + + // Repeat purchase: reactivate enrollment + const existingEnrollment = await tx.enrollment.findUnique({ + where: { userId_courseId: { userId, courseId: course.id } }, + select: { id: true }, + }); + + const enrollment = existingEnrollment + ? await tx.enrollment.update({ + where: { id: existingEnrollment.id }, data: { - status: 'REFUNDED', - cancelledAt: new Date(), - cancellationReason: 'Refund processed', + status: EnrollmentStatus.ACTIVE, + pricingTierId: checkout.pricingTierId, + tier: checkout.pricingTier.tier, + cancelledAt: null, + cancellationReason: null, + deletedAt: null, + }, + }) + : await tx.enrollment.create({ + data: { + userId, + courseId: course.id, + pricingTierId: checkout.pricingTierId, + tier: checkout.pricingTier.tier, + status: EnrollmentStatus.ACTIVE, + enrolledAt: new Date(), }, }); - } + + // Link payment to enrollment + const alreadyLinked = await tx.payment.findFirst({ + where: { enrollmentId: enrollment.id }, + select: { id: true }, + }); + if (!alreadyLinked) { + await tx.payment.update({ + where: { id: localPayment.id }, + data: { enrollmentId: enrollment.id }, + }); + } + + await tx.checkoutSession.update({ + where: { id: checkout.id }, + data: { + status: CheckoutStatus.PAID, + paymongoPaymentId: paymentIdPm, + paidAt: new Date(), + userId, + }, }); -} \ No newline at end of file + + return { + enrollmentId: enrollment.id, + paymentId: localPayment.id, + userId, + tierName: checkout.pricingTier.name, + email: checkout.email, + rawClaimToken: rawClaimToken ?? null, + }; +} + +/** + * Send post-purchase emails: enrollment confirmation and, if the buyer was + * a new placeholder (guest checkout) user, the account-claim link carrying + * the raw claim token minted in `findOrCreateUserByEmail`. This is the ONLY + * place that token is delivered — never logged. + */ +async function sendPostPurchaseEmails(result: PostPurchaseResult): Promise { + try { + await issueInvoiceForPayment(result.paymentId); + } catch (err) { + logger.error({ err, paymentId: result.paymentId }, 'Failed to issue invoice'); + } + + const user = await db.user.findUnique({ + where: { id: result.userId }, + select: { email: true, name: true }, + }); + const to = user?.email ?? result.email; + if (!to) return; + + // Send enrollment confirmation + sendEnrollmentConfirmationEmail({ + to, + studentName: user?.name ?? to.split('@')[0] ?? to, + tierName: result.tierName, + }).catch((err: Error) => logger.error({ err }, 'Enrollment confirmation email failed')); + + // If user is a new placeholder (guest checkout), they need a claim link. + if (result.rawClaimToken) { + sendAccountClaimEmail({ + to, + rawClaimToken: result.rawClaimToken, + tierName: result.tierName, + }).catch((err: Error) => logger.error({ err }, 'Account claim email failed')); + } +} diff --git a/src/lib/prisma-errors.ts b/src/lib/prisma-errors.ts new file mode 100644 index 0000000..12aa7b5 --- /dev/null +++ b/src/lib/prisma-errors.ts @@ -0,0 +1,18 @@ +/** + * Narrow helpers for Prisma runtime errors. + * + * Prisma throws `PrismaClientKnownRequestError` with a string `code`. We match + * on the code without importing the class (keeps these usable from modules that + * mock `@prisma/client`). + */ + +import 'server-only'; + +/** True for a unique-constraint violation (P2002). */ +export function isUniqueConstraintError(e: unknown): boolean { + return ( + e instanceof Error && + 'code' in e && + (e as { code?: unknown }).code === 'P2002' + ); +} diff --git a/src/lib/redirect-url.ts b/src/lib/redirect-url.ts new file mode 100644 index 0000000..d5c59a7 --- /dev/null +++ b/src/lib/redirect-url.ts @@ -0,0 +1,35 @@ +/** + * Redirect URL validator (C3 / XSS / open-redirect defence). + * + * Shared between server and client components. No server-only imports. + */ + +export function validateRedirectUrl( + raw: string | null | undefined, + fallback = '/', +): string { + if (!raw || typeof raw !== 'string') return fallback; + + // Check for control characters BEFORE trimming — a path like '/path\n' + // must be rejected even though trim() would strip the trailing newline. + if (/[\x00-\x1f\x7f\\]/.test(raw)) return fallback; + + const trimmed = raw.trim(); + if (!trimmed) return fallback; + + // Must start with a single '/' — reject '//', backslash, javascript:, data: + if (!trimmed.startsWith('/')) return fallback; + if (trimmed.startsWith('//')) return fallback; + + // Reject URL schemes (e.g. javascript:, data:, vbscript:, file:) + const schemeMatch = trimmed.match(/^\/?([a-zA-Z][a-zA-Z0-9+\-.]*:)/); + if (schemeMatch) return fallback; + + // Reject encoded schemes (%6A%61%76%61%73%63%72%69%70%74 = "javascript") + if (/^\/[^/]*%[0-9a-fA-F]{2}/.test(trimmed)) return fallback; + + // Reject paths that look like absolute URLs with host (e.g. /https://evil.com) + if (/^\/(https?|ftp):\/\//i.test(trimmed.slice(1))) return fallback; + + return trimmed; +} diff --git a/src/lib/refunds.ts b/src/lib/refunds.ts index 51be783..da419eb 100644 --- a/src/lib/refunds.ts +++ b/src/lib/refunds.ts @@ -67,12 +67,24 @@ export async function hasBlockingRefundRequest(paymentId: string): Promise { - const completed = await db.refundRequest.findMany({ +type RefundDbClient = typeof db | Parameters[0]>[0]; + +/** + * The amount already refunded for this payment (in PHP): the sum of every + * PROCESSED refund request. This is the single source of truth for cumulative + * refunded amount (audit C8) — both the admin approval path and the + * `payment.refunded` webhook write `Payment.refundAmountPhp` from this value + * instead of each adding its own amount, so a refund can never be counted + * twice regardless of which path runs first. + */ +export async function alreadyRefundedAmountPhp( + paymentId: string, + client: RefundDbClient = db, +): Promise { + const completed = await client.refundRequest.findMany({ where: { paymentId, deletedAt: null, diff --git a/src/lib/validation.ts b/src/lib/validation.ts index f105547..c3553c8 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -23,15 +23,23 @@ export type ActionResult = // Auth schemas // --------------------------------------------------------------------------- +// H6: emails are canonicalized (trimmed + lowercased) BEFORE validation so a +// single spelling is stored and matched everywhere. `.trim()` runs before +// `.email()` so surrounding whitespace never fails a legitimate address. +const canonicalEmail = (invalidMessage: string) => + z.string().trim().toLowerCase().email(invalidMessage); + export const signUpSchema = z .object({ - email: z.string().email('Enter a valid email. Example: [email protected]'), + email: canonicalEmail('Enter a valid email. Example: [email protected]'), password: z .string() .min(8, 'Password must be at least 8 characters.') .max(128, 'Password is too long.'), confirmPassword: z.string(), name: z.string().min(1, 'Enter your name.').max(100).optional(), + // Present only when claiming a guest-checkout placeholder account (C5/C6). + claimToken: z.string().min(1).max(256).optional(), }) .refine((data) => data.password === data.confirmPassword, { message: 'Passwords do not match.', @@ -39,7 +47,7 @@ export const signUpSchema = z }); export const signInSchema = z.object({ - email: z.string().email('Enter a valid email.'), + email: canonicalEmail('Enter a valid email.'), password: z.string().min(1, 'Enter your password.'), }); diff --git a/src/lib/xp.ts b/src/lib/xp.ts new file mode 100644 index 0000000..6540aac --- /dev/null +++ b/src/lib/xp.ts @@ -0,0 +1,53 @@ +/** + * Idempotent XP awards (audit C10). + * + * Every XP grant goes through `awardXpOnce`. The XpLedger row and the + * User.xp increment commit in ONE transaction, and the ledger's unique + * (userId, eventKey) index is the concurrency gate: + * + * - The ledger insert is the FIRST statement in the transaction. If a + * concurrent request (or a webhook replay) already awarded this event, + * the insert violates the unique index, the whole transaction rolls + * back, and no XP is granted. We surface that as `false`. + * - On success, both writes are durable together — XP can never be + * incremented without a matching ledger entry, and vice versa. + * + * Callers must run any other side effects (marking a lesson complete, + * transitioning a tool session) as their own idempotent writes; XP is the + * only thing this function owns. + */ + +import 'server-only'; + +import { db } from './db'; +import { isUniqueConstraintError } from './prisma-errors'; + +/** + * Award `amount` XP to `userId` exactly once for `eventKey`. + * + * @returns `true` if this call granted the XP, `false` if it was already + * granted for this event key (no-op). + */ +export async function awardXpOnce( + userId: string, + eventKey: string, + amount: number, + reason: string, +): Promise { + try { + await db.$transaction(async (tx) => { + // Ledger insert first — its unique index is the exactly-once gate. + await tx.xpLedger.create({ + data: { userId, eventKey, amount, reason }, + }); + await tx.user.update({ + where: { id: userId }, + data: { xp: { increment: amount }, lastActiveAt: new Date() }, + }); + }); + return true; + } catch (e) { + if (isUniqueConstraintError(e)) return false; + throw e; + } +}