Skip to content

[#321] Blade/update stripe payment theming#415

Open
myr124 wants to merge 17 commits intomainfrom
blade/update-stripe-payment-theming
Open

[#321] Blade/update stripe payment theming#415
myr124 wants to merge 17 commits intomainfrom
blade/update-stripe-payment-theming

Conversation

@myr124
Copy link
Contributor

@myr124 myr124 commented Mar 10, 2026

Why

To update theming and appearance of stripe dashboard so that it integrates with blade's aesthetic. It also transitions us from a checkout session based payment portal (legacy) to embedded (stripe elements)

What

Closes: #321

  • Changes payment procedures to use stripe elements and paymentintent instead of checkout sessions.
  • Made new page to house these elements and make them fit blade theming (dark and light mode)
  • Added non-refundable note and note stating that system is powered by stripe
image image

Additional Notes

  • Webhook server needs to listen for "payment_intent.succeeded" instead of old checkout session events

Test Plan

  • Ran a test payment trial in blade UI, that involved me removing my duePayments table entry, clicking on pay dues button and going through payment process. The process led to a successful payment page with stripe checkout id, row being inserted back into duePayments table and my status changing from "Not Paid" to "Paid"
  • Ran stripe webhook listener to check for payment webhook events, when payments were tested, the appropriate webhook was sent
  • Checked test stripe dashboard and payments were successfully routed
image image

Checklist

  • Database: No schema changes, OR I have contacted the Development Lead to run db:push before merging
  • Environment Variables: No environment variables changed, OR I have contacted the Development Lead to modify them on Coolify BEFORE merging.

Summary by CodeRabbit

  • New Features

    • Hosted Stripe checkout with Payment Elements, themed UI (light/dark), order summary, and email collection; dedicated gated checkout page.
  • Improvements

    • Simplified dashboard flow to open the checkout page.
    • Clearer handling of in-progress ("processing") payments and improved success redirects.
  • Bug Fixes

    • Prevents duplicate payment records and improves error messaging and validation.

@myr124 myr124 linked an issue Mar 10, 2026 that may be closed by this pull request
@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Migrates dues payment from Stripe Checkout sessions to Stripe PaymentIntents + Stripe Elements: payment button now routes to a local /member/checkout, adds a client-side CheckoutForm that creates/uses a PaymentIntent, updates API/webhook to fulfill PaymentIntents idempotently, and enforces a DB unique constraint for member-year dues.

Changes

Cohort / File(s) Summary
Payment Navigation & Page
apps/blade/src/app/_components/dashboard/member/payment/payment-button.tsx, apps/blade/src/app/member/checkout/page.tsx
Removed TRPC Checkout Session flow from the payment button; button now routes to /member/checkout. Added gated checkout page (session-guarded, redirects if already paid).
Checkout UI & Success Flow
apps/blade/src/app/_components/dashboard/member/checkout-form.tsx, apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx
Added client-side Stripe Elements CheckoutForm (creates PaymentIntent via createPaymentIntent, theme-aware appearance, confirmPayment flow). Success page now reads payment_intent param, validates status, and shows a processing state.
API / Webhook / TRPC
packages/api/src/routers/dues-payment.ts, apps/blade/src/app/api/membership/route.ts
Added createPaymentIntent() TRPC mutation returning clientSecret. Replaced Checkout Session success flow with PaymentIntent-based validation. Added fulfillPaymentIntent(paymentIntentId) and extended webhook handling to idempotently insert DuesPayment.
Database Schema
packages/db/src/schemas/knight-hacks.ts
Added composite unique constraint on dues_payment (memberId, year) to prevent duplicate member-year payments; used with onConflictDoNothing in inserts.
Docs / Cleanup
MIGRATION_STATUS.md
Removed MIGRATION_STATUS.md.

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as Payment Button / Checkout Page
    participant Client as CheckoutForm (Browser)
    participant TRPC as Backend (TRPC)
    participant Stripe as Stripe API
    participant Webhook as Webhook Handler
    participant DB as Database

    User->>UI: Click "Pay"
    UI->>UI: router.push("/member/checkout")
    UI->>TRPC: validatePaidDues()
    TRPC-->>UI: not paid
    UI->>Client: render CheckoutForm
    Client->>TRPC: createPaymentIntent()
    TRPC->>Stripe: Create PaymentIntent (metadata: member_id)
    Stripe-->>TRPC: client_secret
    TRPC-->>Client: clientSecret
    Client->>Stripe: confirmPayment(return_url, receipt_email)
    Stripe-->>User: redirect to success page with payment_intent
    User->>UI: open success page (payment_intent)
    UI->>TRPC: orderSuccess(paymentIntentId)
    TRPC->>Stripe: retrieve PaymentIntent
    Stripe-->>TRPC: payment intent data (status, metadata)
    TRPC->>DB: insert DuesPayment (idempotent / onConflictDoNothing)
    Webhook->>TRPC: payment_intent.succeeded -> fulfillPaymentIntent(paymentIntentId)
    TRPC->>DB: idempotent insert (guarded by unique constraint)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

Major, Feature, API, Database, UI

Suggested reviewers

  • DVidal1205
  • must108
🚥 Pre-merge checks | ✅ 6 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The MIGRATION_STATUS.md deletion is unrelated to #321's Stripe payment theming objectives and represents out-of-scope housekeeping. Remove the MIGRATION_STATUS.md deletion from this PR and merge it separately, or update the PR description to justify why it belongs with the Stripe payment changes.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title follows the required format [#321] and concisely describes the main change: updating Stripe payment theming.
Linked Issues check ✅ Passed All objectives from #321 are met: dark theme integration (buildAppearance with isDark), embedded Stripe Elements + PaymentIntent (CheckoutForm.tsx, createPaymentIntent mutation), and PaymentIntent webhook handling added.
No Hardcoded Secrets ✅ Passed The pull request properly manages sensitive credentials through environment variables rather than hardcoded strings, with comprehensive pattern searches confirming secure handling.
Validated Env Access ✅ Passed Pull request correctly uses validated env import pattern across all modified files with no direct process.env access violations.
No Typescript Escape Hatches ✅ Passed No TypeScript escape hatches found. All code uses explicit type annotations, optional chaining, nullish coalescing, and type guards.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch blade/update-stripe-payment-theming

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@myr124 myr124 added the Blade Change modifies code in Blade app label Mar 10, 2026
@myr124 myr124 self-assigned this Mar 10, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (1)
apps/blade/src/app/_components/dashboard/member/checkout-form.tsx (1)

177-200: Drive the displayed price from the same source as Stripe.

$25.00 is duplicated three times here while the server charges CLUB.MEMBERSHIP_PRICE. The next price change can put the UI and Stripe total out of sync.

Keep the summary bound to the shared price constant

Add import { CLUB } from "@forge/consts"; near the other imports, then derive one formatted value:

+    const membershipPrice = `$${(CLUB.MEMBERSHIP_PRICE / 100).toFixed(2)}`;
+
     return (
         <div className="mx-auto max-w-4xl overflow-hidden rounded-lg border bg-card shadow-sm">
             <div className="grid md:grid-cols-2">
                 {/* Left — order summary */}
                 <div className="flex flex-col justify-between bg-muted p-8">
@@
-                        <h2 className="mt-2 text-4xl font-bold">$25.00</h2>
+                        <h2 className="mt-2 text-4xl font-bold">{membershipPrice}</h2>
@@
-                            <span className="font-medium">$25.00</span>
+                            <span className="font-medium">{membershipPrice}</span>
@@
-                            <span>$25.00</span>
+                            <span>{membershipPrice}</span>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/blade/src/app/_components/dashboard/member/checkout-form.tsx` around
lines 177 - 200, The UI is hardcoding "$25.00" in checkout-form.tsx in three
places causing drift from the server value; import the CLUB constant (import {
CLUB } from "@forge/consts") at the top of the file, compute a single
formattedPrice from CLUB.MEMBERSHIP_PRICE (use Intl.NumberFormat or your
project's currency formatter) inside the CheckoutForm component, and replace the
three literal "$25.00" occurrences (the h2 amount, the line-item amount, and the
"Total due today" amount) with that formattedPrice so the display always matches
CLUB.MEMBERSHIP_PRICE.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/blade/src/app/_components/dashboard/member/checkout-form.tsx`:
- Around line 98-103: The current conditional in checkout-form.tsx treats
paymentIntent.status === "processing" the same as "succeeded" and routes to
router.push(`/member/success?payment_intent=${paymentIntent.id}`), but
downstream logic (membership-success-page.tsx and api.duesPayment.orderSuccess)
only considers "succeeded" final; change the handling so only "succeeded" routes
to the success page and handle "processing" as a pending state instead (e.g.,
render or route to a pending UI or keep the user on the checkout/pending state
until a webhook or poll confirms success), updating the paymentIntent.status
check in the function that performs the router.push to exclude "processing" and
add the pending flow for "processing" intents.

In `@apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx`:
- Around line 25-27: Remove the client-side debug log that prints sensitive
payment data: locate the console.log(data) in the membership-success-page React
component (membership-success-page.tsx) and delete that line (or replace it with
a safe, non-sensitive debug statement), ensuring no payer email or payment
intent id is logged to the browser console.

In `@apps/blade/src/app/api/membership/route.ts`:
- Around line 78-86: The current select-then-insert race on DuesPayment (the
existing query and db.insert of validated.data) allows duplicates; add a
schema-backed unique key (e.g., payment_intent_id or a unique composite like
(member_id, period) depending on your domain) to the DuesPayment table, include
that unique field in validated.data (and in the payment_intent.succeeded flow),
and replace the preflight select with an atomic upsert/insert-with-conflict
behavior (use your ORM's insert...onConflict/doNothing or upsert API against
DuesPayment) so the database enforces idempotency instead of the non-atomic
existing check.
- Around line 130-137: The webhook handler currently returns non-2xx responses
for business outcomes; change the response codes for the cases handling
payment_intent.payment_failed, payment_intent.canceled,
checkout.session.async_payment_failed, checkout.session.expired (and any
checkout.session.canceled branch) to return a 2xx acknowledgement (e.g.,
Response("OK", { status: 200 })) so Stripe will not retry delivery, and instead
use the existing logger to record the business outcome/state (e.g., log event
type and reason) inside the same branches; ensure only genuine processing errors
(signature/db failures) still return non-2xx.

In `@packages/api/src/routers/dues-payment.ts`:
- Around line 128-142: discord.log(...) is called unconditionally causing
duplicate audit posts; move the discord.log call inside the idempotent branch
guarded by existing.length === 0 so it only runs when you actually insert a new
DuesPayment. Specifically, after the db.insert(DuesPayment).values({ memberId,
amount: paymentIntent.amount, paymentDate: new Date(paymentIntent.created *
1000), year: new Date().getFullYear() }) completes, call discord.log with the
same payload (using paymentIntent.amount and ctx.session.user.discordUserId) and
remove the unconditional call outside that block.
- Around line 64-90: Before creating a new Stripe PaymentIntent in
createPaymentIntent, query the DuesPayment table for the found member (use
member[0].id) to detect an existing successful/paid dues record and reject the
request; if a paid dues row exists throw a TRPCError (e.g., code "CONFLICT" or
"BAD_REQUEST") with a clear message instead of creating a new payment intent.
Ensure the check runs after locating the Member and before
stripe.paymentIntents.create, referencing DuesPayment and member[0].id to locate
any completed payments and short-circuit duplicate checkout attempts.
- Around line 110-149: The handler currently trusts the PaymentIntent id and
returns sensitive info without verifying ownership; retrieve
paymentIntent.metadata?.member_id (the memberId) and compare it to the
authenticated user identifier in ctx.session.user (e.g. ctx.session.user.id or
ctx.session.user.memberId — use whichever field represents the member record in
your app); if the metadata is missing or does not strictly equal the session
user’s id, throw a TRPCError with code "FORBIDDEN" and stop processing; perform
this check immediately after retrieving the PaymentIntent (before inserting into
DuesPayment, logging with discord.log, or returning receipt_email) so only the
owner can view/acknowledge their payment.

---

Nitpick comments:
In `@apps/blade/src/app/_components/dashboard/member/checkout-form.tsx`:
- Around line 177-200: The UI is hardcoding "$25.00" in checkout-form.tsx in
three places causing drift from the server value; import the CLUB constant
(import { CLUB } from "@forge/consts") at the top of the file, compute a single
formattedPrice from CLUB.MEMBERSHIP_PRICE (use Intl.NumberFormat or your
project's currency formatter) inside the CheckoutForm component, and replace the
three literal "$25.00" occurrences (the h2 amount, the line-item amount, and the
"Total due today" amount) with that formattedPrice so the display always matches
CLUB.MEMBERSHIP_PRICE.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: e41d5ed3-cb6e-47fd-976e-680f04dfc421

📥 Commits

Reviewing files that changed from the base of the PR and between c936e2a and dd5dfb8.

📒 Files selected for processing (6)
  • apps/blade/src/app/_components/dashboard/member-dashboard/payment/payment-button.tsx
  • apps/blade/src/app/_components/dashboard/member/checkout-form.tsx
  • apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx
  • apps/blade/src/app/api/membership/route.ts
  • apps/blade/src/app/member/checkout/page.tsx
  • packages/api/src/routers/dues-payment.ts

Comment on lines +130 to +137
case "payment_intent.payment_failed":
return new Response("Payment failed", {
status: 401,
});
case "payment_intent.canceled":
return new Response("Payment canceled", {
status: 410,
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Stripe webhooks, does returning a non-2xx status for a handled event cause Stripe to retry delivery, and should expected payment_intent.payment_failed/payment_intent.canceled events still return 2xx?

💡 Result:

Yes. For Stripe webhooks, any delivery that doesn’t get a successful 2xx response (including 3xx redirects, 4xx, 5xx, or timeouts) is treated as a failed delivery, and Stripe will automatically retry (in live mode, for up to ~3 days with exponential backoff; shorter in sandbox/test). Stripe’s dashboard will show the event as Pending/Failed and when the next retry will occur. [1]

And yes: if you successfully received and handled an event—even when the payment outcome is failure/canceled—you should still return 2xx for expected events like payment_intent.payment_failed and payment_intent.canceled. A non-2xx should be reserved for cases where your webhook processing failed (for example, you couldn’t verify/parse the event, your DB/queue is down, etc.) and you want Stripe to retry. [1][2]


🏁 Script executed:

# Read the membership webhook route to understand the full context
head -200 apps/blade/src/app/api/membership/route.ts | cat -n

Repository: KnightHacks/forge

Length of output: 5564


Return 2xx for all handled Stripe events; reserve error codes for webhook processing failures.

The webhook handler returns 401/410 for payment_intent.payment_failed and payment_intent.canceled, but these are expected business outcomes, not delivery failures. Stripe treats any non-2xx response as a failed delivery and retries the event (up to ~3 days in live mode)—causing unnecessary retries of already-handled events.

Additionally, three other events have the same issue: checkout.session.async_payment_failed (line 113), checkout.session.expired (line 123), and checkout.session.canceled if added. Reserve non-2xx status codes only for actual webhook processing failures (e.g., signature verification failed, database unavailable).

The logger is already imported; use it to record the event state:

Fix for all affected events
     case "checkout.session.async_payment_failed":
+      logger.warn("Async payment failed", { sessionId: event.data.object.id });
-      return new Response("Payment failed", {
-        status: 401,
+      return new Response("Event received", {
+        status: 200,
       });
     case "checkout.session.expired":
+      logger.warn("Checkout session expired", { sessionId: event.data.object.id });
-      return new Response("Checkout session expired", {
-        status: 408,
+      return new Response("Event received", {
+        status: 200,
       });
     case "payment_intent.payment_failed":
+      logger.warn("Payment failed", { paymentIntentId: event.data.object.id });
-      return new Response("Payment failed", {
-        status: 401,
+      return new Response("Event received", {
+        status: 200,
       });
     case "payment_intent.canceled":
+      logger.warn("Payment canceled", { paymentIntentId: event.data.object.id });
-      return new Response("Payment canceled", {
-        status: 410,
+      return new Response("Event received", {
+        status: 200,
       });

Per Stripe webhook documentation: acknowledge receipt with 2xx after processing, letting your logging track the business outcome.

📝 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
case "payment_intent.payment_failed":
return new Response("Payment failed", {
status: 401,
});
case "payment_intent.canceled":
return new Response("Payment canceled", {
status: 410,
});
case "payment_intent.payment_failed":
logger.warn("Payment failed", { paymentIntentId: event.data.object.id });
return new Response("Event received", {
status: 200,
});
case "payment_intent.canceled":
logger.warn("Payment canceled", { paymentIntentId: event.data.object.id });
return new Response("Event received", {
status: 200,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/blade/src/app/api/membership/route.ts` around lines 130 - 137, The
webhook handler currently returns non-2xx responses for business outcomes;
change the response codes for the cases handling payment_intent.payment_failed,
payment_intent.canceled, checkout.session.async_payment_failed,
checkout.session.expired (and any checkout.session.canceled branch) to return a
2xx acknowledgement (e.g., Response("OK", { status: 200 })) so Stripe will not
retry delivery, and instead use the existing logger to record the business
outcome/state (e.g., log event type and reason) inside the same branches; ensure
only genuine processing errors (signature/db failures) still return non-2xx.

@myr124 myr124 requested a review from a team as a code owner March 10, 2026 19:09
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/api/src/routers/dues-payment.ts`:
- Around line 78-83: The current dues lookup only filters by memberId
(existingDues) and must also be scoped to the billing year; compute a single
billingYear (e.g., from the incoming request or a helper like
computeBillingYear()) once and use it in the query predicates: add
eq(DuesPayment.billingYear, billingYear) to the .where(...) that builds
existingDues and to the later read/lookup used when deciding whether to record a
new payment (the same memberId-only predicate on Lines 143-147). Ensure both the
pre-check (existingDues) and the payment-recording/read paths use the same
billingYear variable so uniqueness by (memberId, year) is honored.
- Around line 142-155: Replace the race-prone check-then-insert with a single
conflict-aware insert into DuesPayment that uses the (memberId, year) unique key
as the ON CONFLICT target (e.g. via
db.insert(DuesPayment).values(...).onConflict(columns: [DuesPayment.memberId,
DuesPayment.year]).doNothing() or the equivalent in your ORM), capture whether
the insert actually affected a row (via returned row count/returning), and only
emit the Discord/logging action when the insert succeeded (i.e., this process
"won" the write); remove the separate select() existence check and ensure the
inserted payload still uses memberId, paymentIntent.amount,
paymentIntent.created -> paymentDate, and new Date().getFullYear() for year.
- Around line 119-124: The current check throws for any PaymentIntent.status !==
"succeeded" — update the handler around the paymentIntent variable so it only
throws a TRPCError for terminal failure statuses (e.g., "requires_action",
"requires_payment_method", "canceled") and returns non-terminal states (like
"processing") to the client so they can show a pending state; keep the existing
fulfillment path for paymentIntent.status === "succeeded" and ensure final
settlement is handled by your payment_intent.succeeded webhook and the frontend
success page logic (membership-success-page.tsx) is updated to display
"processing" as pending rather than an error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 322d9b00-7c6f-4d5f-8aef-47d569deb34a

📥 Commits

Reviewing files that changed from the base of the PR and between dd5dfb8 and 9b06fcf.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml, !pnpm-lock.yaml
📒 Files selected for processing (6)
  • MIGRATION_STATUS.md
  • apps/blade/src/app/_components/dashboard/member/checkout-form.tsx
  • apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx
  • apps/blade/src/app/api/membership/route.ts
  • packages/api/src/routers/dues-payment.ts
  • packages/db/src/schemas/knight-hacks.ts
💤 Files with no reviewable changes (1)
  • MIGRATION_STATUS.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/blade/src/app/_components/dashboard/member/checkout-form.tsx
  • apps/blade/src/app/api/membership/route.ts

@myr124
Copy link
Contributor Author

myr124 commented Mar 10, 2026

Most of the code rabbit changes were pretty intuitive to apply and easy to understand the rationale for. The race condition fix was a little difficult to wrap my head around though. But it prevents duplication through a constraint

(table) => ({
    uniqueMemberYear: unique().on(table.memberId, table.year),
  }),

2nd statement in image
image

We have to update our schema with db:push for this. Let me know what you think

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (4)
packages/api/src/routers/dues-payment.ts (4)

127-143: ⚠️ Potential issue | 🔴 Critical

Verify the PaymentIntent belongs to ctx.session.user before fulfilling it.

This handler trusts the caller-provided PaymentIntent id and uses metadata.member_id directly, but it never proves that member maps back to the authenticated user. Any signed-in user who gets another valid intent id can read its receipt email and attempt fulfillment for someone else. Reject missing or foreign member_id before any insert or logging. As per coding guidelines packages/api/**: "Authorization checks in every procedure".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 127 - 143, The code
currently trusts paymentIntent.metadata.member_id and inserts a DuesPayment
without confirming the intent actually belongs to the authenticated user; before
doing any insert or logging, validate that paymentIntent.metadata.member_id is
present and maps to ctx.session.user (or the expected member record) and reject
if it is missing or does not match the authenticated user; locate the logic
around paymentIntent, memberId, db and DuesPayment and perform an
ownership/authorization check (e.g., compare memberId to ctx.session.user.id or
query the Members table for a record owned by ctx.session.user) and return an
error/abort if the check fails, only then proceed with inserting into
DuesPayment.

79-83: ⚠️ Potential issue | 🟠 Major

Scope the dues guard to the billing year.

This lookup only filters by memberId, so any historical dues row will block checkout forever once the (memberId, year) uniqueness rule is in play. Compute one billingYear and include it here, then reuse that same year in the fulfillment path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 79 - 83, The lookup
for existing dues only filters by memberId; compute the billing year once (e.g.,
billingYear) and add eq(DuesPayment.billingYear, billingYear) to the where
clause that builds existingDues, and then reuse that same billingYear variable
in the fulfillment path so both the guard and the row creation/fulfillment use
the identical (memberId, billingYear) pair; update references to existingDues,
DuesPayment, and any fulfillment code to use the billingYear variable.

129-150: ⚠️ Potential issue | 🔴 Critical

Make fulfillment a single idempotent write and only log when it wins.

The select-then-insert here still races the webhook path, so the loser can hit the (memberId, year) unique constraint after a successful payment. discord.log() also runs even when this request did not create a new dues row. Collapse this to one conflict-aware insert and emit the audit log only when that insert actually succeeds.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 129 - 150, Replace the
select-then-insert race with a single conflict-aware insert on the DuesPayment
table (include memberId and year in the unique constraint) using your DB
client's ON CONFLICT DO NOTHING / upsert API (call
db.insert(DuesPayment).values(...).onConflictDoNothing() or equivalent). After
the insert, check the insert result/affected rows to determine whether the
insert actually succeeded and only call discord.log(...) when the insert won
(i.e., affectedRows > 0); do not run the prior select or log unconditionally.
Ensure you still set amount and paymentDate from paymentIntent and compute year
as before.

120-125: ⚠️ Potential issue | 🟠 Major

Treat processing as pending, not failed.

createPaymentIntent() enables us_bank_account, and those PaymentIntents can legitimately come back as processing while the bank transfer settles. Throwing on every non-succeeded status will turn valid ACH payments into false failures; only reject terminal failure states here and let payment_intent.succeeded finish fulfillment.

For Stripe PaymentIntents created with payment_method_types ["card", "us_bank_account"], can status "processing" be returned after confirmation, and should fulfillment wait for the payment_intent.succeeded webhook instead of treating "processing" as a failure?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 120 - 125, The current
check in the dues-payment handler that throws whenever paymentIntent.status !==
"succeeded" is treating transient states like "processing" (used by
us_bank_account ACH flows) as failures; update the logic around the
paymentIntent.status check (the block referencing paymentIntent.status and the
createPaymentIntent() flow) to only throw for terminal failure states (e.g.,
"canceled" or "requires_payment_method") while treating "processing" (and other
non-terminal states such as "requires_capture") as pending: return a pending
response or defer fulfillment and do not throw, and only fulfill when
paymentIntent.status === "succeeded".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx`:
- Around line 20-23: The code currently sets paymentIntentId =
searchParams.get("payment_intent") ?? "" and immediately calls
api.duesPayment.orderSuccess.useQuery(paymentIntentId); instead, detect a
missing/empty payment_intent first and avoid calling the backend: if
searchParams.get("payment_intent") is null/empty, render the invalid
confirmation state (or redirect) and do not invoke
api.duesPayment.orderSuccess.useQuery; otherwise pass the real id to the hook
and/or use the query's enabled option (e.g., enabled: Boolean(paymentIntentId))
so the query only runs when paymentIntentId is present. Ensure references:
paymentIntentId, searchParams, and api.duesPayment.orderSuccess.useQuery are
updated accordingly.

---

Duplicate comments:
In `@packages/api/src/routers/dues-payment.ts`:
- Around line 127-143: The code currently trusts
paymentIntent.metadata.member_id and inserts a DuesPayment without confirming
the intent actually belongs to the authenticated user; before doing any insert
or logging, validate that paymentIntent.metadata.member_id is present and maps
to ctx.session.user (or the expected member record) and reject if it is missing
or does not match the authenticated user; locate the logic around paymentIntent,
memberId, db and DuesPayment and perform an ownership/authorization check (e.g.,
compare memberId to ctx.session.user.id or query the Members table for a record
owned by ctx.session.user) and return an error/abort if the check fails, only
then proceed with inserting into DuesPayment.
- Around line 79-83: The lookup for existing dues only filters by memberId;
compute the billing year once (e.g., billingYear) and add
eq(DuesPayment.billingYear, billingYear) to the where clause that builds
existingDues, and then reuse that same billingYear variable in the fulfillment
path so both the guard and the row creation/fulfillment use the identical
(memberId, billingYear) pair; update references to existingDues, DuesPayment,
and any fulfillment code to use the billingYear variable.
- Around line 129-150: Replace the select-then-insert race with a single
conflict-aware insert on the DuesPayment table (include memberId and year in the
unique constraint) using your DB client's ON CONFLICT DO NOTHING / upsert API
(call db.insert(DuesPayment).values(...).onConflictDoNothing() or equivalent).
After the insert, check the insert result/affected rows to determine whether the
insert actually succeeded and only call discord.log(...) when the insert won
(i.e., affectedRows > 0); do not run the prior select or log unconditionally.
Ensure you still set amount and paymentDate from paymentIntent and compute year
as before.
- Around line 120-125: The current check in the dues-payment handler that throws
whenever paymentIntent.status !== "succeeded" is treating transient states like
"processing" (used by us_bank_account ACH flows) as failures; update the logic
around the paymentIntent.status check (the block referencing
paymentIntent.status and the createPaymentIntent() flow) to only throw for
terminal failure states (e.g., "canceled" or "requires_payment_method") while
treating "processing" (and other non-terminal states such as "requires_capture")
as pending: return a pending response or defer fulfillment and do not throw, and
only fulfill when paymentIntent.status === "succeeded".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 8b14a623-6d6a-413f-b78b-6199bca61ec7

📥 Commits

Reviewing files that changed from the base of the PR and between 9b06fcf and 6c7f72d.

📒 Files selected for processing (3)
  • apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx
  • apps/blade/src/app/api/membership/route.ts
  • packages/api/src/routers/dues-payment.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/blade/src/app/api/membership/route.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (1)
packages/api/src/routers/dues-payment.ts (1)

143-144: ⚠️ Potential issue | 🔴 Critical

Authorization gap: verify the PaymentIntent belongs to the current user.

The procedure retrieves a PaymentIntent by ID from user input but never confirms that paymentIntent.metadata.member_id belongs to ctx.session.user. This allows any authenticated user who obtains a valid PaymentIntent ID to:

  1. View another user's receipt_email
  2. Potentially trigger fulfillment for another member

Per coding guidelines: "Authorization checks in every procedure."

🛡️ Ownership check
       const memberId = paymentIntent.metadata.member_id ?? "";
 
+      // Verify ownership: the PaymentIntent must belong to the current user
+      const owner = await db
+        .select({ userId: Member.userId })
+        .from(Member)
+        .where(eq(Member.id, memberId))
+        .limit(1);
+
+      if (owner.length === 0 || owner[0].userId !== ctx.session.user.id) {
+        throw new TRPCError({
+          code: "FORBIDDEN",
+          message: "Payment does not belong to the current user.",
+        });
+      }
+
       const billingYear = new Date().getFullYear();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 143 - 144, Retrieve
the current user id from ctx.session.user (e.g., ctx.session.user.id) and
enforce ownership by comparing it to paymentIntent.metadata.member_id (the
memberId variable) after fetching the PaymentIntent in the procedure; if they
differ, throw an authorization error (e.g., TRPCError with code "FORBIDDEN") and
stop processing so the receipt_email and any fulfillment actions are only
accessible for the owning user.
🧹 Nitpick comments (1)
packages/api/src/routers/dues-payment.ts (1)

91-98: Use the already-extracted memberId variable for consistency.

Line 96 re-accesses member[0]?.id when you've already safely extracted memberId on line 78. Using the existing variable improves readability and avoids redundant optional chaining.

🔧 Suggested fix
     const paymentIntent = await stripe.paymentIntents.create({
       amount: price,
       currency: "usd",
       payment_method_types: ["card", "us_bank_account"],
       metadata: {
-        member_id: member[0]?.id ?? "",
+        member_id: memberId,
       },
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 91 - 98, Replace the
redundant member[0]?.id usage with the already-extracted memberId when creating
the Stripe PaymentIntent; in the payment creation call
(stripe.paymentIntents.create / paymentIntent) use metadata: { member_id:
memberId ?? "" } so the code consistently references the memberId variable and
avoids repeated optional chaining.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx`:
- Around line 43-64: The terminalFailureStatuses array in dues-payment.ts
currently omits "requires_capture", allowing Stripe payment intents that require
capture to be treated as successful; update the terminalFailureStatuses constant
(used in the payment result validation logic in dues-payment.ts) to include
"requires_capture" so that these intents are treated as failures and trigger the
existing error path (preventing membership creation and allowing the frontend
isError handling to redirect).
- Around line 27-31: The current early-return branch that calls toast.error and
router.push during render should be moved into a useEffect to avoid side effects
during render; update the logic around paymentIntentId in
membership-success-page.tsx so that when paymentIntentId is falsy you call
toast.error(...) and router.replace(SIGN_IN_PATH) from inside a useEffect
(dependent on paymentIntentId and router) and still return null from the
component render until navigation occurs; replace router.push with
router.replace to avoid leaving the invalid URL in history.

In `@packages/api/src/routers/dues-payment.ts`:
- Around line 134-141: The early return for PaymentIntent with status
"processing" exposes receipt_email before verifying ownership; after retrieving
paymentIntent in the handler (the paymentIntent variable returned from Stripe),
immediately perform the authorization check by comparing
paymentIntent.metadata.member_id to ctx.session.user (or the appropriate user id
field) and throw/return an unauthorized error if they don't match, before any
returns that expose email or other sensitive fields—apply this change to the
branch that currently returns the object for status === "processing" so the
ownership check runs first.

---

Duplicate comments:
In `@packages/api/src/routers/dues-payment.ts`:
- Around line 143-144: Retrieve the current user id from ctx.session.user (e.g.,
ctx.session.user.id) and enforce ownership by comparing it to
paymentIntent.metadata.member_id (the memberId variable) after fetching the
PaymentIntent in the procedure; if they differ, throw an authorization error
(e.g., TRPCError with code "FORBIDDEN") and stop processing so the receipt_email
and any fulfillment actions are only accessible for the owning user.

---

Nitpick comments:
In `@packages/api/src/routers/dues-payment.ts`:
- Around line 91-98: Replace the redundant member[0]?.id usage with the
already-extracted memberId when creating the Stripe PaymentIntent; in the
payment creation call (stripe.paymentIntents.create / paymentIntent) use
metadata: { member_id: memberId ?? "" } so the code consistently references the
memberId variable and avoids repeated optional chaining.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 704e0632-e3d5-4bf5-801d-41a4eb65121c

📥 Commits

Reviewing files that changed from the base of the PR and between 6c7f72d and eabea23.

📒 Files selected for processing (2)
  • apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx
  • packages/api/src/routers/dues-payment.ts

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx (1)

39-43: ⚠️ Potential issue | 🟡 Minor

Side effects during render: move isError handling into useEffect.

The toast.error() and router.push() calls here execute during render, which can fire multiple times as React reconciles. The !paymentIntentId case was correctly moved to useEffect, but this isError case was missed.

This causes duplicate error toasts and potential race conditions with navigation.

Move into the existing useEffect
   useEffect(() => {
     if (!paymentIntentId) {
       toast.error("Invalid confirmation link.");
       router.replace(SIGN_IN_PATH);
+      return;
+    }
+
+    if (isError) {
+      toast.error("Something went wrong, please contact support.");
+      router.replace(SIGN_IN_PATH);
     }
-  }, [paymentIntentId, router]);
+  }, [paymentIntentId, isError, router]);

   if (!paymentIntentId) {
     return null;
   }

-  if (isError) {
-    toast.error("Something went wrong, please contact support.");
-    router.push(SIGN_IN_PATH);
+  if (isError) {
     return null;
   }

Also note: use router.replace() instead of router.push() to prevent the broken URL from staying in browser history (consistent with the paymentIntentId case).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx`
around lines 39 - 43, The isError branch currently calls toast.error(...) and
router.push(...) during render; move this logic into the existing useEffect that
handles !paymentIntentId so side effects run only after mount, and replace
router.push with router.replace to avoid leaving the broken URL in history;
locate the isError check and relocate its toast.error and navigation calls into
the same useEffect (keeping the toast message and using router.replace),
ensuring the component returns null or fallback UI during render while effects
handle the notifications/navigation.
🧹 Nitpick comments (2)
packages/api/src/routers/dues-payment.ts (2)

64-100: Well-structured PaymentIntent creation with proper guards.

This implementation correctly:

  • Validates membership before creating the intent
  • Scopes the existing dues check to the current billing year (line 83)
  • Stores member_id in metadata for later verification
  • Returns the client secret for frontend use

One small defensive improvement: paymentIntent.client_secret can be null in Stripe's TypeScript definitions (though practically it's always present on creation). Consider a guard or assertion.

Optional defensive check
     const paymentIntent = await stripe.paymentIntents.create({
       amount: price,
       currency: "usd",
       payment_method_types: ["card", "us_bank_account"],
       metadata: {
         member_id: memberId,
       },
     });
+
+    if (!paymentIntent.client_secret) {
+      throw new TRPCError({
+        code: "INTERNAL_SERVER_ERROR",
+        message: "Failed to create payment intent.",
+      });
+    }
+
     return { clientSecret: paymentIntent.client_secret };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 64 - 100, In
createPaymentIntent (protectedProcedure.mutation) add a defensive check after
stripe.paymentIntents.create: verify paymentIntent.client_secret is non-null and
if it is null throw a TRPCError (e.g., INTERNAL) with a clear message (or
otherwise handle/assert) before returning { clientSecret:
paymentIntent.client_secret }; this ensures the createPaymentIntent flow fails
loudly if Stripe returns a null client_secret and avoids returning an unexpected
null to the frontend.

174-176: Consider formatting the amount in dollars for readability.

The Discord log currently shows paymentIntent.amount in cents (e.g., 2500). For staff reviewing logs, dollars would be clearer.

Suggested format
       if (inserted.length > 0) {
         await discord.log({
-          message: `A member has successfully paid their dues. ${paymentIntent.amount}`,
+          message: `A member has successfully paid their dues. $${(paymentIntent.amount / 100).toFixed(2)}`,
           title: "Dues Paid",
           color: "success_green",
           userId: ctx.session.user.discordUserId,
         });
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/api/src/routers/dues-payment.ts` around lines 174 - 176, The Discord
log is showing paymentIntent.amount in cents; update the discord.log call (where
paymentIntent.amount is used) to convert cents to dollars and format for
readability (e.g., divide by 100 and format to two decimal places or use
Intl.NumberFormat for USD) so the message shows a dollar amount like "$25.00"
instead of "2500"; keep the log title "Dues Paid" and include the formatted
amount in the message string.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx`:
- Around line 39-43: The isError branch currently calls toast.error(...) and
router.push(...) during render; move this logic into the existing useEffect that
handles !paymentIntentId so side effects run only after mount, and replace
router.push with router.replace to avoid leaving the broken URL in history;
locate the isError check and relocate its toast.error and navigation calls into
the same useEffect (keeping the toast message and using router.replace),
ensuring the component returns null or fallback UI during render while effects
handle the notifications/navigation.

---

Nitpick comments:
In `@packages/api/src/routers/dues-payment.ts`:
- Around line 64-100: In createPaymentIntent (protectedProcedure.mutation) add a
defensive check after stripe.paymentIntents.create: verify
paymentIntent.client_secret is non-null and if it is null throw a TRPCError
(e.g., INTERNAL) with a clear message (or otherwise handle/assert) before
returning { clientSecret: paymentIntent.client_secret }; this ensures the
createPaymentIntent flow fails loudly if Stripe returns a null client_secret and
avoids returning an unexpected null to the frontend.
- Around line 174-176: The Discord log is showing paymentIntent.amount in cents;
update the discord.log call (where paymentIntent.amount is used) to convert
cents to dollars and format for readability (e.g., divide by 100 and format to
two decimal places or use Intl.NumberFormat for USD) so the message shows a
dollar amount like "$25.00" instead of "2500"; keep the log title "Dues Paid"
and include the formatted amount in the message string.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 2a473477-5bfe-44ae-8a5e-9eec9ecf92cf

📥 Commits

Reviewing files that changed from the base of the PR and between eabea23 and 11851b3.

📒 Files selected for processing (2)
  • apps/blade/src/app/_components/dashboard/member/membership-success-page.tsx
  • packages/api/src/routers/dues-payment.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Blade Change modifies code in Blade app

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Update Stripe payment checkout to dark theme / custom endpoint

1 participant