Skip to content

feat(invoice): Invoice PDF generation & email delivery - #34

Merged
codebestia merged 5 commits into
ShadeProtocol:mainfrom
angelraph:feat/invoice-pdf-generation-email
Jul 26, 2026
Merged

feat(invoice): Invoice PDF generation & email delivery#34
codebestia merged 5 commits into
ShadeProtocol:mainfrom
angelraph:feat/invoice-pdf-generation-email

Conversation

@angelraph

@angelraph angelraph commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements on-demand invoice PDF generation and email delivery as specified in #26 — no invoice document is ever persisted; every request/send regenerates the PDF from live Prisma data.

  • PDF generation: src/services/invoice-pdf.services.ts exports generateInvoicePdf(invoice, merchant): Promise<Buffer> using pdfkit. Pure function — no DB, filesystem, or network access. Renders merchant business name/logo (data-URI logos only, to keep the function side-effect free and deterministic — no fetch of remote URLs), description, amount + token (plus fiat breakdown for FIXED_FIAT invoices), status, payment slug, created/paid dates, and payer address when present.
  • Download endpoints:
    • GET /invoices/:id/pdf (protected via authenticateMerchant, ownership-scoped — 404 for other merchants' invoices)
    • GET /pay/:slug/pdf (public, reuses the same 404/410 visibility rules as GET /pay/:slug via a shared assertInvoiceVisible helper extracted in pay.services.ts)
    • Both stream the generated buffer straight into the response (Content-Type: application/pdf, Content-Disposition: attachment) — nothing is written to disk or a bucket.
  • Email delivery: email.service.ts's sendViaResend/sendViaSmtp now accept an optional attachments: { filename, content: Buffer }[]. New sendInvoiceEmail(invoice, merchant) generates the PDF fresh and sends it as an attachment — no-ops (no throw) when invoice.email is unset.
  • Send endpoint: POST /invoices/:id/send (protected, ownership-scoped) triggers delivery; returns 400 with a clear error when the invoice has no email on file, rather than silently no-op-ing.
  • Storage seam: src/services/storage/invoice-pdf.storage.ts adds the InvoicePdfStorage interface + mockInvoicePdfStorage stub exactly as specified — compiles, but is not imported or called by any handler in this PR.

No new Prisma models or fields were needed.

Test plan

  • generateInvoicePdf unit tests (tests/unit/invoice-pdf.services.test.ts) generate real PDFs from fixture data and assert valid %PDF-/%%EOF structure, covering FIXED_FIAT pricing, paid invoices with payer/datePaid, data-URI logos, non-data-URI logos (gracefully skipped, no network call), and merchants with no branding.
  • sendInvoiceEmail unit tests across all three providers:
    • email.service.test.ts — console provider (default), no-op when no email, reflects live state per call
    • email.service.resend.test.ts — real attachment payload sent through a mocked Resend SDK (mocks only the third-party network client, not our logic), plus the Resend-error-propagates case
    • email.service.smtp.test.ts — same via a mocked Nodemailer transport
  • invoice.routes.test.ts — added coverage for GET /invoices/:id/pdf (401/404/200 with real PDF body + headers) and POST /invoices/:id/send (401/404/400-no-email/200-sends)
  • pay.routes.test.ts (new) — GET /pay/:slug/pdf covering 404, 410 (cancelled/paid/refunded), 410 expired, and 200 with a real PDF body
  • Full suite: npm test → 28 suites / 221 tests passing
  • tsc --noEmit clean (no new errors; pre-existing @stellar/stellar-sdk type noise unrelated to this change)
  • eslint/prettier clean on all touched files

🤖 Generated with Claude Code

Closes #26

Summary by CodeRabbit

  • New Features
    • Added endpoints to download invoice PDFs and to email invoices with the PDF attached.
    • Added PDF rendering with optional merchant logo, status details, and currency/amount formatting.
  • Bug Fixes
    • Centralized invoice visibility rules for not-found, expired, and terminal statuses (cancelled/paid/refunded).
  • Tests
    • Added/expanded integration and unit tests for PDF responses, email delivery across providers, attachment contents, and PDF rendering edge cases.

Adds on-demand invoice PDF rendering via pdfkit, protected/public download
endpoints, and merchant-triggered email delivery with the PDF attached.
Nothing is persisted to disk or object storage — every request regenerates
the PDF from live Prisma data.

- generateInvoicePdf(invoice, merchant): pure, DB-free, unit-testable
- GET /invoices/:id/pdf (merchant-owned) and GET /pay/:slug/pdf (public,
  reusing the existing 404/410 visibility rules)
- email.service.ts: sendViaResend/sendViaSmtp accept Buffer attachments;
  sendInvoiceEmail() only sends when invoice.email is set
- POST /invoices/:id/send (400 when no email on file)
- storage/invoice-pdf.storage.ts: unused seam for a future persistence issue

Closes ShadeProtocol#26

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 51b35c0d-09fc-472a-ac5d-69352e57ef70

📥 Commits

Reviewing files that changed from the base of the PR and between 2535aa1 and 334b599.

📒 Files selected for processing (5)
  • src/services/email.service.ts
  • tests/unit/email.service.resend.test.ts
  • tests/unit/email.service.smtp.test.ts
  • tests/unit/email.service.test.ts
  • tests/unit/invoice-pdf.services.test.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • tests/unit/email.service.resend.test.ts
  • tests/unit/email.service.test.ts
  • tests/unit/invoice-pdf.services.test.ts
  • tests/unit/email.service.smtp.test.ts
  • src/services/email.service.ts

📝 Walkthrough

Walkthrough

Adds on-demand invoice PDF generation, authenticated and public PDF download routes, invoice email delivery with PDF attachments, shared invoice visibility checks, a mock storage seam, and integration/unit test coverage.

Changes

Invoice PDF and Email Delivery

Layer / File(s) Summary
PDF rendering and storage seam
package.json, src/services/invoice-pdf.services.ts, src/services/storage/invoice-pdf.storage.ts, tests/unit/invoice-pdf.services.test.ts
Adds PDFKit-based in-memory invoice rendering, optional logo and payment fields, PDF dependencies, a mock storage interface, and renderer tests.
Invoice retrieval and visibility rules
src/services/invoice.services.ts, src/services/pay.services.ts, src/controllers/pay.controllers.ts, src/routes/pay.routes.ts, tests/integration/pay.routes.test.ts
Adds merchant-inclusive invoice retrieval, shared 404/410 visibility validation, and the public GET /pay/:slug/pdf flow.
Merchant PDF and send endpoints
src/controllers/invoice.controllers.ts, src/routes/invoice.routes.ts, tests/integration/invoice.routes.test.ts
Adds authenticated invoice PDF download and email-send handlers with ownership checks, response headers, validation, and integration coverage.
Invoice email delivery and provider attachments
src/services/email.service.ts, tests/unit/email.service*.test.ts, tests/integration/auth.email-otp.test.ts, tests/integration/merchant.register.test.ts
Adds PDF attachment support for Resend and SMTP, invoice email generation, console logging, provider tests, and updated email-service mocks.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant InvoiceRoutes
  participant InvoiceService
  participant PdfService
  participant EmailService
  participant EmailProvider

  Client->>InvoiceRoutes: Request invoice PDF or send invoice
  InvoiceRoutes->>InvoiceService: Fetch invoice with merchant
  InvoiceService-->>InvoiceRoutes: Invoice and merchant
  InvoiceRoutes->>PdfService: Generate PDF
  PdfService-->>InvoiceRoutes: PDF Buffer
  InvoiceRoutes->>EmailService: Send invoice with PDF attachment
  EmailService->>EmailProvider: Deliver email
  EmailProvider-->>Client: Delivery response
Loading

Possibly related PRs

Suggested reviewers: codebestia

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: invoice PDF generation plus email delivery.
Linked Issues check ✅ Passed The changes implement the linked issue's PDF generation, download routes, email attachment flow, send endpoint, and unused storage seam.
Out of Scope Changes check ✅ Passed The PR stays within scope; the added tests and dependency updates support the PDF and email delivery features.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

tests/unit/email.service.resend.test.ts

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): tests/unit/email.service.resend.test.ts

tests/unit/email.service.smtp.test.ts

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): tests/unit/email.service.smtp.test.ts

tests/unit/email.service.test.ts

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): tests/unit/email.service.test.ts

  • 1 others

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@angelraph

Copy link
Copy Markdown
Contributor Author

This is ready for review. All acceptance criteria from #26 are implemented — PDF generation, protected/public download endpoints, email delivery with attachments, and the storage seam.

Locally verified before pushing:

  • npm test — 28 suites / 221 tests passing
  • tsc --noEmit — clean
  • eslint / prettier — clean on all touched files

I noticed the CI workflows (test.yml, formatting check) are showing action_required and haven't run yet — looks like they're waiting on a maintainer to approve running Actions for this PR. Could someone with write access approve the workflow run when you get a chance? Happy to address any review feedback.

@angelraph

Copy link
Copy Markdown
Contributor Author

Re: the "Review failed" notice above — that's a transient error on CodeRabbit's own service ("An error occurred during the review process"), not related to this PR's content. Re-requesting it below.

@coderabbitai review

In the meantime, this PR is ready for maintainer review: PDF generation, download endpoints, email delivery with attachments, and the storage seam are all implemented per #26, with 221 passing tests and clean typecheck/lint locally. Still just waiting on a maintainer to approve the CI workflow run and on a review approval to unblock the merge. Thanks for taking a look!

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

@angelraph: Re-triggering the review for #34 now. Thanks for the thorough implementation and verification details.

✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@angelraph

Copy link
Copy Markdown
Contributor Author

Hey @codebestia, this one is ready whenever you get a chance to look at it. It closes #26 and covers the full spec: PDF generation, the protected and public download routes, email delivery with attachments, and the storage seam. 221 tests pass locally and CodeRabbit's review is running again after hitting a rate limit earlier.

Two things need someone with write access on your end: approving the pending GitHub Actions run, and an approving review to unblock the merge. Happy to make changes if anything needs adjusting.

@angelraph

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@angelraph

Copy link
Copy Markdown
Contributor Author

@codebestia @Depo-dev could one of you review this when you get a chance? CI is green (tests, formatting, CodeRabbit all passing), just needs a review to unblock the merge.

@codebestia codebestia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @angelraph
Please address the review changes.
Good job so far.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new assertInvoiceVisible helper was extracted but the original resolveInvoiceBySlug visibility check is missing the expiresAt range validation from the payload before extraction. Looking at the diff:

Lines 36-46 of the original had the full visibility check
The extracted assertInvoiceVisible (lines 14-27) correctly checks CANCELLED | PAID | REFUNDED and expiry
BUT resolveInvoiceBySlug at line 52 calls assertInvoiceVisible(invoice) after already having selected and returned a filtered view of the invoice
The issue: expiresAt is included in the Prisma select (line 22), so it's available—but confirmPayment (line 60 onward) still has the old inlined visibility check duplicated. This creates:

Logic duplication → maintenance burden and risk of divergence
Incomplete refactoring → confirmPayment doesn't use the new helper
Impact: While the specific routes work, the visibility rules are not consistently abstracted, risking future bugs when status/expiry logic changes.

Fix: Apply assertInvoiceVisible consistently in confirmPayment as well (lines 70-80 should be replaced with a call to assertInvoiceVisible(invoice)).

Comment on lines +70 to +77
const logoBuffer = decodeLogo(merchant.logo);
if (logoBuffer) {
try {
doc.image(logoBuffer, { fit: [80, 80] });
doc.moveDown();
} catch {
// Corrupt/undecodable image data — skip it rather than fail the render.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The try-catch silently swallows all errors from doc.image(), including:

Out-of-memory errors (if a malicious/oversized PNG is embedded)
File descriptor exhaustion
Other unexpected PDFKit failures
This masks real failures that should bubble up and be retried or logged.

Impact:

Silent failures make debugging production issues extremely difficult
No observability into PDF generation failures
Could mask security issues (e.g., DOS via malicious PNG payloads)
Fix: Catch only Error but inspect the message/type and only suppress image-specific errors:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// invoice.routes.ts
router.get('/:id', getInvoiceController);      // line 17
router.get('/:id/pdf', getInvoicePdfController); // line 18 (NEW)

// pay.routes.ts  
router.get('/:slug', resolveInvoiceController);     // line 10
router.get('/:slug/pdf', getInvoicePdfController);   // line 11 (NEW)

Express matches routes top-to-bottom, first-come-first-served. The route /:id on line 17 will intercept /:id/pdf on line 18 before it's evaluated, because Express splits on / and tries to match /:id first:

GET /api/v1/invoices/123/pdf → matches /:id with id=123, never reaches /:id/pdf
This means the PDF download will always 404 (invoice 123/pdf doesn't exist as an ID).

Impact: Public PDF download endpoint for authenticated merchants is completely broken.

Fix: Reorder routes in invoice.routes.ts to place more specific routes before generic ones:

router.get('/:id/pdf', getInvoicePdfController);    // More specific first
router.post('/:id/send', sendInvoiceController);    // More specific first
router.get('/:id', getInvoiceController);           // Generic fallback

Addresses CodeRabbit review on PR ShadeProtocol#34: the bare catch around doc.image()
hid all failures with no observability. Still skips embedding on a bad
logo (so a corrupt merchant upload doesn't fail the whole invoice PDF),
but now logs the error so real failures aren't invisible.
@angelraph

Copy link
Copy Markdown
Contributor Author

Addressed the review feedback:

  1. pay.services.ts visibility-check duplication — already resolved prior to this pass; confirmPayment calls the shared assertInvoiceVisible helper instead of an inlined check.
  2. Route ordering (/:id before /:id/pdf) — verified this isn't actually a bug. Tested directly against our Express 5 setup: GET /:id/pdf correctly hits the specific route, not /:id, since Express requires an exact segment-count match by default (not prefix matching). No change made.
  3. Silent error swallowing in invoice-pdf.services.ts — fixed in 2535aa1. The bare catch {} around doc.image() now logs the error via console.error before continuing, so a bad/corrupt merchant logo still won't fail the whole PDF render, but the failure is no longer invisible.

Typecheck, lint, and the existing invoice-pdf.services.test.ts suite (7/7) all pass after the change.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit/invoice-pdf.services.test.ts (1)

106-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for a corrupt (but regex-matching) logo.

Current tests only cover a valid PNG and a non-data-URI logo. Add a case with well-formed base64 that isn't a valid image (e.g. data:image/png;base64,YWJjZGVm) to exercise the doc.image() catch branch and confirm it logs and continues instead of throwing — this is the exact path a past review flagged as a swallowed-error risk.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/invoice-pdf.services.test.ts` around lines 106 - 120, Add a unit
test alongside the existing logo cases in generateInvoicePdf coverage using a
data URI with valid base64 that is not a valid image, such as the specified
abcdef payload. Assert the PDF remains valid and verify the image failure is
logged, exercising the doc.image() catch path without allowing the error to
escape.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/services/email.service.ts`:
- Around line 122-146: Update the console-provider fallback in sendInvoiceEmail
to remove invoice.email from the stdout message; retain only the non-PII invoice
identifier and existing PDF size/context.

In `@tests/unit/email.service.test.ts`:
- Around line 1-6: Explicitly control EMAIL_PROVIDER across the affected test
modules before importing email.service: in tests/unit/email.service.test.ts,
ensure it is unset before the dynamic import and restore the prior value
afterward; in tests/unit/email.service.resend.test.ts and
tests/unit/email.service.smtp.test.ts, save and restore the environment value
around their module-load mutations. Keep sendInvoiceEmail’s existing test
behavior unchanged and apply the setup consistently to all three sites.

---

Nitpick comments:
In `@tests/unit/invoice-pdf.services.test.ts`:
- Around line 106-120: Add a unit test alongside the existing logo cases in
generateInvoicePdf coverage using a data URI with valid base64 that is not a
valid image, such as the specified abcdef payload. Assert the PDF remains valid
and verify the image failure is logged, exercising the doc.image() catch path
without allowing the error to escape.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 16ea8f20-cd24-485f-afeb-a274718281d6

📥 Commits

Reviewing files that changed from the base of the PR and between c197dff and 2535aa1.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (18)
  • package.json
  • src/controllers/invoice.controllers.ts
  • src/controllers/pay.controllers.ts
  • src/routes/invoice.routes.ts
  • src/routes/pay.routes.ts
  • src/services/email.service.ts
  • src/services/invoice-pdf.services.ts
  • src/services/invoice.services.ts
  • src/services/pay.services.ts
  • src/services/storage/invoice-pdf.storage.ts
  • tests/integration/auth.email-otp.test.ts
  • tests/integration/invoice.routes.test.ts
  • tests/integration/merchant.register.test.ts
  • tests/integration/pay.routes.test.ts
  • tests/unit/email.service.resend.test.ts
  • tests/unit/email.service.smtp.test.ts
  • tests/unit/email.service.test.ts
  • tests/unit/invoice-pdf.services.test.ts

Comment thread src/services/email.service.ts
Comment thread tests/unit/email.service.test.ts
…OVIDER across tests

Addresses CodeRabbit review on ShadeProtocol#34: the console-provider fallback logged
invoice.email directly; tests mutating process.env.EMAIL_PROVIDER at module
scope could leak that value into sibling test files sharing a Jest worker.
Also adds coverage for a corrupt-but-regex-matching logo data URI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@angelraph

Copy link
Copy Markdown
Contributor Author

@codebestia Pushed a fix addressing the CodeRabbit review comments (PII in the console-provider log, EMAIL_PROVIDER isolation across test modules, and added logo-corruption test coverage). CI (test.yml / Code Formatting Check) is stuck in action_required for the latest commit — could you approve the workflow run so it can execute?

@angelraph

Copy link
Copy Markdown
Contributor Author

@codebestia Just a friendly nudge — the workflow run for the latest fix commit (909c75c) is still sitting in action_required. Could you approve it when you get a chance so CI can run?

@angelraph

Copy link
Copy Markdown
Contributor Author

@codebestia All three review items are addressed in 909c75c:

  • Dropped payer email (PII) from the console-provider log in email.service.ts
  • Isolated EMAIL_PROVIDER across the email service test files so they don't leak state between each other
  • Added test coverage for a corrupt-but-regex-matching logo data URI in invoice-pdf.services.test.ts

Ready for another look whenever you have a chance.

@codebestia

Copy link
Copy Markdown
Contributor

@angelraph Please fix the CI failure

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@angelraph

Copy link
Copy Markdown
Contributor Author

@codebestia CI failure is fixed — pushed in 334b599 (prettier flagged the collapsed console.log line in email.service.ts, reformatted to satisfy the line-width rule, verified locally with prettier --check).

The new workflow runs for this commit are stuck in action_required on GitHub's side — they need a maintainer to approve running Actions for an outside contributor's push. I don't have the access to approve that myself. Could you approve the pending run on the Actions tab so CI can go green? Thanks for the fast turnaround.

@codebestia codebestia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!
Nice Implementation.
Thank you for your contribution.

@codebestia
codebestia merged commit cfb68da into ShadeProtocol:main Jul 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Invoice PDF Generation & Email Delivery

2 participants