Developer Docs

Stripe Invoice Integration

This document explains how Stripe invoice data is wired end-to-end: from the Stripe API through the payments subgraph to the frontend billing page at /orgs/[slug]/billing.

Architecture Overview

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     invoices.list()     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”     orgInvoices(orgId)     โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Stripe  โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚   payments   โ”‚ โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚  ngwenya-    โ”‚
โ”‚   API    โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚   subgraph   โ”‚ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ โ”‚    front     โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜    InvoiceRecord[]     โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜      GraphQL query       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Data Flow

  1. User creates a paid subscription โ†’ confirmSubscription or createOrgCheckoutSession creates a Stripe Customer and persists stripeCustomerId on the BillingSubscription MongoDB document.
  2. Stripe generates invoices automatically on each billing cycle.
  3. Frontend requests invoices via orgInvoices(orgId, limit) GraphQL query.
  4. Backend resolves by finding the org's BillingSubscription โ†’ reading stripeCustomerId โ†’ calling stripe.invoices.list({ customer, limit }).
  5. Frontend displays the invoice list with amount, date, status badge, invoice number, description, and PDF download link.

The `stripeCustomerId` Lifecycle

Event stripeCustomerId Value
Org created (free Starter bootstrap) undefined โ€” no Stripe object needed
First paid checkout (confirmSubscription) Set to cus_xxx from Stripe Customer
Checkout via Stripe Checkout (handleSubscriptionCreated) Set from stripeSubscription.customer
Cancel subscription Retained โ€” customer still exists in Stripe

Key implication: The orgInvoices query returns [] for orgs that have never had a paid plan, because there's no stripeCustomerId to query against. This is correct โ€” not an error.

GraphQL Schema

Query

query GetOrgInvoices($orgId: ID!, $limit: Int) {
  orgInvoices(orgId: $orgId, limit: $limit) {
    id
    amount       # cents (e.g. 2900 = $29.00)
    status       # PAID | OPEN | FAILED | VOID | DRAFT
    date         # ISO 8601 string
    pdfUrl       # Stripe-hosted PDF URL, or '#' if unavailable
    invoiceNumber # e.g. "INV-0001" (nullable)
    description   # e.g. "Pro Plan โ€” Monthly" (nullable)
  }
}

InvoiceRecord Type (Backend)

@ObjectType({ description: 'Invoice record from Stripe' })
export class InvoiceRecord {
  @Field() id: string;
  @Field(() => Int) amount: number;
  @Field() status: string;
  @Field() date: string;
  @Field() pdfUrl: string;
  @Field({ nullable: true }) invoiceNumber?: string;
  @Field({ nullable: true }) description?: string;
}

Frontend Data Layer

`fetchBillingData(orgId)` โ€” `mockData.ts`

Returns a BillingData object with two important flags:

Flag Meaning When true
isMock Using hardcoded fallback data Backend unreachable (network error)
isFreePlan Org is on the free Starter plan planId is 'starter' or 'plan_starter'

isMock is NOT set when invoices are empty โ€” an empty invoice list from the backend is valid (new paid plan, no billing cycle completed yet).

Invoice Interface

export interface Invoice {
  id: string;
  amount: number;
  status: 'PAID' | 'OPEN' | 'FAILED' | 'VOID' | 'DRAFT' | string;
  date: string;
  pdfUrl: string;
  invoiceNumber?: string;
  description?: string;
}

UI Behavior Matrix

Scenario Invoice Panel Shows
Backend unreachable "Demo Data" badge + hardcoded mock invoices
Free Starter plan, backend reachable Empty state: "No invoices on the Starter plan" with upgrade prompt
Paid plan, no invoices yet Empty state: "No invoices generated yet" with billing cycle note
Paid plan with invoices Real invoice list with amounts, dates, status badges, PDF links
  • Real invoices: Opens Stripe-hosted PDF in a new tab (target="_blank")
  • Mock/placeholder invoices: PDF link is disabled (greyed out <span>)

Status Badges

Status Color
PAID Green
OPEN Amber
FAILED Red
VOID Grey
Other Default grey

File Reference

File Repo Purpose
ngwenya-deck/src/routes/orgs/[slug]/billing/+page.svelte ngwenya-deck Billing page UI
ngwenya-deck/src/routes/orgs/[slug]/billing/mockData.ts ngwenya-deck Data layer, types, GraphQL queries, mock fallbacks
ngwenya-deck/src/lib/queries/subscriptions.ts ngwenya-deck Shared GET_ORG_INVOICES query + InvoiceRecord type
ngwenya-deck/src/lib/config/plans.ts ngwenya-deck Centralized plan config (pricing, limits, Stripe IDs)
apps/payments/src/subscription/subscription.resolver.ts ngwenya-federation GraphQL resolver for orgInvoices
apps/payments/src/subscription/subscription.service.ts ngwenya-federation getOrgInvoices() โ€” Stripe API integration

Testing

Frontend Unit Tests (`tests/stripe-invoices.test.ts`)

  • Invoice interface accepts all fields including optional invoiceNumber and description
  • BillingData shape validation (isMock, isFreePlan)
  • isFreePlan detection for starter and plan_starter variants
  • Plan config structure from centralized source
  • PDF URL validation logic

Backend Unit Tests (`subscription.service.spec.ts`)

  • Returns [] when no stripeCustomerId exists
  • Returns [] when no subscription found for org
  • Maps all Stripe invoice fields including invoiceNumber and description
  • Falls back to line item description when invoice-level description is null
  • Returns [] on Stripe API errors (graceful degradation)