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
- User creates a paid subscription โ
confirmSubscription or createOrgCheckoutSession creates a Stripe Customer and persists stripeCustomerId on the BillingSubscription MongoDB document.
- Stripe generates invoices automatically on each billing cycle.
- Frontend requests invoices via
orgInvoices(orgId, limit) GraphQL query.
- Backend resolves by finding the org's
BillingSubscription โ reading stripeCustomerId โ calling stripe.invoices.list({ customer, limit }).
- 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 |
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)