5. Building Block View¶
This section decomposes the system into its static building blocks. It is filled incrementally as each milestone makes a part of the system real; the blocks documented here are those that exist and are exercised by tests today. Sections not yet built are marked as such.
5.1 Authentication & Tenancy¶
Authentication and multi-tenancy are the load-bearing infrastructure every other
building block depends on. Realizing ADR 0002
(single-database multi-tenancy with a tenant_id discriminator), this block
establishes three guarantees that hold for every request to a protected route,
for the lifetime of the project: the request is authenticated, its tenant
is resolved and bound, and the caller's role is checked. Every business
feature built afterward — customers, products, invoices, e-invoicing — inherits
these guarantees automatically rather than re-implementing them.
The decisive design choice is that tenant isolation is a structural property
of the query layer, not a discipline developers must remember. A model opts
into tenant ownership by using the BelongsToTenant trait; from that point every
query against it is filtered to the active tenant by a global scope, with no
where clause in application code. Isolation that depends on each developer
remembering a where tenant_id = ? is isolation that eventually leaks; isolation
enforced once, at the scope, does not.
The request pipeline¶
A request to a protected endpoint passes through the middleware pipeline before reaching a controller. Each stage is a distinct building block with a single responsibility:
flowchart LR
req[HTTP request] --> sanctum[auth:sanctum
session guard]
sanctum -->|authenticated| resolve[ResolveTenant
binds currentTenant/Id]
sanctum -->|unauthenticated| reject401[401]
resolve --> role[EnsureRole
ordinal rank check]
role -->|sufficient| ctrl[Controller]
role -->|insufficient| reject403[403]
ctrl --> scope[TenantScope
auto-filters every query]
scope --> db[(Postgres
single DB, tenant_id)]
The single most important seam in the whole milestone is the currentTenantId
container binding. It is the interface between ResolveTenant and every
BelongsToTenant model:
ResolveTenantis the only producer of that binding on request paths. It runs after authentication, reads the authenticated user's tenant, and binds both the tenant model (currentTenant) and its id (currentTenantId) into the container for the duration of the request.TenantScopeis the only consumer. On every query of a tenant-owned model it readscurrentTenantIdand adds a table-qualifiedwhere tenant_id = ?. If no tenant is bound — console commands, seeders, the registration flow that creates the very first tenant — the scope is a deliberate no-op, so trusted server-side code runs unscoped by design.
Because there is exactly one producer and one consumer of that binding, tenant isolation on request paths is enforced in a single place that can be read, audited, and tested in isolation. That is what makes isolation a structural property rather than a per-query convention.
Building blocks¶
| Block | Type | Responsibility |
|---|---|---|
Tenant, User |
Eloquent models | The tenant is the isolation boundary; the user belongs to exactly one tenant. Both expose a public UUID externally; the auto-increment id stays internal. |
Role |
Backed enum | Typed roles (owner/admin/member) with an ordinal rank, so an invalid role cannot exist in the model layer and gates compare by rank. |
BelongsToTenant |
Trait | Opt-in tenant ownership: registers the global scope, auto-stamps tenant_id on create from the request context, and makes tenant_id immutable after creation. |
TenantScope |
Global query scope | Filters every query of a tenant-owned model to the bound tenant. The consumer side of the currentTenantId seam. |
ResolveTenant |
Middleware | Binds currentTenant / currentTenantId after authentication. The producer side of the seam. |
EnsureRole |
Middleware | Route-level role gate, by ordinal rank (an owner satisfies role:admin). |
AuthController |
Controller | register (provisions a tenant and its owner in one transaction), login, logout, me. |
UserResource, TenantResource |
API resources | The output boundary: expose UUIDs only — never the password, remember-token, or internal id. |
Interfaces¶
| Interface | Producer | Consumer | Contract |
|---|---|---|---|
currentTenantId (container binding) |
ResolveTenant |
TenantScope |
The active tenant's internal id, bound per request after auth. Absent for trusted server-side code, where the scope is a no-op. |
| Session cookie (HttpOnly) | Sanctum stateful guard | auth:sanctum |
First-party SPA authentication; the session lives in a cookie JavaScript cannot read, with CSRF protection. |
TenantMismatchException |
BelongsToTenant (update guard) |
Caller | Thrown if code attempts to reassign a record's tenant_id after creation — moving data across tenants is never legitimate. |
withoutTenantScope() |
BelongsToTenant |
Trusted callers | The single, greppable, auditable escape hatch for a legitimate cross-tenant query. |
Why cookie sessions, not tokens¶
The SPA and API share a top-level domain, so Sanctum's stateful session guard is
the correct and more secure choice: the session lives in an HttpOnly cookie that
JavaScript cannot read, with built-in CSRF protection, immune to the
XSS token-theft that localStorage bearer tokens invite. Sanctum's token
abilities remain available for a future mobile client or third-party API; they
are simply not used now.
5.2 Customer resource¶
Customers are the first tenant-owned business resource, and the first feature composed almost entirely from the authentication and tenancy primitives of §5.1 rather than from new isolation machinery. This is the point of the design: the customer building block adds a model, a controller, validation, and an output boundary — and no tenant-ownership logic of its own. Isolation, tenant stamping, and the cross-tenant 404 are all inherited.
The Customer model opts into tenant ownership with the BelongsToTenant
trait, so every query against it is already filtered to the active tenant by
TenantScope. It carries a per-tenant document number (§5.3), a typed
CustomerType (company/individual) and Country (the 27 EU member states),
soft-delete support so a deleted customer's history survives, and a
payment_terms_days field laid down now for invoicing to read later. VAT IDs
are validated by a per-country format rule with the German checksum verified
in full; live VIES verification is deferred.
The create-customer flow¶
The flow below is the whole architecture in miniature. Read it for what the
controller does not do: it writes no tenant_id, performs no ownership
check, and computes no number by hand. The role gate is middleware, the number
comes from a row-locked sequence, and the tenant_id is stamped by the trait.
flowchart TD
req[POST /api/v1/customers] --> sanctum[auth:sanctum]
sanctum --> tenant[ResolveTenant
binds currentTenant]
tenant --> role[role:admin gate]
role --> ctrl[CustomerController::store]
ctrl --> seq[SequenceGenerator.next
SELECT ... FOR UPDATE]
seq --> num[K-YYYY-NNNNN]
ctrl --> stamp[BelongsToTenant
auto-stamps tenant_id]
stamp --> insert[(customers
tenant-scoped insert)]
The read path makes the same point from the other direction. The controller's
show, update, and destroy actions take a route-model-bound
Customer $customer. Because the binding query runs through the tenant
global scope — and because ResolveTenant is forced ahead of route-model
binding in the middleware priority list so the tenant is bound before the
lookup runs — a customer belonging to another tenant is simply not found.
Laravel returns 404, not 403, with no if ($customer->tenant_id !== …)
check anywhere in the controller. 404 rather than 403 is deliberate: a 403
would confirm the record exists, leaking its existence across the tenant
boundary; a 404 reveals nothing. This is the cross-tenant contract the tenancy
milestone defined and deferred, now enforced structurally rather than by
convention.
Building blocks¶
| Block | Type | Responsibility |
|---|---|---|
Customer |
Eloquent model (BelongsToTenant, SoftDeletes) |
The first tenant-owned business model. UUID-keyed externally; per-tenant-unique document number; case-insensitive search scope. |
CustomerType |
Backed enum | company / individual, with a requiresCompanyName() predicate the validation reads. |
Country |
Backed enum | The 27 EU member states as ISO 3166-1 alpha-2 codes; the basis for VAT-format selection. |
VatId |
Validation rule | Per-country VAT-ID format, plus the full German USt-IdNr checksum. VIES online verification deferred to a later version. |
StoreCustomerRequest, UpdateCustomerRequest |
Form requests | Validated create/update. The update request deliberately omits number, making it immutable. |
CustomerController |
Resource controller | CRUD + soft-delete/restore + paginated search. Contains zero ownership or tenant-assignment code. |
CustomerResource |
API resource | The output boundary: exposes the UUID as id, never the internal id or tenant_id. |
Interfaces¶
| Interface | Producer | Consumer | Contract |
|---|---|---|---|
GET/POST/PATCH/DELETE /api/v1/customers |
CustomerController |
SPA, API clients | Reads behind auth:sanctum + tenant; writes additionally behind role:admin. |
Route-model binding {customer} |
SubstituteBindings (after ResolveTenant) |
CustomerController |
Resolves the UUID through the tenant scope, so cross-tenant lookups 404 automatically. |
| Customer number | SequenceGenerator (§5.3) |
CustomerController::store |
An atomic K-YYYY-NNNNN issued once at creation; immutable thereafter. |
5.3 Numbering service¶
Every German business document needs a gapless, per-tenant, annually-resetting
number: customers K-YYYY-NNNNN, invoices R-…, expenses E-…. This block
provides that once, generically, so customers consume it now and invoices and
expenses consume it later without change.
The mechanism is a single counter row per (tenant_id, document_type, year) in
number_sequences, and a SequenceGenerator service that increments it inside
a database transaction under a SELECT ... FOR UPDATE row lock. The lock is the
correctness mechanism: two simultaneous creates for the same tenant, type, and
year serialize on that one row, so each receives a distinct, consecutive value
rather than colliding. A unique (tenant_id, document_type, year) index sits
beneath the application logic as an integrity backstop — even if the logic ever
faltered, the database itself refuses a duplicate counter row.
NumberSequence is the one tenant-owned-by-convention model that deliberately
does not use BelongsToTenant: the generator manages tenant scoping by hand
inside the lock, and the trait's global scope and auto-stamp would interfere with
the locked lookup that may need to create the first row. The numbering year is an
explicit argument, defaulting to the current year, so a back-dated document lands
in the correct annual sequence rather than the sequence of its insertion date.
Building blocks¶
| Block | Type | Responsibility |
|---|---|---|
NumberSequence |
Eloquent model | One counter row per (tenant, document type, year). Accessed only through SequenceGenerator, never queried directly. |
SequenceGenerator |
Service | Issues PREFIX-YYYY-NNNNN atomically under a row lock. The single producer of document numbers. |
DocumentType |
Backed enum | customer/invoice/expense, each with its single-letter prefix (K/R/E). |
Interfaces¶
| Interface | Producer | Consumer | Contract |
|---|---|---|---|
SequenceGenerator::next(Tenant, DocumentType, ?year) |
SequenceGenerator |
Resource controllers (Customers now; invoices, expenses later) | Returns a distinct, gapless, formatted number under a row lock. Correct under concurrency on Postgres. |
Unique (tenant_id, document_type, year) index |
number_sequences migration |
Database | Integrity backstop guaranteeing one counter row per tenant/type/year, independent of application logic. |
5.4 Product catalog & the money/units value layer¶
The catalog is the second tenant-owned business resource, and like customers it
adds no isolation machinery of its own — Product uses BelongsToTenant, so
tenant scoping, stamping, and the cross-tenant 404 are all inherited from §5.1.
What the catalog introduces instead are two primitives that invoicing depends on
and will reuse unchanged: a money value layer that keeps money as integer
cents everywhere, and a unit enum that carries its UN/ECE code for EN 16931
line output.
The cents boundary¶
The decisive rule for money is FR-026 / TC-12: money is integer cents, never a
float. This milestone makes that rule executable rather than aspirational. The
amount is integer cents at every hop — the client form, the wire, the column, and
back — and the only thing that ever performs money arithmetic or formatting is
brick/money, which uses arbitrary-precision BigDecimal internally. A PHP
float is not merely discouraged; it is rejected at the cast boundary rather
than silently rounded.
flowchart LR
eur["EUR input '120,00'"] -->|eurosToCents| cents[integer cents 12000]
cents -->|HTTP JSON| api[StoreProductRequest
validates integer]
api --> col[(unit_price_cents
BIGINT)]
col -->|price accessor| money[brick/money Money]
money -->|Money::format| out["'120,00 €'"]
float[PHP float] -.->|rejected by MoneyCast| money
Two pieces enforce this rather than leave it to reviewer vigilance. The column is
a BIGINT (unsignedBigInteger) — a test queries information_schema to assert
the stored type is an integer family, so even a regression in application code
cannot make the schema floating point. And MoneyCast::set accepts a Money or
integer-like cents and throws on anything else, so the one mistake FR-026
exists to prevent — constructing money from a float — fails loudly at the seam.
On the client the same discipline holds in miniature: eurosToCents converts the
EUR field to integer cents before the request, and the only division by 100 is in
centsToEuros, a one-way render for the eye that is never fed back into storage.
A deliberate modeling choice worth recording: the column is named
unit_price_cents so the "this is cents" fact is never lost, and Product
exposes it through a price accessor returning a brick/money Money. The
reusable MoneyCast stays available for models whose attribute is literally the
money name; this model uses the accessor instead. One money approach per model —
never both, since registering price in casts() and as an accessor would
collide.
Unit codes live on the enum¶
EN 16931 requires a UN/ECE Recommendation 20 unit code on every invoice line. The
Unit enum carries that code on each case (Stück → H87, Stunde → HUR,
Kilogramm → KGM, Meter → MTR, Quadratmeter → MTK, Tag → DAY,
pauschal → LS), so invoicing reads $unit->uneceCode() directly with no
separate lookup table to drift out of sync. The backed value is the German
display label; the UN/ECE code is the machine code emitted into ZUGFeRD/XRechnung.
Placing the mapping on the enum now, ahead of invoicing needing it, is the same
move as putting numbering on a generic service: the dependency inherits a correct
implementation instead of reinventing one.
Archive, not delete¶
Products are archived, never deleted (FR-027). Archiving is an is_active
flag, not a soft delete: an archived product stays fully readable because an
invoice line may reference it indefinitely, but the active() scope drops it from
the product picker for new invoices. There is deliberately no destroy
endpoint — a DELETE returns 405, proven by test — because a product that an
invoice depends on must always resolve.
Building blocks¶
| Block | Type | Responsibility |
|---|---|---|
Product |
Eloquent model (BelongsToTenant) |
The catalog model. UUID-keyed externally; integer-cents pricing via a price accessor; active() and case-insensitive search() scopes. No tenant-ownership code of its own. |
VatRate |
Backed enum (int) | The three German rates 19/7/0 (FR-024); multipliers returned as decimal strings for brick/money arithmetic, never floats. |
Unit |
Backed enum (string) | The seven supported units (FR-025), each carrying its UN/ECE Rec 20 code via uneceCode(). |
Money (support) |
Value helper | The single place integer cents become a German EUR string and back, over brick/money. |
MoneyCast |
Eloquent cast | Bridges an integer-cents column and a brick/money Money; throws on a float (FR-026). Reusable; not used by Product, which uses the accessor. |
StoreProductRequest, UpdateProductRequest |
Form requests | Validated create/update; price arrives as integer cents. The update request omits is_active — archiving is an explicit action, not a silent field write. |
ProductController |
Resource controller | CRUD + archive/unarchive + paginated search. No destroy. Contains zero ownership or tenant-assignment code. |
ProductResource |
API resource | Exposes unit_price_cents, a formatted unit_price_formatted, and unit_code — so the client computes on cents, displays the string, and has the EN 16931 code ready. |
Interfaces¶
| Interface | Producer | Consumer | Contract |
|---|---|---|---|
GET/POST/PATCH /api/v1/products, …/{product}/archive, …/unarchive |
ProductController |
SPA, API clients | Reads behind auth:sanctum + tenant; writes additionally behind role:admin. No DELETE (products archive). |
unit_price_cents (integer cents) |
client eurosToCents / API |
unit_price_cents BIGINT column |
Money crosses every boundary as integer cents; a float is rejected at MoneyCast. |
Unit::uneceCode() |
Unit enum |
Invoicing (later), ProductResource |
The EN 16931 line-unit code, on the enum, so there is no separate mapping table. |
Route-model binding {product} |
SubstituteBindings (after ResolveTenant) |
ProductController |
Resolves the UUID through the tenant scope, so cross-tenant lookups 404 automatically. |
5.5 Invoicing core¶
Invoicing is the milestone the whole project was pointed at: it composes the
customers to bill, the products to bill for, the money to bill in, and the
numbers to bill under into one aggregate. Like the customer and catalog blocks
it inherits tenant isolation from §5.1 rather than re-implementing it — Invoice
and Payment use BelongsToTenant; InvoiceLine is not independently scoped
because it is only ever reached through its parent invoice. What this block adds
that is genuinely new is two pieces of pure domain logic kept out of the
controllers: a line-level VAT calculator and an explicit state machine.
Line-level VAT rounding is the load-bearing rule¶
The single most important correctness rule in the system is EN 16931 BR-CO-17:
VAT is computed per line, rounded per line, then summed — never computed once
on the document subtotal. The two approaches differ by one or two cents on
multi-rate invoices, and that difference fails KoSIT validation. InvoiceCalculator
implements the rule; it takes plain InvoiceLineInput value objects and returns
an immutable InvoiceTotals carrying the subtotal, the total VAT, the grand
total, and a per-rate breakdown (rate → {net, vat}) that ZUGFeRD's tax
subtotal groups require. The calculator is DB-free and pure, so it is testable in
isolation and reusable by the model layer now and the ZUGFeRD generator later.
All arithmetic is brick/math BigDecimal with explicit HALF_UP rounding to
whole cents; no PHP float ever touches a monetary value. Quantities are decimal
strings (2.5 hours is valid) and stay in arbitrary precision until the explicit
per-line rounding. The decisive evidence that the rule is actually implemented is
a test that constructs a case where line-level rounding (30 cents) and
document-level rounding (29) genuinely diverge — if anyone "simplifies" the
calculator to tax the subtotal, that test goes red.
The lifecycle is one transition table, not scattered conditionals¶
An invoice has a legally-meaningful lifecycle, enforced by InvoiceStateMachine
(see ADR 0005 and the §6 runtime view):
draft → issued → sent → (partially_paid | paid), with any non-paid state able
to be cancelled, and paid/cancelled terminal. The legal transitions live in
one table; assertCanTransition is the single gate every transition passes
through, throwing InvalidTransitionException — which renders as HTTP 409
carrying the current and attempted states — on an illegal move. The controller
never re-encodes legality. Issuing is the pivotal transition: it mints the
R-YYYY-NNNNN number via the §5.3 SequenceGenerator, sets issued_at, freezes
the line items (only a draft is editable — a write to an issued invoice 409s), and
dispatches GenerateInvoiceDocument.
That job is, in this milestone, a stub: it implements ShouldQueue and logs.
The real ZUGFeRD/PDF generation (FR-041–FR-048) is a separate milestone; here the
issue flow's contract — mint, lock, dispatch — is complete and tested (via
Queue::fake/assertPushed) without building the generator prematurely. The
stub's handle() is the seam the e-invoicing milestone plugs into.
Totals are server-authoritative¶
The server is the single source of truth for money. Invoice::recalculateTotals()
runs the calculator on every draft create and update and persists both the
document totals and each line's computed net and VAT; the API ignores any
client-sent totals entirely. The SPA editor shows a live preview using the same
line-level algorithm so what the user sees matches what the server stores, but the
preview never writes totals — it is convenience, not authority.
Payment-terms precedence (FR-036, FR-040) is resolved by a pure static helper,
Invoice::resolvePaymentTerms(): an explicit invoice-level value wins, else the
customer's default, else the tenant default of 14 days — used at draft creation to
derive the due date.
flowchart TD
ctrl[InvoiceController::store] --> terms[resolvePaymentTerms
invoice > customer > 14]
ctrl --> lines[syncLines
catalog or ad-hoc]
lines --> calc[InvoiceCalculator
BR-CO-17 per-line VAT]
calc --> totals[InvoiceTotals
subtotal, per-rate, total]
totals --> persist[(invoices + invoice_lines
stored cents)]
issue[InvoiceActionController::issue] --> sm[InvoiceStateMachine
assertCanTransition]
sm --> seq[SequenceGenerator.next
R-YYYY-NNNNN]
issue --> job[GenerateInvoiceDocument
stub: dispatched, logs]
Building blocks¶
| Block | Type | Responsibility |
|---|---|---|
InvoiceCalculator |
Service (pure) | Line-level VAT rounding per BR-CO-17 in BigDecimal; returns InvoiceTotals. DB-free and reusable. |
InvoiceTotals, InvoiceLineInput |
Value objects (readonly) | The calculator's output (subtotal, total VAT, grand total, per-rate breakdown) and its decoupled input unit. |
InvoiceStateMachine |
Service (pure) | The lifecycle as one transition table; assertCanTransition is the single gate. |
InvalidTransitionException |
Exception | Self-renders as HTTP 409 with current + attempted state. |
InvoiceState, PaymentMethod |
Backed enums | Lifecycle states (with allowsLineEditing() / isTerminal()) and payment methods. |
Invoice |
Eloquent model (BelongsToTenant) |
Aggregate root owning lines and payments; recalculateTotals(), paidCents(), resolvePaymentTerms(). Number null while draft, unique per tenant once minted. |
InvoiceLine |
Eloquent model | Decimal quantity (never float), cents price, persisted per-line net/vat; optional product reference. Isolation inherited via the aggregate, not its own scope. |
Payment |
Eloquent model (BelongsToTenant) |
A recorded payment; the sum drives the partially_paid/paid transition. |
GenerateInvoiceDocument |
Queued job (stub) | Dispatched on issue; logs only. The seam for ZUGFeRD/PDF (FR-041+). |
InvoiceController |
Controller | Create/update drafts (server-recomputed totals), list, show. |
InvoiceActionController |
Controller | issue / send / record-payment / cancel — every transition through the state machine. |
InvoiceResource, InvoiceLineResource, PaymentResource |
API resources | Controlled JSON; cents plus formatted strings; per-rate-ready line data. |
Interfaces¶
| Interface | Producer | Consumer | Contract |
|---|---|---|---|
GET/POST/PATCH /api/v1/invoices, …/{invoice}/{issue,send,payments,cancel} |
InvoiceController, InvoiceActionController |
SPA, API clients | Reads behind auth:sanctum + tenant; writes additionally behind role:admin. |
InvoiceCalculator::calculate(list<InvoiceLineInput>) |
InvoiceCalculator |
Invoice::recalculateTotals, SPA preview (mirrored), ZUGFeRD (later) |
Line-level VAT, HALF_UP, float-free; returns InvoiceTotals with the per-rate breakdown. |
InvoiceStateMachine::assertCanTransition(from, to) |
InvoiceStateMachine |
Action controller | Throws InvalidTransitionException (409) on an illegal transition; the single legality gate. |
GenerateInvoiceDocument dispatch |
InvoiceActionController::issue |
Queue (ZUGFeRD milestone) | Issuing dispatches the job; the handler is a stub until e-invoicing lands. |
SequenceGenerator::next(Tenant, DocumentType::Invoice) |
§5.3 | InvoiceActionController::issue |
Mints the R-YYYY-NNNNN number atomically when an invoice is issued. |
5.6 and beyond¶
The remaining building blocks (e-invoicing, expense tracking, dashboard, tenant settings) are documented as each is built in its milestone.