Download invoices as PDF
Go · Go · intermediate · modification
Adds the PDF download behind the new Download button on the invoices page. The handler parses the id from the path, loads the invoice through a new `Store.Get` that reuses the list's column set and scanner, and renders it with the existing `RenderPDF`, so the file is the same document customers get by email. Rendering goes to a buffer first, so a failure still answers a clean 500 instead of a half-written PDF; the response is marked `no-store`, and every download is logged for finance's audit trail. Tested locally from the invoices page: the downloads match the emailed copies.
Billing service on Go 1.22 and the standard library (`net/http` method-pattern `ServeMux`, `log/slog`, `database/sql` with the pgx stdlib driver, PostgreSQL 16). `main` wraps the mux in the auth middleware, so every `/api/` handler runs for a signed-in user; `auth.FromContext(ctx)` returns an `auth.Member` with `UserID`, `OrgID` and `Role`, and a user belongs to exactly one org. Package `invoices` already has the `Handler` type with the `List` handler (`GET /api/invoices`, which calls `Store.ListForOrg` with the caller's `OrgID`), `writeJSON` and `writeError(w, status, code)` in `respond.go`, and `RenderPDF(w io.Writer, inv Invoice) error` in `render.go`, used by the monthly invoice email. `invoices.id` is a `bigserial`, so ids are sequential across all orgs, and they appear in the web app's URLs (`/invoices/1042`). An invoice shows the customer's name and the amounts billed.
Requirements
- `GET /api/invoices/{id}/pdf` returns the invoice as a PDF download: `Content-Type: application/pdf`, `Content-Disposition: attachment; filename="<number>.pdf"`, and `Cache-Control: private, no-store`, since invoices hold customer data. Invoice numbers are `INV-` followed by six or more digits (assigned by the existing issuing code), so they go into the header as they are.
- Invoices are org data. Any member of an org (admin, member or viewer) may download that org's invoices, the same set `GET /api/invoices` lists for them, and no others. An id that is malformed, unknown or another org's answers 404 `{"error": "not_found"}`, without revealing which of the three it was.
- The document comes from the existing `RenderPDF`, so the download matches the copy sent by the monthly invoice email. A database or rendering failure answers 500 `{"error": "internal"}` and is logged with the invoice id; no internals reach the client.
- Each download served is logged at info level with the invoice id and the user id (finance asked for an audit trail). Emailing or regenerating invoices is out of scope.
Files touched
- internal/invoices/routes.go
- internal/invoices/store.go
- internal/invoices/pdf.go
--- internal/invoices/routes.go
h := &Handler{Store: &Store{DB: db}}
mux.HandleFunc("GET /api/invoices", h.List)
+ mux.HandleFunc("GET /api/invoices/{id}/pdf", h.PDF)
}
--- internal/invoices/store.go
"context"
"database/sql"
+ "errors"
"time"
)
+
+var ErrNotFound = errors.New("invoice not found")
type Invoice struct {
}
+// Get returns one invoice by id, or ErrNotFound.
+func (s *Store) Get(ctx context.Context, id int64) (Invoice, error) {
+ inv, err := scanInvoice(s.DB.QueryRowContext(ctx,
+ `SELECT `+invoiceColumns+` FROM invoices WHERE id = $1`, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return Invoice{}, ErrNotFound
+ }
+ return inv, err
+}
+
--- internal/invoices/pdf.go
+package invoices