Skip to main content

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

  1. Frontend calls startSubscriptionCheckout(planId, companyId?, promoCodeId?)
  2. Backend validates the plan and company context
  3. If promoCodeId is provided, the associated Stripe coupon is attached
  4. If plan.trialDays > 0, subscription_data.trial_period_days is passed to Stripe
  5. A Stripe Checkout Session (mode: 'subscription') is created and the URL is returned
  6. User completes payment on Stripe
  7. Webhook checkout.session.completed -- creates local subscription with INCOMPLETE status
  8. Webhook invoice.paid -- sets status to ACTIVE (or TRIALING), creates invoice record
  9. Event subscription.created -- triggers initial credit allocation to the company balance
Subscription Model (User-Based)

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, stripePriceId stays 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
Plan.price DTO allows 0

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:

PlanCredit Pack
Stripe Price typeRecurring (recurring.interval=month/year)One-time (no recurring)
Product name prefixPlan: Credit Pack:
Metadata keymetadata.planIdmetadata.creditPackId
Stripe Service methodcreatePlanPrice()createCreditPackPrice()
Backend resolvercreatePlan / updatePlan / syncPlanStripePricing / syncAllPlansStripePricingcreateCreditPack / updateCreditPack
Skip ruleprice = 0 OR billingMode = POSTPAIDprice = 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

  1. Frontend calls purchaseCreditPack(input: { creditPackId, companyId? })
  2. Backend creates a PENDING CreditPackPurchase record
  3. Stripe Products and Prices are auto-created if the pack lacks them (defense in depth — should already exist from createCreditPack)
  4. A Stripe Checkout Session (mode: 'payment') is created with metadata purchaseType: 'credit_pack'
  5. User completes payment on Stripe
  6. Webhook checkout.session.completed routes by metadata.purchaseType === 'credit_pack'
  7. Credits are added to the user's personal balance (not the company balance)
  8. Purchase record is marked COMPLETED
warning

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

EventPurpose
checkout.session.completedSubscription activation or credit pack purchase (routes by metadata)
invoice.paidPayment confirmed, activate subscription, create invoice
invoice.payment_failedPayment failed, set status to PAST_DUE
invoice.finalizedInvoice ready for collection
invoice.finalization_failedInvoice finalization error
invoice.payment_action_required3D Secure / SCA authentication needed
invoice.upcomingRenewal notification (~7 days before)
customer.subscription.createdNew subscription, emits subscription.created event
customer.subscription.updatedPlan change or status change
customer.subscription.deletedSubscription cancelled
customer.subscription.trial_will_endTrial ending in 3 days
customer.subscription.pausedPayment collection paused
customer.subscription.resumedPayment collection resumed
charge.refundedFull 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.

TriggerInvoice Status
invoice.paidPAID
invoice.finalizedPENDING
invoice.payment_failedFAILED
charge.refundedREFUNDED 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 ACTIVE subscriptions — trial users are subject to the plan's feature caps)
  • Monthly credit replenishment includes TRIALING subscriptions
  • Stripe auto-charges when the trial ends (status becomes ACTIVE)
  • Cancelling before trial end results in no charge

Trial expiry notifications (3 channels):

SourceTiming
Stripe webhook trial_will_end3 days before
Subscription expiry cron (daily 9AM)7, 3, 1 days before trialEnd
trial.expiring eventTriggers 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)

  1. Code exists in the database
  2. Code is active (isActive = true)
  3. Not expired (expiresAt > now)
  4. Usage limit not reached (usageCount < usageLimit)
  5. User hasn't already redeemed this code
  6. Code is applicable to the selected plan (or applicablePlanIds is empty for all plans)
  7. User is in the allowed list (or restrictedToUserIds is empty for all users)
  8. 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

EventTriggerAction
subscription.createdNew subscription activatedGrant initial creditsPerMonth to company balance
user.createdNew user registeredGrant initial credits if viewMode === JOB_SEEKER (default: 9)
credits.replenishedMonthly cron (daily 2AM, 30-day cycle)Add creditsPerMonth to company balance
payment.succeededinvoice.paid webhookEmitted for notification listeners
payment.failedinvoice.payment_failed webhookEmitted for notification listeners
trial.expiringTrial ending soonBranded email + in-app notification

Environment Variables

VariableRequiredDescription
STRIPE_SECRET_KEYYesStripe API secret key
STRIPE_API_VERSIONYesStripe API version
STRIPE_WEBHOOK_SECRETYesWebhook signing secret
FRONTEND_SUCCESS_URLYesRedirect after successful payment
FRONTEND_CANCEL_URLYesRedirect if payment cancelled
JOB_SEEKER_INITIAL_CREDITSNoInitial credits for new job seekers (default: 9)