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¶
Coach — Year 1 — RM 599/year¶
Coach — Renewal — RM 199/year¶
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:
Step 3 — Register Webhook in Stripe Dashboard¶
- Go to Stripe Dashboard → Developers → Webhooks → Add endpoint
- Endpoint URL:
https://mind-meditate.com/api/stripe/webhook - Events to listen for:
checkout.session.completedinvoice.payment_succeededcustomer.subscription.deleted- Copy the Signing secret → paste into
STRIPE_WEBHOOK_SECRETin VPS.env→ restart PM2
Webhook Behaviour¶
checkout.session.completed — Explorer path¶
- Reads
userId+planfrom session metadata. - Idempotency check via
processedWebhookscollection (keyed bysession.id). - Sets
users/{uid}:plan: 'explorer',planExpiresAt(+1 year). - Records commission in
commissionscollection ifreferralCodepresent. - Increments
usedCount/usedByon promo code ifpromoCodeIdpresent. - Writes
processedWebhooks/{session.id}to prevent duplicate processing.
checkout.session.completed — Coach path¶
- Sets
users/{uid}.coachAppStatus = 'payment_received'— does not setplan: 'coach'. - Finds matching
coachApplicationsdoc by email + status'approved_pending_payment'→ updates to'payment_received'. - Creates a Stripe
SubscriptionSchedule: Phase 1 atSTRIPE_COACH_FIRST_PRICE_ID(1 iteration), Phase 2 atSTRIPE_COACH_RENEWAL_PRICE_ID(open-ended).
invoice.payment_succeeded (subscription_cycle)¶
- Extends
users/{uid}.planExpiresAtby 1 year from today.
customer.subscription.deleted¶
- Sets
users/{uid}.plan = 'free', clearsplanExpiresAtandcoachAppStatus. - Queries
coachescollection by user email → setsstatus: '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-planwhich batch-setsplan: 'explorer'on all member user docs. No Stripe — covered by manual B2B payment.Admin-granted access: Admin sets
plan: 'explorer'with noplanExpiresAtin AdminDashboard.hasPaidAccess()returnstruewhenplanExpiresAtis 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_failedhandling — surface a grace-period warning to the user - [ ] Stripe idempotency keys on checkout session creation (prevent duplicate sessions on retry)