Files
caiwuzongzhang/docs/decisions/006-intercompany-positions.md
T
腾讯WorkBuddy 85293b79df B-44: intercompany ledger events, subject review and drill-down evidence
- migration 6: manual_records, ledger_event_revisions chain, current
  projections, source claims, subject suggestions, eligible_position_events
- ledger_events.py: bank-event reconciliation, reversal/adjustment/reopen,
  append-only revision chain and rebuildable current projection
- subjects.py: fixed subject mirror, draft suggestion dictionary, explicit
  administrator subject confirmation with expected_revision + idempotency
- manual_records.py: submit, approve new/link, return/exception/reverse,
  candidate hints, idempotent replay and concurrency-safe claims
- positions.py: Decimal aggregation, both-perspective conservation asserts,
  cutoff window, unresolved gross buckets, keyset pagination, evidence
  visibility (visible/masked/missing)
- server.py: admin + company intercompany APIs with tenant isolation (404 on
  cross-tenant reads, 403 on company writes) and auto reconcile wiring
- admin/company portals: balance directory, pair drill-down drawer, evidence
  drawer, subject/manual audit queue, company balance summary
- tests: ledger events, subjects, manual records, positions, HTTP API and
  migration persistence (233 total, all green)
2026-08-19 11:58:25 +08:00

4.9 KiB

006: Intercompany positions, subject review and drill-down evidence (B-44)

Status: accepted (2026-08-19)

Decision

The intercompany ledger adds one canonical layer above B-43's eligible events:

  • Append-only ledger events. A canonical ledger_event carries an append-only ledger_event_revisions chain. A B-43 eligible bank event first becomes a pending_subject revision; an administrator confirms the subject into a confirmed revision. Corrections are never in-place edits — a reversal or adjustment is a new ledger event with its own effective date, and the original event keeps its history so earlier cutoffs are not rewritten.
  • Manual records as immutable submitted facts. Companies submit manual_records; only an administrator-approved record becomes a confirmed ledger event (approve_new) or joins one (approve_link). Returned, exception and pending records never affect a balance and never leak to the counterparty. Approved facts change only through reverse (a new opposite event) — the original is never edited.
  • One perspective, fixed mirror. Subjects are stored from one participating company's perspective (receivable/payable/other_receivable/other_payable); the other side is the fixed mirror (应收<->应付, 其他应收<->其他应付), so the two companies can never book conflicting subjects.
  • Subjects are confirmed, never auto-posted. Bank summary/purpose text only feeds a deterministic suggestion dictionary (subject-suggest-draft-v1, not group-approved). Without an approved trade dictionary every bank event stays in subject review until an administrator confirms.
  • Decimal-only aggregation. All money is stored as TEXT decimal strings and aggregated with Python Decimal. SQLite SUM, JavaScript Number and Python float never touch financial math. Different currencies are aggregated and displayed separately; nothing is converted to a group total.
  • Conservation is asserted. For every company pair and currency the two perspectives must mirror exactly (C_A == -C_B); a violation raises a calculation exception instead of rendering an unbalanced number.
  • Unresolved is absolute gross. Unresolved amounts are summed by absolute value per currency (never netted), broken down by reason (subject_review, unmatched_single, manual_pending), with count and gross amount exposed on every balance response.
  • B-45 boundary. Until the B-45 opening balance exists, every response returns opening.status=unavailable, opening.amount=null and result.kind=period_net_change; the UI labels this "期间净变动", never "期末余额".

Background

B-43 produces eligible_intercompany_events as the only bank-event entry point. Before B-44 there was no canonical financial event, no statutory subject, no manual-record approval, and no server-side balance API. The revision-chain design is inherited from transfer_match_decisions in migration 5 and from the append-only audit posture of the rest of the system.

Consequences

  • Positive: balances are deterministic, conservable, auditable and drillable from a group directory down to bank source rows; manual records cannot double count; corrections never mutate evidence; company portals are tenant-scoped on the server.
  • Negative: pending bank events and returned/exception manual records are intentionally invisible to counterparties, which can surprise cashiers who expect symmetric disclosure; subject confirmation is manual until a group-approved dictionary exists.
  • Operational: migration 6 is forward-only for production once approvals/revisions exist; pre-production it can be rolled back with --rollback-to 5. Read aggregation runs against the rebuildable current projection (no day snapshots yet); if B-45 monthly close needs them, immutable monthly snapshots can be added behind the same API contract.

Files

  • src/bank_importer/ledger_events.py — event lifecycle, revision chain, bank reconciliation, reversal/adjustment/reopen, projection rebuild.
  • src/bank_importer/subjects.py — subject constants/mirror, suggestion dictionary, confirm_subject.
  • src/bank_importer/manual_records.py — submission, approval (new/link), return/exception/reverse, candidate hints, idempotency.
  • src/bank_importer/positions.py — Decimal aggregation, directories, pairs, events, evidence visibility, unresolved buckets, keyset pagination.
  • server.py/api/admin/intercompany/* and /api/company/intercompany/* plus /api/admin/subject-reviews and /api/admin/manual-records.
  • db.py migration 6 — manual_records, manual_record_decisions, ledger_events, ledger_event_revisions, current-pointer projections, source claim tables, ledger_subject_suggestions, eligible_position_events.
  • Tests: test_ledger_events.py, test_manual_records.py, test_positions.py, test_positions_api.py, extended test_persistence.py.