Skip to main content

Quetzal — Design Principles

Source of truth for the V1 contract. Automated agents (contract-guardian, leak-auditor) enforce these rules on every PR. Changing a principle requires an ADR and human approval.


1. Resources, not processes (REST first)

The API exposes resources with state machines. State transitions happen via PATCH on the resource — not via action endpoints (/finalize, /correct, /retry, /force-transition).

PATCH /v1/documents/{document_id} with {status: "submitted"}POST /v1/documents/{document_id}/finalize

PATCH /v1/documents/{document_id} with {receiver: {...}} (triggers internal correction) ❌ POST /v1/documents/{document_id}/correct

If an operation cannot be expressed as a PATCH on the resource (or a POST / DELETE on the collection), the domain is likely mismodeled — revisit before reaching for a verb.

Documented exceptions

Two action endpoints exist in V1. Both are intentional and survived a review against the resource-only alternative. New verb endpoints require an ADR.

EndpointWhy it's not a PATCH
POST /v1/documents/{id}/cancelCancellation is a regulated operation with tax-authority side effects, not a local state flip. SAT (MX) requires a motivo code (01–04); DIAN (CO) cancellation emits a credit note as a separate fiscal document; SRI (EC) and SUNAT (PE) require their own annotated cancellation calls. The result is asynchronous — the tax authority may reject the request. A request body distinct from the resource shape is needed. Stripe's POST /v1/invoices/{id}/void follows the same reasoning.
POST /v1/documents/{id}/voidVoid differs from cancel: it discards a document that was never emitted to a tax authority (stuck in draft or local error). It is a cleanup operation, semantically incompatible with cancel.

In both cases the body carries operation-specific fields that have no home on the Document resource itself. Concretely: reason (free-text, all countries), motivo (SAT motivo code, MX only), and related_document_id (reference to the credit note that materializes the cancellation, CO/DIAN only — other countries don't expose this field). The exact per-country body shape lives in SPEC/openapi.yaml.


2. Hard encapsulation (retry and force-transition are NOT APIs)

Retry policy lives inside the service. An internal supervisor retries according to policy; on exhaustion it marks the document failed or abandoned with a reason.

POST /v1/documents/{id}/retryPOST /v1/documents/{id}/force-transition

If ops needs to nudge a stuck document manually, the path is an internal admin UI calling private service methods — not a REST endpoint. Audit metadata (who, when, why) is built into the admin path.


3. Invoicing is radically abstract

Quetzal Invoicing has no knowledge of:

  • Students, families, campuses, schools
  • Mattilda billing modes (PUE, PPD, factoring, surcharges)
  • Mattilda concepts (membership, inscription, scholarship, discount)
  • EntityCandidate, FiscalGroup, BillingPolicyView, campus_settings

It receives: {line_items, receiver, issuer, country, document_type, metadata}. It returns: a Document resource with a state machine.

"It could invoice coffee." — If a proposal requires Invoicing to understand a Mattilda concept, that concept lives upstream (Billing / Cobranza), not here.


4. metadata is an escape hatch — with rules

metadata (on Document, line_items[], receiver, issuer) accepts a free map<string, string>.

Hard rule: the executor, confirmer, side_effects, and post_processing stages MUST NOT read metadata. Only the caller (Cobranza, Anticipos, etc.) understands it.

If an executor needs a field, it must be first-class on the contract (with an ADR) or already resolved into another field (receiver.tax_id, line_items[].description, etc.).

metadata is for the caller's own use — correlation, internal references — not for the service.


5. Owned identity, not derived

The Document resource owns its own id (a UUID generated by Quetzal). It is NOT derived from the charge account (charge_account_id), the payment (payment_id), or any other resource the caller owns.

To reference the resource that originated the document, use metadata.source_ref (opaque to Quetzal) or an explicit link in Document.references[].


6. JSON represents the entity, not the process

GET /v1/documents/{document_id} returns the state of the resource, not a sequence of internal pipeline stages.

{id, status, fiscal_id, line_items, events_url}{plan_stage: "completed", gatekeep_stage: "passed", execute_stage: "pending", ...}

Internal stages are an implementation detail. The client sees business state.


7. Semantic modularity, not granular

Anti-pattern: six tiny microservices coordinated through queues (the Klip story). Accepted pattern: cohesive modules with clear semantic responsibilities.

  • Quetzal Invoicing is one service — executor, confirmer, etc. do not live in separate repos.
  • Tax-authority adapters MAY live in their own repos when they predate Quetzal (FacturaGreen, Gurusoft, SAE).
  • Billing (Cobranza), Anticipos, Parties are separate bounded contexts with their own repos when they are extracted.

8. Multi-tenant from day one

Every entity carries a tenant_id (mattilda, algebraix, …). Query-layer filters are automatic; tests verify isolation.

TenantContext is injected on every request (from the JWT claim) and travels through the full call stack.


9. Production data over synthetic

Golden tests are built from real cases (Retool corpus, audited production documents), not invented payloads. Shadow mode is mandatory before a per-country cutover.


10. Executable specs

SPEC/openapi.yaml is the source of truth. Code is generated from and validated against it. Changing the spec requires an ADR. PRs touching the spec are reviewed by contract-guardian.


Style Reference

Quetzal adopts the Zalando RESTful API Guidelines (https://opensource.zalando.com/restful-api-guidelines/) as the canonical style reference. Naming, formatting, error handling, pagination, idempotency, and event conventions follow Zalando except where an internal principle is stricter (see §1–2 on URL verbs, §4 on opaque metadata).

Zalando rules adopted and the deliberate divergences are detailed in the API Style Guide.


References