Quetzal — System Design Document
Status: Active design — V2 (FV3 absorption in progress) Authors: Platform team Last updated: 2026-05
Table of Contents
- Overview
- Goals and Non-Goals
- Current State
- Proposed Architecture
- Bounded Contexts — Detailed
- Integration Patterns
- Data Model
- API Contracts
- Multi-Tenancy Model
- Migration Strategy
- Phased Rollout
- Risks and Mitigations
- 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
| System | Repo | Role today | Status |
|---|---|---|---|
regulatory_invoice_changes | regulatory-invoices | Fiscal compliance pipeline — plan, gate, execute, side effects | CO certified (cancel, update_receiver); MX/EC stub |
invoices-server | invoices-server | Mattilda charge accounts — owns invoices and invoice_items tables | Active, gRPC API with 50+ RPCs |
invoices_submission | regulatory-invoices | EC invoice submission (legacy) | Active, being superseded |
regulatory_invoices | regulatory-invoices | MX invoice emission (legacy) | Active, being superseded |
gurusoft module | regulatory-invoices | CO fiscal provider integration (DIAN via Gurusoft) | Active |
sae module | regulatory-invoices | EC fiscal provider integration (SRI via SAE) | Active |
fg module | regulatory-invoices | MX fiscal provider integration (SAT via Factura Green) | Active |
Key Problems
-
Naming collision: "Invoice" means two different things — a charge account in
invoices-server(who owes what) and a fiscal document inregulatory_invoice_changes(what gets sent to the tax authority). This causes constant confusion across teams. -
Mattilda-specific domain concepts hardcoded in shared infrastructure:
FiscalGroup(INVOICE_MAIN, SURCHARGES, PAYMENTS),ConceptType(MEMBERSHIP, INSCRIPTION, COMPLEMENT),EntityCandidate(CAMPUS, MATTILDA), andBillingPolicyViewflags (factoring_enabled,allow_cashier_billing) are embedded in the fiscal compliance domain. Any new tenant must work around these. -
No tenant abstraction:
campus_idis the primary isolation mechanism. There is notenant_id. Multi-tenancy is not modeled — it is assumed that everything belongs to Mattilda. -
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, andFiscalReconciliationService. No single place owns this decision. -
Fiscal compliance reads directly from Mattilda's database:
SqlContextDataGatewayreads frominvoices,invoice_items,campus_settings,receipts,user_tax_data, andfamily_groups_usersat 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
| From | To | Mechanism | Rationale |
|---|---|---|---|
| Tenant apps | Accounts & Ledger | Sync API (HTTP/gRPC) | Write confirmation needed |
| Accounts & Ledger | Billing Orchestration | Domain events (internal) | Same repo, in-process or internal queue |
| Billing Orchestration | Fiscal Compliance | Internal queue (same SNS/outbox) | Decoupled execution |
| Fiscal Compliance | Accounts & Ledger | Events (fiscal_document.emitted) | Update submission ledger projection |
| Quetzal | invoices-server | Events (account.updated) | Local projection sync during migration |
| invoices-server | Quetzal | Sync 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
| Field | Type | Notes |
|---|---|---|
id | UUID | Stable identifier |
tenant_id | str | Which company owns this account |
payer_id | str | Who pays — family_group_id, user_id, etc. |
beneficiary_id | str | Who benefits — student_id, enrollee_id, etc. |
org_unit_id | str | Campus, school, branch — tenant defines what this means |
account_type | str | Configurable per tenant ("membership", "tuition", "enrollment") |
period_ref | str? | Reference to billing period if applicable |
entries | list[Entry] | All charge and credit line items |
status | AccountStatus | OPEN, PAID, OVERDUE, CANCELLED |
balance | Money | Derived from entries (charges - credits) |
due_date | date? | When payment is due |
metadata | dict | Tenant-specific fields (factoring, rvoe, cct, etc.) |
version | int | Optimistic locking |
Commands
| Command | Description |
|---|---|
CreateChargeAccount | Initialize a new account with optional initial entries |
AddEntry | Add a charge, credit, or adjustment line item |
RemoveEntry | Mark an entry as cancelled |
AdjustEntry | Change the amount of an existing entry |
ApplyPayment | Record a payment and reduce pending amounts on entries |
UpdateDueDate | Change the account due date |
CloseAccount | Mark account as PAID (no remaining balance) |
CancelAccount | Mark account as CANCELLED with reason |
Events Emitted
| Event | Payload | Consumers |
|---|---|---|
account.created | Full AccountSnapshot | Billing (evaluate if emission needed) |
account.entry_added | EntrySnapshot + new balance | Billing (re-evaluate) |
account.payment_applied | payment_ref + allocation + new balance | Billing (MX: payment complement trigger) |
account.balance_changed | old/new amounts + reason | Billing, dashboards |
account.status_changed | old/new status + reason | Billing (cancellation may trigger credit note) |
Queries
| Query | Returns | Notes |
|---|---|---|
GetAccount(account_id) | AccountSnapshot | The primary contract for Billing |
GetAccountStatement(filters) | paginated statement | Replaces invoices-server GetAccountStatementPaginated |
GetBalance(account_id) | Money | Current 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
| Component | Derived From | Role |
|---|---|---|
TriggerEvaluator | Scattered SQS handlers + SyncEmissionsService | Event → "should I evaluate billing for this account?" |
PolicyEngine | EmissionPolicyResolver | Account snapshot + policy → emission intents |
IntentMaterializer | CountryChangeRules._resolve_emission() | Intent + country → FiscalEmissionRequest |
GapDetector | FiscalReconciliationService + FiscalGapDetector | Detect missing documents → create emission requests |
CycleScheduler | External 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
| Data | Source | How |
|---|---|---|
AccountSnapshot | Accounts & Ledger (same repo) | Internal query |
SubmissionLedger | Fiscal Compliance | Event-driven projection (Billing maintains its own read model) |
TenantBillingPolicy | Tenant configuration store | Direct query |
| Receiver tax data | External identity service | Direct query (unchanged from today) |
| Issuer fiscal identity | fiscal_credentials service | Direct 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.
| Field | Type | Replaces |
|---|---|---|
emit_triggers | set[str] | emmit_trigger + emmit_method |
cycle_day | int? | ppd_billing_day / emmit_day |
allowed_document_types | set[str] | emmit_credit_note, emmit_nd, etc. |
cancellation_strategy | enum | cancellation_strategy |
surcharge_mode | enum | surcharge_mode |
surcharge_day | int? | surcharge_billing_day |
grouping_strategy | enum | FiscalGroup (made configurable) |
operations_start_date | date? | operations_start_date |
extra | map[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
| Aspect | Today | Target |
|---|---|---|
| Input | ChangeRequest (trigger type) + pipeline reads context | FiscalEmissionRequest (complete, pre-built) |
Reads invoices/invoice_items | Yes — SqlContextDataGateway | No — all data in the request |
Reads campus_settings | Yes — builds BillingPolicyView | No — Billing owns this |
| Mattilda domain concepts | FiscalGroup, ConceptType, EntityCandidate hardcoded | Removed or replaced by tenant config |
TenantId | Not present | First-class field in all entities |
| Context data gateway | SqlContextDataGateway (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.
| Event | Consumers |
|---|---|
fiscal_document.emitted | Billing (update submission ledger projection), invoices-server (update receipts) |
fiscal_document.cancelled | Billing, invoices-server |
fiscal_document.failed | Alerting, 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:
- Quetzal API responds with the created
AccountSnapshotsynchronously - Tenant adapter (e.g. invoices-server) updates its local projection from the response
- 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
| Method | Path | Description |
|---|---|---|
POST | /v1/accounts | Create a charge account with optional initial entries |
GET | /v1/accounts/{id} | Get account snapshot |
POST | /v1/accounts/{id}/entries | Add 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}/payments | Apply a payment — reduces pending amounts |
DELETE | /v1/accounts/{id} | Cancel account with reason |
GET | /v1/accounts/{id}/statement | Paginated account statement |
GET | /v1/accounts | List accounts (tenant-scoped, filterable) |
Billing Orchestration API
| Method | Path | Description |
|---|---|---|
POST | /v1/billing/evaluate | Manually trigger billing evaluation for an account |
POST | /v1/billing/sync | Bulk 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:
| Method | Path | Description |
|---|---|---|
POST | /v1/fiscal/{change_id}/retry | Retry a BLOCKED or EXECUTION_FAILED change |
POST | /v1/fiscal/{change_id}/force-transition | Admin force-transition with age check |
GET | /v1/fiscal/{change_id} | Get change status and actions |
GET | /v1/fiscal/{change_id}/actions | Get 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 tenantalgebraix— example partner companylottus-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_idcolumn on all tables enforced at the query layer- All repository methods receive
TenantContextand applyWHERE 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_typevalues (e.g. Mattilda uses "membership", "inscription"; another company uses "tuition", "registration")entry_typevalues (the building blocks of charge entries)TenantBillingPolicyper 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-phase | invoices-server role | Quetzal role | Criteria to advance |
|---|---|---|---|
| 3a: Events sync | Source of truth, publishes events | Consumes events, builds projection | Event schema stable, projection passes parity checks |
| 3b: Shadow writes | Source of truth, also writes to Quetzal | Accepts shadow writes, compares results | Zero diff between local DB and Quetzal projection for 2 weeks |
| 3c: Delegate writes | Delegates writes to Quetzal API, reads local | Source of truth for writes | All write endpoints migrated, no direct DB writes |
| 3d: Read from projection | Reads from local projection fed by Quetzal events | Publishes events to feed projection | Read latency acceptable, no data loss |
| 3e: Thin adapter | Validates Mattilda business rules, translates to Quetzal API | Full source of truth | invoices-server has zero persistence logic |
Billing Orchestration Extraction (Phase 2)
The following components move from regulatory-invoices to quetzal/src/modules/billing/:
| Component | Source in regulatory-invoices | Target in quetzal |
|---|---|---|
EmissionPolicyResolver | domain/rules/emission_policy_resolver.py | billing/application/policy_engine.py |
ColombiaChangeRules (planning) | domain/rules/colombia_change_rules.py | billing/adapters/co_intent_materializer.py |
SyncEmissionsService | application/services/sync_emissions_service.py | billing/application/cycle_trigger.py |
FiscalReconciliationService | application/services/fiscal_reconciliation_service.py | billing/application/gap_detector.py |
BillingPolicyView | domain/billing_context.py | billing/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:
- Copy
regulatory_invoice_changesmodule intoquetzal/src/modules/fiscal_compliance/ - Add
tenant_idto all domain entities and DB tables (default:'mattilda') - Remove Mattilda-specific domain concepts (
FiscalGroup,ConceptType,EntityCandidate) - Replace
ChangeRequestinput withFiscalEmissionRequest(plan stage simplified) - Recreate CDK stacks in
quetzal/cdk/(import existing SQS queues to avoid data loss) - Blue/green deployment: route new change requests to Quetzal pipeline, keep
regulatory-invoicespipeline for in-flight changes - Drain
regulatory-invoicespipeline, archive repo
ContextDataGateway Retirement (Phase 3→4)
| Phase | Active Implementation |
|---|---|
| 0–2 | SqlContextDataGateway (reads Mattilda DB directly) |
| 3 | SqlContextDataGateway (prod) + CoreContextDataGateway (shadow/test) |
| 4 | CoreContextDataGateway only (SqlContextDataGateway retired) |
11. Phased Rollout
Summary Table
| Phase | Product Value | Core Progress |
|---|---|---|
| 0 (current) | CO cancel + update_receiver certified | Pipeline exists in regulatory-invoices |
| 1 | MX + EC cancel + update_receiver | Clean interfaces, Mattilda concepts tagged |
| 2 | All use cases × 3 countries (CO + MX + EC) | Quetzal repo active, Billing extracted, pipeline migrated |
| 3 | Core model validated (shadow testing) | Accounts & Ledger built, invoices-server syncing to Quetzal |
| 4 | Multi-tenant ready, first external tenant | Full 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
| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
Core model cannot represent all invoices-server data — ChargeAccountAggregate is missing a concept that exists in invoices-server (e.g. a billing model edge case) | High | Medium | Shadow 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 emissions | High | Low | Feature 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 move | High | Low | Blue/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 abstractions — account_type, entry_type, and TenantBillingPolicy do not cover a real case from CompanyX | Medium | Medium | Phase 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 flow | Medium | Low | Same 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-phases | Medium | Medium | Quetzal 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.
| # | Question | Relevant Phase | Options |
|---|---|---|---|
| 1 | Hosting model — Lambda (like regulatory-invoices) or ECS/Fargate for the Quetzal API? | Phase 2 | Lambda: lower ops overhead, matches existing patterns. ECS: better for long-running connections, easier local development. |
| 2 | Auth model — How do tenant apps authenticate to the Quetzal API? | Phase 2 | JWT with tenant_id claim (preferred). API key per tenant. mTLS. |
| 3 | SubmissionLedger access — Event-driven projection (Billing maintains its own) or synchronous query to Fiscal Compliance? | Phase 2 | Event projection: eventually consistent, no cross-context query. Sync query: simpler, always fresh. Recommendation: event projection (see section 6). |
| 4 | Proto 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. |
| 5 | Timeline estimates — What are the realistic timelines for each phase given current team capacity? | All | To be estimated by team in planning session. |
| 6 | Tenant onboarding process — How does a new tenant get set up in Quetzal? Self-service API, admin tooling, or manual? | Phase 4 | To be defined when first external tenant is confirmed. |