PrimeCal Subscription System
Overview
PrimeCal's subscription system provides self-service plan management backed by Stripe Billing. Users can subscribe to paid plans, manage billing details, invite team members via seats, and access features gated by their plan tier.
This system is completely separate from the existing Stripe Connect reservation billing — it shares only the STRIPE_SECRET_KEY but uses a different Stripe product (Billing/Checkout vs. Connect).
Architecture
flowchart TD
U[User] -->|selects plan| FE[Frontend /app/subscription]
FE -->|POST /api/subscription/checkout| BE[Backend SubscriptionModule]
BE -->|create checkout session| S[Stripe Billing]
S -->|hosted checkout page| U
U -->|completes payment| S
S -->|webhook event| WH[POST /api/subscription/webhook]
WH -->|activates subscription| DB[(PostgreSQL)]
DB -->|user_entitlements cache| EG[FeatureAccessGuard]
EG -->|allows/blocks| API[Protected API endpoints]
Data Flow
- User browses plans at
/app/subscription - User clicks Subscribe → backend creates Stripe Checkout Session
- User completes payment on Stripe's hosted page
- Stripe fires
checkout.session.completedwebhook - Backend activates subscription, invalidates entitlement cache
- All subsequent API calls check entitlement cache (15-min TTL)
Environment Variables
# Required
STRIPE_SECRET_KEY=sk_live_... # Shared with reservation system
STRIPE_SUBSCRIPTION_WEBHOOK_SECRET=whsec_... # Separate from reservation webhook
STRIPE_PORTAL_RETURN_URL=https://app.primecal.eu/app/subscription
# Optional (with defaults)
ENABLE_SUBSCRIPTIONS=true
SUBSCRIPTION_GRACE_PERIOD_DAYS=7
SEAT_INVITATION_EXPIRY_DAYS=7
ENTITLEMENT_CACHE_TTL_MINUTES=15
Setup
- Run migrations:
cd backend-nestjs && npm run typeorm migration:run - Configure Stripe Dashboard:
- Enable Stripe Tax → add Hungary + EU tax rates
- Customer Portal: allow plan switch, cancellation, billing info, payment methods
- Register webhook:
https://app.primecal.eu/api/subscription/webhook - Copy webhook signing secret to
STRIPE_SUBSCRIPTION_WEBHOOK_SECRET
- System Admin creates plans: Log in as
admin@primecal.local→ navigate to/subscription - Set environment variables and restart backend
User Guide
Free Plan (default)
All new users are automatically assigned to the Free plan with no action required. Free plan:
- 1 seat (owner only)
- Limited features (configured by admin)
- No credit card required
Upgrading
- Navigate to
/app/subscription(via User Menu → Subscription) - Select a plan from the plan picker
- Choose billing period (monthly or yearly)
- Optionally enter a coupon code
- Click Subscribe → redirected to Stripe-hosted checkout
- Complete payment → returned to
/app/subscription?success=true
Managing Billing
Active subscribers can open the Stripe Customer Portal via Manage Billing button on the Current Plan card. From there users can:
- Update payment method
- Download invoices
- Change billing address
- Cancel subscription
Grace Period
When a payment fails, the subscription enters a 7-day grace period. During this time:
- Access continues as normal
- An orange warning banner is shown on
/app/subscription - User must update their payment method via Stripe Portal
- After 7 days without payment, access is revoked
Seat Management
Plans with seat_limit > 1 show the Seat Management panel. The subscription owner can:
- Invite team members by email
- Resend expired invitations (tokens valid 7 days)
- Revoke active seats
Invited members receive an in-app notification with an accept link at /seat-accept?token=....
Billing Profile
Users can save a billing address and VAT ID in the Billing Address section. This information syncs to their Stripe customer record asynchronously (DB is the source of truth; Stripe sync failures are logged but don't block saves).
API Reference
GET /api/subscription/plans
Returns publicly visible plans with pricing. No authentication required for public listing.
GET /api/subscription/current
Returns the authenticated user's current subscription state.
Response:
{
"status": "active",
"planSlug": "pro",
"planName": "Pro",
"billingPeriod": "monthly",
"currentPeriodEnd": "2026-07-25T00:00:00.000Z",
"cancelAtPeriodEnd": false,
"gracePeriodEndsAt": null,
"isSuspended": false,
"seatUsed": 1,
"seatLimit": 5,
"featureSlugs": ["automation", "calendar_sync"]
}
GET /api/subscription/entitlements
Returns the authenticated user's current feature slugs.
POST /api/subscription/checkout
Creates a Stripe Checkout Session.
Request:
{ "planId": 2, "billingPeriod": "monthly", "couponCode": "SAVE20" }
Response:
{ "checkoutUrl": "https://checkout.stripe.com/...", "sessionId": "cs_..." }
POST /api/subscription/portal
Creates a Stripe Customer Portal session. Returns { "portalUrl": "..." }.
POST /api/subscription/coupons/validate
Validates a coupon before checkout.
Request: { "code": "SAVE20", "planId": 2 }
Response: { "valid": true, "discountType": "percentage", "discountValue": 20 }
POST /api/subscription/cancel
Schedules subscription cancellation at the end of the current billing period.
POST /api/subscription/webhook
Stripe webhook endpoint. Requires Stripe-Signature header. No JWT auth.
Security Notes
- All endpoints (except
/webhookand/plans) require JWT authentication - Webhook signature verified via
stripe.webhooks.constructEvent()before any DB access - Webhook events are idempotent — replay of same
stripe_event_idis a no-op - Entitlement cache fails closed on error (user loses access rather than gains it)
- Seat invitation tokens are 256-bit random hex, expire in 7 days, single-use
- No credit card data ever touches PrimeCal servers — Stripe Checkout handles all card input
Troubleshooting
| Problem | Solution |
|---|---|
| Checkout session creation fails | Verify plan has stripe_price_id_monthly/yearly set (Stripe Sync button in admin portal) |
| Webhook events not received | Check STRIPE_SUBSCRIPTION_WEBHOOK_SECRET matches Stripe Dashboard webhook secret |
| User still on free after checkout | Check webhook delivery in Stripe Dashboard; verify webhook URL is publicly reachable |
| Portal button does nothing | User needs stripeCustomerId — created on first checkout |
| Entitlements stale after plan change | Cache TTL is 15 min; admin can force by adjusting ENTITLEMENT_CACHE_TTL_MINUTES=0 temporarily |