Skip to content

Feat/stripe#40

Open
vincenttao04 wants to merge 43 commits into
mainfrom
feat/stripe
Open

Feat/stripe#40
vincenttao04 wants to merge 43 commits into
mainfrom
feat/stripe

Conversation

@vincenttao04
Copy link
Copy Markdown
Contributor

@vincenttao04 vincenttao04 commented May 4, 2026

PR Review 3 (Current)

New Changes

  • Merged the latest main changes into this branch.
  • Updated user.ts by replacing the hasPaid Boolean field with a latestMembershipYear Number field.
  • Updated authRoutes.ts so deriveRole() determines member role using latestMembershipYear.
  • Added a getMembershipYear() helper in authRoutes.ts and userController.ts to determine the correct membership year.
    • December payments are counted toward the following membership year.
    • Note: Since there is no dedicated utilities file, the helper's code is duplicated across both files.

Updated Verification

MongoDB

After successful payment and signup:

  • A new User document should be created.
  • The latestMembershipYear field should be set to 2026.

PR Review 2

New Changes

Backend

  • Added a dedicated Payment model to track Stripe payments separately from user accounts.
  • paymentController.ts now creates a local Payment record when the Stripe Payment Intent is created.
    • Payments start as status: "pending" with userId: null.
  • webhookController.ts now updates existing payment records based on Stripe events.
    • Successful payments are marked as status: "succeeded".
    • Failed or cancelled payments are marked as status: "failed".
  • userController.ts now links the created user to an existing successful payment instead of storing payment fields directly on the User model.

Frontend

  • Signup.tsx now checks that the Stripe Payment Intent succeeded before calling the signup endpoint.

Updated Payment Flow

  1. User fills in the signup form and submits payment.
  2. Server creates a Stripe Payment Intent and a local Payment record with status: "pending" and userId: null.
  3. Frontend confirms payment using Stripe.js.
  4. If Stripe confirms the Payment Intent succeeded, the frontend calls the signup endpoint with the paymentIntentId.
  5. Stripe may also send payment_intent.succeeded to webhookController.ts, which updates the local Payment record to status: "succeeded".
  6. userController.ts also verifies the Payment Intent directly with Stripe before creating the user, so it does not rely only on the webhook.
  7. After the user is created, the existing Payment record is updated with userId.

Note: webhookController.ts and userController.ts run asynchronously, so either may update the payment status first.

Updated Security

  • Payment state is now tracked independently from user creation.
  • Incomplete signups can now be detected through Payment records, where:
    • status: "succeeded"
    • userId: null
  • This addresses the previous edge case where a user could lose network connection after payment succeeded but before account creation.
  • Backend still verifies the Payment Intent with Stripe before creating an account, so the frontend success state is not trusted on its own.
  • Payment reuse prevention now relies on whether the Payment record already has a linked userId.

Updated Verification

MongoDB

After successful payment and signup:

  • A User document should be created.
  • A separate Payment document should exist with:
    • status: "succeeded"
    • userId linked to the created user
    • stripePaymentIntentId populated
    • googleUid populated
    • paidAt populated

If payment succeeds but account creation does not complete, the Payment document should remain as:

status: "succeeded",
userId: null

Additional Notes

Stripe CLI

For local development, Stripe webhooks will only reach the backend if Stripe CLI is forwarding events to the local server:

stripe listen --forward-to localhost:3000/api/payments/webhook

Incomplete payment records

Because the Payment record is now created before payment confirmation, MongoDB may also contain pending or failed records where userId: null.

This is expected. The tradeoff allows us to detect the more important edge case where a payment succeeds but account creation does not complete, such as if the user loses network connection before the signup request finishes. Stripe webhook events will update records to succeeded or failed where possible.

PR Review 1

Changes

Backend

  • Installed Stripe SDK v21 to avoid latest-version TypeScript compatibility issues
  • paymentController.ts: creates a Stripe Payment Intent server-side; membership price is hardcoded at $5 NZD (not client-controlled)
  • webhookController.ts: listens for Stripe events and logs payment_intent.succeeded; structured for future event ticket support
  • userController.ts: verifies payment with Stripe before creating a user account; sets membershipPaid: true and paidAt on creation
  • paymentRoutes.ts / webhookRoutes.ts: registers /api/payments/create-payment-intent and /api/payments/webhook
  • index.ts: webhook route mounted before express.json() to preserve raw body for Stripe signature verification
  • user.ts: added membershipPaid, stripePaymentIntentId (unique, sparse), and paidAt fields to the existing User model

Frontend

  • Installed @stripe/react-stripe-js and @stripe/stripe-js
  • Signup.tsx: integrated Stripe CardElement into the existing sign up form; payment is processed inline before account creation

Payment Flow

  1. User signs into Google Authentication and is taken to /signup
  2. User fills in the sign-up form and card details
  3. On submit, the server creates a Payment Intent for $5 NZD
  4. Stripe.js confirms the card payment client-side
  5. On success, the server creates the User account with membershipPaid: true, and stores the stripePaymentIntentId and paidAt fields.

Security

  • Price is server-controlled: amount is hardcoded on the backend; the client only sends the payment type, never the amount. In future, this can be moved into a custom admin panel so admins can manage pricing, events, and related settings without code changes.
  • Payment Intent verified with Stripe: server calls stripe.paymentIntents.retrieve() to confirm status === "succeeded" before creating any account
  • Payment Intent reuse blocked: server checks no existing user holds the submitted paymentIntentId; prevents one payment being used to create multiple accounts
  • Webhook signature verified: all incoming webhook events are verified using STRIPE_WEBHOOK_SECRET before processing
  • Frontend validation before payment: all form fields are validated before Stripe is contacted; prevents unnecessary Payment Intents being created
  • Double submission prevented: submit button disabled during processing and while Stripe.js is loading

.env

Add the following to your .env files:

# server/.env
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..." # generated locally using Stripe CLI, each developer should generate their own (after PR is merged)

# client/.env
VITE_STRIPE_PUBLISHABLE_KEY="pk_test_..."

How to Test

Steps

  1. Add .env variables
  2. Sign in with Google and navigate to /signup
  3. 2Fill in the form and use the following Stripe test card: Card number: 4242 4242 4242 4242, Expiry: any future date, CVC: any 3 digits.
  4. Submit; you should be redirected to /

Verify

MongoDB: user document created with membershipPaid: true, paidAt set, and stripePaymentIntentId populated

image

Additional Notes

Rare Edge Case

  • If the user loses network connection after the payment succeeds but before the account is created, they may be charged without getting an account.
  • A proper fix would be complex and would require idempotency keys, local storage, and a payment recovery flow.
  • The simpler solution is to provide a support contact and manually resolve the issue using Stripe Payment Intent records that do not have an associated User account. This can be implemented later as part of the custom admin panel.

STRIPE_WEBHOOK_SECRET

  • For reviewing this PR, use the 3 keys I sent you to keep setup simple.
  • After testing, each developer should generate their own local STRIPE_WEBHOOK_SECRET by running:
stripe login
stripe listen --forward-to localhost:3000/api/payments/webhook
  • Copy the printed whsec_... value from the terminal and add it to server/.env:
STRIPE_WEBHOOK_SECRET="whsec_..."

Copy link
Copy Markdown
Collaborator

@RLee64 RLee64 left a comment

Choose a reason for hiding this comment

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

Nice work so far, should be able to integrate a lot better into the current system once #38 is merged into main

@vincenttao04 vincenttao04 requested a review from RLee64 May 15, 2026 14:23
RLee64
RLee64 previously requested changes May 17, 2026
Copy link
Copy Markdown
Collaborator

@RLee64 RLee64 left a comment

Choose a reason for hiding this comment

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

Great work!

Instead of associating user to stripePaymentIntentId, I think it should actually be the other way around. That is, we'll want a new collection that stores Payment objects (probably with stripePaymentIntentId as the id value, and an associated user ID as an attribute). This can then extend to logging transactions for events as well.

When you check for duplicate stripePaymentIntentId, it's then best to check this new Payment collection. Users shouldn't normally be deleted, but it won't be as reputable as a dedicated location. That said, if there's some sort of way to indicate to stripe that a stripePaymentIntent has somehow been 'used' to grant some sort of privilege, that'd be even better.

Also, just to verify my understanding, the client_secret is what associates a payment intent with a specific client right? Is there anything extra that needs to be done to verify that the paymentIntentId is coming from the initiating user (i.e. prevent malicious user from using my paymentIntentId)? Or should that be enough to make things secure?

Flagging this as requiring a re-review before merging since it's important payment-related code.

Comment thread server/src/controllers/webhookController.ts
@RLee64
Copy link
Copy Markdown
Collaborator

RLee64 commented May 17, 2026

Also merge main and resolve conflicts :)

@vincenttao04 vincenttao04 requested a review from RLee64 May 22, 2026 15:18
@vincenttao04 vincenttao04 dismissed RLee64’s stale review May 23, 2026 02:24

Implemented :)

RLee64
RLee64 previously requested changes May 25, 2026
Copy link
Copy Markdown
Collaborator

@RLee64 RLee64 left a comment

Choose a reason for hiding this comment

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

Nice work! Just one last step before we can merge this in, you'll need to merge main and update the admin auth code to work with this new payment system (since role payment plays a key role in determining roles). Unfortunately, the hasPaid/membershipPaid field is still necessary for our system as there may be multiple membership payments throughout the lifetime of an account (i.e. users paying for 2025 vs 2026).

Perhaps we try to automate this system a bit, and store the latest calender year a user has paid for 🤔 . This would be better than a boolean field that admins have to reset by manually press a button to reset at the end of each year. This also lets us get a bit more granular (if the clients wanted), where if a user paid in Dec 2025, maybe we set their paid for year to 2026 (since nothing happens in Dec anyways).

In a future ticket, we'll need to create a recovery flow to handle successful payments without actual accounts (as you've acknowledged, this PR is long enough). Some ideas for when we get around to it:

  • Hold sign-up info in a temporary location between submission and payment (and then fetching when entering the sign-up page to see if there's an almost complete account in process). This is a relatively more user-friendly method.
  • Make users re-enter their information, but check if there's an existing successful payment ID to use for account creation. This is more robust since backend crashes won't cause issues.

@vincenttao04 vincenttao04 dismissed RLee64’s stale review May 25, 2026 06:44

Implemented :)

@vincenttao04 vincenttao04 requested a review from RLee64 May 25, 2026 07:04
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.

2 participants