Payment System
Stripe-powered billing for subscriptions, credit pack purchases, invoices, promo codes, and free trials.
Architecture
Frontend ──GraphQL──> Backend (/graphql)
|
┌─────────────┼─────────────────┐
v v v
Plans Subscriptions Promo Codes
| | |
└──────┬──────┘ |
v v
Stripe Checkout ◄──── Stripe Coupon
|
v
POST /stripe/webhook
|
v
PostgreSQL (Prisma)
All payment flows use Stripe Checkout Sessions. The backend creates a session, returns the URL, and the frontend redirects the user to Stripe. After payment, Stripe fires webhooks back to POST /stripe/webhook for server-side processing.
Subscription Checkout Flow
- Frontend calls
startSubscriptionCheckout(planId, companyId?, promoCodeId?) - Backend validates the plan and company context
- If
promoCodeIdis provided, the associated Stripe coupon is attached - If
plan.trialDays > 0,subscription_data.trial_period_daysis passed to Stripe - A Stripe Checkout Session (
mode: 'subscription') is created and the URL is returned - User completes payment on Stripe
- Webhook
checkout.session.completed-- creates local subscription withINCOMPLETEstatus - Webhook
invoice.paid-- sets status toACTIVE(orTRIALING), creates invoice record - Event
subscription.created-- triggers initial credit allocation to the company balance
Subscriptions are user-based — they belong to the subscribing user, not the company. Companies inherit access via Company.billingOwnerId, which is auto-set on checkout completion. One user's subscription covers all companies where that user is the billing owner. The companyId field on Subscription is metadata only (for admin/audit).
Duplicate prevention: Each company can have at most one active subscription. Stripe checkout automatically deactivates all existing active subscriptions (including enterprise POSTPAID ones) before creating a new one.
Plan Stripe Auto-Sync
When a paid PREPAID plan is created or updated, the backend automatically creates the corresponding Stripe Product and recurring Price. This eliminates the need to manually run scripts or configure Stripe IDs.
Rules:
- Free plans (price = 0): skip Stripe entirely,
stripePriceIdstays null - POSTPAID plans (enterprise): skip Stripe — they bypass the checkout flow
- Price changes on existing plans: a new Stripe Price is created (Stripe prices are immutable). Existing subscribers remain on their original price.
- Checkout auto-fix (defense in depth): if a user tries to subscribe to a plan that somehow has no
stripePriceId, the backend auto-creates the Stripe pricing at checkout time before proceeding
CreatePlanInput.price is validated with @Min(0), not @IsPositive(). This is intentional so POSTPAID/enterprise plans (invoiced monthly, never charged through Stripe) can be saved with price=0 and still render correctly on the pricing page as "Custom / Contact Sales". Free-tier PREPAID plans (price=0) also skip Stripe via needsStripePricing() in plan.service.ts. The DTO comment in src/payment/plan/dto/create-plan.input.ts explains the reasoning; do not change it back to @IsPositive().
Plan.price Unit Convention
Plan.price is stored as whole currency units (e.g. 24 = €24.00, 99 = €99.00), NOT cents. The backend multiplies by 100 when creating the Stripe Price:
// src/payment/stripe/stripe.service.ts:159
unit_amount: Math.round(Number(plan.price) * 100)
This is the single source of the ×100 factor. Frontend forms must NOT pre-multiply on save, and pricing display code must NOT divide by 100 on load — Plan.price is already the human-readable value. A 2026-04-21 incident shipped a 100× mispricing bug because both the frontend's PlanForm.tsx and the backend's stripe.service.ts multiplied by 100, producing 240000 / 990000 cents in Stripe live-mode (€2,400 / €9,900/month instead of €24 / €99). After the fix, 8 frontend files stopped dividing/multiplying by 100; display code uses Plan.price verbatim and formats via Intl.NumberFormat.
Syncing Existing Plans
Two admin mutations are available for fixing plans that were created before auto-sync was added:
# Sync a single plan
mutation { syncPlanStripePricing(id: "plan-uuid") { id stripePriceId stripeProductId stripeConfigured } }
# Sync all plans missing Stripe pricing
mutation { syncAllPlansStripePricing { id name stripePriceId stripeConfigured } }
The stripeConfigured field returns true if the plan either has a valid stripePriceId or doesn't need one (free/POSTPAID).
Credit Pack Stripe Auto-Sync
Credit packs use the same auto-sync pattern as Plans, but create one-time prices instead of recurring ones. When createCreditPack is called via the admin GraphQL mutation, the backend immediately creates the matching Stripe Product and Price and stores their IDs on the row.
Differences from Plan auto-sync:
| Plan | Credit Pack | |
|---|---|---|
| Stripe Price type | Recurring (recurring.interval=month/year) | One-time (no recurring) |
Product name prefix | Plan: | Credit Pack: |
| Metadata key | metadata.planId | metadata.creditPackId |
| Stripe Service method | createPlanPrice() | createCreditPackPrice() |
| Backend resolver | createPlan / updatePlan / syncPlanStripePricing / syncAllPlansStripePricing | createCreditPack / updateCreditPack |
| Skip rule | price = 0 OR billingMode = POSTPAID | price = 0 |
The metadata fields (planId, creditPackId) are how the webhook handler routes a checkout.session.completed event back to the correct DB row. Never strip them from a manually-created Stripe Product/Price or the webhook will fail to fulfill the purchase.
Credit Pack Checkout Flow
- Frontend calls
purchaseCreditPack(input: { creditPackId, companyId? }) - Backend creates a
PENDINGCreditPackPurchaserecord - Stripe Products and Prices are auto-created if the pack lacks them (defense in depth — should already exist from
createCreditPack) - A Stripe Checkout Session (
mode: 'payment') is created with metadatapurchaseType: 'credit_pack' - User completes payment on Stripe
- Webhook
checkout.session.completedroutes bymetadata.purchaseType === 'credit_pack' - Credits are added to the user's personal balance (not the company balance)
- Purchase record is marked
COMPLETED
Credits from pack purchases always land in the user's personal balance. Users explicitly call transferCredits to route them wherever they want — into a company they billing-own, between two companies they billing-own, or back to their personal balance. See Credit Transfers for the full input shape and auth rules.
Webhook Processing
The endpoint POST /stripe/webhook handles 14 Stripe events with signature verification via STRIPE_WEBHOOK_SECRET.
Idempotency
Every event is recorded in the StripeEvent table before processing. Duplicate stripeEventId values are caught via a unique constraint, preventing double-processing even on Stripe retries.
Handled Events
| Event | Purpose |
|---|---|
checkout.session.completed | Subscription activation or credit pack purchase (routes by metadata) |
invoice.paid | Payment confirmed, activate subscription, create invoice |
invoice.payment_failed | Payment failed, set status to PAST_DUE |
invoice.finalized | Invoice ready for collection |
invoice.finalization_failed | Invoice finalization error |
invoice.payment_action_required | 3D Secure / SCA authentication needed |
invoice.upcoming | Renewal notification (~7 days before) |
customer.subscription.created | New subscription, emits subscription.created event |
customer.subscription.updated | Plan change or status change |
customer.subscription.deleted | Subscription cancelled |
customer.subscription.trial_will_end | Trial ending in 3 days |
customer.subscription.paused | Payment collection paused |
customer.subscription.resumed | Payment collection resumed |
charge.refunded | Full or partial refund processed |
Error Strategy
- Critical events (
invoice.paid,invoice.payment_failed,charge.refunded): errors are re-thrown, causing a 500 so Stripe retries. - Non-critical events (
invoice.upcoming,trial_will_end): errors are logged but swallowed. - Idempotency failures: return 200 OK immediately.
Invoice System
Invoices are created automatically by webhook handlers. For enterprise (POSTPAID) companies, invoices are also generated by a monthly cron job and include an OVERDUE status for past-due invoices. See Enterprise Post-Paid Billing for the full enterprise invoice lifecycle.
| Trigger | Invoice Status |
|---|---|
invoice.paid | PAID |
invoice.finalized | PENDING |
invoice.payment_failed | FAILED |
charge.refunded | REFUNDED or PARTIALLY_REFUNDED |
Manual invoices can be created via createManualInvoice for back-office adjustments. Invoice PDFs are fetched from Stripe via invoicePdfUrl(stripeInvoiceId).
Free Trial System
Plans with trialDays > 0 use Stripe-native free trials. Card info is collected at checkout, but $0 is charged during the trial period.
Behavior during trial:
- Subscription status is
TRIALING - Full plan features and credits are available immediately
- Plan limits are enforced (same as
ACTIVEsubscriptions — trial users are subject to the plan's feature caps) - Monthly credit replenishment includes
TRIALINGsubscriptions - Stripe auto-charges when the trial ends (status becomes
ACTIVE) - Cancelling before trial end results in no charge
Trial expiry notifications (3 channels):
| Source | Timing |
|---|---|
Stripe webhook trial_will_end | 3 days before |
| Subscription expiry cron (daily 9AM) | 7, 3, 1 days before trialEnd |
trial.expiring event | Triggers branded email + in-app notification |
Promo Codes
Promo codes support PERCENTAGE (e.g., 20% off) and FIXED_AMOUNT (e.g., $10 off) discounts. Codes with a stripeCouponId apply discounts directly in the Stripe Checkout Session.
Validation Rules (8 checks)
- Code exists in the database
- Code is active (
isActive = true) - Not expired (
expiresAt > now) - Usage limit not reached (
usageCount < usageLimit) - User hasn't already redeemed this code
- Code is applicable to the selected plan (or
applicablePlanIdsis empty for all plans) - User is in the allowed list (or
restrictedToUserIdsis empty for all users) - User has accepted the invitation (if
requiresInvitation = true)
Invitation System
Promo codes can require invitations. Invitations are sent via email with a secure token (7-day expiry). Statuses: PENDING, ACCEPTED, EXPIRED, DECLINED. Accepting an invitation adds the user to the code's restricted user list.
Reseller Flow
Bulk promo code purchases via PromoCodePlan bundles: buy a bundle through Stripe checkout, webhook auto-generates N promo codes assigned to the purchaser.
Event-Driven Credit Allocation
| Event | Trigger | Action |
|---|---|---|
subscription.created | New subscription activated | Grant initial creditsPerMonth to company balance |
user.created | New user registered | Grant initial credits if viewMode === JOB_SEEKER (default: 9) |
credits.replenished | Monthly cron (daily 2AM, 30-day cycle) | Add creditsPerMonth to company balance |
payment.succeeded | invoice.paid webhook | Emitted for notification listeners |
payment.failed | invoice.payment_failed webhook | Emitted for notification listeners |
trial.expiring | Trial ending soon | Branded email + in-app notification |
Environment Variables
| Variable | Required | Description |
|---|---|---|
STRIPE_SECRET_KEY | Yes | Stripe API secret key |
STRIPE_API_VERSION | Yes | Stripe API version |
STRIPE_WEBHOOK_SECRET | Yes | Webhook signing secret |
FRONTEND_SUCCESS_URL | Yes | Redirect after successful payment |
FRONTEND_CANCEL_URL | Yes | Redirect if payment cancelled |
JOB_SEEKER_INITIAL_CREDITS | No | Initial credits for new job seekers (default: 9) |