Skip to content

Stripe Integration Guide

Last updated: June 2026 (revised — MongoDB references verified)


Overview

Payments flow entirely through Stripe Checkout — Stripe hosts the payment page, so no card data ever touches our servers. The integration runs entirely on the VPS backend (hostinger/personality-app/src/routes/stripe.js) — there are no Firebase Cloud Functions.

Route Auth What it does
POST /api/stripe/create-checkout Required Creates a Stripe Checkout session, returns { url }
POST /api/stripe/webhook None (Stripe signature) Handles payment events, updates MongoDB
POST /api/stripe/verify-session Required Fallback plan activation if webhook was missed

Payment flow (Explorer):

Client (Pricing page)
  └─ calls createCheckoutSession() in paymentService.ts
       └─ POST /api/stripe/create-checkout  (authenticated)
            └─ Stripe Checkout session created → { url } returned
                 └─ Browser redirects to Stripe-hosted page
                      └─ User pays → Stripe fires webhook
                           └─ POST /api/stripe/webhook (raw body, Stripe signature):
                                ├─ Verifies signature (STRIPE_WEBHOOK_SECRET)
                                ├─ Idempotency check (processedWebhooks collection)
                                ├─ Sets users/{uid}: plan:'explorer', planExpiresAt (+1 year)
                                ├─ Records coach commission if referralCode set
                                └─ Increments promo code usedCount if promoCodeId set
                                     └─ User redirected to /payment/success
                                          └─ Page calls POST /api/stripe/verify-session (fallback)

Payment flow (Coach) — two-step activation:

Admin pre-approves in AdminDashboard → user.coachAppStatus = 'approved_pending_payment'
  └─ Coach sees payment gate in CoachPortal
       └─ POST /api/stripe/create-checkout (plan:'coach')
            └─ Session at STRIPE_COACH_FIRST_PRICE_ID
                 └─ Coach pays → Stripe fires webhook
                      └─ POST /api/stripe/webhook:
                           ├─ Sets users/{uid}.coachAppStatus = 'payment_received'
                           ├─ Updates coachApplications doc → status: 'payment_received'
                           └─ Creates SubscriptionSchedule (RM 599 → RM 199/year)
                                └─ Admin sees "Activate Coach" button in AdminDashboard
                                     └─ Admin activates: coaches doc created, plan:'coach' set

Current Plans

Plan Price Billing Access duration plan value
Explorer RM 99/year Yearly recurring subscription 1 year (auto-renewed via invoice.payment_succeeded) 'explorer'
Coach RM 599 yr 1, RM 199/yr renewal Stripe Subscription Schedule (two phases) 1 year 'coach' (set by admin, not webhook)

Step 1 — Create Price Objects in the Stripe Dashboard

Go to Stripe Dashboard → Products → Add product for each plan.

Explorer — RM 99/year

Product name:  Explorer Plan
Price:         MYR 99.00 / year (recurring)

Coach — Year 1 — RM 599/year

Product name:  Coach Plan (Year 1)
Price:         MYR 599.00 / year (recurring)

Coach — Renewal — RM 199/year

Product name:  Coach Plan (Renewal)
Price:         MYR 199.00 / year (recurring)

Copy all three Price IDs (price_xxxx) — you'll need them for Step 2.


Step 2 — Set VPS Environment Variables

SSH into the VPS and add to /opt/personality-app/.env:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_EXPLORER_PRICE_ID=price_...
STRIPE_COACH_FIRST_PRICE_ID=price_...
STRIPE_COACH_RENEWAL_PRICE_ID=price_...
APP_URL=https://mind-meditate.com

# Agent commissions (share with /opt/agents-service/.env)
INTERNAL_API_KEY=<shared-secret>
AGENTS_SERVICE_URL=http://127.0.0.1:5003

Then restart the app:

pm2 restart personality-app --update-env && pm2 save

Step 3 — Register Webhook in Stripe Dashboard

  1. Go to Stripe Dashboard → Developers → Webhooks → Add endpoint
  2. Endpoint URL: https://mind-meditate.com/api/stripe/webhook
  3. Events to listen for:
  4. checkout.session.completed
  5. invoice.payment_succeeded
  6. customer.subscription.deleted
  7. Copy the Signing secret → paste into STRIPE_WEBHOOK_SECRET in VPS .env → restart PM2

Webhook Behaviour

checkout.session.completed — Explorer path

  1. Reads userId + plan from session metadata.
  2. Idempotency check via processedWebhooks collection (keyed by session.id).
  3. Sets users/{uid}: plan: 'explorer', planExpiresAt (+1 year).
  4. Records commission in commissions collection if referralCode present.
  5. Increments usedCount / usedBy on promo code if promoCodeId present.
  6. Writes processedWebhooks/{session.id} to prevent duplicate processing.

checkout.session.completed — Coach path

  1. Sets users/{uid}.coachAppStatus = 'payment_received' — does not set plan: 'coach'.
  2. Finds matching coachApplications doc by email + status 'approved_pending_payment' → updates to 'payment_received'.
  3. Creates a Stripe SubscriptionSchedule: Phase 1 at STRIPE_COACH_FIRST_PRICE_ID (1 iteration), Phase 2 at STRIPE_COACH_RENEWAL_PRICE_ID (open-ended).

invoice.payment_succeeded (subscription_cycle)

  • Extends users/{uid}.planExpiresAt by 1 year from today.

customer.subscription.deleted

  • Sets users/{uid}.plan = 'free', clears planExpiresAt and coachAppStatus.
  • Queries coaches collection by user email → sets status: 'inactive'.

What each plan grants

plan value planExpiresAt hasPaidAccess() Coach Portal How set
'free' false No Default
'explorer' ISO date 1 year out true (while not expired) No Stripe webhook or admin grant
'coach' ISO date 1 year out true (while not expired) Yes (after admin activation) Admin activation after Stripe payment confirmed

Organisation access: Admin calls POST /api/organisations/:id/activate-plan which batch-sets plan: 'explorer' on all member user docs. No Stripe — covered by manual B2B payment.

Admin-granted access: Admin sets plan: 'explorer' with no planExpiresAt in AdminDashboard. hasPaidAccess() returns true when planExpiresAt is absent.


Test Cards (Stripe Test Mode)

Card number Scenario
4242 4242 4242 4242 Payment succeeds
4000 0000 0000 9995 Payment declined (insufficient funds)
4000 0025 0000 3155 Requires 3D Secure authentication

Expiry: any future date. CVC: any 3 digits.

Use test Price IDs (created in test mode in Stripe Dashboard) during development. Switch to live Price IDs for production.


Environment Variables Summary

Variable Where set What it holds
VITE_STRIPE_PUBLISHABLE_KEY .env (frontend) Stripe publishable key (pk_live_...)
STRIPE_SECRET_KEY VPS .env Stripe secret key (sk_live_...)
STRIPE_WEBHOOK_SECRET VPS .env Webhook signing secret from Stripe Dashboard
STRIPE_EXPLORER_PRICE_ID VPS .env Stripe Price ID for Explorer plan (RM 99/year)
STRIPE_COACH_FIRST_PRICE_ID VPS .env Stripe Price ID for Coach plan year 1 (RM 599/year)
STRIPE_COACH_RENEWAL_PRICE_ID VPS .env Stripe Price ID for Coach plan renewal (RM 199/year)
APP_URL VPS .env https://mind-meditate.com — used for Stripe redirect URLs
INTERNAL_API_KEY VPS .env Shared secret used to call agents-service commission endpoint
AGENTS_SERVICE_URL VPS .env http://127.0.0.1:5003 — base URL for internal agents-service calls

Agent Commission Flow

After a successful checkout, stripe.js calls the standalone agents-service (fire-and-forget, non-fatal):

Stripe webhook fires checkout.session.completed
  └─ postAgentCommission() in stripe.js
       └─ POST http://127.0.0.1:5003/api/agents/internal/record-commission
            Headers: X-Internal-Api-Key: <INTERNAL_API_KEY>
            Body: { agentCode, product, customerId, sessionId, saleAmount, currency }

product values: 'mind-meditate:explorer' or 'mind-meditate:coach'

Commission tiers (defined in server/src/utils/agentCommissions.js):

Tier Lifetime sales Rate
Starter 0–10 15%
Bronze 11–25 18%
Silver 26–50 20%
Gold 51+ 25%

Bonuses: first_10_sales (+RM100), flash_5_in_3days (+RM150), repeat_customer (+5%), top_performer (+RM300).

If agents-service is unreachable, the commission is silently dropped — it never blocks or fails the payment response.


Remaining TODO

  • [ ] Coach plan button on Pricing.tsx — no self-serve coach signup; admin initiates via pre-approval flow
  • [ ] invoice.payment_failed handling — surface a grace-period warning to the user
  • [ ] Stripe idempotency keys on checkout session creation (prevent duplicate sessions on retry)