Zum Hauptinhalt springen
Was this helpful?

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

  1. User browses plans at /app/subscription
  2. User clicks Subscribe → backend creates Stripe Checkout Session
  3. User completes payment on Stripe's hosted page
  4. Stripe fires checkout.session.completed webhook
  5. Backend activates subscription, invalidates entitlement cache
  6. 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

  1. Run migrations: cd backend-nestjs && npm run typeorm migration:run
  2. 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
  3. System Admin creates plans: Log in as admin@primecal.local → navigate to /subscription
  4. 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

  1. Navigate to /app/subscription (via User Menu → Subscription)
  2. Select a plan from the plan picker
  3. Choose billing period (monthly or yearly)
  4. Optionally enter a coupon code
  5. Click Subscribe → redirected to Stripe-hosted checkout
  6. 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 /webhook and /plans) require JWT authentication
  • Webhook signature verified via stripe.webhooks.constructEvent() before any DB access
  • Webhook events are idempotent — replay of same stripe_event_id is 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

ProblemSolution
Checkout session creation failsVerify plan has stripe_price_id_monthly/yearly set (Stripe Sync button in admin portal)
Webhook events not receivedCheck STRIPE_SUBSCRIPTION_WEBHOOK_SECRET matches Stripe Dashboard webhook secret
User still on free after checkoutCheck webhook delivery in Stripe Dashboard; verify webhook URL is publicly reachable
Portal button does nothingUser needs stripeCustomerId — created on first checkout
Entitlements stale after plan changeCache TTL is 15 min; admin can force by adjusting ENTITLEMENT_CACHE_TTL_MINUTES=0 temporarily