Skip to main content

Quetzal — System Design Document

Status: Active design — V2 (FV3 absorption in progress) Authors: Platform team Last updated: 2026-05


Table of Contents

  1. Overview
  2. Goals and Non-Goals
  3. Current State
  4. Proposed Architecture
  5. Bounded Contexts — Detailed
  6. Integration Patterns
  7. Data Model
  8. API Contracts
  9. Multi-Tenancy Model
  10. Migration Strategy
  11. Phased Rollout
  12. Risks and Mitigations
  13. Open Questions

1. Overview

Quetzal is a unified Financial Core platform with one operative thesis: Mattilda is the first tenant, not the only one. The platform is designed so that any educational company — Mattilda today, partner schools tomorrow — writes directly to Quetzal without building its own financial infrastructure.

Mattilda operates educational platforms across Colombia (CO), Mexico (MX), Ecuador (EC), and Peru (PE). Each company in this network has historically run its own financial modules — charge accounts, payments, billing, fiscal compliance — leading to fragmented sources of truth and duplicated logic. Quetzal consolidates these concerns into three bounded contexts:

  • Accounts & Ledger — the source of truth for student and family charges, credits, and balances.
  • Billing Orchestration — decides when and what fiscal documents to emit, configurable per tenant.
  • Fiscal Compliance — executes document emission against tax authorities (DIAN, SAT, SRI, SUNAT), already validated against CO, MX, EC, and PE.

Every public contract is intentionally minimal and free of Mattilda-specific concepts. New tenants integrate against the same APIs as Mattilda, with no escape hatches that hard-code educational-domain assumptions into the platform.

The name Quetzal — the sacred Mesoamerican bird and a currency name across Central America — reflects the platform's role as the unit of financial value that connects multiple entities.


2. Goals and Non-Goals

Goals

  • Single source of truth for student and family financial accounts across all companies
  • Unified billing orchestration configurable per tenant — no hardcoded company-specific logic
  • Reusable fiscal compliance pipeline across countries (CO, MX, EC, PE) and tenants
  • Multi-tenant from day 1 — tenant isolation at data and configuration level
  • Incremental migration — Mattilda migrates gradually via the Strangler Fig pattern; new tenants go direct
  • Spec-driven development — cross-context contracts defined as protobuf schemas before implementation

Non-Goals

  • B2B invoicing (Mattilda billing schools for platform fees) — remains a Mattilda-specific concern; the fiscal compliance pipeline can be called for B2B document emission, but the billing logic stays outside Quetzal
  • Payment gateway integrations (Stripe, bank APIs, PSEs) — out of scope; Quetzal records that a payment was applied, not how it was collected
  • ERP / general accounting (general ledger, accounts payable/receivable beyond student billing) — not in scope
  • Big-bang migration — invoices-server does not migrate overnight; the Strangler Fig pattern is the only acceptable migration approach
  • Per-country deployment isolation — Quetzal is a single deployment serving all countries with tenant-level filtering

3. Current State

Existing Systems

SystemRepoRole todayStatus
regulatory_invoice_changesregulatory-invoicesFiscal compliance pipeline — plan, gate, execute, side effectsCO certified (cancel, update_receiver); MX/EC stub
invoices-serverinvoices-serverMattilda charge accounts — owns invoices and invoice_items tablesActive, gRPC API with 50+ RPCs
invoices_submissionregulatory-invoicesEC invoice submission (legacy)Active, being superseded
regulatory_invoicesregulatory-invoicesMX invoice emission (legacy)Active, being superseded
gurusoft moduleregulatory-invoicesCO fiscal provider integration (DIAN via Gurusoft)Active
sae moduleregulatory-invoicesEC fiscal provider integration (SRI via SAE)Active
fg moduleregulatory-invoicesMX fiscal provider integration (SAT via Factura Green)Active

Key Problems

  1. Naming collision: "Invoice" means two different things — a charge account in invoices-server (who owes what) and a fiscal document in regulatory_invoice_changes (what gets sent to the tax authority). This causes constant confusion across teams.

  2. Mattilda-specific domain concepts hardcoded in shared infrastructure: FiscalGroup (INVOICE_MAIN, SURCHARGES, PAYMENTS), ConceptType (MEMBERSHIP, INSCRIPTION, COMPLEMENT), EntityCandidate (CAMPUS, MATTILDA), and BillingPolicyView flags (factoring_enabled, allow_cashier_billing) are embedded in the fiscal compliance domain. Any new tenant must work around these.

  3. No tenant abstraction: campus_id is the primary isolation mechanism. There is no tenant_id. Multi-tenancy is not modeled — it is assumed that everything belongs to Mattilda.

  4. Billing orchestration logic is scattered: The logic that decides when and what to invoice lives across an external cron job, SQS message handlers (billing_date_reached_handler, payment_received_handler), SyncEmissionsService, EmissionPolicyResolver, ColombiaChangeRules, and FiscalReconciliationService. No single place owns this decision.

  5. Fiscal compliance reads directly from Mattilda's database: SqlContextDataGateway reads from invoices, invoice_items, campus_settings, receipts, user_tax_data, and family_groups_users at query time. The pipeline is tightly coupled to Mattilda's data model. A different tenant cannot use the pipeline without providing the same table structure.

Communication Diagram (Current)


4. Proposed Architecture

Repository

All three bounded contexts live in a single repo (quetzal). The fiscal compliance pipeline migrates from regulatory-invoices during Phase 2. See ADR-001 for the rationale.

Bounded Contexts

Communication Patterns

FromToMechanismRationale
Tenant appsAccounts & LedgerSync API (HTTP/gRPC)Write confirmation needed
Accounts & LedgerBilling OrchestrationDomain events (internal)Same repo, in-process or internal queue
Billing OrchestrationFiscal ComplianceInternal queue (same SNS/outbox)Decoupled execution
Fiscal ComplianceAccounts & LedgerEvents (fiscal_document.emitted)Update submission ledger projection
Quetzalinvoices-serverEvents (account.updated)Local projection sync during migration
invoices-serverQuetzalSync API (writes)Strangler Fig Phase 3

5. Bounded Contexts — Detailed

5.1 Accounts & Ledger

Responsibility: Single source of truth for "who owes what." Records charges, credits, adjustments, and payment applications. Derives balances from entries — never stores a balance directly.

Design lineage: The ChargeAccountAggregate is directly informed by the InvoiceAggregate in invoices-server. The command pattern (process(command) → events → state mutation), amount recalculation, upsert policies, and conflict resolution are ported and generalized. Mattilda-specific fields (rvoe, cct, factoring) move to metadata.

Aggregate: ChargeAccountAggregate

FieldTypeNotes
idUUIDStable identifier
tenant_idstrWhich company owns this account
payer_idstrWho pays — family_group_id, user_id, etc.
beneficiary_idstrWho benefits — student_id, enrollee_id, etc.
org_unit_idstrCampus, school, branch — tenant defines what this means
account_typestrConfigurable per tenant ("membership", "tuition", "enrollment")
period_refstr?Reference to billing period if applicable
entrieslist[Entry]All charge and credit line items
statusAccountStatusOPEN, PAID, OVERDUE, CANCELLED
balanceMoneyDerived from entries (charges - credits)
due_datedate?When payment is due
metadatadictTenant-specific fields (factoring, rvoe, cct, etc.)
versionintOptimistic locking

Commands

CommandDescription
CreateChargeAccountInitialize a new account with optional initial entries
AddEntryAdd a charge, credit, or adjustment line item
RemoveEntryMark an entry as cancelled
AdjustEntryChange the amount of an existing entry
ApplyPaymentRecord a payment and reduce pending amounts on entries
UpdateDueDateChange the account due date
CloseAccountMark account as PAID (no remaining balance)
CancelAccountMark account as CANCELLED with reason

Events Emitted

EventPayloadConsumers
account.createdFull AccountSnapshotBilling (evaluate if emission needed)
account.entry_addedEntrySnapshot + new balanceBilling (re-evaluate)
account.payment_appliedpayment_ref + allocation + new balanceBilling (MX: payment complement trigger)
account.balance_changedold/new amounts + reasonBilling, dashboards
account.status_changedold/new status + reasonBilling (cancellation may trigger credit note)

Queries

QueryReturnsNotes
GetAccount(account_id)AccountSnapshotThe primary contract for Billing
GetAccountStatement(filters)paginated statementReplaces invoices-server GetAccountStatementPaginated
GetBalance(account_id)MoneyCurrent balance

AccountSnapshot (the contract Billing reads)

Defined in schemas/accounts/events.proto. Contains account identity, status, all entries with amounts, current balance, and metadata. This is what replaces the current pattern of Billing reading directly from invoices and invoice_items tables.


5.2 Billing Orchestration

Responsibility: Decides when and what fiscal documents to emit. Takes no actions itself — produces FiscalEmissionRequest commands for Fiscal Compliance to execute.

Design lineage: Consolidates logic currently scattered across EmissionPolicyResolver, ColombiaChangeRules (planning part), SyncEmissionsService, FiscalReconciliationService, and external cron handlers.

Components

ComponentDerived FromRole
TriggerEvaluatorScattered SQS handlers + SyncEmissionsServiceEvent → "should I evaluate billing for this account?"
PolicyEngineEmissionPolicyResolverAccount snapshot + policy → emission intents
IntentMaterializerCountryChangeRules._resolve_emission()Intent + country → FiscalEmissionRequest
GapDetectorFiscalReconciliationService + FiscalGapDetectorDetect missing documents → create emission requests
CycleSchedulerExternal cron (today)Time-based billing cycle triggers

Inputs

  • Domain events from Accounts & Ledger (account.created, account.payment_applied, etc.)
  • Scheduled billing cycles (internal scheduler, replaces external cron)
  • Manual triggers via API (POST /billing/evaluate)

What Billing Reads

DataSourceHow
AccountSnapshotAccounts & Ledger (same repo)Internal query
SubmissionLedgerFiscal ComplianceEvent-driven projection (Billing maintains its own read model)
TenantBillingPolicyTenant configuration storeDirect query
Receiver tax dataExternal identity serviceDirect query (unchanged from today)
Issuer fiscal identityfiscal_credentials serviceDirect query (unchanged from today)

Output

FiscalEmissionRequest published to internal queue → consumed by Fiscal Compliance pipeline.

Defined in schemas/billing/fiscal_emission_request.proto.

TenantBillingPolicy

Replaces BillingPolicyView (which reads from campus_settings). Now a first-class configuration entity per tenant, optionally overridden per org_unit_id.

Defined in schemas/billing/billing_policy.proto.

FieldTypeReplaces
emit_triggersset[str]emmit_trigger + emmit_method
cycle_dayint?ppd_billing_day / emmit_day
allowed_document_typesset[str]emmit_credit_note, emmit_nd, etc.
cancellation_strategyenumcancellation_strategy
surcharge_modeenumsurcharge_mode
surcharge_dayint?surcharge_billing_day
grouping_strategyenumFiscalGroup (made configurable)
operations_start_datedate?operations_start_date
extramap[str, str]factoring, allow_cashier_billing, etc.

5.3 Fiscal Compliance

Responsibility: Execute fiscal document emission against tax authorities. Receives a complete FiscalEmissionRequest and processes it through the pipeline. Does not decide what to emit or why — that is Billing's job.

Migration: Currently lives in regulatory-invoices. Moves to Quetzal during Phase 2. See ADR-001.

What Changes

AspectTodayTarget
InputChangeRequest (trigger type) + pipeline reads contextFiscalEmissionRequest (complete, pre-built)
Reads invoices/invoice_itemsYes — SqlContextDataGatewayNo — all data in the request
Reads campus_settingsYes — builds BillingPolicyViewNo — Billing owns this
Mattilda domain conceptsFiscalGroup, ConceptType, EntityCandidate hardcodedRemoved or replaced by tenant config
TenantIdNot presentFirst-class field in all entities
Context data gatewaySqlContextDataGateway (Mattilda DB reads)CoreContextDataGateway (reads from Quetzal API)

What Stays Unchanged

  • Pipeline stages: plan → gatekeeping → execute → side effects → post-processing
  • State machine: ChangeStatus, ActionStatus, all transitions
  • Country executors: CoCompositeExecutor, MX/EC executors
  • Provider integrations: Gurusoft, SAE, Factura Green
  • Recovery mechanisms: supervisor, auto-retry, force-transition
  • Transactional outbox pattern
  • CDK infrastructure (SQS FIFO, Lambda, DLQs) — recreated in Quetzal's CDK

Events Emitted

Defined in schemas/fiscal_compliance/events.proto.

EventConsumers
fiscal_document.emittedBilling (update submission ledger projection), invoices-server (update receipts)
fiscal_document.cancelledBilling, invoices-server
fiscal_document.failedAlerting, supervisor recovery

6. Integration Patterns

Primary Cross-Context Contract: FiscalEmissionRequest

This is the most critical interface in the system. It decouples Billing Orchestration from Fiscal Compliance — Billing assembles everything the pipeline needs, and the pipeline executes without reading any external data.

See schema: schemas/billing/fiscal_emission_request.proto

Billing Orchestration Fiscal Compliance
assembles: receives:
- tenant + country FiscalEmissionRequest
- document type (self-contained)
- line items with amounts
- pre-resolved receiver executes:
- pre-resolved issuer gatekeeping
- reference doc id (if applicable) provider call
- metadata side effects

Why this matters: Today the pipeline's plan stage reads invoices, invoice_items, campus_settings, user_tax_data, and family_groups_users at runtime. Any new tenant must provide the same database schema. With FiscalEmissionRequest, the pipeline is data-source agnostic.

Event Flow

Write-then-Read Consistency

When a tenant app writes a charge account and immediately reads it back:

  1. Quetzal API responds with the created AccountSnapshot synchronously
  2. Tenant adapter (e.g. invoices-server) updates its local projection from the response
  3. The async domain event arrives later and is a no-op (idempotent)

No eventual consistency gap for the calling client. Background reads (account statements, reports) accept a short lag from the async projection.

SubmissionLedger Access

Billing Orchestration needs to know what fiscal documents already exist for an account (to avoid duplicate emissions and to detect gaps).

Approach: Event-driven projection. Billing maintains its own read model of the submission ledger, fed by fiscal_document.emitted and fiscal_document.cancelled events from Fiscal Compliance. This avoids a synchronous cross-context query on the hot path.


7. Data Model

Quetzal Database (new, separate from Mattilda DB)

-- Accounts & Ledger

CREATE TABLE charge_accounts (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
payer_id TEXT NOT NULL,
beneficiary_id TEXT NOT NULL,
org_unit_id TEXT NOT NULL,
account_type TEXT NOT NULL,
period_ref TEXT,
status TEXT NOT NULL, -- OPEN, PAID, OVERDUE, CANCELLED
due_date DATE,
metadata JSONB NOT NULL DEFAULT '{}',
external_refs JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
version INTEGER NOT NULL DEFAULT 0
);

CREATE INDEX ON charge_accounts (tenant_id, org_unit_id);
CREATE INDEX ON charge_accounts (tenant_id, beneficiary_id);
CREATE INDEX ON charge_accounts (tenant_id, status);

CREATE TABLE charge_entries (
id UUID PRIMARY KEY,
account_id UUID NOT NULL REFERENCES charge_accounts(id),
entry_type TEXT NOT NULL, -- configurable per tenant
entry_category TEXT NOT NULL, -- CHARGE, CREDIT, ADJUSTMENT
calculation_mode TEXT NOT NULL, -- FIXED, PERCENTAGE
amount NUMERIC NOT NULL,
pending_amount NUMERIC NOT NULL,
currency TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL, -- ACTIVE, OVERWRITTEN, CANCELLED
metadata JSONB NOT NULL DEFAULT '{}',
external_ref TEXT,
apply_date DATE,
due_date DATE,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX ON charge_entries (account_id);
CREATE INDEX ON charge_entries (account_id, status);

-- Billing Orchestration

CREATE TABLE tenant_billing_policies (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
org_unit_id TEXT, -- NULL = policy applies to whole tenant
country TEXT NOT NULL,
policy JSONB NOT NULL, -- TenantBillingPolicy fields
active_from DATE NOT NULL,
active_to DATE, -- NULL = currently active
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);

CREATE UNIQUE INDEX ON tenant_billing_policies (tenant_id, org_unit_id, country, active_from)
WHERE active_to IS NULL;

CREATE TABLE billing_evaluations (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
account_id UUID NOT NULL,
trigger_reason TEXT NOT NULL,
decision TEXT NOT NULL, -- EMIT, SKIP, REJECT
intents JSONB NOT NULL,
evaluated_at TIMESTAMPTZ NOT NULL
);

-- Billing: submission ledger projection (fed by fiscal_document events)

CREATE TABLE submission_ledger_entries (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
account_id UUID NOT NULL,
document_id UUID NOT NULL,
document_type TEXT NOT NULL,
fiscal_id TEXT, -- ID from tax authority
status TEXT NOT NULL, -- EMITTED, CANCELLED, FAILED
amount NUMERIC NOT NULL,
currency TEXT NOT NULL,
reference_doc_id UUID,
emitted_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}'
);

CREATE INDEX ON submission_ledger_entries (tenant_id, account_id);

Fiscal Compliance (existing tables, extended)

-- Add tenant_id to existing tables in regulatory-invoices DB
-- (applied during Phase 2 migration)

ALTER TABLE regulatory_invoice_changes
ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'mattilda';

ALTER TABLE regulatory_invoice_change_actions
ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'mattilda';

ALTER TABLE regulatory_invoice_post_processing_tasks
ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'mattilda';

8. API Contracts

Accounts & Ledger API

MethodPathDescription
POST/v1/accountsCreate a charge account with optional initial entries
GET/v1/accounts/{id}Get account snapshot
POST/v1/accounts/{id}/entriesAdd a charge, credit, or adjustment entry
PATCH/v1/accounts/{id}/entries/{entry_id}Adjust an entry (amount, description)
DELETE/v1/accounts/{id}/entries/{entry_id}Cancel an entry
POST/v1/accounts/{id}/paymentsApply a payment — reduces pending amounts
DELETE/v1/accounts/{id}Cancel account with reason
GET/v1/accounts/{id}/statementPaginated account statement
GET/v1/accountsList accounts (tenant-scoped, filterable)

Billing Orchestration API

MethodPathDescription
POST/v1/billing/evaluateManually trigger billing evaluation for an account
POST/v1/billing/syncBulk evaluate all eligible accounts for an org unit
GET/v1/billing/policies/{tenant_id}Get billing policies for a tenant
PUT/v1/billing/policies/{tenant_id}Create or update billing policy
GET/v1/billing/ledger/{account_id}Get submission ledger for an account

Fiscal Compliance API

The fiscal compliance pipeline is primarily event-driven (receives FiscalEmissionRequest via internal queue). HTTP endpoints exist for operational tasks:

MethodPathDescription
POST/v1/fiscal/{change_id}/retryRetry a BLOCKED or EXECUTION_FAILED change
POST/v1/fiscal/{change_id}/force-transitionAdmin force-transition with age check
GET/v1/fiscal/{change_id}Get change status and actions
GET/v1/fiscal/{change_id}/actionsGet action history

Authentication

All API endpoints require a tenant-scoped JWT. The tenant_id claim in the JWT is the authoritative tenant context — no tenant can read or write data belonging to another tenant.

Details TBD — see Open Questions.


9. Multi-Tenancy Model

Tenant Definition

A tenant is an educational company that uses Quetzal as its financial platform:

  • mattilda — first and primary tenant
  • algebraix — example partner company
  • lottus-school — example partner company

Tenants are not countries. A single tenant can operate in multiple countries (Mattilda operates in CO, MX, EC). Country is a dimension within a tenant, not a tenant itself.

Data Isolation

  • tenant_id column on all tables enforced at the query layer
  • All repository methods receive TenantContext and apply WHERE tenant_id = ? automatically
  • No cross-tenant queries are possible through normal service paths
  • Database-level row security policies as an additional enforcement layer (future)

Configuration Isolation

Each tenant defines its own:

  • account_type values (e.g. Mattilda uses "membership", "inscription"; another company uses "tuition", "registration")
  • entry_type values (the building blocks of charge entries)
  • TenantBillingPolicy per org_unit and country (when to bill, what documents to emit, how to group)
  • Fiscal credentials per country

TenantContext

Travels with every request and is injected into all service calls:

@dataclass(frozen=True)
class TenantContext:
tenant_id: str
country: Country
org_unit_id: str

Infrastructure

Shared infrastructure (single database, single set of queues, single Lambda deployment) with tenant_id filtering. This is the right tradeoff for the current scale.

Future option: If a tenant requires stronger isolation guarantees (data residency, compliance, separate SLAs), per-tenant database schemas can be introduced without changing the application model — the TenantContext already provides the routing key.


10. Migration Strategy

invoices-server → Quetzal (Strangler Fig)

See ADR-002 for full rationale.

Sub-phaseinvoices-server roleQuetzal roleCriteria to advance
3a: Events syncSource of truth, publishes eventsConsumes events, builds projectionEvent schema stable, projection passes parity checks
3b: Shadow writesSource of truth, also writes to QuetzalAccepts shadow writes, compares resultsZero diff between local DB and Quetzal projection for 2 weeks
3c: Delegate writesDelegates writes to Quetzal API, reads localSource of truth for writesAll write endpoints migrated, no direct DB writes
3d: Read from projectionReads from local projection fed by Quetzal eventsPublishes events to feed projectionRead latency acceptable, no data loss
3e: Thin adapterValidates Mattilda business rules, translates to Quetzal APIFull source of truthinvoices-server has zero persistence logic

Billing Orchestration Extraction (Phase 2)

The following components move from regulatory-invoices to quetzal/src/modules/billing/:

ComponentSource in regulatory-invoicesTarget in quetzal
EmissionPolicyResolverdomain/rules/emission_policy_resolver.pybilling/application/policy_engine.py
ColombiaChangeRules (planning)domain/rules/colombia_change_rules.pybilling/adapters/co_intent_materializer.py
SyncEmissionsServiceapplication/services/sync_emissions_service.pybilling/application/cycle_trigger.py
FiscalReconciliationServiceapplication/services/fiscal_reconciliation_service.pybilling/application/gap_detector.py
BillingPolicyViewdomain/billing_context.pybilling/domain/billing_policy.py (generalized)

A feature flag controls whether the old path (in-process, inside regulatory-invoices) or the new path (via Quetzal Billing) is active during the transition.

Fiscal Compliance Pipeline Migration (Phase 2)

The pipeline moves from regulatory-invoices to quetzal. Migration steps:

  1. Copy regulatory_invoice_changes module into quetzal/src/modules/fiscal_compliance/
  2. Add tenant_id to all domain entities and DB tables (default: 'mattilda')
  3. Remove Mattilda-specific domain concepts (FiscalGroup, ConceptType, EntityCandidate)
  4. Replace ChangeRequest input with FiscalEmissionRequest (plan stage simplified)
  5. Recreate CDK stacks in quetzal/cdk/ (import existing SQS queues to avoid data loss)
  6. Blue/green deployment: route new change requests to Quetzal pipeline, keep regulatory-invoices pipeline for in-flight changes
  7. Drain regulatory-invoices pipeline, archive repo

ContextDataGateway Retirement (Phase 3→4)

PhaseActive Implementation
0–2SqlContextDataGateway (reads Mattilda DB directly)
3SqlContextDataGateway (prod) + CoreContextDataGateway (shadow/test)
4CoreContextDataGateway only (SqlContextDataGateway retired)

11. Phased Rollout

Summary Table

PhaseProduct ValueCore Progress
0 (current)CO cancel + update_receiver certifiedPipeline exists in regulatory-invoices
1MX + EC cancel + update_receiverClean interfaces, Mattilda concepts tagged
2All use cases × 3 countries (CO + MX + EC)Quetzal repo active, Billing extracted, pipeline migrated
3Core model validated (shadow testing)Accounts & Ledger built, invoices-server syncing to Quetzal
4Multi-tenant ready, first external tenantFull integration, invoices-server → thin Mattilda adapter

Phase Diagrams

Phase 1 — Country Expansion + Clean Boundaries

Phase 2 — Quetzal Active, Billing Extracted, Pipeline Migrated

Phase 3 — Accounts & Ledger + Shadow Testing

Phase 4 — Full Integration + Multi-Tenant


12. Risks and Mitigations

RiskImpactLikelihoodMitigation
Core model cannot represent all invoices-server dataChargeAccountAggregate is missing a concept that exists in invoices-server (e.g. a billing model edge case)HighMediumShadow testing in Phase 3 validates parity before any write migration. Bidirectional mapping must be verified at the field level before advancing.
Billing extraction breaks existing emissions — moving EmissionPolicyResolver and related logic to a new module disrupts the certified CO emissionsHighLowFeature flag controls old path (in-process) vs new path (Quetzal Billing). Old path remains active until new path is validated in staging for 2+ weeks.
Pipeline migration disrupts certified fiscal flow — the SQS queues contain in-flight messages during the moveHighLowBlue/green deployment: new change requests route to Quetzal pipeline, regulatory-invoices pipeline drains existing messages. Queues are imported into Quetzal CDK (not recreated).
Second tenant reveals wrong abstractionsaccount_type, entry_type, and TenantBillingPolicy do not cover a real case from CompanyXMediumMediumPhase 4 explicitly budgets for model iteration. The abstractions are intentionally minimal — metadata: dict provides an escape hatch. First external tenant onboarding is the proof.
Cross-context latency — Billing to Fiscal Compliance communication adds latency to the emission flowMediumLowSame SQS FIFO pattern already proven in regulatory-invoices pipeline. Internal queue, not cross-service HTTP.
invoices-server migration stalls — Mattilda engineering capacity does not advance the strangler fig sub-phasesMediumMediumQuetzal is fully functional for new tenants regardless. Mattilda migration is independent of the Core's roadmap. The worst outcome is Mattilda continues using invoices-server longer than planned.

13. Open Questions

These questions are open at the time of writing. Each should be resolved as an ADR before the relevant phase begins.

#QuestionRelevant PhaseOptions
1Hosting model — Lambda (like regulatory-invoices) or ECS/Fargate for the Quetzal API?Phase 2Lambda: lower ops overhead, matches existing patterns. ECS: better for long-running connections, easier local development.
2Auth model — How do tenant apps authenticate to the Quetzal API?Phase 2JWT with tenant_id claim (preferred). API key per tenant. mTLS.
3SubmissionLedger access — Event-driven projection (Billing maintains its own) or synchronous query to Fiscal Compliance?Phase 2Event projection: eventually consistent, no cross-context query. Sync query: simpler, always fresh. Recommendation: event projection (see section 6).
4Proto vs OpenAPI — Protobuf for all schemas or OpenAPI for HTTP APIs and Protobuf for events?Phase 1 (decide before Phase 2)Protobuf everywhere: codegen, typed, versioned. OpenAPI for HTTP: more tooling, easier for REST consumers.
5Timeline estimates — What are the realistic timelines for each phase given current team capacity?AllTo be estimated by team in planning session.
6Tenant onboarding process — How does a new tenant get set up in Quetzal? Self-service API, admin tooling, or manual?Phase 4To be defined when first external tenant is confirmed.