From 09343a285f3608b1c3bc3796df5ab0c084e0ce8b Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 18 Jul 2026 21:56:23 +0000
Subject: [PATCH 1/9] fix(schema): reconcile pr33/pr34 audit migrations into
one clean migration
Both PR #33 and PR #34 independently added User.claimTokenHash /
claimTokenExpiresAt via separate migrations that would collide
(duplicate-column error) if stacked. This adds ONE new migration
(20260718000000_audit_integrity_fixes) against current main's schema,
based on pr34's superset migration content (XpLedger table, partial
unique indexes for one-active-refund-per-payment and
one-active-certificate-per-user-course, unique RefundRequest.paymongoRefundId,
canonical-email backfill) plus pr33's User_claimTokenHash_idx index that
pr34 was missing.
Verified by applying both migrations to a fresh local Postgres 16
instance via `prisma migrate deploy` and confirming `prisma migrate diff`
between the resulting DB and schema.prisma is empty (no drift).
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
.../migration.sql | 76 +++++++++++++++++++
prisma/schema.prisma | 37 ++++++++-
2 files changed, 112 insertions(+), 1 deletion(-)
create mode 100644 prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
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..4a35fad
--- /dev/null
+++ b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
@@ -0,0 +1,76 @@
+-- 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 — those need manual reconciliation before a case-insensitive
+-- uniqueness constraint (citext / normalized column) can be enforced.
+-- ----------------------------------------------------------------------------
+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 o.email = lower(btrim(u.email))
+ );
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)
// ============================================================================
From c7ec106ed91f5921388049f77abb4b93966cbc16 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 18 Jul 2026 21:56:46 +0000
Subject: [PATCH 2/9] fix(payments): wire up the Source-based webhook flow that
main never handled, and fix guest-checkout claim-token delivery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Top-priority fix: checkout.ts creates a PayMongo Source (the SDK has no
checkoutSessions resource — see paymongo.ts), but the webhook route only
handled checkout_session.payment.* events and explicitly dropped
source.chargeable / payment.paid — the events a Source-based purchase
actually fires. No real purchase could complete on main. This brings
handleSourceChargeable/handlePaymentPaid (source: pr33) into
enrollment.ts and wires them into the webhook route's switch statement,
adapted to current main's handler signatures and enum names.
Fixes pr33's dead guest-claim-token code along the way: pr33 generated a
raw claim token in findOrCreateUserByEmail but every call site (including
the new Source-flow handlers) discarded it and only logged that "a token
was emailed" — no email was ever sent. This wires the real flow end to
end: findOrCreateUserByEmail (now using pr34's claim-token.ts mint/hash
helpers, canonicalized email, PLACEHOLDER_PASSWORD_PREFIX) returns
rawClaimToken -> both handleCheckoutPaid and the new Source-flow handlers
thread it through -> sendAccountClaimEmail (pr34, new) sends it after the
DB transaction commits -> checkout/complete's ClaimCard (pr34, masked
email) tells the guest to check their inbox instead of redirecting them
to a tokenless signup that would now be rejected -> signup page/form
(claim query param + hidden input, readOnly email) and the atomic
updateMany-based token consumption in actions/auth.ts (pr34 — guards on
id + placeholder marker + token hash + expiry in one UPDATE, closing the
double-claim race pr33's findUnique-then-update version had) complete
the loop.
Also layers pr33's H1 fix onto checkout.ts: the CheckoutSession is now
created before the PayMongo Source so its id can be embedded as
`checkout_id` on the redirect URL, letting /checkout/complete reliably
find the session. Deviation from pr33: its version used a literal
'__pending__' placeholder for paymongoSourceId (which is @unique and
nullable) while creating the session, which would collide under two
concurrent checkouts; this leaves the field unset instead and updates it
once the real source id exists.
Also fixes a real bug found while porting: pr33's Source-flow Payment.create
call omitted the required `netAmountPhp` field entirely, which would have
failed at the Prisma layer.
handlePaymentRefunded's cumulative-refund math now derives from
alreadyRefundedAmountPhp (pr34, extended here to accept a transaction
client) instead of a locally-tracked sum, so the admin approval path and
the webhook can never double-count the same refund.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
src/app/(public)/auth/signup/SignUpForm.tsx | 19 +-
src/app/(public)/auth/signup/page.tsx | 18 +-
src/app/(public)/checkout/complete/page.tsx | 71 ++-
src/app/actions/auth.ts | 57 +-
src/app/actions/checkout.ts | 60 ++-
src/app/api/paymongo/webhook/route.ts | 49 +-
src/lib/claim-token.ts | 38 ++
src/lib/email.tsx | 110 ++++
src/lib/enrollment.ts | 570 ++++++++++++++++++--
src/lib/refunds.ts | 22 +-
src/lib/validation.ts | 12 +-
11 files changed, 921 insertions(+), 105 deletions(-)
create mode 100644 src/lib/claim-token.ts
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 (
-
+
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..fc1d059 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 = {
@@ -48,12 +48,17 @@ 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 — rejects
+ // '//evil.example', 'javascript:', encoded schemes, etc., not just a bare
+ // '//' prefix.
+ const returnUrl = validateRedirectUrl(rawReturnUrl, undefined) || undefined;
+ const checkout = await resolveCheckout(checkoutSessionId, returnUrl);
if (status === 'failed') {
return ;
@@ -64,17 +69,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 +113,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.
+
+
+
+
+ Already claimed? Sign in
+
+
+
+
+ );
+}
+
function PendingCard({ checkoutId }: { checkoutId?: string } = {}) {
return (
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..ec211e5 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';
@@ -45,7 +51,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 +158,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,
},
};
@@ -179,18 +211,12 @@ export async function createCheckoutSessionAction(
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/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/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/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..5dd8c19 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,431 @@ 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 } },
- });
- if (enrollment) {
- await tx.enrollment.update({
- where: { id: enrollment.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;
+};
+
+/**
+ * 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 must call
+ * `createPaymentFromSource` to convert the source into an actual Payment.
+ *
+ * If the Payment is returned with status=paid, we also create the local
+ * Payment + Enrollment immediately.
+ */
+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 ?? {};
+
+ return db.$transaction(async (tx) => {
+ const firstTime = await markWebhookProcessed(
+ eventId,
+ 'source.chargeable',
+ 'source',
+ sourceId,
+ `amount=${amountCentavos}`,
+ 200,
+ tx,
+ );
+ if (!firstTime) return null;
+
+ // Find the CheckoutSession by source ID with all fields needed
+ // for processPaymentPaidInTransaction (pricingTierId, discountCodeId, tier info)
+ const checkout = (await tx.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) {
+ logger.warn({ sourceId }, 'source.chargeable: no checkout session found for source');
+ return null;
+ }
+
+ // H2: Verify amount consistency with webhook amount and validate currency.
+ // The finalAmountPhp is in centavos — verify consistency with webhook amount.
+ const currency = event.data.attributes.data.attributes.currency;
+ if (currency && currency !== 'PHP') {
+ logger.error(
+ { sourceId, currency, expectedCurrency: 'PHP' },
+ 'source.chargeable: unexpected currency — rejecting',
+ );
+ throw new Error(`Unexpected currency: ${currency}. Expected PHP.`);
+ }
+
+ const expectedAmount = checkout.finalAmountPhp;
+ if (expectedAmount !== amountCentavos) {
+ logger.warn(
+ { sourceId, expectedAmount, actualAmount: amountCentavos },
+ 'source.chargeable: amount mismatch — creating payment with webhook amount',
+ );
+ }
+
+ try {
+ const payment = await createPaymentFromSource({
+ amountCentavos,
+ sourceId,
+ description: `Checkout ${checkout.id} — Amazon PH Academy`,
+ metadata: { checkoutId: checkout.id, ...metadata },
+ });
+
+ logger.info(
+ { sourceId, paymentId: payment.id, status: payment.status },
+ 'source.chargeable: payment created from source',
+ );
+
+ // If already paid, process immediately
+ if (payment.status === 'paid') {
+ const result = await processPaymentPaidInTransaction(
+ tx,
+ payment,
+ checkout,
+ event.data.attributes.data.attributes.type,
+ );
+ if (result) {
+ // Send enrollment confirmation + claim token email
+ await sendPostPurchaseEmails(result);
+ }
+ }
+
+ return payment;
+ } catch (err) {
+ logger.error({ err, sourceId }, 'source.chargeable: failed to create payment from source');
+ throw err; // Will be caught by webhook handler
+ }
+ });
+}
+
+/**
+ * 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 sourceId = event.data.attributes.data.attributes.source?.id;
+
+ 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);
+ }
+
+ return processPaymentInCheckout(tx, paymentIdPm, amountCentavos, checkout);
+ });
+}
+
+/**
+ * Internal: process a paid payment within a checkout session transaction.
+ */
+async function processPaymentInCheckout(
+ tx: DbClient,
+ paymentIdPm: string,
+ amountCentavos: number,
+ checkout: SourceFlowCheckout,
+): Promise<{ enrollmentId: string; paymentId: string } | null> {
+ // H2: Reconcile amount and currency. Validate before proceeding.
+ // Note: payment.paid events from PayMongo carry amount in the top-level
+ // event which we already checked. Currency should be PHP.
+ if (checkout.finalAmountPhp !== amountCentavos) {
+ logger.warn(
+ { checkoutId: checkout.id, expected: checkout.finalAmountPhp, actual: amountCentavos },
+ 'payment.paid: amount mismatch — creating enrollment with webhook amount',
+ );
+ }
+
+ // For a Payment-based flow, the paymongoPaymentId on the CheckoutSession
+ // was already set when we created the Payment (or it's set now).
+ // Source type not available from payment.paid webhook — default to GCASH
+ const result = await processPaymentPaidInTransaction(
+ tx,
+ { id: paymentIdPm, status: 'paid', amount: amountCentavos, paidAt: new Date().toISOString() },
+ checkout,
+ );
+
+ // 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,
+ method: paymentMethod ? mapPaymentMethod(paymentMethod) : PaymentMethod.GCASH,
+ 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/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.'),
});
From 35dcd5ea000f6bf62acd581082158c57a13d0e3b Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 18 Jul 2026 21:57:09 +0000
Subject: [PATCH 3/9] fix(integrity): replace check-then-act races with
DB-enforced idempotency
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
progress.ts: XP awards now go through XpLedger-backed awardXpOnce
(pr34, new src/lib/xp.ts) — the ledger's unique (userId, eventKey) index
makes every award exactly-once under concurrency, replacing a plain
xp: {increment} that could double-fire on a retry or a racing request.
Also brings pr34's H1 quiz-gate fix: markLessonCompleteAction now rejects
manual completion when the lesson has a published quiz, closing a path
where a student could get credit (and certificate eligibility) without
passing it. pr33 has neither fix.
tools.ts: submitToolSession's session-transition (IN_PROGRESS -> GRADED/
SUBMITTED) is now a single atomic updateMany claim — only the winner of
a race writes the grade and XP; everyone else reconciles from the
recorded terminal state via awardXpOnce's own idempotency, so a retry or
concurrent double-submit can't double-grant XP or clobber a graded
session's stored state. saveToolSession also gets an atomic
IN_PROGRESS-only guard (H3) so a late autosave can't overwrite an
already-graded session — pr33 only fixes the submit race, not this one.
refunds.ts: createRefundRequestAction now relies on the partial unique
index added in the schema migration (one active refund per payment) as
the concurrency backstop for its pre-check, catching P2002 instead of
pr33's findFirst-then-create inside a plain transaction (Read Committed
doesn't actually close that race). approveRefundAction's error handling
(C9) now distinguishes an explicit 4xx rejection from PayMongo (safe to
mark FAILED) from an ambiguous outcome — timeout, 5xx, connection reset
— which is left APPROVED (not re-approvable, not FAILED) for the
payment.refunded webhook to reconcile; pr33's version unconditionally
marks FAILED on any error, which risks a double-refund if the provider
call actually succeeded. A DB failure after a successful PayMongo refund
(state 2) now reports success with a PROCESSING status instead of
falsely telling the admin the refund failed.
Brings over pr34's src/lib/prisma-errors.ts (isUniqueConstraintError)
used by all three files above.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
src/app/actions/progress.ts | 225 ++++++++++++++++++++++------------
src/app/actions/refunds.ts | 156 ++++++++++++++++--------
src/app/actions/tools.ts | 232 +++++++++++++++++++++++++++---------
src/lib/prisma-errors.ts | 18 +++
src/lib/xp.ts | 53 ++++++++
5 files changed, 502 insertions(+), 182 deletions(-)
create mode 100644 src/lib/prisma-errors.ts
create mode 100644 src/lib/xp.ts
diff --git a/src/app/actions/progress.ts b/src/app/actions/progress.ts
index 422be1f..e9e8a50 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,35 +94,41 @@ 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(),
- },
- });
+ // Mark complete (idempotent) then award XP exactly once (C10). The two are
+ // deliberately separate: re-completing a lesson must never re-grant XP, but
+ // the progress row staying COMPLETED is harmless.
+ 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,
+ },
});
+ await awardXpOnce(
+ user.id,
+ `lesson-complete:${lesson.id}`,
+ lesson.xpReward,
+ 'Lesson completed',
+ );
+
revalidatePath(`/dashboard/courses/${data.courseSlug}`);
revalidatePath(`/dashboard/courses/${data.courseSlug}/lessons/${data.lessonSlug}`);
revalidatePath('/dashboard');
@@ -149,6 +163,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,57 +185,56 @@ 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, mark lesson complete (idempotent) and award XP exactly once.
+ // 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).
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 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,
+ },
});
+
+ 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',
+ );
}
revalidatePath(`/dashboard/courses/${data.courseSlug}/lessons/${data.lessonSlug}`);
@@ -235,4 +253,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..8d2b24f 100644
--- a/src/app/actions/refunds.ts
+++ b/src/app/actions/refunds.ts
@@ -34,6 +34,8 @@ import {
PAYMONGO_REFUND_REASON,
} from '@/lib/refunds';
import { sendRefundStatusEmail } from '@/lib/email';
+import { logger } from '@/lib/logger';
+import { isUniqueConstraintError } from '@/lib/prisma-errors';
// ---------------------------------------------------------------------------
// Student: create refund request
@@ -92,16 +94,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 +221,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 +234,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,17 +241,89 @@ 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),
+ },
+ });
+ 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',
+ );
+ revalidatePath('/admin/refunds');
+ revalidatePath(`/admin/refunds/${request.id}`);
+ return {
+ success: false,
+ error:
+ 'Refund status is unknown (network error). It has been left pending and will reconcile automatically. 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',
+ );
revalidatePath('/admin/refunds');
revalidatePath(`/admin/refunds/${request.id}`);
- return { success: false, error: `Refund API call failed: ${message}` };
+ // State 2 is NOT a failure — the money left PayMongo. Report it honestly.
+ return {
+ success: true,
+ data: { requestId: request.id, status: 'PROCESSING', paymongoRefundId },
+ };
}
revalidatePath('/admin/refunds');
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/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/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;
+ }
+}
From 9eb7b6b21ec17a54e94311b0839db32867e35306 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 18 Jul 2026 21:57:24 +0000
Subject: [PATCH 4/9] fix(security): port forward pr33-only fixes with no pr34
overlap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- New src/lib/redirect-url.ts: validateRedirectUrl() rejects javascript:,
data:, //, backslash, control chars, and encoded-scheme redirect
targets. Wired into the sign-in page/form's `redirect` query param (C3)
— closes an open-redirect / XSS vector that previously trusted the
param verbatim.
- src/lib/auth.ts requireAdmin(): now loads the user's role from the DB
instead of trusting the JWT's role claim, which can be stale (e.g. an
admin demoted after their token was issued keeps admin access for the
rest of the cookie's lifetime otherwise).
- prisma/seed.ts: removes the [email protected] / ChangeMe123!
fallback — seeding now fails loudly when ADMIN_EMAIL/ADMIN_PASSWORD
aren't set (always, not just in production), instead of silently
creating a well-known admin account.
- src/lib/db.ts: removes ProcessedWebhook from SOFT_DELETE_MODELS — it
has no deletedAt column, so every read on that model was injecting a
filter on a field that doesn't exist.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
prisma/seed.ts | 23 ++++++++++++--
src/app/(public)/auth/signin/SignInForm.tsx | 3 +-
src/app/(public)/auth/signin/page.tsx | 5 ++-
src/lib/auth.ts | 17 ++++++++--
src/lib/db.ts | 4 ++-
src/lib/redirect-url.ts | 35 +++++++++++++++++++++
6 files changed, 80 insertions(+), 7 deletions(-)
create mode 100644 src/lib/redirect-url.ts
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 69316fc..0e77ad4 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -34,8 +34,27 @@ 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';
+ const rawEmail = (process.env.ADMIN_EMAIL ?? '').trim();
+ 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 },
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/lib/auth.ts b/src/lib/auth.ts
index 36ab98a..70ccb6a 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -198,8 +198,21 @@ 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 },
+ });
+
+ const effectiveRole = dbUser?.role ?? user.role;
+ if (effectiveRole !== 'ADMIN') {
+ log.warn(
+ { component: 'auth', userId: user.id, role: effectiveRole },
+ 'non-admin → redirect /',
+ );
redirect('/');
}
log.debug({ component: 'auth', userId: user.id }, 'requireAdmin ok');
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/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;
+}
From 3c4bf0be9cd889c2cb7a5641b9a854a99775f0d2 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sat, 18 Jul 2026 21:57:41 +0000
Subject: [PATCH 5/9] test: reconcile and extend coverage for the audit
remediation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- enrollment.test.ts: merges pr33's new-handler test scaffolding with
pr34's mock updates (discountCode.updateMany, refundRequest.findMany,
sendAccountClaimEmail, canonical-email node:crypto mocks), then adds
fresh coverage tailored to the actual merged implementation:
handleSourceChargeable/handlePaymentPaid happy paths, currency/duplicate/
not-found guards, the H4 atomic discount-limit guard, and — the key
regression guard for this whole change — that a NEW guest user created
during the Source-flow handlers actually gets sendAccountClaimEmail
called with the raw token, not just a log line.
- xp.test.ts: pr34's awardXpOnce tests, unchanged (new file, no
reconciliation needed).
- auth-actions.test.ts / tool-actions.test.ts: pr34's additions for the
atomic claim-token consumption and the atomic tool-session guards.
- validation.test.ts: pr33's validateRedirectUrl test suite.
- auth.test.ts: requireAdmin mock updates for the two-DB-query shape,
plus a new test asserting a demoted user with a stale ADMIN JWT is
still redirected once the DB read disagrees. Omits pr33's
generateClaimToken/verifyClaimToken tests — those functions were
superseded by pr34's claim-token.ts and never added to auth.ts.
Also fixes a latent test-pollution bug found while writing the new
handleCheckoutPaid discount-limit test: wireHappyPath() queues two
mockResolvedValueOnce values for user.findUnique, but a test whose flow
throws mid-transaction (before the post-commit lookup) only consumes the
first — the second leaks into and corrupts whichever test runs next.
Fixed by giving that test its own minimal, non-leaking mock setup.
All 280 tests pass (25 suites). Coverage: 87.5% statements / 77.6%
branches / 87.3% functions / 88.7% lines on src/lib — above the 70% gate.
Co-Authored-By: Claude Sonnet 5
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
.../actions/__tests__/auth-actions.test.ts | 59 ++-
.../actions/__tests__/tool-actions.test.ts | 26 +-
src/lib/__tests__/auth.test.ts | 24 +-
src/lib/__tests__/enrollment.test.ts | 401 +++++++++++++++++-
src/lib/__tests__/validation.test.ts | 49 +++
src/lib/__tests__/xp.test.ts | 64 +++
6 files changed, 607 insertions(+), 16 deletions(-)
create mode 100644 src/lib/__tests__/xp.test.ts
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/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts
index a40de07..1ae112a 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,23 @@ 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');
});
diff --git a/src/lib/__tests__/enrollment.test.ts b/src/lib/__tests__/enrollment.test.ts
index 238961b..a46dab6 100644
--- a/src/lib/__tests__/enrollment.test.ts
+++ b/src/lib/__tests__/enrollment.test.ts
@@ -5,17 +5,23 @@ const mockDb = vi.hoisted(() => {
return {
processedWebhook: { create: 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,165 @@ 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' }),
+ }),
+ );
+ // 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();
+ });
+ });
+
+ 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');
+ });
+});
From ad71c2d29e2dab15a15330830933c8eb2e073b75 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 19 Jul 2026 03:33:22 +0000
Subject: [PATCH 6/9] =?UTF-8?q?fix(payments,security):=20harden=20reconcil?=
=?UTF-8?q?iation=20=E2=80=94=20webhook=20idempotency,=20cert=20races,=20r?=
=?UTF-8?q?efund=20audit=20log?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Addresses the ten findings from the post-reconciliation review pass.
Payments (src/lib/enrollment.ts):
- handleSourceChargeable: claim webhook idempotency BEFORE charging, run the
PayMongo call OUTSIDE the DB transaction (no locks/pooled connection held
across an HTTP round-trip), release the claim on charge failure so a
transient error retries instead of losing the sale, and fire post-purchase
emails after commit (they read via the top-level client and can't see a
transaction's uncommitted rows).
- H2 amount/currency check now rejects underpayment and a non-PHP/empty
currency instead of warning-and-proceeding; shared assertPaymentMatchesCheckout
covers both the source.chargeable and payment.paid paths.
- payment.paid now forwards the real source type so non-GCash payments aren't
mis-recorded (fallback is OTHER, never a silent GCASH).
Checkout:
- checkout.ts marks the CheckoutSession ERROR when Source creation fails so it
isn't orphaned as a permanent PENDING row (no cron/TTL reclaims it).
- checkout/complete resolves strictly by session id; drop the dead
paymongoReference fallback that was being fed the internal returnUrl.
Certificates (src/lib/certificates.ts):
- issueCertificate catches the H7 unique-index P2002 and returns the winning
row; the auto-issue page render no longer crashes to the error boundary on a
lost race.
Refunds (src/app/actions/refunds.ts):
- approve/reject now write AuditLog entries at every state transition
(AGENTS.md: every admin action logs, no exceptions), best-effort so a log
failure never misreports a refund that moved money.
Auth (src/lib/auth.ts):
- requireAdmin fails closed when the authoritative role lookup returns null
rather than falling back to the JWT's possibly-stale role.
Progress (src/app/actions/progress.ts):
- award XP before marking a lesson complete so any XP failure leaves the lesson
cleanly retryable instead of "completed but no XP"; documents why a single
shared transaction is incompatible with the ledger's P2002 exactly-once gate.
Email backfill:
- H6 migration now emits a WARNING for collision-skipped rows; add
scripts/report-email-collisions.ts to list them for manual reconciliation.
Tests: cert P2002 idempotency, requireAdmin null-role fail-closed, source
underpayment rejection; enrollment tests updated for post-commit email timing.
All: typecheck clean, lint 0 errors, 283 tests pass, coverage 88/86/78/87%
(>70% gate), build green.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
.../migration.sql | 23 ++
scripts/report-email-collisions.ts | 59 +++++
.../courses/[courseSlug]/certificate/page.tsx | 11 +-
src/app/(public)/checkout/complete/page.tsx | 19 +-
src/app/actions/checkout.ts | 13 +
src/app/actions/progress.ts | 60 +++--
src/app/actions/refunds.ts | 42 +++
src/lib/__tests__/auth.test.ts | 15 ++
src/lib/__tests__/certificates.test.ts | 24 ++
src/lib/__tests__/enrollment.test.ts | 16 +-
src/lib/auth.ts | 8 +-
src/lib/certificates.ts | 88 ++++---
src/lib/enrollment.ts | 248 +++++++++++-------
13 files changed, 459 insertions(+), 167 deletions(-)
create mode 100644 scripts/report-email-collisions.ts
diff --git a/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
index 4a35fad..cf2f0f0 100644
--- a/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
+++ b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
@@ -74,3 +74,26 @@ WHERE u.email <> lower(btrim(u.email))
WHERE o.id <> u.id
AND 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 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/scripts/report-email-collisions.ts b/scripts/report-email-collisions.ts
new file mode 100644
index 0000000..1f1c967
--- /dev/null
+++ b/scripts/report-email-collisions.ts
@@ -0,0 +1,59 @@
+#!/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) });
+
+async function main() {
+ 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 u.email <> lower(btrim(u.email))
+ AND EXISTS (
+ SELECT 1 FROM "User" o
+ WHERE o.id <> u.id
+ AND o.email = lower(btrim(u.email))
+ )
+ ORDER BY canonical, u.email
+ `;
+
+ if (rows.length === 0) {
+ console.log('No email-canonicalization collisions — every account is canonical.');
+ return;
+ }
+
+ console.log(`Found ${rows.length} account(s) needing manual reconciliation:\n`);
+ for (const r of rows) {
+ console.log(` ${r.id} ${r.email} → collides with existing "${r.canonical}"`);
+ }
+ console.log(
+ '\nResolve each by merging or renaming the duplicate account, then re-run the H6 UPDATE for that row.',
+ );
+}
+
+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..be2b372 100644
--- a/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx
+++ b/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx
@@ -70,10 +70,17 @@ 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 — 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 {
+ verificationHash = null;
+ }
}
const issuedDateFmt = new Intl.DateTimeFormat('en-PH', {
diff --git a/src/app/(public)/checkout/complete/page.tsx b/src/app/(public)/checkout/complete/page.tsx
index fc1d059..8a35617 100644
--- a/src/app/(public)/checkout/complete/page.tsx
+++ b/src/app/(public)/checkout/complete/page.tsx
@@ -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 } },
},
@@ -58,7 +53,7 @@ export default async function CheckoutCompletePage({ searchParams }: PageProps)
// '//evil.example', 'javascript:', encoded schemes, etc., not just a bare
// '//' prefix.
const returnUrl = validateRedirectUrl(rawReturnUrl, undefined) || undefined;
- const checkout = await resolveCheckout(checkoutSessionId, returnUrl);
+ const checkout = await resolveCheckout(checkoutSessionId);
if (status === 'failed') {
return ;
diff --git a/src/app/actions/checkout.ts b/src/app/actions/checkout.ts
index ec211e5..30d0ece 100644
--- a/src/app/actions/checkout.ts
+++ b/src/app/actions/checkout.ts
@@ -43,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';
// ---------------------------------------------------------------------------
@@ -205,6 +207,17 @@ 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}` };
}
diff --git a/src/app/actions/progress.ts b/src/app/actions/progress.ts
index e9e8a50..d634759 100644
--- a/src/app/actions/progress.ts
+++ b/src/app/actions/progress.ts
@@ -101,9 +101,21 @@ export const markLessonCompleteAction = createSafeAction(slugsSchema, async (dat
throw new Error('Complete this lesson by passing its quiz.');
}
- // Mark complete (idempotent) then award XP exactly once (C10). The two are
- // deliberately separate: re-completing a lesson must never re-grant XP, but
- // the progress row staying COMPLETED is harmless.
+ // 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 },
@@ -122,13 +134,6 @@ export const markLessonCompleteAction = createSafeAction(slugsSchema, async (dat
},
});
- await awardXpOnce(
- user.id,
- `lesson-complete:${lesson.id}`,
- lesson.xpReward,
- 'Lesson completed',
- );
-
revalidatePath(`/dashboard/courses/${data.courseSlug}`);
revalidatePath(`/dashboard/courses/${data.courseSlug}/lessons/${data.lessonSlug}`);
revalidatePath('/dashboard');
@@ -201,10 +206,26 @@ export const submitQuizAction = createSafeAction(submitQuizSchema, async (data)
timeSpentSeconds: data.timeSpentSeconds ?? 0,
});
- // If passed, mark lesson complete (idempotent) and award XP exactly once.
- // 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).
+ // 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 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 },
@@ -222,19 +243,6 @@ export const submitQuizAction = createSafeAction(submitQuizSchema, async (data)
xpEarned: lesson.xpReward + QUIZ_PASS_BONUS_XP,
},
});
-
- 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',
- );
}
revalidatePath(`/dashboard/courses/${data.courseSlug}/lessons/${data.lessonSlug}`);
diff --git a/src/app/actions/refunds.ts b/src/app/actions/refunds.ts
index 8d2b24f..c51e138 100644
--- a/src/app/actions/refunds.ts
+++ b/src/app/actions/refunds.ts
@@ -36,6 +36,25 @@ import {
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
@@ -256,6 +275,11 @@ export async function approveRefundAction(
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}` };
@@ -268,6 +292,10 @@ export async function approveRefundAction(
{ 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 {
@@ -317,6 +345,10 @@ export async function approveRefundAction(
{ 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.
@@ -326,6 +358,12 @@ export async function approveRefundAction(
};
}
+ await auditRefundAction('APPROVE_REFUND', request.id, {
+ amountPhp: request.amountPhp,
+ paymongoRefundId,
+ status: 'PROCESSED',
+ });
+
revalidatePath('/admin/refunds');
revalidatePath(`/admin/refunds/${request.id}`);
revalidatePath('/dashboard/payments');
@@ -384,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/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts
index 1ae112a..c51fc22 100644
--- a/src/lib/__tests__/auth.test.ts
+++ b/src/lib/__tests__/auth.test.ts
@@ -262,6 +262,21 @@ describe('auth.ts', () => {
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');
+ });
+
// ── verifyToken: invalid payload shape (lines 80-81) ────────────────────
it('verifyToken returns null when payload fields have wrong types', async () => {
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 a46dab6..98fe756 100644
--- a/src/lib/__tests__/enrollment.test.ts
+++ b/src/lib/__tests__/enrollment.test.ts
@@ -3,7 +3,7 @@ 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(), findFirst: fn(), update: fn(), updateMany: fn() },
payment: { create: fn(), findUnique: fn(), findFirst: fn(), update: fn() },
@@ -675,6 +675,10 @@ describe('enrollment.ts', () => {
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(
@@ -726,6 +730,16 @@ describe('enrollment.ts', () => {
).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', () => {
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 70ccb6a..5053e2a 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -207,10 +207,12 @@ export async function requireAdmin(): Promise {
select: { role: true },
});
- const effectiveRole = dbUser?.role ?? user.role;
- if (effectiveRole !== 'ADMIN') {
+ // 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: effectiveRole },
+ { component: 'auth', userId: user.id, role: dbUser?.role ?? 'missing' },
'non-admin → redirect /',
);
redirect('/');
diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts
index ed2f173..e5227bf 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/enrollment.ts b/src/lib/enrollment.ts
index 5dd8c19..06d2618 100644
--- a/src/lib/enrollment.ts
+++ b/src/lib/enrollment.ts
@@ -577,15 +577,63 @@ type SourceFlowCheckout = {
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 must call
+ * the user authorizes the payment on PayMongo's hosted page. We call
* `createPaymentFromSource` to convert the source into an actual Payment.
*
- * If the Payment is returned with status=paid, we also create the local
- * Payment + Enrollment immediately.
+ * 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,
@@ -594,90 +642,103 @@ export async function handleSourceChargeable(
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;
+ }
- return db.$transaction(async (tx) => {
- const firstTime = await markWebhookProcessed(
- eventId,
- 'source.chargeable',
- 'source',
- sourceId,
- `amount=${amountCentavos}`,
- 200,
- tx,
- );
- if (!firstTime) return null;
-
- // Find the CheckoutSession by source ID with all fields needed
- // for processPaymentPaidInTransaction (pricingTierId, discountCodeId, tier info)
- const checkout = (await tx.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) {
- 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 });
- // H2: Verify amount consistency with webhook amount and validate currency.
- // The finalAmountPhp is in centavos — verify consistency with webhook amount.
- const currency = event.data.attributes.data.attributes.currency;
- if (currency && currency !== 'PHP') {
- logger.error(
- { sourceId, currency, expectedCurrency: 'PHP' },
- 'source.chargeable: unexpected currency — rejecting',
+ // 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 },
+ });
+ } 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'),
);
- throw new Error(`Unexpected currency: ${currency}. Expected PHP.`);
- }
+ logger.error({ err, sourceId }, 'source.chargeable: charge failed; released claim for retry');
+ throw err; // surfaced to the webhook route → 500 → PayMongo retries
+ }
- const expectedAmount = checkout.finalAmountPhp;
- if (expectedAmount !== amountCentavos) {
- logger.warn(
- { sourceId, expectedAmount, actualAmount: amountCentavos },
- 'source.chargeable: amount mismatch — creating payment with webhook amount',
- );
- }
+ 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 {
- const payment = await createPaymentFromSource({
- amountCentavos,
- sourceId,
- description: `Checkout ${checkout.id} — Amazon PH Academy`,
- metadata: { checkoutId: checkout.id, ...metadata },
- });
-
- logger.info(
- { sourceId, paymentId: payment.id, status: payment.status },
- 'source.chargeable: payment created from source',
- );
-
- // If already paid, process immediately
- if (payment.status === 'paid') {
- const result = await processPaymentPaidInTransaction(
+ result = await db.$transaction((tx) =>
+ processPaymentPaidInTransaction(
tx,
payment,
checkout,
event.data.attributes.data.attributes.type,
- );
- if (result) {
- // Send enrollment confirmation + claim token email
- await sendPostPurchaseEmails(result);
- }
- }
-
- return payment;
+ ),
+ );
} catch (err) {
- logger.error({ err, sourceId }, 'source.chargeable: failed to create payment from source');
- throw err; // Will be caught by webhook handler
+ 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;
}
/**
@@ -693,7 +754,10 @@ export async function handlePaymentPaid(
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,
@@ -735,10 +799,10 @@ export async function handlePaymentPaid(
logger.warn({ paymentId: paymentIdPm }, 'payment.paid: no checkout session found');
return null;
}
- return processPaymentInCheckout(tx, paymentIdPm, amountCentavos, checkoutByPayment);
+ return processPaymentInCheckout(tx, paymentIdPm, amountCentavos, checkoutByPayment, sourceType, currency);
}
- return processPaymentInCheckout(tx, paymentIdPm, amountCentavos, checkout);
+ return processPaymentInCheckout(tx, paymentIdPm, amountCentavos, checkout, sourceType, currency);
});
}
@@ -750,24 +814,28 @@ async function processPaymentInCheckout(
paymentIdPm: string,
amountCentavos: number,
checkout: SourceFlowCheckout,
+ paymentMethod?: string,
+ currency?: string,
): Promise<{ enrollmentId: string; paymentId: string } | null> {
- // H2: Reconcile amount and currency. Validate before proceeding.
- // Note: payment.paid events from PayMongo carry amount in the top-level
- // event which we already checked. Currency should be PHP.
- if (checkout.finalAmountPhp !== amountCentavos) {
- logger.warn(
- { checkoutId: checkout.id, expected: checkout.finalAmountPhp, actual: amountCentavos },
- 'payment.paid: amount mismatch — creating enrollment with webhook amount',
- );
+ // 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;
}
- // For a Payment-based flow, the paymongoPaymentId on the CheckoutSession
- // was already set when we created the Payment (or it's set now).
- // Source type not available from payment.paid webhook — default to GCASH
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)
@@ -820,7 +888,9 @@ async function processPaymentPaidInTransaction(
netAmountPhp: checkout.finalAmountPhp,
currency: 'PHP',
paymongoPaymentId: paymentIdPm,
- method: paymentMethod ? mapPaymentMethod(paymentMethod) : PaymentMethod.GCASH,
+ // 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(),
},
From 0a1904420b25e909dfbe2448cd5a14e9fa223cc8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 19 Jul 2026 05:21:27 +0000
Subject: [PATCH 7/9] review: address CodeRabbit findings on #46
- seed.ts: completion log no longer prints the admin password or the removed
well-known [email protected]/ChangeMe123! defaults (dead ?? branches).
- certificate page: log auto-issue failures via the structured logger (userId,
courseId) instead of a silent catch.
- checkout/complete: pass '' fallback to validateRedirectUrl so missing/invalid
returnUrl becomes undefined, letting a failed checkout link back to /pricing
instead of /.
- refunds: ambiguous-outcome message now says "left for automatic
reconciliation" (the request stays APPROVED, not PENDING).
- Remove em-dashes from comments added by this branch (repo guideline).
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
.../migration.sql | 2 +-
prisma/seed.ts | 5 ++-
scripts/report-email-collisions.ts | 4 +--
.../courses/[courseSlug]/certificate/page.tsx | 9 +++--
src/app/(public)/checkout/complete/page.tsx | 7 ++--
src/app/actions/checkout.ts | 2 +-
src/app/actions/progress.ts | 2 +-
src/app/actions/refunds.ts | 4 +--
src/lib/__tests__/auth.test.ts | 2 +-
src/lib/__tests__/enrollment.test.ts | 2 +-
src/lib/auth.ts | 4 +--
src/lib/certificates.ts | 2 +-
src/lib/enrollment.ts | 36 +++++++++----------
13 files changed, 43 insertions(+), 38 deletions(-)
diff --git a/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
index cf2f0f0..9fcce6f 100644
--- a/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
+++ b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
@@ -76,7 +76,7 @@ WHERE u.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
+-- 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
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 0e77ad4..5ada820 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -401,9 +401,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
index 1f1c967..cfe07ed 100644
--- a/scripts/report-email-collisions.ts
+++ b/scripts/report-email-collisions.ts
@@ -4,7 +4,7 @@
* 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
+ * 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.
@@ -38,7 +38,7 @@ async function main() {
`;
if (rows.length === 0) {
- console.log('No email-canonicalization collisions — every account is canonical.');
+ console.log('No email-canonicalization collisions - every account is canonical.');
return;
}
diff --git a/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx b/src/app/(dashboard)/courses/[courseSlug]/certificate/page.tsx
index be2b372..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';
@@ -71,14 +72,18 @@ 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 — fall back to showing
+ // 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) {
try {
const issued = await issueCertificate(user.id, course.id);
verificationHash = issued?.verificationHash ?? null;
- } catch {
+ } catch (err) {
+ logger.error(
+ { err, userId: user.id, courseId: course.id },
+ 'Certificate auto-issue failed; showing progress fallback',
+ );
verificationHash = null;
}
}
diff --git a/src/app/(public)/checkout/complete/page.tsx b/src/app/(public)/checkout/complete/page.tsx
index 8a35617..20a9f76 100644
--- a/src/app/(public)/checkout/complete/page.tsx
+++ b/src/app/(public)/checkout/complete/page.tsx
@@ -49,10 +49,11 @@ export default async function CheckoutCompletePage({ searchParams }: PageProps)
// 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 — rejects
+ // C3: validateRedirectUrl replaces the ad hoc same-origin check. It rejects
// '//evil.example', 'javascript:', encoded schemes, etc., not just a bare
- // '//' prefix.
- const returnUrl = validateRedirectUrl(rawReturnUrl, undefined) || undefined;
+ // '//' 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') {
diff --git a/src/app/actions/checkout.ts b/src/app/actions/checkout.ts
index 30d0ece..e00343f 100644
--- a/src/app/actions/checkout.ts
+++ b/src/app/actions/checkout.ts
@@ -208,7 +208,7 @@ export async function createCheckoutSessionAction(
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
+ // 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({
diff --git a/src/app/actions/progress.ts b/src/app/actions/progress.ts
index d634759..8863df1 100644
--- a/src/app/actions/progress.ts
+++ b/src/app/actions/progress.ts
@@ -104,7 +104,7 @@ export const markLessonCompleteAction = createSafeAction(slugsSchema, async (dat
// 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 —
+ // 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
diff --git a/src/app/actions/refunds.ts b/src/app/actions/refunds.ts
index c51e138..cb2bced 100644
--- a/src/app/actions/refunds.ts
+++ b/src/app/actions/refunds.ts
@@ -41,7 +41,7 @@ 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
+ * 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(
@@ -301,7 +301,7 @@ export async function approveRefundAction(
return {
success: false,
error:
- 'Refund status is unknown (network error). It has been left pending and will reconcile automatically. Do not retry.',
+ 'Refund status is unknown (network error). It has been left for automatic reconciliation. Do not retry.',
};
}
diff --git a/src/lib/__tests__/auth.test.ts b/src/lib/__tests__/auth.test.ts
index c51fc22..a16263a 100644
--- a/src/lib/__tests__/auth.test.ts
+++ b/src/lib/__tests__/auth.test.ts
@@ -264,7 +264,7 @@ describe('auth.ts', () => {
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,
+ // 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',
diff --git a/src/lib/__tests__/enrollment.test.ts b/src/lib/__tests__/enrollment.test.ts
index 98fe756..8d547ff 100644
--- a/src/lib/__tests__/enrollment.test.ts
+++ b/src/lib/__tests__/enrollment.test.ts
@@ -676,7 +676,7 @@ describe('enrollment.ts', () => {
}),
);
// Post-purchase emails fire AFTER the transaction commits (best-effort,
- // not awaited inside it) — let the pending microtasks drain before
+ // 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
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index 5053e2a..c992050 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -199,7 +199,7 @@ export async function requireAuth(): Promise {
export async function requireAdmin(): Promise {
const user = await requireAuth();
- // H3: Load the authoritative role from the database — the JWT claim can be
+ // 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({
@@ -208,7 +208,7 @@ export async function requireAdmin(): Promise {
});
// 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
+ // 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(
diff --git a/src/lib/certificates.ts b/src/lib/certificates.ts
index e5227bf..c274b51 100644
--- a/src/lib/certificates.ts
+++ b/src/lib/certificates.ts
@@ -114,7 +114,7 @@ export async function issueCertificate(
// 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
+ // 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 {
diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts
index 06d2618..989b477 100644
--- a/src/lib/enrollment.ts
+++ b/src/lib/enrollment.ts
@@ -581,7 +581,7 @@ type SourceFlowCheckout = {
* 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.
+ * 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.
@@ -593,20 +593,20 @@ function assertPaymentMatchesCheckout(
context: Record,
): void {
if (currency !== undefined && currency !== 'PHP') {
- logger.error({ ...context, currency }, 'payment currency is not PHP — rejecting');
+ 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',
+ '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)',
+ 'payment amount exceeds expected - proceeding (overpayment)',
);
}
}
@@ -620,19 +620,19 @@ function assertPaymentMatchesCheckout(
*
* 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 —
+ * 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
+ * - 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
+ * - 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
+ * 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
+ * (never awaited inside it - they read via the top-level `db` client, which
* cannot see a transaction's uncommitted rows).
*/
export async function handleSourceChargeable(
@@ -644,7 +644,7 @@ export async function handleSourceChargeable(
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.
+ // 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,
@@ -670,7 +670,7 @@ export async function handleSourceChargeable(
},
})) as SourceFlowCheckout | null;
if (!checkout) {
- // Unknown source — leave the event marked (retrying won't conjure a
+ // 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;
@@ -682,18 +682,18 @@ export async function handleSourceChargeable(
// retry storm) rather than looping on the same mismatch.
assertPaymentMatchesCheckout(checkout.finalAmountPhp, amountCentavos, currency, { sourceId });
- // State 1 — external provider call, OUTSIDE any DB transaction.
+ // 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`,
+ description: `Checkout ${checkout.id} - Amazon PH Academy`,
metadata: { checkoutId: checkout.id, ...metadata },
});
} catch (err) {
// Release the idempotency claim so PayMongo's retry can re-attempt the
- // charge — a transient failure must not silently lose the sale.
+ // charge - a transient failure must not silently lose the sale.
await db.processedWebhook
.deleteMany({ where: { paymongoEventId: eventId } })
.catch((cleanupErr) =>
@@ -711,7 +711,7 @@ export async function handleSourceChargeable(
// 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
+ // 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;
@@ -731,7 +731,7 @@ export async function handleSourceChargeable(
);
}
if (result) {
- // Best-effort emails, AFTER commit — never awaited inside the transaction.
+ // Best-effort emails, AFTER commit - never awaited inside the transaction.
sendPostPurchaseEmails(result).catch((err: Error) =>
logger.error({ err }, 'Failed to send post-purchase emails'),
);
@@ -827,7 +827,7 @@ async function processPaymentInCheckout(
paymentId: paymentIdPm,
});
} catch (err) {
- logger.error({ err, checkoutId: checkout.id }, 'payment.paid: amount/currency check failed — not granting access');
+ logger.error({ err, checkoutId: checkout.id }, 'payment.paid: amount/currency check failed - not granting access');
return null;
}
@@ -889,7 +889,7 @@ async function processPaymentPaidInTransaction(
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.
+ // 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(),
From 0a4cd7e17c0dece10e3abfb099ee28339491cef0 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 19 Jul 2026 05:45:48 +0000
Subject: [PATCH 8/9] fix(ci,seed): unblock E2E/Lighthouse seed +
canonical-email collision safety
CI (the actual failing checks):
- E2E and Lighthouse jobs ran `tsx prisma/seed.ts` without ADMIN_EMAIL /
ADMIN_PASSWORD, which the C5 hardening now requires (seed throws
"ADMIN_EMAIL must not be empty" and aborts before seeding tiers/courses).
Provide CI-only test admin credentials in both job env blocks.
CodeRabbit Major findings:
- seed.ts: canonicalize ADMIN_EMAIL with trim().toLowerCase() to match the
app's sign-in lookup contract (a mixed-case env value otherwise seeds a row
auth can't find).
- H6 email backfill: the collision guard compared canonical-to-exact, so two
rows that are BOTH non-canonical but share a canonical form (e.g.
'Foo@Bar.com' + 'FOO@bar.com') both passed the guard and both UPDATE to the
same value in one MVCC-snapshotted statement, violating the unique email
index and failing the migration. Compare canonical-to-canonical so every
member of a colliding group is skipped. Same fix in the WARNING count query
and in scripts/report-email-collisions.ts (grouped HAVING COUNT(*) > 1).
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
.github/workflows/ci.yml | 8 ++++++
.../migration.sql | 16 +++++++++---
prisma/seed.ts | 4 ++-
scripts/report-email-collisions.ts | 25 +++++++++++--------
4 files changed, 38 insertions(+), 15 deletions(-)
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
index 9fcce6f..a0c1e67 100644
--- a/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
+++ b/prisma/migrations/20260718000000_audit_integrity_fixes/migration.sql
@@ -63,8 +63,16 @@ CREATE UNIQUE INDEX "Certificate_active_per_user_course_key"
-- ----------------------------------------------------------------------------
-- H6: backfill existing emails to canonical (trimmed, lowercase) form.
-- Rows whose canonical form would collide with another account are left
--- untouched — those need manual reconciliation before a case-insensitive
+-- 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))
@@ -72,11 +80,11 @@ WHERE u.email <> lower(btrim(u.email))
AND NOT EXISTS (
SELECT 1 FROM "User" o
WHERE o.id <> u.id
- AND o.email = lower(btrim(u.email))
+ 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
+-- 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
@@ -91,7 +99,7 @@ BEGIN
AND EXISTS (
SELECT 1 FROM "User" o
WHERE o.id <> u.id
- AND o.email = lower(btrim(u.email))
+ 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;
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 5ada820..89f13ab 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -39,7 +39,9 @@ async function upsertAdminUser(): Promise {
// account — that fallback shipping to production would be a standing
// account-takeover hole.
const isProduction = process.env.NODE_ENV === 'production';
- const rawEmail = (process.env.ADMIN_EMAIL ?? '').trim();
+ // 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(
diff --git a/scripts/report-email-collisions.ts b/scripts/report-email-collisions.ts
index cfe07ed..b266da6 100644
--- a/scripts/report-email-collisions.ts
+++ b/scripts/report-email-collisions.ts
@@ -23,31 +23,36 @@ if (!process.env.DATABASE_URL) {
const prisma = new PrismaClient({ adapter: new PrismaPg(process.env.DATABASE_URL) });
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 u.email <> lower(btrim(u.email))
- AND EXISTS (
- SELECT 1 FROM "User" o
- WHERE o.id <> u.id
- AND o.email = lower(btrim(u.email))
- )
+ 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 account is canonical.');
+ console.log('No email-canonicalization collisions - every canonical email is unique.');
return;
}
- console.log(`Found ${rows.length} account(s) needing manual reconciliation:\n`);
+ 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) {
- console.log(` ${r.id} ${r.email} → collides with existing "${r.canonical}"`);
+ console.log(` ${r.id} ${r.email} -> canonical "${r.canonical}"`);
}
console.log(
- '\nResolve each by merging or renaming the duplicate account, then re-run the H6 UPDATE for that row.',
+ '\nResolve each group by merging or renaming the duplicate accounts, then re-run the H6 UPDATE.',
);
}
From 6fb46b62446281a1ac09ded46d9d965ef6e73297 Mon Sep 17 00:00:00 2001
From: Claude
Date: Sun, 19 Jul 2026 05:49:49 +0000
Subject: [PATCH 9/9] fix(script): mask emails by default in collision report
(CWE-532)
The email-collision report printed full addresses (PII) to stdout, which
CI/cron/log-aggregation could retain. Mask the local part by default
(keeping the opaque user id, which is the actionable key), and require an
explicit SHOW_EMAILS=1 opt-in to print full addresses for reconciliation.
Co-Authored-By: Claude Opus 4.8
Claude-Session: https://claude.ai/code/session_01SmyprYCco2Hhu48UySiEkk
---
scripts/report-email-collisions.ts | 21 ++++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
diff --git a/scripts/report-email-collisions.ts b/scripts/report-email-collisions.ts
index b266da6..0ba315d 100644
--- a/scripts/report-email-collisions.ts
+++ b/scripts/report-email-collisions.ts
@@ -22,6 +22,20 @@ if (!process.env.DATABASE_URL) {
}
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
@@ -49,7 +63,12 @@ async function main() {
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) {
- console.log(` ${r.id} ${r.email} -> canonical "${r.canonical}"`);
+ 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.',