Invoice Package Architecture

How @eventjuicer/invoice is layered, and which layer a change belongs in.

Verified against the codebase on 2026-09-16. This file is the shape of the package. The policy — seller routing, department rules, invoice language, unpaid finals, installments, Fakturownia's client matcher — lives in CLAUDE.md and is the authority when the two disagree.

Four layers

app route / server action │ resolves credentials + settings, owns the ActivityLog write ▼ binder resolvePurchaseInvoiceClient ← rebinds to the FROZEN seller │ ▼ helper generateProformaInvoice, convertProformaByPurchase, │ downloadInvoice, processInstallmentInvoice, … ▼ driver BaseInvoiceDriver ← FakturowniaDriver one method = one provider call

Only one provider exists (fakturownia); the driver seam is what keeps the helpers provider-agnostic, not a second implementation.


1. Driver — BaseInvoiceDriver

Location: src/base-invoice-driver.ts, implemented by src/drivers/fakturownia-driver.ts.

A method here is one provider call with no business decisions.

MethodNotes
createProformaInvoice()The only place a buyer block is built from scratch
convertProformaToFinalInvoice()SAFE — duplicate check + OID
UNSAFE_convertProformaToFinalInvoice()No check, no OID
createCorrectiveInvoice()Credit note
updateInstallmentPayment()Non-abstract; the base throws unless a driver overrides it
getInvoice() / getInvoiceByPurchaseId()Reads. The purchase lookup is optional on the interface
listInvoices()Filterable, including by oid
getInvoicePDF()Returns a Buffer
sendInvoiceByEmail()See "Delivery" below — the parameter name matters
deleteInvoice()Cancel / delete
getConfig()The bound config, including organizerId and apiUrl

OID is the duplicate guard. Every document carries oid: "purchase_<id>" with oid_unique: 'yes'. The SAFE conversion lists invoices by that OID first and, when a final already exists, returns it with alreadyExists: true riding in metadata rather than issuing a second one. UNSAFE_ sets no OID and performs no check — it exists for partial-payment cases and will happily create duplicates.

InvoiceResponse surfaces oid and buyerEmail at the top level, not only in metadata, because admin routes read them straight off a driver result.

Hooks

InvoiceConfig.hooks lets the app observe driver calls without the driver knowing anything about our database. apps/admin/lib/invoice.ts uses onCreateProformaInvoice, onConvertProformaToFinalInvoice and onSendInvoiceByEmail to write invoice ids and sent_at back onto ParticipantPurchase.metadata.


2. Binder — resolvePurchaseInvoiceClient

Location: src/resolve-purchase-invoice-client.ts

Every purchase-based operation starts here, and every helper below calls it first. It:

  1. loads the purchase,
  2. refuses it if config.organizerId names a different organizer,
  3. reads ParticipantPurchase.metadata.invoice_seller — the frozen seller policy — and, when present, returns a client rebuilt from it,
  4. refuses when the frozen seller's account origin differs from the configured apiUrl. Stored invoice ids have no account discriminator, so looking a document up in the wrong account is worse than failing.

⛔ It parses with purchaseSellerPolicySchema, never invoiceRoleSellerConfigSchema. The settings schema defaults departmentConfig to [], and on a purchase absent and empty are opposite instructions: absent means "use the organizer's current rules", [] means "this seller has none and does not inherit". That collapse shipped on 2026-09-14 and produced documents with no department_id, which Fakturownia answers by trying to create a department — refused on an account with bank-account-change protection on.

getPurchaseInvoice() is the read-only twin, for callers that have already authorized the purchase.


3. Helpers — business workflows

Location: src/*.ts, one file per workflow, each exported on its own path.

HelperSignatureDoes
generateProformaInvoice(client, purchaseId, sendToEmail?)Full proforma workflow; returns the existing document if one exists
generateProformaInvoiceWithConfig(…)Same, with an explicit config override
convertProformaByPurchase(client, purchaseId, paymentDate?, sellDate?)Finds the proforma, routes installments, converts SAFE, links the final onto the purchase
convertProformaForBankTransfer(client, purchaseId, organizerId)Conversion driven by a matched bank transaction
downloadInvoice(client, purchaseId, sendToEmail?)Final if it exists, else proforma
downloadProformaInvoice(client, purchaseId, …)Proforma specifically
processInstallmentInvoice({ invoiceClient, purchaseId, organizerId, issue })Schedule-aware issuing
synchronizeInstallmentInvoice({ organizerId, purchaseId })Updates an existing final after allocation changes; never creates or mails
canIssueUnpaidFinal({ tickets, paymentRequiredRoles })Pure policy predicate

⚠️ The helper is downloadInvoice, not downloadInvoiceSmart — that is the file name (download-invoice-smart.ts), not the export.

Why helpers sit outside the driver: the driver answers "how do I talk to Fakturownia"; the helper answers "how do we invoice in this business". Keeping them apart is what lets a helper be unit-tested without a provider, and what stops conditionals piling up inside driver methods.

convertProformaByPurchase is a good illustration of what a helper owns and a driver cannot: it re-binds the client to the frozen seller, detects a scheduled purchase and routes to processInstallmentInvoice, converts, then writes the final invoice id back onto the purchase — and reports stage: 'link' when the document exists but that write failed, so a retry repairs the link instead of issuing a second document.


4. App layer — credentials, settings, audit

The package resolves nothing ambient. The route does:

// apps/admin/app/api/resources/invoices/convert-to-final/route.ts import { getInvoiceClient } from '@/lib/invoice'; import { convertProformaByPurchase } from '@eventjuicer/invoice/convert-proforma-by-purchase'; const purchase = await getPurchaseByPurchaseId({ purchaseId: Number(purchaseId) }); const organizerId = purchase.organizer_id; // from the ROW, not the session context const { authenticated, allowedOrganizerIds } = await resolveContext(session.user.email); if (!authenticated || !allowedOrganizerIds.includes(organizerId)) return ApiErrors.forbidden(…); const invoiceClient = await getInvoiceClient(organizerId); // AccessKey + settings + hooks const result = await convertProformaByPurchase(invoiceClient, purchaseId);

And each issuing app's invoice module (apps/admin/lib/invoice.ts; invoice.ts at the app root in apps/ecommerceberlin and apps/expojuicer):

export async function getInvoiceClient(organizerId: number) { const settings = await resolveInvoiceSettings({ organizerId, app: 'admin' }); const apiKey = await requireFakturowniaApiKey(organizerId); return createInvoiceClient('fakturownia', { ...invoiceConfig, ...settings, apiKey, hooks, resolveTicketName }); }

Rules that fall out of this:

  • Never build a client at module scope. createInvoiceClient is synchronous and throws without a key, so export const invoiceClient = … both forces an ambient credential and runs the throw during next build. All three issuing apps (admin, ecommerceberlin, expojuicer) expose getInvoiceClient(organizerId) instead.
  • default-config carries transport defaults only — no seller, no pricing, no credential. Every issuing app shares it; a key there would be one Fakturownia account for every tenant.
  • Gate authorization before calling getInvoiceClient, because it throws on a missing key and a refusal placed after it stops being uniform.
  • getInvoiceClient(config?) exported from create-invoice-client.ts is not the same function. It reads INVOICE_PROVIDER from the environment and is the legacy provider-picker; the per-organizer getInvoiceClient(organizerId) lives in each issuing app's invoice module.
  • The route owns the ActivityLog write (repo rule #21). Commands and helpers stay pure.

Import style

Direct file imports, not the barrel (repo rule #1):

// ✅ import { convertProformaByPurchase } from '@eventjuicer/invoice/convert-proforma-by-purchase'; import { createInvoiceClient } from '@eventjuicer/invoice/create-invoice-client'; import { requireFakturowniaApiKey } from '@eventjuicer/invoice/server/resolve-fakturownia-api-key'; // ❌ src/index.ts exists for compatibility; new code does not use it import { convertProformaByPurchase } from '@eventjuicer/invoice';

Server-only resolvers

src/server/* reaches the database and must stay out of client bundles:

ModuleResolves
resolve-fakturownia-api-keyFAKTUROWNIA_API_KEY from AccessKey, per organizer and environment
resolve-invoice-settingsSeller, pricing and department settings from the organizer's business-rules bucket
resolve-ticket-display-nameCatalogue labels for archived purchase lines

Delivery: email_to, never recipient

POST /invoices/<id>/send_by_email.json takes exactly four optional parameters: email_to, email_cc, email_pdf, update_buyer_email.

⛔ recipient is not one of them — it is an invoice field. Fakturownia ignores unknown query parameters silently, so posting it returns 200 and looks delivered while the mail goes to the document's stored buyer_email. This driver did exactly that from the day it was written; fixed 2026-09-16 and pinned by URL assertions in fakturownia-driver-response-shape.test.ts.

Assert the request URL when you touch a send path. Every route-level test mocks the driver, and a mock accepts a parameter name the real API ignores.

The related, still-open issue — Fakturownia matching a buyer to an existing client card by buyer_name / buyer_tax_no / buyer_email, and buyer_email being ambiguous across companies in our data — is documented in CLAUDE.md § "Filing".


Where does a change go?

You are adding…Put it in
A direct provider API call every provider would implementthe driver
A multi-step workflow, a decision, or error recoverya helper file in src/
Something needing an AccessKey, an app-setting or a DB readsrc/server/*, called by the app
Anything needing a session, tenancy check or audit entrythe route / server action
A pure predicate over purchase dataits own file (see can-issue-unpaid-final.ts)

Do not:

  • reach for UNSAFE_convertProformaToFinalInvoice() without a partial-payment reason;
  • put business logic in a driver method, or provider calls straight in a route;
  • write a helper that only wraps one driver call;
  • resolve a credential or app-setting anywhere below the app layer.