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)
This commit is contained in:
腾讯WorkBuddy
2026-08-19 11:58:25 +08:00
parent f99917321b
commit 85293b79df
18 changed files with 6902 additions and 142 deletions
+814 -1
View File
@@ -10,7 +10,10 @@ from http.cookies import SimpleCookie
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from bank_importer import auth, importing, master_data, matching, multipart, personal_transit
from bank_importer import (
auth, importing, ledger_events, manual_records, master_data, matching,
multipart, personal_transit, positions, subjects,
)
from bank_importer.db import connect, migrate, utc_now
@@ -101,6 +104,68 @@ class AppHandler(SimpleHTTPRequestHandler):
self._handle_company_transfer_event_detail(int(company_event_match.group(1)))
return
# B-44 intercompany positions (admin)
if path == "/api/admin/intercompany/balances":
self._handle_admin_intercompany_balances(query)
return
admin_pair = re.fullmatch(
r"/api/admin/intercompany/pairs/(\d+)/(\d+)", path
)
if admin_pair:
self._handle_admin_intercompany_pair(
int(admin_pair.group(1)), int(admin_pair.group(2)), query
)
return
if path == "/api/admin/intercompany/events":
self._handle_admin_intercompany_events(query)
return
if path == "/api/admin/subject-reviews":
self._handle_admin_subject_reviews(query)
return
if path == "/api/admin/manual-records":
self._handle_admin_manual_records(query)
return
admin_event_match = re.fullmatch(r"/api/admin/intercompany/events/(\d+)", path)
if admin_event_match:
self._handle_admin_intercompany_event_detail(int(admin_event_match.group(1)))
return
admin_evidence = re.fullmatch(
r"/api/admin/intercompany/events/(\d+)/evidence", path
)
if admin_evidence:
self._handle_admin_intercompany_evidence(int(admin_evidence.group(1)))
return
# B-44 intercompany positions (company, own-company scope)
if path == "/api/company/intercompany/balances":
self._handle_company_intercompany_balances(query)
return
company_pair = re.fullmatch(r"/api/company/intercompany/pairs/(\d+)", path)
if company_pair:
self._handle_company_intercompany_pair(int(company_pair.group(1)), query)
return
if path == "/api/company/intercompany/events":
self._handle_company_intercompany_events(query)
return
if path == "/api/company/manual-records":
self._handle_company_manual_records(query)
return
if path == "/api/company/companies":
self._handle_company_companies()
return
company_event_match = re.fullmatch(r"/api/company/intercompany/events/(\d+)", path)
if company_event_match:
self._handle_company_intercompany_event_detail(
int(company_event_match.group(1))
)
return
company_evidence = re.fullmatch(
r"/api/company/intercompany/events/(\d+)/evidence", path
)
if company_evidence:
self._handle_company_intercompany_evidence(int(company_evidence.group(1)))
return
if path == "/admin.html" and not self._guard_page("admin"):
return
if path == "/company.html" and not self._guard_page("company"):
@@ -167,6 +232,29 @@ class AppHandler(SimpleHTTPRequestHandler):
if mapping_review:
self._handle_admin_review_personal_mapping(int(mapping_review.group(1)))
return
# B-44 intercompany positions (admin writes)
subject_decision = re.fullmatch(
r"/api/admin/intercompany/events/(\d+)/subject-decisions", path
)
if subject_decision:
self._handle_admin_subject_decision(int(subject_decision.group(1)))
return
adjustment = re.fullmatch(
r"/api/admin/intercompany/events/(\d+)/adjustments", path
)
if adjustment:
self._handle_admin_intercompany_adjustment(int(adjustment.group(1)))
return
manual_decision = re.fullmatch(r"/api/admin/manual-records/(\d+)/decisions", path)
if manual_decision:
self._handle_admin_manual_record_decision(int(manual_decision.group(1)))
return
# B-44 intercompany positions (company writes)
if path == "/api/company/manual-records":
self._handle_company_manual_records_submit()
return
self._send_json(404, {"status": "error", "message": "接口不存在。"})
# ------------------------------------------------------------------
@@ -1611,6 +1699,11 @@ class AppHandler(SimpleHTTPRequestHandler):
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"重跑匹配失败:{exc}"})
return
try:
ledger_events.reconcile_bank_events(connection, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
auth.audit(
connection, "transfer_reconcile", actor=user,
target=f"rows:{len(row_ids)}",
@@ -1664,6 +1757,11 @@ class AppHandler(SimpleHTTPRequestHandler):
except matching.MatchInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
try:
ledger_events.reconcile_bank_events(connection, actor=user)
except Exception as exc:
self._send_json(500, {"status": "error", "message": f"同步往来事件失败:{exc}"})
return
self._send_json(200, {"status": "ok", "decision": payload})
finally:
connection.close()
@@ -1974,6 +2072,721 @@ class AppHandler(SimpleHTTPRequestHandler):
return rows
# ------------------------------------------------------------------
# B-44 intercompany positions (admin)
# ------------------------------------------------------------------
def _parse_window(self, query: dict[str, list[str]]) -> tuple[str, str]:
raw_from = (query.get("from") or [None])[0]
raw_cutoff = (query.get("cutoff") or [None])[0]
if raw_cutoff is None:
raw_cutoff = positions.today_shanghai()
try:
return positions.validate_window(raw_from, raw_cutoff)
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
raise
def _parse_limit(self, query: dict[str, list[str]], default: int = 50) -> int:
raw = (query.get("limit") or [str(default)])[0]
try:
return max(1, min(int(raw), 200))
except ValueError:
return default
def _parse_int(self, query: dict[str, list[str]], key: str, label: str) -> int | None:
raw = (query.get(key) or [None])[0]
if not raw:
return None
try:
return int(raw)
except ValueError:
self._send_json(400, {"status": "error", "message": f"{label}参数无效。"})
raise
def _handle_admin_intercompany_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
from_, cutoff = self._parse_window(query)
company_id = self._parse_int(query, "company_id", "company_id")
currency = (query.get("currency") or [None])[0]
limit = self._parse_limit(query)
cursor = (query.get("cursor") or [None])[0]
except Exception:
return
try:
payload = positions.company_balances(
connection, from_=from_, cutoff=cutoff, currency=currency,
company_id=company_id, limit=limit, cursor=cursor,
)
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_admin_intercompany_pair(
self, company_a: int, company_b: int, query: dict[str, list[str]]
) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
if not self._companies_exist(connection, (company_a, company_b)):
self._send_json(404, {"status": "error", "message": "公司不存在。"})
return
try:
from_, cutoff = self._parse_window(query)
currency = (query.get("currency") or [None])[0]
except Exception:
return
try:
payload = positions.pair_detail(
connection, company_a, company_b,
from_=from_, cutoff=cutoff, currency=currency,
)
except positions.PositionError as exc:
self._send_json(422, {"status": "error", "message": str(exc)})
return
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _companies_exist(self, connection, ids: tuple[int, ...]) -> bool:
for company_id in ids:
row = connection.execute(
"SELECT id FROM companies WHERE id = ?", (company_id,)
).fetchone()
if row is None:
return False
return True
def _handle_admin_intercompany_events(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
from_, cutoff = self._parse_window(query)
currency = (query.get("currency") or [None])[0]
company_id = self._parse_int(query, "company_id", "company_id")
company_a = self._parse_int(query, "company_a", "company_a")
company_b = self._parse_int(query, "company_b", "company_b")
subject = (query.get("subject") or [None])[0]
state = (query.get("state") or [None])[0]
posting_kind = (query.get("posting_kind") or [None])[0]
source_kind = (query.get("source_kind") or [None])[0]
limit = self._parse_limit(query)
cursor = (query.get("cursor") or [None])[0]
except Exception:
return
try:
payload = positions.list_events(
connection, from_=from_, cutoff=cutoff, currency=currency,
company_id=company_id, company_a=company_a, company_b=company_b,
subject=subject, state=state, posting_kind=posting_kind,
source_kind=source_kind, limit=limit, cursor=cursor,
)
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_admin_subject_reviews(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
try:
from_, cutoff = self._parse_window(query)
company_id = self._parse_int(query, "company_id", "company_id")
limit = self._parse_limit(query)
cursor = (query.get("cursor") or [None])[0]
except Exception:
return
try:
payload = positions.subject_review_queue(
connection, from_=from_, cutoff=cutoff,
company_id=company_id, limit=limit, cursor=cursor,
)
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_admin_manual_records(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
company_id = self._parse_int(query, "company_id", "company_id")
if company_id is None and (query.get("company_id") or [None])[0]:
return
state = (query.get("state") or [None])[0]
limit = self._parse_limit(query, 100)
try:
rows = manual_records.list_records(
connection, company_id=company_id, state=state, limit=limit
)
except manual_records.ManualInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
items = []
for row in rows:
payload = manual_records._row_payload(connection, row)
items.append(payload)
self._send_json(200, {"status": "ok", "records": items})
finally:
connection.close()
def _handle_admin_intercompany_event_detail(self, event_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
payload = positions.event_detail(connection, event_id)
if payload is None:
self._send_json(404, {"status": "error", "message": "事件不存在。"})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_admin_intercompany_evidence(self, event_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
payload = positions.event_evidence(connection, event_id)
if payload is None:
self._send_json(404, {"status": "error", "message": "事件不存在。"})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_admin_subject_decision(self, event_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
try:
perspective_company_id = int(data["perspective_company_id"])
expected_revision = (
int(data["expected_revision"])
if data.get("expected_revision") is not None
else None
)
except (KeyError, TypeError, ValueError):
self._send_json(
400, {"status": "error", "message": "perspective_company_id 或 expected_revision 无效。"}
)
return
try:
payload = subjects.confirm_subject(
connection,
event_id,
perspective_company_id=perspective_company_id,
subject_code=str(data.get("subject_code") or ""),
reason=str(data.get("reason") or ""),
expected_revision=expected_revision,
request_key=str(data.get("request_key") or "") or None,
actor=user,
)
except subjects.SubjectConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except subjects.SubjectInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection, "subject_confirm", actor=user,
target=f"ledger_event:{event_id}",
detail=f"revision:{payload['revision']};subject:{payload['subject_code']}",
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "revision": payload})
finally:
connection.close()
def _handle_admin_intercompany_adjustment(self, event_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
action = str(data.get("action") or "")
reason = str(data.get("reason") or "")
request_key = str(data.get("request_key") or "") or None
if not reason:
self._send_json(400, {"status": "error", "message": "必须填写操作原因。"})
return
try:
if action == "reverse":
event_id, _revision_id = ledger_events.create_reversal(
connection, event_id,
source_kind="adjustment",
source_revision_token=None,
effective_at=data.get("effective_at") or None,
reason=reason, actor=user, idempotency_key=request_key,
)
outcome: dict[str, object] = {
"action": "reverse", "ledger_event_id": event_id,
}
elif action == "adjust":
try:
effective_at = str(data.get("effective_at") or "")
amount = str(data.get("amount") or "")
currency = str(data.get("currency") or "")
payer = int(data["payer_company_id"])
payee = int(data["payee_company_id"])
perspective = int(data["perspective_company_id"])
subject_code = str(data.get("subject_code") or "")
except (KeyError, TypeError, ValueError):
self._send_json(
400, {"status": "error", "message": "adjust 参数不完整或无效。"}
)
return
event_id, _revision_id = ledger_events.create_adjustment(
connection, event_id,
effective_at=effective_at, amount=amount, currency=currency,
payer_company_id=payer, payee_company_id=payee,
perspective_company_id=perspective, subject_code=subject_code,
reason=reason, actor=user, idempotency_key=request_key,
)
outcome = {"action": "adjust", "ledger_event_id": event_id}
elif action == "reopen":
event_id, _revision_id = ledger_events.reopen_subject(
connection, event_id, reason=reason, actor=user,
idempotency_key=request_key,
)
outcome = {"action": "reopen", "ledger_event_id": event_id}
else:
self._send_json(
400,
{"status": "error", "message": "action 必须是 reverse、adjust 或 reopen。"},
)
return
except ledger_events.LedgerConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except ledger_events.LedgerInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection, f"ledger_{action}", actor=user,
target=f"ledger_event:{event_id}", detail=reason, ip=self._client_ip,
)
self._send_json(200, {"status": "ok", **outcome})
finally:
connection.close()
def _handle_admin_manual_record_decision(self, record_id: int) -> None:
connection = connect(DB_PATH)
try:
user = self._require_admin(connection)
if user is None:
return
data = self._read_json_body()
if data is None:
return
try:
expected_decision_id = (
int(data["expected_decision_id"])
if data.get("expected_decision_id") is not None
else None
)
except (TypeError, ValueError):
self._send_json(400, {"status": "error", "message": "expected_decision_id 无效。"})
return
try:
payload = manual_records.decide(
connection,
record_id,
str(data.get("action") or ""),
reason=str(data.get("reason") or ""),
expected_decision_id=expected_decision_id,
request_key=str(data.get("request_key") or "") or None,
actor=user,
subject_code=data.get("subject_code"),
target_ledger_event_id=data.get("target_ledger_event_id"),
)
except manual_records.ManualConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except manual_records.ManualInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", "decision": payload})
finally:
connection.close()
# ------------------------------------------------------------------
# B-44 intercompany positions (company, own-company scope only)
# ------------------------------------------------------------------
def _company_intercompany_scope(self, connection):
"""Reject company users with 403; return the session company id."""
user = self._require_user(connection)
if user is None:
return None, None
if user["role"] != "company":
self._send_json(403, {"status": "error", "message": "该操作仅限公司用户。"})
return None, None
return user, user["company_id"]
def _handle_company_intercompany_balances(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
try:
from_, cutoff = self._parse_window(query)
currency = (query.get("currency") or [None])[0]
limit = self._parse_limit(query)
cursor = (query.get("cursor") or [None])[0]
except Exception:
return
try:
payload = positions.company_balances(
connection, from_=from_, cutoff=cutoff, currency=currency,
company_id=company_id, limit=limit, cursor=cursor,
)
counterparties = self._company_counterparty_summary(
connection, company_id, from_, cutoff, currency
)
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload, "counterparties": counterparties})
finally:
connection.close()
def _company_counterparty_summary(
self, connection, company_id: int, from_: str, cutoff: str, currency: str | None
) -> list[dict[str, object]]:
events = positions.load_events(
connection, from_=from_, cutoff=cutoff, currency=currency,
company_id=company_id,
)
buckets: dict[int, dict[str, object]] = {}
for event in events:
counterparty = (
event["payee_company_id"]
if int(event["payer_company_id"]) == int(company_id)
else event["payer_company_id"]
)
name = (
event["payee_company_name"]
if int(event["payer_company_id"]) == int(company_id)
else event["payer_company_name"]
)
bucket = buckets.setdefault(
counterparty,
{
"counterparty_company_id": counterparty,
"counterparty_company_name": name,
"currency": event["currency"],
"signed": 0,
"event_count": 0,
},
)
bucket["signed"] += positions.signed_amount(event, company_id)
bucket["event_count"] += 1
# Counterparties with pending-subject exposure also show up so the
# company portal never hides unconfirmed balances.
pending = connection.execute(
"""
SELECT p.payer_company_id, p.payee_company_id, p.amount, p.currency,
cpayer.name AS payer_company_name, cpayee.name AS payee_company_name
FROM current_ledger_event_revisions cur
JOIN ledger_event_revisions p ON p.id = cur.revision_id
JOIN companies cpayer ON cpayer.id = p.payer_company_id
JOIN companies cpayee ON cpayee.id = p.payee_company_id
WHERE p.state = 'pending_subject'
AND p.effective_at >= ? AND p.effective_at <= ?
AND (p.payer_company_id = ? OR p.payee_company_id = ?)
""",
(from_, cutoff + "T23:59:59", company_id, company_id),
).fetchall()
for row in pending:
if currency and row["currency"] != currency:
continue
counterparty = (
row["payee_company_id"]
if int(row["payer_company_id"]) == int(company_id)
else row["payer_company_id"]
)
name = (
row["payee_company_name"]
if int(row["payer_company_id"]) == int(company_id)
else row["payer_company_name"]
)
buckets.setdefault(
counterparty,
{
"counterparty_company_id": counterparty,
"counterparty_company_name": name,
"currency": row["currency"],
"signed": 0,
"event_count": 0,
},
)
items = []
for counterparty, bucket in sorted(buckets.items()):
cur = bucket["currency"]
signed = bucket["signed"]
unresolved = positions.unresolved_for_company(
connection, company_id, cutoff, currency=cur,
counterparty_filter=(company_id, counterparty),
)
direction = "receivable" if signed > 0 else ("payable" if signed < 0 else None)
items.append(
{
"counterparty_company_id": counterparty,
"counterparty_company_name": bucket["counterparty_company_name"],
"currency": cur,
"result": {
"kind": "period_net_change",
"signed_amount": str(signed),
"direction": direction,
"label": "期间净变动",
},
"unresolved": unresolved,
"event_count": bucket["event_count"],
}
)
return items
def _handle_company_intercompany_pair(
self, counterparty_id: int, query: dict[str, list[str]]
) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
if not self._companies_exist(connection, (counterparty_id,)):
self._send_json(404, {"status": "error", "message": "公司不存在。"})
return
try:
from_, cutoff = self._parse_window(query)
currency = (query.get("currency") or [None])[0]
except Exception:
return
try:
payload = positions.pair_detail(
connection, company_id, counterparty_id,
from_=from_, cutoff=cutoff, currency=currency,
)
except positions.PositionError as exc:
self._send_json(422, {"status": "error", "message": str(exc)})
return
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_intercompany_events(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
try:
from_, cutoff = self._parse_window(query)
currency = (query.get("currency") or [None])[0]
subject = (query.get("subject") or [None])[0]
state = (query.get("state") or [None])[0]
posting_kind = (query.get("posting_kind") or [None])[0]
source_kind = (query.get("source_kind") or [None])[0]
limit = self._parse_limit(query)
cursor = (query.get("cursor") or [None])[0]
except Exception:
return
try:
payload = positions.list_events(
connection, from_=from_, cutoff=cutoff, currency=currency,
subject=subject, state=state, posting_kind=posting_kind,
source_kind=source_kind, viewer_company_id=company_id,
limit=limit, cursor=cursor,
)
except positions.PositionInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_intercompany_event_detail(self, event_id: int) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
payload = positions.event_detail(connection, event_id)
if payload is None:
self._send_json(404, {"status": "error", "message": "事件不存在。"})
return
event = payload["event"]
if company_id not in (event["payer_company_id"], event["payee_company_id"]):
self._send_json(404, {"status": "error", "message": "事件不存在。"})
return
event.update(
positions.event_payload(
connection,
connection.execute(
positions._DETAIL_SELECT + " WHERE p.ledger_event_id = ?",
(event_id,),
).fetchone(),
viewer_company_id=company_id,
)
)
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_intercompany_evidence(self, event_id: int) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
detail = positions.event_detail(connection, event_id)
if detail is None:
self._send_json(404, {"status": "error", "message": "事件不存在。"})
return
event = detail["event"]
if company_id not in (event["payer_company_id"], event["payee_company_id"]):
self._send_json(404, {"status": "error", "message": "事件不存在。"})
return
payload = positions.event_evidence(
connection, event_id, viewer_company_id=company_id
)
if payload is None:
self._send_json(404, {"status": "error", "message": "事件不存在。"})
return
self._send_json(200, {"status": "ok", **payload})
finally:
connection.close()
def _handle_company_companies(self) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
rows = connection.execute(
"SELECT id, name FROM companies ORDER BY id"
).fetchall()
self._send_json(
200,
{"status": "ok", "companies": [dict(row) for row in rows]},
)
finally:
connection.close()
def _handle_company_manual_records(self, query: dict[str, list[str]]) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
state = (query.get("state") or [None])[0]
limit = self._parse_limit(query, 100)
try:
rows = manual_records.list_records(
connection, company_id=company_id, state=state, limit=limit
)
except manual_records.ManualInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
# A company only ever sees its own submissions, never declarations
# where it is merely the counterparty.
own = [
manual_records._row_payload(connection, row)
for row in rows
if row["company_id"] == company_id
]
self._send_json(200, {"status": "ok", "records": own})
finally:
connection.close()
def _handle_company_manual_records_submit(self) -> None:
connection = connect(DB_PATH)
try:
user, company_id = self._company_intercompany_scope(connection)
if company_id is None:
return
data = self._read_json_body()
if data is None:
return
try:
payload = manual_records.submit(
connection,
company_id=company_id,
counterparty_company_id=int(data["counterparty_company_id"]),
occurred_at=str(data.get("occurred_at") or ""),
direction=str(data.get("direction") or ""),
amount=str(data.get("amount") or ""),
currency=str(data.get("currency") or ""),
funding_source=str(data.get("funding_source") or ""),
requested_subject=str(data.get("requested_subject") or ""),
request_key=str(data.get("request_key") or ""),
actor=user,
bank_account_id=data.get("bank_account_id"),
personal_transit_mapping_id=data.get("personal_transit_mapping_id"),
related_source_row_id=data.get("related_source_row_id"),
summary=data.get("summary"),
reason=data.get("reason"),
evidence=data.get("evidence"),
)
except manual_records.ManualConflictError as exc:
self._send_json(409, {"status": "error", "message": str(exc)})
return
except manual_records.ManualInputError as exc:
self._send_json(400, {"status": "error", "message": str(exc)})
return
auth.audit(
connection, "manual_submit", actor=user,
target=f"manual_record:{payload['id']}",
detail=f"amount:{payload['amount']};currency:{payload['currency']}",
ip=self._client_ip,
)
self._send_json(200, {"status": "ok", "record": payload})
finally:
connection.close()
# ------------------------------------------------------------------
# Request/response plumbing
# ------------------------------------------------------------------