Browse documentation

Frontend Specs

Section 12 — Admin Billing (`/admin/billing`)

Role access: ADMIN only. VIEWER/EDITOR/CLIENT redirected to /dashboard.

docs/specs/frontend/admin-spec-04b-admin-billing.md
On this page

Role access: ADMIN only. VIEWER/EDITOR/CLIENT redirected to /dashboard. Layout: 3-tab interface: Revenue, Subscriptions, Invoices. 1200px max-width. Sidebar: carbon (#12161E) background. Notes: Admin billing is the platform-level financial dashboard. It aggregates all client subscriptions, payments, and revenue. Does not show individual client billing details (that's in the client dashboard). All monetary values in platform default currency (USD unless changed in system config).


12.1 Revenue Dashboard#

1. Purpose#

Platform-wide financial overview. Admins track MRR, ARR, revenue growth, churn, and revenue by plan. The "financial health" screen for the platform business.

2. Visual Layout#

Section A: Revenue KPI Cards (top, full width)

  • 6 cards in a row:
    • MRR (Monthly Recurring Revenue): ${N} (e.g., $12,450). Trend: ↑/↓ {N}% vs last month. Green if up, red if down.
    • ARR (Annual Recurring Revenue): ${N} (MRR × 12). Same trend.
    • Total Clients: {N} active paying clients. Subtitle: "{N} Starter, {N} Growth, {N} Pro".
    • Churn Rate (last 30d): {N}% with trend. Green if < 5%, amber if < 10%, red if >= 10%.
    • Revenue Growth (YoY): {N}% vs same month last year. Green if positive.
    • Avg Revenue Per Client: ${N} (MRR / active clients). Trend vs last month.
  • Each card: white background, shadow-sm, 8px radius. Value in text-2xl text-primary. Currency in text-sm text-muted above value. Trend in text-sm with arrow.

Section B: Revenue Charts (middle, full width)

  • 3 charts in a row (desktop), stacked (mobile):
    • MRR Trend: Line chart. MRR over last 12 months. X-axis: month. Y-axis: USD. 1 line for total MRR + 3 dashed lines for Starter/Growth/Pro MRR (toggleable via legend). Tooltip: total + breakdown.
    • Revenue by Plan: Donut chart. Share of revenue by plan (Starter, Growth, Pro). Percentages inside slices. Center: total MRR. Legend outside with colors.
    • Churn & New Clients: Bar chart. Dual series: new clients (green bars) + churned clients (red bars) per month. Last 12 months. X-axis: month. Y-axis: count.
  • Chart time range: "Last 12 months" (default), "Last 6 months", "Last 3 months", "Year to date", "All time".
  • Each chart has download icon (PNG export).

Section C: Revenue Table (bottom, full width)

  • Table: Month | New MRR | Expansion MRR | Contraction MRR | Churned MRR | Net MRR | Clients | Churned
  • Month: "Jan 2026", "Feb 2026", etc. Current month highlighted with bg-blue-50.
  • New MRR: MRR from new clients. Green +${N}.
  • Expansion MRR: MRR from upgrades (Starter → Growth, etc.). Green +${N}.
  • Contraction MRR: MRR from downgrades. Red -${N}.
  • Churned MRR: MRR lost from cancellations. Red -${N}.
  • Net MRR: New + Expansion - Contraction - Churned. Green if positive, red if negative.
  • Clients: Net client count at end of month.
  • Churned: Number of clients who cancelled.
  • Sorting: Default by month (descending). Clickable headers.
  • Pagination: 12 per page (1 year). Default shows last 12 months.
  • Export: "Export CSV" button above table.

3. Data Source (tRPC endpoint)#

admin.getRevenueDashboard.useQuery({
  timeRange: z.enum(["3M", "6M", "12M", "YTD", "ALL"]).default("12M"),
}, { refetchInterval: 300000 }); // 5min static

admin.getRevenueTable.useQuery({
  timeRange: z.enum(["3M", "6M", "12M", "YTD", "ALL"]).default("12M"),
  page: z.number().min(1).default(1),
  pageSize: z.number().min(1).max(24).default(12),
});

4. Zod Schema#

const RevenueKpiSchema = z.object({
  mrr: z.number(), // in cents
  mrrTrendPercent: z.number(),
  arr: z.number(),
  arrTrendPercent: z.number(),
  totalClients: z.number(),
  clientsByPlan: z.object({
    starter: z.number(),
    growth: z.number(),
    pro: z.number(),
  }),
  churnRatePercent: z.number(),
  churnRateTrendPercent: z.number(),
  revenueGrowthYoYPercent: z.number(),
  arpu: z.number(), // avg revenue per client
  arpuTrendPercent: z.number(),
});

const RevenueChartSchema = z.object({
  mrrTrend: z.array(z.object({
    month: z.string(), // "2026-01"
    totalMrr: z.number(),
    starterMrr: z.number(),
    growthMrr: z.number(),
    proMrr: z.number(),
  })),
  revenueByPlan: z.array(z.object({
    plan: z.enum(["STARTER", "GROWTH", "PRO"]),
    revenue: z.number(),
    percentage: z.number(),
  })),
  churnAndNew: z.array(z.object({
    month: z.string(),
    newClients: z.number(),
    churnedClients: z.number(),
  })),
});

const RevenueTableRowSchema = z.object({
  month: z.string(), // "2026-01"
  monthName: z.string(), // "Jan 2026"
  newMrr: z.number(),
  expansionMrr: z.number(),
  contractionMrr: z.number(),
  churnedMrr: z.number(),
  netMrr: z.number(),
  endOfMonthClients: z.number(),
  churnedClients: z.number(),
});

const RevenueDashboardResponseSchema = z.object({
  kpis: RevenueKpiSchema,
  charts: RevenueChartSchema,
  table: z.array(RevenueTableRowSchema),
  currency: z.string().default("USD"),
});

5. Fetch Frequency#

  • Static (300000ms / 5min): Revenue data is aggregated and changes slowly (daily at most). 5min polling.
  • On-demand: Time range change, pagination, export.
  • SSE: When a new subscription is created or cancelled, push update to MRR and client count (rare, but real-time awareness is useful).

6. Data Manipulations#

  • Currency formatting: All values in USD (or platform default currency). $12,450 with commas. No cents for MRR/ARR (round to dollar). Cents for ARPU: $47.50.
  • Trend formatting: +{N}% in green, -{N}% in red. Arrow: ↑ green, ↓ red, — gray.
  • MRR chart: Line chart with 1 solid line (total) + 3 dashed lines (plan breakdown). Legend toggleable. Fill under total line: bg-blue-50.
  • Donut chart: Revenue by plan. Colors from palette (no Ember orange for chart elements). Starter = blue, Growth = green, Pro = purple. Center text: total MRR formatted.
  • Churn bar chart: Green bars for new clients, red bars for churned. Side-by-side per month.
  • Table row colors: Net MRR positive → green +${N}; negative → red -${N}. Current month row: bg-blue-50 highlight.
  • Client breakdown: Subtitle under "Total Clients" shows plan distribution: "8 Starter, 12 Growth, 3 Pro". Plan badges colored.
  • Churn rate: churned clients / total clients at start of period × 100. 1 decimal.
  • ARPU: MRR / active clients. Monthly. Trend vs last month.
  • YoY growth: Compare current month MRR to same month last year. If no data last year, show "N/A (new platform)".

7. Rationale#

  • MRR and ARR: The two most important SaaS metrics. MRR for month-to-month tracking. ARR for valuation and annual planning. Both front and center.
  • Revenue by plan: Tells you which plan drives the business. If Pro is 70% of revenue but only 10% of clients, the business is enterprise-heavy. If Starter is 60% of revenue, it's volume-driven. Informs pricing and packaging decisions.
  • Churn + new client chart: Net growth = new - churned. If new clients are flat but churned is rising, the business is shrinking. Visualized as opposing bars — easy to see net direction.
  • Revenue table: The "SaaS metrics table" — new MRR, expansion, contraction, churned, net. This is the standard SaaS revenue waterfall. Admins can see which month had a big churn event or expansion wave.
  • 5min polling: Revenue changes when subscriptions are created, upgraded, downgraded, or cancelled. These are relatively infrequent (a few per day for a small platform). 5min is sufficient.
  • Trends: Context is everything. $12,450 MRR is good, but is it growing or shrinking? The trend arrow + percentage tells the story immediately.
  • Churn rate threshold: 5% monthly churn is the "acceptable" threshold for SaaS. < 5% = healthy, 5-10% = concerning, > 10% = crisis. Color coding reflects this.
  • ARPU: Average revenue per client. If ARPU is rising, clients are upgrading. If falling, downgrades or new low-value clients. Trend tells the direction.
  • Plan badges: Visual identification of plan mix without needing to read numbers. "3 Pro" in purple badge = high-value clients.
  • Current month highlight: Revenue table shows current month in blue — admin knows where they are in the month and how much MRR is left to close.
  • Export: Financial data often needs to be shared with accountants, investors, or board members. CSV export is standard.

8. Interaction Flows#

  • Time range: Click "Last 6 months" → charts and table reload → URL updates.
  • Chart legend: Click "Pro" in MRR chart legend → Pro line toggles on/off. Click "Growth" → Growth line toggles. Reset restores all.
  • Table sort: Click "Net MRR" header → sorts by net MRR descending. Click "Churned" → sorts by churned clients descending.
  • Export CSV: Click "Export CSV" → download starts. Filename: revenue_{timeRange}_{YYYYMMDD}.csv.
  • Chart download: Click download icon on any chart → PNG export. Filename: {chartName}_{timeRange}.png.
  • Drill to clients: Click "{N} Pro" in KPI card subtitle → navigates to Subscription Manager with Pro plan filter.
  • Impersonation: Read-only. All charts, tables, KPIs viewable. No exports. No actions. Data is platform-wide, not client-specific, so no PII concerns.

9. Error States#

  • Loading: Skeleton cards (6) + skeleton charts (3) + skeleton table (8 rows).
  • No revenue data: "No revenue data available. Subscriptions will appear here once clients sign up and pay."
  • Stripe disconnect: If Stripe integration is down, red banner: "⚠️ Stripe connection unavailable. Revenue data may be stale. Check integration status." → link to /admin/system.
  • Export fail: Toast "Export failed. Try a smaller time range."
  • Chart fail: If chart data is missing for a period, show gap in line chart. Tooltip: "No data for this period."
  • Currency mismatch: If system currency changed recently, banner: "Currency was changed from {old} to {new} on {date}. Historical values may not be directly comparable."
  • No permission: Non-ADMIN redirected.

10. Role-Based Variations#

  • ADMIN: Full access — view all metrics, time ranges, export, charts.
  • Other roles: No access.
  • Impersonation: Read-only. All revenue data viewable. No exports. No actions. Financial data is platform-wide, not client-specific.

12.2 Subscription Manager#

1. Purpose#

Manage all client subscriptions in one place. Admins view, modify, cancel, and refund subscriptions. Used for customer support, billing disputes, and plan migrations.

2. Visual Layout#

Section A: Subscription Filters (top, full width)

  • Search: "Search by client name or email"
  • Plan filter: Multi-select (Starter, Growth, Pro)
  • Status filter: Multi-select (ACTIVE, TRIAL, PAST_DUE, CANCELLED, PAUSED)
  • Date range: "Started: Last 30 days", "Last 90 days", "All time"
  • Sort: "Start date", "MRR", "Plan", "Status" (dropdown)
  • "Export CSV" button (secondary, right-aligned)
  • "Bulk Actions" dropdown (disabled until rows selected)

Section B: Subscription Table (below, full width)

  • Table: Client | Plan | Status | MRR | Start Date | End Date | Billing Cycle | Payment Method | Auto-Renew | Actions
  • Client: Avatar + name + email (truncated). Click → navigates to /admin/clients detail.
  • Plan: Badge with plan name + color (Starter = blue, Growth = green, Pro = purple). Plan price below: "$49/mo".
  • Status:
    • ACTIVE: Green badge + "Active"
    • TRIAL: Blue badge + "Trial ({N} days left)"
    • PAST_DUE: Red badge + "Past Due ({N} days)" + "Retry Payment" button
    • CANCELLED: Gray badge + "Cancelled on {date}"
    • PAUSED: Amber badge + "Paused on {date}"
  • MRR: ${N} per month. Formatted with currency.
  • Start Date: "{date}" or "Today".
  • End Date: "{date}" or "—" (if auto-renew). For TRIAL: "Ends {date}".
  • Billing Cycle: "Monthly" or "Annual" (with discount badge if annual).
  • Payment Method: Card icon (Visa/Mastercard/Amex) + last 4 digits. "•••• 4242".
  • Auto-Renew: Toggle switch. ON = green, OFF = gray. Toggleable by admin.
  • Actions: 3-dot menu:
    • "Change Plan" → modal with plan selector
    • "Pause Subscription" → confirmation → status changes to PAUSED
    • "Cancel Subscription" → confirmation → cancellation flow
    • "Refund Last Payment" → modal with amount input (default: full last payment)
    • "View Invoices" → navigates to Invoice tab with client filter
    • "Send Payment Reminder" → email sent immediately
    • "Extend Trial" → modal with days input (for TRIAL clients)

Section C: Subscription Detail Panel (slide-in from right)

  • Click row → panel:
    • Client info (name, email, company, plan, status, MRR).
    • Subscription timeline: visual timeline of events (started, upgraded, downgraded, paused, resumed, cancelled, refunded).
    • Payment history: last 10 payments with status (succeeded, failed, refunded).
    • Usage metrics: content generated, posts published, leads captured, storage used (vs plan limits).
    • Plan limits: progress bars for each limit (e.g., "Content: 45/100", "Storage: 2.1GB/5GB"). Amber if > 80%, red if >= 100%.
    • "Upgrade Recommendation" banner: if client is at > 80% of any limit, show: "{clientName} is at {N}% of their {limit} limit. Consider suggesting an upgrade." with "Send Upgrade Email" button.

3. Data Source (tRPC endpoint)#

admin.getSubscriptions.useQuery({
  search: z.string().optional(),
  plans: z.array(z.enum(["STARTER", "GROWTH", "PRO"])).optional(),
  statuses: z.array(z.enum(["ACTIVE", "TRIAL", "PAST_DUE", "CANCELLED", "PAUSED"])).optional(),
  dateRange: z.object({ from: z.date().optional(), to: z.date().optional() }).optional(),
  sortBy: z.enum(["START_DATE", "MRR", "PLAN", "STATUS"]).default("START_DATE"),
  sortOrder: z.enum(["ASC", "DESC"]).default("DESC"),
  page: z.number().min(1).default(1),
  pageSize: z.number().min(1).max(100).default(25),
}, { refetchInterval: 60000 }); // 1min standard

admin.changePlan.useMutation({
  subscriptionId: z.string().uuid(),
  newPlan: z.enum(["STARTER", "GROWTH", "PRO"]),
  effectiveImmediately: z.boolean().default(false),
});
admin.pauseSubscription.useMutation({ subscriptionId: z.string().uuid(), reason: z.string().optional() });
admin.cancelSubscription.useMutation({ subscriptionId: z.string().uuid(), reason: z.string().optional(), atPeriodEnd: z.boolean().default(true) });
admin.refundPayment.useMutation({
  paymentId: z.string().uuid(),
  amount: z.number().min(1).optional(), // cents, default full amount
  reason: z.string().optional(),
});
admin.toggleAutoRenew.useMutation({ subscriptionId: z.string().uuid(), autoRenew: z.boolean() });
admin.extendTrial.useMutation({ subscriptionId: z.string().uuid(), days: z.number().min(1).max(30) });
admin.sendPaymentReminder.useMutation({ subscriptionId: z.string().uuid() });

4. Zod Schema#

const SubscriptionStatusSchema = z.enum(["ACTIVE", "TRIAL", "PAST_DUE", "CANCELLED", "PAUSED"]);

const SubscriptionSchema = z.object({
  id: z.string().uuid(),
  clientId: z.string().uuid(),
  clientName: z.string(),
  clientEmail: z.string().email(),
  clientAvatar: z.string().url().optional(),
  plan: z.enum(["STARTER", "GROWTH", "PRO"]),
  planPrice: z.number(), // monthly price in cents
  status: SubscriptionStatusSchema,
  mrr: z.number(), // in cents
  startDate: z.date(),
  endDate: z.date().optional(), // for cancelled or trial ending
  trialEndsAt: z.date().optional(),
  billingCycle: z.enum(["MONTHLY", "ANNUAL"]),
  paymentMethod: z.object({
    type: z.enum(["CARD", "BANK_TRANSFER", "PAYPAL"]),
    brand: z.enum(["VISA", "MASTERCARD", "AMEX", "DISCOVER", "UNKNOWN"]).optional(),
    last4: z.string().optional(),
  }).optional(),
  autoRenew: z.boolean().default(true),
  pastDueDays: z.number().optional(),
  cancellationDate: z.date().optional(),
  cancellationReason: z.string().optional(),
  createdAt: z.date(),
  updatedAt: z.date(),
});

const SubscriptionDetailSchema = z.object({
  subscription: SubscriptionSchema,
  timeline: z.array(z.object({
    event: z.string(), // e.g., "SUBSCRIPTION_STARTED", "PLAN_UPGRADED", "PAYMENT_SUCCEEDED"
    timestamp: z.date(),
    details: z.string().optional(),
    metadata: z.record(z.any()).optional(),
  })),
  paymentHistory: z.array(z.object({
    id: z.string().uuid(),
    amount: z.number(),
    status: z.enum(["SUCCEEDED", "FAILED", "REFUNDED", "PENDING"]),
    createdAt: z.date(),
    description: z.string().optional(),
  })),
  usage: z.object({
    contentGenerated: z.number(),
    contentLimit: z.number(),
    postsPublished: z.number(),
    postsLimit: z.number(),
    leadsCaptured: z.number(),
    leadsLimit: z.number(),
    storageUsedMb: z.number(),
    storageLimitMb: z.number(),
  }),
});

const SubscriptionListResponseSchema = z.object({
  items: z.array(SubscriptionSchema),
  total: z.number(),
  hasMore: z.boolean(),
});

5. Fetch Frequency#

  • Standard (60000ms / 1min): Subscription status changes (trial ending, payment failing, cancellation) need to be caught. 1min polling.
  • On-demand: Filter change, sort change, pagination, plan change, pause, cancel, refund, any mutation.
  • SSE: When a subscription status changes (e.g., payment fails → PAST_DUE), push update to table. When new subscription created, prepend to table.

6. Data Manipulations#

  • Status badges: Color-coded as described above. TRIAL shows days remaining: "Trial (5 days left)". PAST_DUE shows days past due: "Past Due (3 days)".
  • MRR formatting: ${N} with commas. Cents not shown for whole dollars.
  • Plan badges: Starter = bg-blue-50 + text-blue-700 + blue dot. Growth = bg-green-50 + text-green-700. Pro = bg-purple-50 + text-purple-700.
  • Payment method icon: Card brand → corresponding icon (Visa, Mastercard, Amex). If unknown, generic card icon.
  • Auto-renew toggle: ON = green switch. OFF = gray switch. Toggle immediately → mutation. If mutation fails, switch reverts.
  • Trial countdown: If trial ends in < 3 days, badge turns amber. If ends today, red.
  • Past due actions: "Retry Payment" button appears inline in PAST_DUE rows. Click → Stripe payment intent retry → status updates to ACTIVE or stays PAST_DUE.
  • Usage progress bars: In detail panel, horizontal bars for each limit. Green < 50%, amber < 80%, red >= 100%. Label: "{used}/{limit} ({percent}%)".
  • Upgrade recommendation: If any usage > 80%, show banner in detail panel. Suggests upgrade with 1-click email send.
  • Cancellation flow: Click "Cancel" → modal: "Cancel immediately" or "At end of period" (default). "At end of period" keeps access until paid period ends. "Immediately" stops access now. Both options show refund preview (if any).
  • Bulk actions: Select multiple rows (checkbox) → bulk action bar appears: "Change Plan" (bulk upgrade/downgrade), "Pause", "Cancel", "Export". All apply to selected subscriptions.

7. Rationale#

  • Subscription table: The core of SaaS billing. Every subscription in one place. Searchable, filterable, sortable. The customer support team's primary tool.
  • Status badges: Immediate visual of account health. Green = happy. Red = needs attention. Support team can scan for red badges and prioritize.
  • Plan management: Admins can change client plans directly. If a client emails "I want to upgrade," admin does it in 3 clicks. If a client is abusing the platform, admin can downgrade or pause immediately.
  • Auto-renew toggle: Clients can toggle auto-renew in their settings, but admins can also toggle it for them. Useful for support scenarios ("I want to cancel but keep access until the end of the month" → turn off auto-renew, don't cancel immediately).
  • Trial extension: If a client needs more time to evaluate, admin can extend trial by N days. Common in B2B sales.
  • Refund: If a client is unhappy or was charged by mistake, admin can issue full or partial refund directly from the subscription manager. Integrated with Stripe.
  • Payment retry: For PAST_DUE subscriptions, one-click retry. If the client updated their card, retry succeeds immediately. Reduces churn.
  • Usage limits in detail panel: Shows if a client is approaching their plan limits. If they're at 95% of content limit, they need to upgrade or they'll hit the wall. Proactive upsell opportunity.
  • 1min polling: Subscription status changes are important but not urgent. Payment failures happen at specific times (billing date). 1min is fast enough for support workflows.
  • Bulk actions: If a pricing change is announced, support might need to bulk-upgrade loyal clients. Bulk change plan handles this. Or if a bug caused overcharging, bulk refund.
  • Upgrade recommendation: Automated upsell prompt. Admin sees "Client X is at 90% of their limit" → sends upgrade email. Revenue generation tool.

8. Interaction Flows#

  • Change plan: Click "Change Plan" in menu → modal: plan selector (Starter/Growth/Pro) with pricing + "Effective immediately" toggle (default: ON) → "Change Plan" → confirmation: "Upgrade {clientName} from Starter to Growth? MRR increases from $49 to $149." → confirm → mutation → toast "Plan changed. New MRR: $149." → row updates.
  • Pause: Click "Pause" → confirmation: "Pause {clientName}'s subscription? They will retain access until {endDate} but will not be charged." → confirm → status changes to PAUSED → toast "Subscription paused."
  • Cancel: Click "Cancel" → modal: "Cancel immediately" or "At end of period" → select → show refund preview (if any) → confirm → status changes to CANCELLED or "Cancels on {date}" → toast "Cancellation scheduled."
  • Refund: Click "Refund" → modal: amount input (default: full last payment amount) + reason textarea → "Refund" → mutation → toast "Refund of ${N} issued. Refund ID: {id}." → payment history updates.
  • Retry payment: Click "Retry Payment" on PAST_DUE row → spinner → result: green "Payment succeeded. Subscription active." or red "Payment failed. Card declined." → status updates accordingly.
  • Toggle auto-renew: Click toggle → immediate switch → mutation → toast "Auto-renew {enabled/disabled} for {clientName}."
  • Extend trial: Click "Extend Trial" → modal: days input (1-30) → "Extend" → trial end date updates → toast "Trial extended by {N} days. Ends {date}."
  • Detail panel: Click row → panel slides in → full timeline, payments, usage, limits, upgrade recommendation.
  • Bulk select: Checkbox on row → select multiple → bulk action bar → "Change Plan" → applies to all selected.
  • Impersonation: Read-only. All action menus disabled. Detail panel viewable. Cannot modify subscriptions.

9. Error States#

  • Loading: Skeleton table (10 rows) + skeleton filters.
  • Empty (filtered): "No subscriptions match your filters. Try adjusting plan or status."
  • Empty (no subscriptions): "No subscriptions yet. Clients will appear here after they sign up and choose a plan."
  • Stripe error: Red banner: "⚠️ Stripe integration error. Subscription changes may fail. Check integration status."
  • Change plan fail: Toast "Plan change failed. Stripe error: {message}. No charges applied."
  • Refund fail: Toast "Refund failed. Payment may have already been refunded or is too old."
  • Retry fail: Inline red text on row: "Retry failed. Card declined: {reason}."
  • Bulk action fail: If bulk action fails for some rows, toast: "Action applied to {N} subscriptions. Failed for {N}. Check individual rows for errors."
  • No permission: Non-ADMIN redirected.

10. Role-Based Variations#

  • ADMIN: Full access — view, filter, sort, change plan, pause, cancel, refund, retry, extend trial, bulk actions, export.
  • Other roles: No access.
  • Impersonation: Read-only. Table viewable. Detail panel accessible. No modifications. Cannot change plan, cancel, refund, or pause. All action menus hidden or disabled.

12.3 Invoice & Payment History#

1. Purpose#

View all invoices and payments across all clients. Admins search, filter, refund, and download invoices. Used for accounting, reconciliation, and support disputes.

2. Visual Layout#

Section A: Filters (top, full width)

  • Search: "Client name, email, invoice ID, or payment ID"
  • Status filter: Multi-select (PAID, UNPAID, OVERDUE, REFUNDED, FAILED)
  • Date range: "Last 30 days", "Last 90 days", "This year", "All time", "Custom"
  • Amount range: "Min" and "Max" inputs (USD)
  • Plan filter: Multi-select (Starter, Growth, Pro)
  • "Export CSV" button (secondary)
  • Sort: "Date", "Amount", "Status" (dropdown)

Section B: Invoice Table (below, full width)

  • Table: Invoice ID | Client | Date | Amount | Status | Payment Method | Receipt | Actions
  • Invoice ID: Short ID (e.g., INV-20260615-001) + copy icon. Click → invoice detail modal.
  • Client: Avatar + name. Click → client detail.
  • Date: "Jun 15, 2026" or relative "2 days ago".
  • Amount: ${N} with currency. If refunded, strikethrough + new amount below: "Refunded: ${N}".
  • Status:
    • PAID: Green badge + checkmark + "Paid on {date}"
    • UNPAID: Amber badge + "Due {date}"
    • OVERDUE: Red badge + "Overdue by {N} days"
    • REFUNDED: Blue badge + "Refunded on {date}"
    • FAILED: Red badge + "Failed: {reason}"
  • Payment Method: Card icon + last 4 digits. Or "Bank Transfer", "PayPal".
  • Receipt: "Download PDF" link (if PAID). "—" if not paid.
  • Actions: 3-dot menu:
    • "View Details" → invoice detail modal
    • "Send Reminder" → email to client (for UNPAID/OVERDUE)
    • "Mark as Paid" → manual payment entry (for bank transfers)
    • "Refund" → refund modal (for PAID)
    • "Void" → void invoice (for UNPAID, removes from billing)
    • "Retry Payment" → for FAILED (Stripe retry)

Section C: Invoice Detail Modal

  • Click row or ID → modal:
    • Header: Invoice ID + status badge + date + amount.
    • Client info: name, email, company, billing address.
    • Line items: table of items (plan charge, proration, tax, discount). Each with quantity, unit price, total.
    • Totals: Subtotal, tax, discount, total.
    • Payment info: method, last 4, transaction ID, Stripe ID (for support reference).
    • Refund history: if refunded, list of refund transactions with amounts and dates.
    • Notes: internal admin notes (not visible to client).
    • "Download PDF" button (ember). "Send to Client" button (secondary).

Section D: Payment Stats (above table)

  • Horizontal row: "Total Invoices: {N}" | "Paid: {N} (${N})" | "Unpaid: {N} (${N})" | "Overdue: {N} (${N})" | "Refunded: {N} (${N})" | "Failed: {N}"
  • Overdue and Failed in red if > 0. Unpaid in amber if > 0.

3. Data Source (tRPC endpoint)#

admin.getInvoices.useQuery({
  search: z.string().optional(),
  statuses: z.array(z.enum(["PAID", "UNPAID", "OVERDUE", "REFUNDED", "FAILED"])).optional(),
  dateRange: z.object({ from: z.date().optional(), to: z.date().optional() }).optional(),
  amountRange: z.object({ min: z.number().optional(), max: z.number().optional() }).optional(),
  plans: z.array(z.enum(["STARTER", "GROWTH", "PRO"])).optional(),
  sortBy: z.enum(["DATE", "AMOUNT", "STATUS"]).default("DATE"),
  sortOrder: z.enum(["ASC", "DESC"]).default("DESC"),
  page: z.number().min(1).default(1),
  pageSize: z.number().min(1).max(100).default(25),
}, { refetchInterval: 60000 }); // 1min standard

admin.getInvoiceDetail.useQuery({
  invoiceId: z.string().uuid(),
}, { enabled: false }); // manual trigger

admin.sendInvoiceReminder.useMutation({ invoiceId: z.string().uuid() });
admin.markInvoicePaid.useMutation({
  invoiceId: z.string().uuid(),
  paymentMethod: z.enum(["BANK_TRANSFER", "CASH", "CHECK", "OTHER"]),
  reference: z.string().optional(),
});
admin.voidInvoice.useMutation({ invoiceId: z.string().uuid(), reason: z.string().optional() });

4. Zod Schema#

const InvoiceStatusSchema = z.enum(["PAID", "UNPAID", "OVERDUE", "REFUNDED", "FAILED"]);

const InvoiceSchema = z.object({
  id: z.string().uuid(),
  invoiceNumber: z.string(), // e.g., "INV-20260615-001"
  clientId: z.string().uuid(),
  clientName: z.string(),
  clientEmail: z.string().email(),
  clientAvatar: z.string().url().optional(),
  plan: z.enum(["STARTER", "GROWTH", "PRO"]).optional(),
  date: z.date(),
  dueDate: z.date().optional(),
  amount: z.number(), // in cents
  status: InvoiceStatusSchema,
  paymentMethod: z.object({
    type: z.enum(["CARD", "BANK_TRANSFER", "PAYPAL", "CASH", "CHECK"]),
    brand: z.enum(["VISA", "MASTERCARD", "AMEX", "DISCOVER", "UNKNOWN"]).optional(),
    last4: z.string().optional(),
  }).optional(),
  stripeInvoiceId: z.string().optional(),
  stripePaymentIntentId: z.string().optional(),
  pdfUrl: z.string().url().optional(),
  refundedAmount: z.number().optional(),
  failedReason: z.string().optional(),
  overdueDays: z.number().optional(),
});

const InvoiceLineItemSchema = z.object({
  description: z.string(),
  quantity: z.number().default(1),
  unitPrice: z.number(), // in cents
  total: z.number(), // in cents
});

const InvoiceDetailSchema = z.object({
  invoice: InvoiceSchema,
  lineItems: z.array(InvoiceLineItemSchema),
  subtotal: z.number(),
  taxAmount: z.number().optional(),
  discountAmount: z.number().optional(),
  total: z.number(),
  paymentDetails: z.object({
    method: z.string(),
    transactionId: z.string().optional(),
    stripeId: z.string().optional(),
    paidAt: z.date().optional(),
  }).optional(),
  refundHistory: z.array(z.object({
    amount: z.number(),
    reason: z.string().optional(),
    createdAt: z.date(),
    stripeRefundId: z.string().optional(),
  })).optional(),
  notes: z.array(z.object({
    text: z.string(),
    createdAt: z.date(),
    createdBy: z.string(),
  })).optional(),
});

const InvoiceListResponseSchema = z.object({
  items: z.array(InvoiceSchema),
  total: z.number(),
  totalPaid: z.number(),
  totalUnpaid: z.number(),
  totalOverdue: z.number(),
  totalRefunded: z.number(),
  failedCount: z.number(),
  hasMore: z.boolean(),
});

5. Fetch Frequency#

  • Standard (60000ms / 1min): Invoice status changes when payments are processed (usually async webhooks). 1min polling catches updates.
  • On-demand: Filter change, sort, pagination, date range, export, detail view.
  • SSE: When invoice status changes (e.g., Stripe webhook marks as PAID), push update to table. When new invoice created, prepend to table.

6. Data Manipulations#

  • Invoice ID: Human-readable format: INV-YYYYMMDD-NNN. Easy to reference in support tickets.
  • Amount formatting: ${N} with commas. Negative amounts (refunds) shown as -${N} in red.
  • Status badges: Color-coded as described. OVERDUE shows days overdue in red. FAILED shows reason truncated to 30 chars with full reason on hover.
  • Date formatting: Default: "Jun 15, 2026". Toggle to relative: "2 days ago".
  • Receipt download: PDF link opens in new tab or downloads. Filename: Invoice_{invoiceNumber}_{clientName}.pdf.
  • Refund strikethrough: If invoice was refunded, original amount is strikethrough + refunded amount shown below. Net = original - refunded.
  • Overdue highlight: Overdue invoices have red left border (4px) on the row. Makes them impossible to miss.
  • Stats: Summed across all invoices in current filter. "Paid: 47 ($2,450)" means 47 invoices totaling $2,450. If filter changes, stats recalculate.
  • Payment method icon: Same as subscription manager. Card brand → icon.
  • Line items: Invoice detail shows full breakdown. Plan charge + prorated amounts + taxes + discounts. Each with quantity and unit price. Total computed server-side, verified client-side.

7. Rationale#

  • Invoice table: The accounting backbone. Every payment, every charge, every refund in one place. Searchable by invoice ID, client, or payment ID. Support team's best friend for billing disputes.
  • Status badges: Immediate visual of payment health. Green = money in the bank. Red = problems. Overdue invoices need immediate follow-up (email, call, pause account).
  • Invoice detail modal: Full receipt. Support can answer "what was I charged for?" by pulling up the invoice and reading line items. No need to log into Stripe.
  • Manual payment entry: Some clients pay by bank transfer or check. Admin can mark invoice as paid manually with reference number. Keeps the system in sync with reality.
  • Refund from invoice: If a client disputes an invoice, admin can refund directly from the invoice view. Integrated with Stripe. Shows refund history so you can't double-refund.
  • Overdue tracking: Overdue invoices = lost revenue. The table highlights them in red. Stats bar shows total overdue amount. If it's $5,000, that's a problem that needs action.
  • 1min polling: Invoice status changes via Stripe webhooks. Webhooks can be delayed or retried. 1min polling ensures the UI catches up even if webhooks are slow.
  • Export: Accounting teams need invoice data in their tools. CSV export with all fields. Can be imported into QuickBooks, Xero, etc.
  • Send reminder: For overdue invoices, one-click email reminder. Template: "Your invoice {invoiceNumber} for ${amount} is overdue by {N} days. Please pay to avoid service interruption." Reduces manual support work.
  • Void: If an invoice was created in error (e.g., double-charged), void it. Voided invoices don't appear in client portal. Different from refund (void = no payment ever happened).
  • Stripe IDs: stripeInvoiceId and stripePaymentIntentId shown in detail for support reference. If a client says "Stripe says payment failed," admin can look up the exact payment intent.

8. Interaction Flows#

  • View invoice: Click invoice ID or "View Details" → modal opens with full line items, totals, payment info, refund history.
  • Download PDF: Click "Download PDF" in table or modal → PDF downloads. Or opens in new tab if browser setting.
  • Send reminder: Click "Send Reminder" on UNPAID/OVERDUE row → confirmation → email sent → toast "Reminder sent to {clientEmail}."
  • Mark as paid: Click "Mark as Paid" → modal: payment method (bank transfer, cash, check, other) + reference number + date → "Mark Paid" → status changes to PAID → toast "Invoice marked as paid."
  • Refund: Click "Refund" → modal: amount (default: full amount) + reason → "Refund" → status changes to REFUNDED (or partially refunded) → toast "Refund processed. Refund ID: {id}."
  • Void: Click "Void" → confirmation: "Void invoice {invoiceNumber}? This cannot be undone." → "Void" → status changes to VOID (or removed from table) → toast "Invoice voided."
  • Retry payment: Click "Retry Payment" on FAILED row → Stripe retry → status updates to PAID or stays FAILED → toast with result.
  • Filter: Change status filter → table reloads. Change date range → table reloads. URL updates with all filters (shareable).
  • Bulk actions: Select multiple invoices (checkbox) → bulk bar → "Send Reminders" (for UNPAID) or "Export" or "Mark Paid".
  • Impersonation: Read-only. All action menus disabled. Table viewable. Detail modal accessible. No modifications, no refunds, no voids.

9. Error States#

  • Loading: Skeleton table (10 rows) + skeleton filters + skeleton stats.
  • Empty (filtered): "No invoices match your filters. Try adjusting date range or status."
  • Empty (no invoices): "No invoices yet. Invoices will be generated when clients are billed."
  • Stripe webhook fail: If Stripe webhooks are failing, red banner: "⚠️ Stripe webhooks are not being received. Invoice status may be stale. Check webhook endpoint." → link to Stripe dashboard.
  • Refund fail: Toast "Refund failed. Invoice may already be refunded or payment is too old. Stripe error: {message}."
  • Void fail: Toast "Void failed. Invoice may be paid or already voided."
  • PDF unavailable: "PDF generation failed. Try downloading again or contact support."
  • Export fail: Toast "Export failed. Large dataset — try a smaller date range."
  • No permission: Non-ADMIN redirected.

10. Role-Based Variations#

  • ADMIN: Full access — view, filter, sort, download, send reminders, mark paid, refund, void, retry, bulk actions, export.
  • Other roles: No access.
  • Impersonation: Read-only. Table viewable. Detail modal accessible. No actions. Cannot modify invoices, send reminders, or process refunds.