Skip to content

fix(payments): handle payment.failed webhook event#63

Merged
projectamazonph merged 1 commit into
mainfrom
claude/implementation-gaps-spec-6lrs2m
Jul 20, 2026
Merged

fix(payments): handle payment.failed webhook event#63
projectamazonph merged 1 commit into
mainfrom
claude/implementation-gaps-spec-6lrs2m

Conversation

@projectamazonph

@projectamazonph projectamazonph commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Summary

  • PayMongo's payment.failed event (fired for the Source-based checkout flow this app actually uses) was previously dropped silently by the webhook handler's default case — a buyer whose card was declined got no notice and the CheckoutSession stayed PENDING forever.
  • Adds handlePaymentFailed() in src/lib/enrollment.ts to mark the session FAILED and best-effort email the buyer a retry link.
  • Adds sendPaymentFailedEmail() template in src/lib/email.tsx.

Test plan

  • pnpm typecheck clean
  • pnpm test passing (includes new/extended enrollment.test.ts coverage)
  • Verify CI (Quality Gates, E2E, Lighthouse) green on this PR

Summary by CodeRabbit

  • New Features

    • Added automated hourly cleanup for expired checkout sessions.
    • Added handling for failed payment notifications, including checkout status updates and payment-failure emails.
    • Added secure authentication for scheduled cleanup requests using CRON_SECRET.
  • Bug Fixes

    • Prevented duplicate payment-failure notifications and protected completed payments from being downgraded.
  • Tests

    • Added coverage for scheduled cleanup, authentication, failed payments, duplicate events, and missing sessions.

Two business-layer spec gaps found in the implementation audit, both about
CheckoutSession lifecycle completeness:

payment.failed handling. The Source-based flow this codebase uses fires
`payment.failed`, which fell through to the webhook route's default case and
was dropped: the session stayed PENDING and the buyer was never told. Add
handlePaymentFailed (marks the session FAILED, refuses to downgrade an
already-PAID session) and a sendPaymentFailedEmail retry notice, wired into
the webhook route. Closes the missing "Payment failed" email trigger.

Checkout-expiry sweep. Abandoned sessions had no path out of
PENDING/AWAITING_PAYMENT, violating the spec guarantee that every session
ends in PAID, EXPIRED, FAILED, or ERROR. Add sweepExpiredCheckouts plus an
hourly Vercel cron at /api/cron/expire-checkouts, guarded by CRON_SECRET
(fails closed when unset).

Covered by unit tests for both handlers and the cron auth guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012nKVRGvpD3ens22gb1p8CW
Copilot AI review requested due to automatic review settings July 20, 2026 04:22

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds PayMongo payment-failure handling with checkout updates and email notifications, plus a scheduled, authenticated endpoint that expires stale checkout sessions hourly.

Changes

Checkout lifecycle handling

Layer / File(s) Summary
Payment failure contracts and email
src/lib/enrollment.ts, src/lib/email.tsx
Adds the payment.failed event type and a branded payment-failure email with a retry link.
Payment failure processing and webhook dispatch
src/lib/enrollment.ts, src/app/api/paymongo/webhook/route.ts, src/lib/__tests__/enrollment.test.ts
Handles payment-failure events idempotently, marks matching sessions as FAILED, preserves PAID sessions, sends notifications, and adds coverage for these paths.
Checkout expiration sweeper and cron endpoint
src/lib/enrollment.ts, src/app/api/cron/expire-checkouts/*, .env.example, vercel.json
Expires stale pending sessions and exposes the operation through a bearer-authenticated hourly cron route with validation tests.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested reviewers: copilot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding handling for PayMongo's payment.failed webhook event.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/implementation-gaps-spec-6lrs2m

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (7)
.env.example (1)

18-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove quotes around the placeholder value.

dotenv-linter flags quote characters on this line.

🔧 Proposed fix
-CRON_SECRET="generate-with-openssl-rand-base64-32"
+CRON_SECRET=generate-with-openssl-rand-base64-32
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.env.example around lines 18 - 21, Update the CRON_SECRET entry in the
environment example to remove the surrounding quote characters while preserving
the placeholder value and accompanying comments.

Source: Linters/SAST tools

src/lib/enrollment.ts (2)

672-702: 🧹 Nitpick | 🔵 Trivial

LGTM! One operational note: sweepExpiredCheckouts scans by status and expiresAt hourly; if CheckoutSession grows large, an index on (status, expiresAt) would keep this query cheap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/enrollment.ts` around lines 672 - 702, Add a database index covering
status and expiresAt for CheckoutSession to support the filtering performed by
sweepExpiredCheckouts. Update the schema or migration definition for
CheckoutSession, preserving the existing query behavior and soft-delete
filtering.

595-601: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Fire-and-forget email may never complete on serverless.

sendPaymentFailedEmail(...).catch(...) isn't awaited, and the caller (webhook/route.ts Line 138) awaits only handlePaymentFailed, which returns as soon as the transaction commits. Once the webhook route sends its response, Vercel can terminate the invocation before this pending promise resolves.

Once the function returns the response payload, it stops processing including any pending background tasks. Consider scheduling this with Next's after() (stable since Next 15.1) or waitUntil() so the send is guaranteed to run to completion instead of being silently dropped.

♻️ Proposed fix using `after()`
+import { after } from 'next/server';
...
   if (notify) {
-    // Best-effort — a failed email must not fail the webhook.
-    sendPaymentFailedEmail({
-      to: notify.email,
-      studentName: notify.email.split('@')[0] ?? 'there',
-      tierName: notify.tierName,
-    }).catch((err: Error) => logger.error({ err }, 'payment.failed: retry email send failed'));
+    // Best-effort — scheduled via after() so it survives past the response.
+    after(() =>
+      sendPaymentFailedEmail({
+        to: notify.email,
+        studentName: notify.email.split('@')[0] ?? 'there',
+        tierName: notify.tierName,
+      }).catch((err: Error) => logger.error({ err }, 'payment.failed: retry email send failed')),
+    );
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/enrollment.ts` around lines 595 - 601, Update the payment-failed
notification flow around sendPaymentFailedEmail and its caller
handlePaymentFailed so the email work is registered with Next.js after() or
waitUntil() instead of being left as an unawaited promise. Preserve the
best-effort behavior and existing error logging while ensuring the scheduled
task can complete after the webhook response is returned.
src/lib/email.tsx (2)

717-724: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Outer wrapper uses Inter instead of the design system fonts.

Every other element in this template (brand label, heading, CTA) uses Space Grotesk, but the outer div falls back to Inter, system-ui, sans-serif. As per coding guidelines, "Use Space Grotesk and JetBrains Mono only in product UI. Do not use Inter or system fonts."

🎨 Proposed fix
       style={{
-        fontFamily: 'Inter, system-ui, sans-serif',
+        fontFamily: 'Space Grotesk, sans-serif',
         backgroundColor: '`#F5F5F0`',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/email.tsx` around lines 717 - 724, Update the outer wrapper’s
fontFamily in the email template to use the design-system font stack, matching
the existing Space Grotesk usage in the brand label, heading, and CTA; remove
Inter and system-font fallbacks while leaving the other wrapper styles
unchanged.

Source: Coding guidelines


696-696: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Em-dashes in user-facing email copy.

The subject line ("Your payment didn't go through — ${tierName}") and body text ("...try again below — if a payment method was declined...") both use em-dashes. As per coding guidelines, "do not use emojis in code or commit messages, and do not use em-dashes. Use periods, commas, or parentheses instead."

✏️ Proposed fix
-    subject: `Your payment didn't go through — ${tierName}`,
+    subject: `Your payment didn't go through (${tierName})`,
-          Hi {studentName}, we couldn&apos;t complete your payment for {tierName}.
-          No charge was made. You can try again below — if a payment method was
-          declined, use a different one.
+          Hi {studentName}, we couldn&apos;t complete your payment for {tierName}.
+          No charge was made. You can try again below. If a payment method was
+          declined, use a different one.

Also applies to: 753-753

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/email.tsx` at line 696, Replace the em-dashes in the user-facing
payment email copy with permitted punctuation such as periods, commas, or
parentheses. Update both the subject template near the payment failure copy and
the body text near the declined payment guidance, preserving the existing
wording and interpolation.

Source: Coding guidelines

src/lib/__tests__/enrollment.test.ts (1)

29-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

LGTM! One coverage gap worth adding: no test exercises the paymongoPaymentId fallback lookup (when sourceId doesn't match), only the paymongoSourceId path is covered.

Also applies to: 132-156, 609-696

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/__tests__/enrollment.test.ts` around lines 29 - 83, Add coverage in
the enrollment handler tests for the fallback lookup using paymongoPaymentId
when the provided sourceId does not match, alongside the existing
paymongoSourceId scenarios. Update the relevant handleSourceChargeable or
payment-event test setup and assertions to verify the fallback record is found
and processed correctly.
src/app/api/cron/expire-checkouts/route.ts (1)

24-38: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

LGTM on the auth flow. Consider wrapping sweepExpiredCheckouts() in a try/catch with logger.error (mirroring the paymongo webhook route's error handling) so a DB failure during the hourly sweep is observable rather than surfacing only as a generic 500.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/cron/expire-checkouts/route.ts` around lines 24 - 38, Wrap the
sweepExpiredCheckouts call in GET with try/catch, log caught database or sweep
errors via logger.error using the cron-expire-checkouts component context, and
return an appropriate JSON 500 response instead of allowing the failure to
surface as an unhandled generic error.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/enrollment.ts`:
- Around line 559-593: Replace the unconditional update in the payment.failed
flow with an atomic conditional updateMany, matching the pattern used by
sweepExpiredCheckouts: target checkout.id and require status not equal to
CheckoutStatus.PAID. Preserve the existing early PAID check and return the
enrollment details only when the conditional mutation succeeds; handle a
zero-row update without downgrading or revoking the paid session.

---

Nitpick comments:
In @.env.example:
- Around line 18-21: Update the CRON_SECRET entry in the environment example to
remove the surrounding quote characters while preserving the placeholder value
and accompanying comments.

In `@src/app/api/cron/expire-checkouts/route.ts`:
- Around line 24-38: Wrap the sweepExpiredCheckouts call in GET with try/catch,
log caught database or sweep errors via logger.error using the
cron-expire-checkouts component context, and return an appropriate JSON 500
response instead of allowing the failure to surface as an unhandled generic
error.

In `@src/lib/__tests__/enrollment.test.ts`:
- Around line 29-83: Add coverage in the enrollment handler tests for the
fallback lookup using paymongoPaymentId when the provided sourceId does not
match, alongside the existing paymongoSourceId scenarios. Update the relevant
handleSourceChargeable or payment-event test setup and assertions to verify the
fallback record is found and processed correctly.

In `@src/lib/email.tsx`:
- Around line 717-724: Update the outer wrapper’s fontFamily in the email
template to use the design-system font stack, matching the existing Space
Grotesk usage in the brand label, heading, and CTA; remove Inter and system-font
fallbacks while leaving the other wrapper styles unchanged.
- Line 696: Replace the em-dashes in the user-facing payment email copy with
permitted punctuation such as periods, commas, or parentheses. Update both the
subject template near the payment failure copy and the body text near the
declined payment guidance, preserving the existing wording and interpolation.

In `@src/lib/enrollment.ts`:
- Around line 672-702: Add a database index covering status and expiresAt for
CheckoutSession to support the filtering performed by sweepExpiredCheckouts.
Update the schema or migration definition for CheckoutSession, preserving the
existing query behavior and soft-delete filtering.
- Around line 595-601: Update the payment-failed notification flow around
sendPaymentFailedEmail and its caller handlePaymentFailed so the email work is
registered with Next.js after() or waitUntil() instead of being left as an
unawaited promise. Preserve the best-effort behavior and existing error logging
while ensuring the scheduled task can complete after the webhook response is
returned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 88e59330-7933-4c6b-98dc-09dc09b41cc6

📥 Commits

Reviewing files that changed from the base of the PR and between 78f1931 and 11be4df.

📒 Files selected for processing (8)
  • .env.example
  • src/app/api/cron/expire-checkouts/route.test.ts
  • src/app/api/cron/expire-checkouts/route.ts
  • src/app/api/paymongo/webhook/route.ts
  • src/lib/__tests__/enrollment.test.ts
  • src/lib/email.tsx
  • src/lib/enrollment.ts
  • vercel.json

Comment thread src/lib/enrollment.ts
Comment on lines +559 to +593
const checkout =
(sourceId
? await tx.checkoutSession.findFirst({
where: { paymongoSourceId: sourceId, deletedAt: null },
select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } },
})
: null) ??
(await tx.checkoutSession.findFirst({
where: { paymongoPaymentId: paymentIdPm, deletedAt: null },
select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } },
}));

if (!checkout) {
logger.warn({ paymentId: paymentIdPm, sourceId }, 'payment.failed: no checkout session found');
return null;
}

// Never downgrade a session that already succeeded — a late/duplicate
// failure event must not revoke a paid enrollment.
if (checkout.status === CheckoutStatus.PAID) {
logger.warn({ checkoutId: checkout.id }, 'payment.failed: session already PAID, ignoring');
return null;
}

await tx.checkoutSession.update({
where: { id: checkout.id },
data: {
status: CheckoutStatus.FAILED,
failedAt: new Date(),
failureReason: reason,
},
});

return { email: checkout.email, tierName: checkout.pricingTier.name };
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

TOCTOU race can let a payment.failed event overwrite a concurrently-committed PAID session.

checkout.status === PAID is checked, then update is issued unconditionally on where: { id: checkout.id }. Under Postgres's default Read Committed isolation, a concurrent payment.paid transaction can commit PAID in between this findFirst and update, so this transaction's snapshot still shows the pre-paid status and proceeds to overwrite it back to FAILED — exactly the invariant the code comments say must never happen ("Never downgrade a session that already succeeded").

Guard the mutation atomically instead of doing check-then-act, the same pattern already used correctly in sweepExpiredCheckouts (Line 689-696) via a conditional updateMany.

🔒 Proposed fix: atomic conditional update
-    // Never downgrade a session that already succeeded — a late/duplicate
-    // failure event must not revoke a paid enrollment.
-    if (checkout.status === CheckoutStatus.PAID) {
-      logger.warn({ checkoutId: checkout.id }, 'payment.failed: session already PAID, ignoring');
-      return null;
-    }
-
-    await tx.checkoutSession.update({
-      where: { id: checkout.id },
-      data: {
-        status: CheckoutStatus.FAILED,
-        failedAt: new Date(),
-        failureReason: reason,
-      },
-    });
+    // Never downgrade a session that already succeeded — the WHERE clause
+    // guards this atomically against a concurrent payment.paid commit.
+    const { count } = await tx.checkoutSession.updateMany({
+      where: { id: checkout.id, status: { not: CheckoutStatus.PAID } },
+      data: {
+        status: CheckoutStatus.FAILED,
+        failedAt: new Date(),
+        failureReason: reason,
+      },
+    });
+    if (count === 0) {
+      logger.warn({ checkoutId: checkout.id }, 'payment.failed: session already PAID, ignoring');
+      return null;
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const checkout =
(sourceId
? await tx.checkoutSession.findFirst({
where: { paymongoSourceId: sourceId, deletedAt: null },
select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } },
})
: null) ??
(await tx.checkoutSession.findFirst({
where: { paymongoPaymentId: paymentIdPm, deletedAt: null },
select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } },
}));
if (!checkout) {
logger.warn({ paymentId: paymentIdPm, sourceId }, 'payment.failed: no checkout session found');
return null;
}
// Never downgrade a session that already succeeded — a late/duplicate
// failure event must not revoke a paid enrollment.
if (checkout.status === CheckoutStatus.PAID) {
logger.warn({ checkoutId: checkout.id }, 'payment.failed: session already PAID, ignoring');
return null;
}
await tx.checkoutSession.update({
where: { id: checkout.id },
data: {
status: CheckoutStatus.FAILED,
failedAt: new Date(),
failureReason: reason,
},
});
return { email: checkout.email, tierName: checkout.pricingTier.name };
});
const checkout =
(sourceId
? await tx.checkoutSession.findFirst({
where: { paymongoSourceId: sourceId, deletedAt: null },
select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } },
})
: null) ??
(await tx.checkoutSession.findFirst({
where: { paymongoPaymentId: paymentIdPm, deletedAt: null },
select: { id: true, email: true, status: true, pricingTier: { select: { name: true } } },
}));
if (!checkout) {
logger.warn({ paymentId: paymentIdPm, sourceId }, 'payment.failed: no checkout session found');
return null;
}
// Never downgrade a session that already succeeded — the WHERE clause
// guards this atomically against a concurrent payment.paid commit.
const { count } = await tx.checkoutSession.updateMany({
where: { id: checkout.id, status: { not: CheckoutStatus.PAID } },
data: {
status: CheckoutStatus.FAILED,
failedAt: new Date(),
failureReason: reason,
},
});
if (count === 0) {
logger.warn({ checkoutId: checkout.id }, 'payment.failed: session already PAID, ignoring');
return null;
}
return { email: checkout.email, tierName: checkout.pricingTier.name };
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/enrollment.ts` around lines 559 - 593, Replace the unconditional
update in the payment.failed flow with an atomic conditional updateMany,
matching the pattern used by sweepExpiredCheckouts: target checkout.id and
require status not equal to CheckoutStatus.PAID. Preserve the existing early
PAID check and return the enrollment details only when the conditional mutation
succeeds; handle a zero-row update without downgrading or revoking the paid
session.

@projectamazonph
projectamazonph merged commit 84753f8 into main Jul 20, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants