Authenticated user retrieves a paginated list of their Stripe invoices, sorted by date descending. Each invoice includes a direct download link (Stripe hosted_invoice_url). Requires a valid JWT access token.
- Invoice data is fetched live from the Stripe Invoices API using the user’s
stripe_customer_id— not stored in TalkIDE DB. - If
stripe_customer_idis null (user has never performed any billing action), an empty list is returned without hitting Stripe. - Pagination uses Stripe’s cursor-based
starting_afterparameter wrapped in a page-friendly envelope. - Invoice statuses:
paid,open,void,uncollectible. FE renders a status pill for each. - PDF download is handled via Stripe
hosted_invoice_url(external Stripe-hosted page) — no proxy through TalkIDE BE. - Related: UC-10001 — payment method registration. UC-10006 —
invoice.*webhook events.
sequenceDiagram
actor User
User->>+FE: opens Profile → Billing & Usage → Invoices section
FE->>FE: onMounted — trigger invoices load (page 1)
FE->>+BE: GET /api/v1/users/me/invoices?limit=10 <br> Authorization: Bearer {accessToken}
BE->>BE: validate JWT access token
alt access token invalid or missing
BE-->>FE: 401 Unauthorized <br> ErrorResponse
end
BE->>DB: SELECT stripe_customer_id FROM users WHERE id = userId
alt stripe_customer_id is null
BE-->>FE: 200 OK <br> InvoiceListResponse (empty items, hasMore=false)
end
BE->>Stripe: stripe.invoices.list({ customer: customerId, limit: 10, starting_after: cursor })
Stripe-->>BE: { data: [Invoice...], has_more: boolean }
BE->>-FE: 200 OK <br> InvoiceListResponse
FE->>-User: render invoices table
opt User clicks "Load more"
User->>+FE: clicks "Load more" button
FE->>+BE: GET /api/v1/users/me/invoices?limit=10&startingAfter={lastInvoiceId} <br> Authorization: Bearer {accessToken}
BE->>Stripe: stripe.invoices.list({ customer: customerId, limit: 10, starting_after: lastInvoiceId })
Stripe-->>BE: { data: [Invoice...], has_more: boolean }
BE->>-FE: 200 OK <br> InvoiceListResponse
FE->>-User: append invoices to table
end
opt User clicks "Download" on an invoice
User->>FE: clicks "Download" link
FE->>User: window.open(hostedInvoiceUrl, "_blank")
Note over User: Stripe-hosted invoice PDF opens in new tab
end
GET /api/v1/users/me/invoices Query parameters:
| Parameter | Type | Required | Default | Note |
|---|---|---|---|---|
limit | integer | no | 10 | Max 50 |
startingAfter | string | no | — | Stripe invoice ID cursor for pagination |
200 OK InvoiceListResponse:
{
"items": [
{
"id": "in_1PqXyz...",
"number": "TALKIDE-0001",
"date": "2026-05-01T00:00:00Z",
"amountDue": 2900,
"currency": "usd",
"status": "paid",
"hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_xxx/test_live_xxx"
},
{
"id": "in_1PqAbc...",
"number": "TALKIDE-0002",
"date": "2026-04-01T00:00:00Z",
"amountDue": 1500,
"currency": "usd",
"status": "open",
"hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_xxx/test_live_yyy"
}
],
"hasMore": false,
"lastId": "in_1PqAbc..."
}
Field notes:
amountDue— integer, amount in the smallest currency unit (cents for USD). FE divides by 100 for display:$29.00.currency— ISO 4217 lowercase (e.g.usd). FE uses for display formatting.status— one of:paid,open,void,uncollectible.hostedInvoiceUrl— Stripe-hosted invoice URL. FE opens in new tab. May benullfor draft invoices (not applicable in production flow).lastId— ID of the last item initems; pass asstartingAfterto fetch the next page.nullwhenitemsis empty.
200 OK InvoiceListResponse (empty — no stripe_customer_id or no invoices):
{
"items": [],
"hasMore": false,
"lastId": null
}
400 Bad Request (invalid query params) ErrorResponse:
{
"status": 400,
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"errors": [
{ "field": "limit", "message": "must be between 1 and 50" }
]
}
401 Unauthorized ErrorResponse:
{
"status": 401,
"code": "AUTHENTICATION_FAILED",
"message": "Access token is missing or invalid"
}
502 Bad Gateway (Stripe API unreachable) ErrorResponse:
{
"status": 502,
"code": "STRIPE_UNAVAILABLE",
"message": "Payment provider is temporarily unavailable. Please try again."
}
Frontend
Validations
| Field / Control | Constraints | Note |
|---|---|---|
limit (query param) | integer 1–50 | Hardcoded to 10 in MVP FE; no user-facing control |
startingAfter | non-empty string if provided | Taken from lastId of previous response |
UX Guidelines
Invoices table in BillingSection.vue:
| Column | Content |
|---|---|
| Invoice # | number (monospace) |
| Date | Formatted from date (e.g. “May 1, 2026”) |
| Amount | amountDue / 100 formatted as currency (e.g. “$29.00”) |
| Status | Pill: paid = green, open = amber, void = neutral (fg-3), uncollectible = rose |
| Download | ”Download PDF” link → window.open(hostedInvoiceUrl, '_blank'). Hidden when hostedInvoiceUrl is null. |
Pagination: “Load more” button below table, visible only when hasMore === true. On click: append items, update lastId cursor.
Empty state: “No invoices yet.” text in fg-3.
Loading state: Skeleton rows (3 rows) during initial load.
Error state (502): Toast (rose, 5 s): “Could not load invoices. Please try again.”
Table container: border-radius: 12px, each row border-bottom: 1px solid var(--line-1), last row no border.
Backend
Validations
| Field | Constraints | Note |
|---|---|---|
JWT Authorization header | not_blank, valid signature, not expired | 401 if missing/invalid |
limit | integer, 1–50 | 400 VALIDATION_ERROR if outside range |
startingAfter | optional string; if provided must be non-blank | 400 if blank string provided |
Test Cases
| GIVEN | WHEN | THEN |
|---|---|---|
| Authenticated user with no stripe_customer_id | GET /invoices is called | 200 OK with empty items list, hasMore=false, lastId=null |
| Authenticated user with stripe_customer_id but no Stripe invoices | GET /invoices is called | 200 OK with empty items list (Stripe returns empty array) |
| Authenticated user with 3 Stripe test invoices (status: paid, open, void) | GET /invoices?limit=10 is called | 200 OK; all 3 invoices returned; correct status, amountDue, hostedInvoiceUrl for each |
| Authenticated user with 15 Stripe invoices, limit=10 | GET /invoices?limit=10 is called | 200 OK; 10 items returned; hasMore=true; lastId = ID of 10th invoice |
| Second page requested using lastId from first page | GET /invoices?limit=10&startingAfter=<lastId> is called | 200 OK; remaining 5 items returned; hasMore=false |
limit=51 (exceeds max) | GET /invoices?limit=51 is called | 400 VALIDATION_ERROR returned |
| No Authorization header | GET /invoices is called | 401 AUTHENTICATION_FAILED returned |
| Stripe API unreachable | GET /invoices is called | 502 STRIPE_UNAVAILABLE returned |
Thanks for the feedback.