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.mdand 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.
| Method | Notes |
|---|---|
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:
- loads the purchase,
- refuses it if
config.organizerIdnames a different organizer, - reads
ParticipantPurchase.metadata.invoice_seller— the frozen seller policy — and, when present, returns a client rebuilt from it, - 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.
| Helper | Signature | Does |
|---|---|---|
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, notdownloadInvoiceSmart— 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.
createInvoiceClientis synchronous and throws without a key, soexport const invoiceClient = …both forces an ambient credential and runs the throw duringnext build. All three issuing apps (admin, ecommerceberlin, expojuicer) exposegetInvoiceClient(organizerId)instead. default-configcarries 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 fromcreate-invoice-client.tsis not the same function. It readsINVOICE_PROVIDERfrom the environment and is the legacy provider-picker; the per-organizergetInvoiceClient(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:
| Module | Resolves |
|---|---|
resolve-fakturownia-api-key | FAKTUROWNIA_API_KEY from AccessKey, per organizer and environment |
resolve-invoice-settings | Seller, pricing and department settings from the organizer's business-rules bucket |
resolve-ticket-display-name | Catalogue 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 implement | the driver |
| A multi-step workflow, a decision, or error recovery | a helper file in src/ |
Something needing an AccessKey, an app-setting or a DB read | src/server/*, called by the app |
| Anything needing a session, tenancy check or audit entry | the route / server action |
| A pure predicate over purchase data | its 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.