09 — Architecture deep dive (simple system)
Status: draft
Thesis: The product is a small event-sourced apply loop + period close. Complexity elsewhere is optional.
Companion: 02-architecture.md · 08-mvp-spec.md · extract 05-extract.md
1. One diagram
┌─────────────────────────────────────────┐
Chargebee ───► │ EXTRACT (Temporal) │
(API) │ SourceAdapter · schedules · cursors │
│ → raw lake (immutable jsonl/objects) │
└──────────────────┬──────────────────────┘
│ run manifest
┌──────────────────▼──────────────────────┐
│ NORMALIZE │
│ CB payload → canonical domain events │
│ (see event catalog) │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ APPLY (domain services) │
│ idempotent open/amend/bill/pay/credit │
│ → OPS DB (contracts, POs, schedules, │
│ billing, AR, applied_events) │
└──────────────────┬──────────────────────┘
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
CLOSE workflows SERVE API ANALYTICS
recognize/period UI / reports ClickHouse
post_journals recon projections
That’s the whole product. No Camunda file spine; no Redis-as-document-DB required; no org-wide “one sync job owns the world” lock as the scale model.
2. Why this stays simple
| Concern | Simple choice | Avoid |
|---|---|---|
| Contract truth | Rows in ops DB from open/amend |
Reconstructing POs from invoice time series every job |
| Incrementality | Cursor + event apply | Snapshot CSV merge + subscription closure reload |
| Scale | Parallelize by tenant / contract | Single org mutex + giant DataFrames |
| Recognition | Schedule lines + recognize(period) |
Buried in sheet builders |
| AR / AC | Projector on billing events | Parallel forever-different file dialect as design |
| Orchestration | Temporal (extract + close) | BPMN for linear ETL |
| Analytics | Project to CH | Making CH the mutable SoR |
3. Data stores (roles)
| Store | Holds | Properties |
|---|---|---|
| Raw lake | Verbatim CB payloads by run | Immutable, replayable |
| Ops DB | Live ASC 606 + AR state | Transactional, strongly consistent apply |
| Cursor DB | Extract bookmarks | Small, per stream/tenant |
| ClickHouse | Waterfalls, metrics, drill indexes | Append/project; rebuildable from ops/events |
| Object storage for exports | Journal files, partner dumps | Optional |
We do not need Redis + SingleStore + S3 sheets as three competing truths. If Redis appears later, it’s cache — not SoR.
4. Apply path (the critical path)
for event in normalized_batch:
if already_applied(tenant, source, event_id): skip
match event.type:
subscription_created → open_contract
subscription_changed → amend_contract
invoice_generated → record_billing
payment_succeeded → record_payment
credit_note_created → record_credit
...
write AppliedEvent
commit
Rules:
- Idempotent — safe replay from lake.
- Ordered enough — per contract/subscription sequence (Temporal workflow or per-key queue). Global total order not required.
- No join back to “all historical invoices” to understand current PO state — state lives in ops DB.
- Policies are data/config (
mod.*,cn.*), not hardcoded spreadsheet branches.
5. Close path
Temporal schedule (monthly/daily):
for tenant in due:
recognize(period)
build deferred waterfall snapshot
optional: post_journals(period)
optional: lock period
Close is a domain workflow, not a side effect of “sync job finished loading Excel.”
6. Multi-tenancy & scale
| Lever | Approach |
|---|---|
| Extract | Per-tenant Temporal schedules; KEDA scale-to-zero (POC) |
| Apply | Workers competing on event partitions keyed by tenant_id or subscription_id |
| Fat tenant | Split apply by subscription key; never load full org history into memory |
| Isolation | Fail one tenant without blocking fleet |
This directly attacks today’s “skewed fat tenant OOM/timeout” failure mode.
7. RevRec vs Accounting Premium
domain events
│
┌───────────┴───────────┐
▼ ▼
Revenue projector AR projector
(contracts, POs, (invoices, AR,
schedules, deferred) cash, CN, aging)
│ │
└───────────┬───────────┘
▼
shared ops DB
(or shared event log + two read models)
Same extract. Two projectors (or one ledger with two account spaces).
Do not design AC as a forever-separate Hotglue dialect — that can be a temporary bridge.
8. Mapping from current system (what moves where)
| Today | Tomorrow |
|---|---|
| tap-chargebee / Hotglue extract | SourceAdapter + Temporal |
contracts.py reconstruction |
Delete as architecture; steal CN/mod edge policies into named policies |
| OrderDetails / BillingSchedule files | Ops DB entities (or ephemeral export if UI needs) |
| Camunda NEW_SYNC_JOB | Extract run + Apply batch (+ separate Close) |
| Redis SO documents | Ops DB |
| Warehouse recognition | recognize + schedule lines |
| AC_* reshape | AR projector |
| MSTR | API + CH |
Migration = shadow accounting compare + cohort cutover (02 phases A–F).
9. Suggested build order (engineering)
- Ops DB schema + apply API in-process (no CB yet) — drive with S01–S05 fixtures.
- Extract Chargebee subset → lake (Temporal POC).
- Normalize mapper CB → events.
- Wire apply to live pulls for one dogfood tenant.
- Close + journal export.
- AR projector for AC-shaped needs.
- Only then expand policies (multi-PO, usage).
Do not start by porting part_processor.py.
10. Complexity budget
Allowed complexity:
- ASC 606 policy matrix (amendments, CNs, methods)
- Idempotent multi-tenant apply
- Extract reliability (cursors, retries, backfill)
Disallowed as default architecture:
- Invoice→PO asof reconstruction
- Org-wide processing mutex
- Dual XL/DB drivers as product spine
- Snapshot CSV as source of truth
- BPMN for “load file then process rows”
If a feature needs complexity, it gets an ADR + scenario — not a new pandas epic.