dodil/erp-brick-payments
DODIL ERP brick: payments
src/brick_payments/application/migration.py
"""The Odoo migration (PAY-44, PAY-15; module.yaml migration.odoo): loads Odoo's bank journals, customer payments,
reconciliations, unreconciled bank lines, refunds, credit limits and follow-up levels as receipts, allocations and
refunds, then reconciles them to Odoo per customer and currency for the owner to sign.
- a MACHINE job (pay:migration:import): it writes through this module's own repositories, never through the person
routes' checks, never into ar_ tables, and is the only writer of rows dated in the past - each carries
origin "odoo:<db>:<model>:<id>" and keeps Odoo's own date, never re-dated;
- idempotent: every row is known by its Odoo key (pay_odoo_map and the natural keys), so a second run adds nothing;
- ar's migration runs FIRST: invoices and credit notes exist, keyed "odoo:<db>:<account.move id>", and are only read;
- the Odoo side's figures are frozen when the run reads them (pay_migration_run), the reconciliation compares them
with what this module holds NOW, and a signature records the figures it signed. Signed = read-only.
The signed door decisions: unreconciled bank credits come over as unallocated receipts (the named customer, else a
suspense customer the cashier clears); cross-currency payments are carried in the invoice's currency with the
receipt's fx_rate and listed as explained differences; every posted payment is loaded; each open invoice's Odoo
follow-up level is carried so reminders do not restart at level 1; statement_import_from becomes the day after the cut-over.
"""
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
from erp_platform.bricks import Refused
from erp_platform.types import BigId
from brick_payments.application import use_cases
from brick_payments.application.inputs import Code, Day
from brick_payments.application.use_cases import (
MIGRATION_IMPORT,
MIGRATION_SIGN,
PAY,
_credit_notes,
_date,
_decide,
_naive_now,
_now,
_s,
_today,
_who,
)
from brick_payments.domain import odoo_map
from brick_payments.domain.errors import RuleBroken
from brick_payments.domain.money import ZERO, money
from brick_payments.domain.receipt import Receipt
from brick_payments.domain.refund import Refund
from brick_payments.domain.settlement import Allocation, outstanding, source_remainder
from brick_payments.infrastructure import odoo, seams
from brick_payments.infrastructure.tables import pay_allocation # noqa: F401 (the table the migration fills)
BY = "odoo-migration"
def _bp_key(bp, cur) -> str:
return f"{bp}|{cur}"
def _month(d) -> str:
return str(d)[:7]
def _unmapped(ids: set, mapping: dict) -> list:
return sorted(i for i in ids if i not in mapping)
@PAY.use_case(kind="command", route="POST /migration/odoo/run", permission=MIGRATION_IMPORT, idempotent=True, tool=False)
def load_payments_from_odoo(caller: dict, db: Code, cutover_date: Day, suspense_bp_id: Optional[BigId] = None) -> dict:
"""Load Odoo's payments side into this bucket (first a test copy, then live): bank accounts, receipts, the
unreconciled bank credits, refunds, allocations from the reconciliations, credit limits and follow-up levels. `db` is
the Odoo database name (it qualifies every key); `cutover_date` is the last day Odoo's bank statements are migrated;
`suspense_bp_id` is the customer that holds credits Odoo never matched to a partner. Run it again and it adds nothing.
ar's migration must have run first."""
ports = odoo.ports()
if ports.reader is None:
raise Refused(409, "No Odoo database is configured (ODOO_URL, ODOO_DB, ODOO_USER, ODOO_PASSWORD): a read-only Odoo user is needed")
with use_cases.repository() as repo, _decide():
run = repo.migration.run()
if run and run["signed_by"]:
raise Refused(409, f"The migration was signed by {run['signed_by']} on {run['signed_at'][:10]} and is read-only now")
today, now = _today(repo), _now()
cutover = _date(cutover_date, "cutover_date")
if cutover >= today:
raise RuleBroken(f"The cut-over date {cutover} is today or later. It is the last day Odoo's bank statements are migrated, so a day that has ended")
if run and run["params"].get("cutover_date") not in (None, cutover.isoformat()):
raise RuleBroken(f"The migration was first run with the cut-over {run['params']['cutover_date']}; a run again keeps it "
f"({cutover} was asked). The first run's record and the cut-over are not changed")
R = ports.reader
company = R.company_currency or "GBP"
journals = R.read("account.journal")
payments = [p for p in R.read("account.payment") if p["partner_type"] == "customer" and p["state"] in ("posted", "paid")]
stmts = [s for s in R.read("account.bank.statement.line") if s["amount"] > 0 and not s.get("payment_id")]
lines = {r["id"]: r for r in R.read("account.move.line")}
partials = sorted(R.read("account.partial.reconcile"), key=lambda r: r["id"])
partners = R.read("res.partner")
# ── gate partners_mapped: every partner Odoo names must be one the register has ────────────────
wanted = ({p["partner"] for p in payments if p["partner"]} | {s["partner"] for s in stmts if s["partner"]}
| {r["partner"] for r in lines.values() if r["partner"]}
| {r["commercial_partner_id"] for r in partners if r["use_partner_credit_limit"] and r["credit_limit"]})
mapping = ports.partners.resolve(sorted(wanted)) if ports.partners else {}
missing = _unmapped(wanted, mapping)
if missing:
raise RuleBroken(f"{len(missing)} Odoo partner(s) are not in business-partner's migration map: {missing[:20]}. "
"Run business-partner's migration first")
loose = [s for s in stmts if not s["partner"]]
if loose and not (suspense_bp_id or repo.settings.current().suspense_bp_id):
raise RuleBroken(f"{len(loose)} unreconciled bank credit(s) name no customer. Give suspense_bp_id, the customer that holds "
"them until the cashier clears each one")
suspense = suspense_bp_id or repo.settings.current().suspense_bp_id
# ── gate invoices_loaded: ar's migration ran first ──────────────────────────────────────────────
try:
invoices = {d.invoice_id: d for d in seams.invoices()}
credit_notes = {n.credit_note_id: n for n in _credit_notes(None, None)}
except seams.Unavailable as down:
raise Refused(down.http, f"{down.reason} - run ar's migration first, and make sure it can be read") from None
# ── the receipts and refunds being loaded, then the allocations they settle ────────────────────
inbound = [p for p in payments if p["payment_type"] == "inbound"]
outbound = [p for p in payments if p["payment_type"] == "outbound"]
receipt_ids = ({odoo_map.key(db, "account.payment", p["id"]) for p in inbound}
| {odoo_map.key(db, "account.bank.statement.line", s["id"]) for s in stmts})
refund_ids = {odoo_map.key(db, "account.payment", p["id"]) for p in outbound}
planned, skipped = [], []
for part in partials:
got, why = odoo_map.classify(part, lines, db, receipt_ids, refund_ids, company)
if got is None:
skipped.append({"ref": odoo_map.key(db, "account.partial.reconcile", part["id"]), "why": why})
elif got.source_kind != "refund" and got.invoice_id not in invoices:
skipped.append({"ref": odoo_map.key(db, "account.partial.reconcile", part["id"]),
"why": f"invoice {got.invoice_id} is not in ar - ar's migration has not loaded it"})
elif got.source_kind == "credit_note" and got.source_id not in credit_notes:
skipped.append({"ref": odoo_map.key(db, "account.partial.reconcile", part["id"]),
"why": f"credit note {got.source_id} is not in ar - ar's migration has not loaded it"})
else:
planned.append(got)
absent = [s for s in skipped if "not in ar" in s["why"]]
if absent:
raise RuleBroken(f"{len(absent)} reconciliation(s) settle documents ar does not have, e.g. {absent[0]['why']}. "
"Run ar's migration first (nothing was written)")
done = repo.migration.mapped([odoo_map.key(db, "account.journal", j["id"]) for j in journals]
+ list(receipt_ids) + list(refund_ids)
+ [odoo_map.key(db, "account.partial.reconcile", p.partial_id) for p in planned])
counts = {k: 0 for k in ("bank_accounts", "receipts", "unreconciled_credits", "refunds", "allocations", "credit_limits",
"followup_levels", "already_loaded", "skipped")}
def mark(ref: str, table: str, target: str) -> None:
repo.migration.add(ref, table, target, now)
for j in journals: # bank accounts
ref = odoo_map.key(db, "account.journal", j["id"])
if ref in done:
counts["already_loaded"] += 1
continue
digits = "".join(ch for ch in (j.get("acc_number") or "") if ch.isalnum())
repo.config.put_bank({"bank_account_id": j["code"], "name": j["name"], "currency": j.get("currency") or company,
"iban_or_sort_code_masked": ("*" * max(0, len(digits) - 4) + digits[-4:]) if digits else "",
"gl_account_id": j.get("gl_account") or "", "origin": ref})
mark(ref, "pay_bank_account", j["code"])
counts["bank_accounts"] += 1
known_banks = {j["code"] for j in journals}
cross = {p.source_id: p for p in planned if p.cross_currency} # receipt -> its rate
problems = []
def make_receipt(rid: str, bp: int, amount, cur: str, on: str, bank: str, ref: str, text: str, method: str) -> Optional[Receipt]:
if bank not in known_banks:
problems.append({"ref": rid, "why": f"journal {bank} is not a bank or cash journal that was read"})
return None
try:
r = Receipt.record(payment_id=rid, bp_id=bp, currency=cur or company, amount=amount, received_on=date.fromisoformat(str(on)[:10]),
today=today, by=BY, at=now, method=method, bank_account_id=bank, bank_reference=ref or "",
recorded_on=date.fromisoformat(str(on)[:10]), # history keeps the bank's date, so as-at views see it
remittance_text=text or "", origin=rid, fx_rate=cross[rid].rate if rid in cross else None)
except RuleBroken as e:
problems.append({"ref": rid, "why": str(e)})
return None
return r
for p in inbound: # receipts from customer payments
rid = odoo_map.key(db, "account.payment", p["id"])
if rid in done:
counts["already_loaded"] += 1
continue
r = make_receipt(rid, mapping[p["partner"]], money(p["amount"]), p["currency"], p["date"], p["journal_code"], p["ref"], p["ref"],
p.get("method_code") or "")
if r:
repo.receipts.add(r)
mark(rid, "pay_receipt", rid)
counts["receipts"] += 1
for s in stmts: # bank credits no payment is behind
rid = odoo_map.key(db, "account.bank.statement.line", s["id"])
if rid in done:
counts["already_loaded"] += 1
continue
r = make_receipt(rid, mapping[s["partner"]] if s["partner"] else suspense, money(s["amount"]), s["currency"], s["date"],
s["journal_code"], s.get("unique_import_id") or "", s.get("payment_ref") or "", "bank_statement")
if r:
repo.receipts.add(r)
if s.get("unique_import_id") and not repo.receipts.ref_holder(r.bank_account_id, s["unique_import_id"]):
repo.receipts.claim_ref(r.bank_account_id, s["unique_import_id"], rid, now)
mark(rid, "pay_receipt", rid)
counts["receipts"] += 1
counts["unreconciled_credits"] += not s.get("is_reconciled")
refund_bp: dict = {}
for p in outbound: # refunds already paid
rid = odoo_map.key(db, "account.payment", p["id"])
if rid in done:
counts["already_loaded"] += 1
continue
when = datetime.fromisoformat(str(p["date"])[:10] + "T00:00:00")
repo.refunds.add(Refund(rid, mapping[p["partner"]], (p["currency"] or company).upper(), money(p["amount"]), "paid", "odoo", when,
"odoo", when, date.fromisoformat(str(p["date"])[:10]), "refund paid in Odoo", p.get("ref") or "",
date.fromisoformat(str(p["date"])[:10]), rid))
mark(rid, "pay_refund", rid)
refund_bp[rid] = mapping[p["partner"]]
counts["refunds"] += 1
seq_of: dict = defaultdict(int) # the allocations, seq = rank within the pair
rows = []
for pl in planned:
seq_of[(pl.source_kind, pl.source_id, pl.invoice_id)] += 1
ref = odoo_map.key(db, "account.partial.reconcile", pl.partial_id)
if ref in done:
counts["already_loaded"] += 1
continue
doc = invoices.get(pl.invoice_id)
bp = doc.bp_id if doc else refund_bp.get(pl.source_id) or _refund_bp(repo, pl.source_id)
rows.append(Allocation(pl.source_kind, pl.source_id, pl.invoice_id, seq_of[(pl.source_kind, pl.source_id, pl.invoice_id)],
pl.amount, pl.currency, pl.allocated_on or cutover, BY, "migrated from Odoo", None, "", "manual", ref, bp,
pl.source_amount))
mark(ref, "pay_allocation", f"{pl.source_kind}|{pl.source_id}|{pl.invoice_id}")
repo.allocations.add(rows, now)
counts["allocations"] = len(rows)
for r in partners: # credit limits
if not (r["use_partner_credit_limit"] and r["credit_limit"] and Decimal(str(r["credit_limit"])) > 0):
continue
ref, bp = odoo_map.key(db, "res.partner", r["id"]), mapping[r["commercial_partner_id"]]
if ref in repo.migration.mapped([ref]) or repo.credit.has(bp, cutover):
continue
repo.credit.add_limit({"bp_id": bp, "valid_from": cutover, "credit_limit_amount": money(r["credit_limit"]), "currency": company,
"reason": "migrated from Odoo", "set_by": BY, "set_at": _naive_now(), "origin": ref}, now)
mark(ref, "pay_credit", f"{bp}|{cutover}")
counts["credit_limits"] += 1
carried: dict = {} # follow-up levels of open invoices
for ln in lines.values():
if ln["move_type"] == "out_invoice" and ln.get("followup_level") and Decimal(str(ln["amount_residual_currency"])) != 0:
inv = odoo_map.ar_id(db, ln["move_id"])
if inv in invoices and ln["followup_level"] > carried.get(inv, (0,))[0]:
carried[inv] = (ln["followup_level"], ln.get("followup_date"), ln["id"])
have = {e.invoice_id: e.level for evs in repo.collections.all_events().values() for e in evs if e.kind == "reminder"}
for inv, (level, when, line_id) in sorted(carried.items()):
ref = odoo_map.key(db, "account.move.line", line_id)
if have.get(inv, 0) >= level or ref in repo.migration.mapped([ref]):
continue
repo.collections.add({"bp_id": invoices[inv].bp_id, "kind": "reminder", "level": level, "ref_id": inv, "invoice_id": inv,
"occurred_on": date.fromisoformat(str(when)[:10]) if when else cutover, "promise_date": None, "reason": "",
"outcome": "", "note": "carried from Odoo's follow-up", "draft_text": "", "status": "sent", "origin": ref,
"recorded_by": BY}, now)
mark(ref, "pay_collection", f"{invoices[inv].bp_id}|reminder|{level}|{inv}")
counts["followup_levels"] += 1
for key_, value in (("statement_import_from", (cutover + timedelta(days=1)).isoformat()),
("suspense_bp_id", int(suspense) if suspense else None)): # the cut-over, and the suspense customer
cur = repo.settings.get(key_)
if key_ == "statement_import_from" and (cur["value"] or ""):
continue # once set (by the first run, or by the owner) a re-run leaves it alone
if value is not None and (cur["value"] if cur["value"] is not None else cur["default"]) != value:
repo.settings.set(key_, value, BY, now)
counts["skipped"] = len(skipped) + len(problems)
params = {"db": db, "cutover_date": cutover.isoformat(), "suspense_bp_id": int(suspense) if suspense else 0}
figures = _odoo_figures(R, db, company, payments, stmts, lines, partials, mapping, suspense)
if run is None:
repo.migration.put_run(params, figures, counts, BY, now) # the first run's record, frozen
else:
repo.migration.add_attempt(params, figures, counts, BY, now) # a re-run is its own record
return {"db": db, "cutover_date": cutover.isoformat(), **counts, "problems": problems[:50], "skipped_detail": skipped[:50],
"next": "open the reconciliation (migration_reconcile); the owner signs it with pay:migration:sign"}
def _refund_bp(repo, refund_id: str) -> Optional[int]:
got = repo.refunds.get(refund_id) # a refund loaded by an EARLIER run (committed, so readable)
return got.bp_id if got else None
def _odoo_figures(R, db: str, company: str, payments: list, stmts: list, lines: dict, partials: list, mapping: dict, suspense) -> dict:
"""The Odoo side of every check, worked out from the rows READ and frozen at the run (the reconciliation compares
these with what this module holds). Strings, to the cent."""
def bp_of(partner):
return mapping[partner] if partner else suspense
open_bal: dict = defaultdict(lambda: ZERO)
for ln in lines.values():
if ln["partner"]:
open_bal[_bp_key(mapping[ln["partner"]], ln["currency"] or company)] += Decimal(str(ln["amount_residual_currency"]))
for s in stmts: # an unreconciled bank credit is money received and not matched: ours carries it
if not s.get("is_reconciled"):
open_bal[_bp_key(bp_of(s["partner"]), s["currency"] or company)] -= Decimal(str(s["amount_residual"]))
by_move: dict = defaultdict(lambda: ZERO)
move_type: dict = {}
for ln in lines.values():
by_move[ln["move_id"]] += Decimal(str(ln["amount_residual_currency"]))
move_type[ln["move_id"]] = ln["move_type"]
invoice_out = {odoo_map.ar_id(db, m): _s(v) for m, v in by_move.items() if move_type[m] == "out_invoice"}
unalloc: dict = {}
for p in payments:
if p["payment_type"] == "inbound":
unalloc[odoo_map.key(db, "account.payment", p["id"])] = _s(-sum((Decimal(str(ln["amount_residual_currency"]))
for ln in lines.values() if ln.get("payment_id") == p["id"]), ZERO))
for s in stmts:
direct = [ln for ln in lines.values() if ln.get("statement_line_id") == s["id"]]
unalloc[odoo_map.key(db, "account.bank.statement.line", s["id"])] = _s(
Decimal(str(s["amount_residual"])) if not s.get("is_reconciled") else -sum((Decimal(str(ln["amount_residual_currency"])) for ln in direct), ZERO))
per_bank: dict = defaultdict(lambda: [0, ZERO])
for p in payments:
if p["payment_type"] == "inbound":
cell = per_bank[f"{p['journal_code']}|{_month(p['date'])}"]
cell[0] += 1
cell[1] += money(p["amount"])
for s in stmts:
cell = per_bank[f"{s['journal_code']}|{_month(s['date'])}"]
cell[0] += 1
cell[1] += money(s["amount"])
out_pay = [p for p in payments if p["payment_type"] == "outbound"]
cross = []
for part in partials:
d, c = lines.get(part["debit_line"]), lines.get(part["credit_line"])
if d and c and d["move_type"] == "out_invoice" and c.get("payment_id") and (c["currency"] or company) != (d["currency"] or company):
cross.append({"payment": odoo_map.key(db, "account.payment", c["payment_id"]), "odoo_amount": _s(part["credit_amount_currency"]),
"odoo_currency": c["currency"], "invoice_amount": _s(part["debit_amount_currency"]), "invoice_currency": d["currency"]})
return {"open_balance": {k: _s(v) for k, v in open_bal.items()}, "invoice_outstanding": invoice_out, "unallocated": unalloc,
"receipts_by_bank_month": {k: [v[0], _s(v[1])] for k, v in per_bank.items()},
"allocated_total": _s(sum((Decimal(str(p["debit_amount_currency"])) for p in partials), ZERO)),
"paid_invoice_count": sum(1 for m, v in by_move.items() if move_type[m] == "out_invoice" and v == 0),
"refunds": [len(out_pay), _s(sum((money(p["amount"]) for p in out_pay), ZERO))], "cross_currency": cross}
def _ours(repo, db: str, caller: Optional[dict]) -> dict:
"""What this module holds for the migrated documents, worked out now."""
prefix = f"odoo:{db}:"
invoices = {d.invoice_id: d for d in seams.invoices() if d.invoice_id.startswith(prefix)}
notes = {n.credit_note_id: n for n in _credit_notes(None, caller) if n.credit_note_id.startswith(prefix)}
allocs = repo.allocations.all()
by_inv: dict = defaultdict(list)
for a in allocs:
by_inv[a.invoice_id].append(a)
open_bal: dict = defaultdict(lambda: ZERO)
inv_out = {}
for i in invoices.values():
o = outstanding(i.total, by_inv.get(i.invoice_id, []))
inv_out[i.invoice_id] = o
open_bal[_bp_key(i.bp_id, i.currency)] += o
receipts = [r for r in repo.receipts.search(limit=1_000_000) if r.origin.startswith(prefix)]
unalloc, per_bank = {}, defaultdict(lambda: [0, ZERO])
for r in receipts:
rem = source_remainder(r.amount, "receipt", r.payment_id, allocs) if r.live else ZERO
unalloc[r.payment_id] = rem
open_bal[_bp_key(r.bp_id, r.currency)] -= rem
cell = per_bank[f"{r.bank_account_id}|{_month(r.received_on)}"]
cell[0] += 1
cell[1] += r.amount
ar_view = defaultdict(lambda: ZERO) # ar's definition: invoices less credit notes, receipts not counted
for i in invoices.values():
ar_view[_bp_key(i.bp_id, i.currency)] += inv_out[i.invoice_id]
for n in notes.values():
rem = source_remainder(n.total, "credit_note", n.credit_note_id, allocs)
open_bal[_bp_key(n.bp_id, n.currency)] -= rem
ar_view[_bp_key(n.bp_id, n.currency)] -= rem
migrated = [a for a in allocs if a.origin.startswith(f"{prefix}account.partial.reconcile:")]
refunds = [r for r in repo.refunds.search(status="paid", limit=1_000_000) if r.origin.startswith(prefix)]
return {"open_balance": dict(open_bal), "ar_view": dict(ar_view), "invoice_outstanding": inv_out, "unallocated": unalloc,
"receipts_by_bank_month": {k: [v[0], v[1]] for k, v in per_bank.items()},
"allocated_total": sum((a.amount for a in migrated), ZERO),
"paid_invoice_count": sum(1 for v in inv_out.values() if v == 0),
"refunds": [len(refunds), sum((r.amount for r in refunds), ZERO)], "receipts": {r.payment_id: r for r in receipts},
"allocations": migrated}
def _row(check: str, subject: str, odoo_v, ours_v, must: str, explanation: str = "", equal: Optional[bool] = None) -> dict:
eq = (odoo_v == ours_v) if equal is None else equal
return {"check": check, "subject": subject, "odoo": odoo_v if isinstance(odoo_v, (str, list, int)) else _s(odoo_v),
"ours": ours_v if isinstance(ours_v, (str, list, int)) else _s(ours_v), "equal": eq, "must": must,
"explanation": explanation}
def _reconcile(repo, caller: Optional[dict]) -> dict:
run = repo.migration.run()
if not run:
raise Refused(404, "The migration has not run yet. Run it first (load_payments_from_odoo)")
db, figs = run["params"]["db"], run["odoo_figures"]
ours = _ours(repo, db, caller)
D = lambda x: Decimal(str(x)) # noqa: E731
rows = []
for k in sorted(set(figs["open_balance"]) | {_bp_key(*k.split("|")) for k in ours["open_balance"]}):
o, w = D(figs["open_balance"].get(k, "0")), ours["open_balance"].get(k, ZERO)
rows.append(_row("open balance per customer and currency", k.replace("|", " "), o, w, "equal",
"Odoo's receivable residual, less the unreconciled bank credits this module carries as unallocated receipts"))
try:
theirs = seams.ar_open_balances(db, caller)
why = "ar's figure counts invoices less credit notes; unallocated receipts are payments' own, so they are not in either side here"
for (bp, cur), v in sorted(theirs.items()):
rows.append(_row("the same open balance as ar's migration", f"{bp} {cur}", v, ours["ar_view"].get(_bp_key(bp, cur), ZERO), "equal", why))
for k, w in sorted(ours["ar_view"].items()):
bp, cur = k.split("|")
if (int(bp), cur) not in theirs and w != ZERO:
rows.append(_row("the same open balance as ar's migration", f"{bp} {cur}", ZERO, w, "equal", "ar has no figure for this customer"))
except seams.Unavailable as down:
rows.append(_row("the same open balance as ar's migration", "all", "n/a", "n/a", "equal", f"ar's figure could not be read: {down.reason}", equal=False))
for inv, v in sorted(figs["invoice_outstanding"].items()):
rows.append(_row("outstanding per invoice", inv, D(v), ours["invoice_outstanding"].get(inv, ZERO), "equal"))
for pid, v in sorted(figs["unallocated"].items()):
rows.append(_row("unallocated per receipt", pid, D(v), ours["unallocated"].get(pid, ZERO), "equal"))
for k, (n, total) in sorted(figs["receipts_by_bank_month"].items()):
w = ours["receipts_by_bank_month"].get(k, [0, ZERO])
rows.append(_row("receipts per bank account and month", k.replace("|", " "), [n, total], [w[0], _s(w[1])], "equal"))
for k in sorted(set(ours["receipts_by_bank_month"]) - set(figs["receipts_by_bank_month"])):
w = ours["receipts_by_bank_month"][k]
rows.append(_row("receipts per bank account and month", k.replace("|", " "), [0, "0.00"], [w[0], _s(w[1])], "equal", "receipts Odoo does not have"))
rows.append(_row("allocated total", db, D(figs["allocated_total"]), ours["allocated_total"], "equal",
"SUM(debit_amount_currency) of the partials read, against SUM(pay_allocation.amount) with origin odoo:account.partial.reconcile"))
rows.append(_row("paid invoices", db, figs["paid_invoice_count"], ours["paid_invoice_count"], "explained",
"Odoo counts payment_state paid; here a paid invoice is one whose outstanding is zero (derived), so an 'in_payment' invoice differs until its receipt is carried"))
rows.append(_row("refunds paid", db, [figs["refunds"][0], figs["refunds"][1]], [ours["refunds"][0], _s(ours["refunds"][1])], "equal"))
for c in figs["cross_currency"]:
alloc = sum((a.amount for a in ours["allocations"] if a.source_id == c["payment"]), ZERO)
r = ours["receipts"].get(c["payment"])
rows.append(_row("cross-currency payments", c["payment"], f"{c['odoo_amount']} {c['odoo_currency']}", f"{_s(alloc)} {c['invoice_currency']}",
"explained", f"paid in {c['odoo_currency']} against a {c['invoice_currency']} invoice: carried in the invoice's currency "
f"(receipt fx_rate {r.fx_rate if r and r.fx_rate is not None else 'n/a'} {c['odoo_currency']} per {c['invoice_currency']}); "
"the realised exchange difference is the ledger's (gl migration)", equal=False))
blocking = [r for r in rows if r["must"] == "equal" and not r["equal"]]
unexplained = [r for r in rows if r["must"] == "explained" and not r["equal"] and not r["explanation"]]
return {"db": db, "cutover_date": run["params"]["cutover_date"], "counts": run["counts"], "checks": rows,
"equal": sum(1 for r in rows if r["equal"]), "explained_differences": sum(1 for r in rows if r["must"] == "explained" and not r["equal"]),
"blocking": len(blocking), "unexplained": len(unexplained), "can_sign": not blocking and not unexplained,
"signed": {"by": run["signed_by"], "at": run["signed_at"]} if run["signed_by"] else None}
@PAY.use_case(kind="query", route="GET /migration/reconcile", permission=MIGRATION_SIGN)
def migration_reconcile(caller: dict, only_differences: bool = False) -> dict:
"""The Odoo migration's reconciliation: every check shows Odoo's figure, ours and whether they are equal, per customer
and currency; a check that must be equal and is not blocks the sign-off; an explained difference names its reason
(a cross-currency payment). Odoo's figures are those frozen when the migration ran; ours are worked out now."""
with use_cases.repository() as repo, _decide():
got = _reconcile(repo, caller)
if only_differences:
got["checks"] = [r for r in got["checks"] if not r["equal"]]
return got
@PAY.use_case(kind="command", route="POST /migration/sign", permission=MIGRATION_SIGN, idempotent=True)
def sign_payments_migration(caller: dict) -> dict:
"""The owner signs that what customers owe is the same on both sides. Refused while any check that must be equal is
not (it names them), or an explained difference has no reason. The signature records the figures it signed and
makes the migration read-only."""
with use_cases.repository() as repo, _decide():
got = _reconcile(repo, caller)
if got["signed"]:
return {**got, "unchanged": True}
if not got["can_sign"]:
bad = [f"{r['check']} ({r['subject']}: Odoo {r['odoo']}, ours {r['ours']})" for r in got["checks"]
if (r["must"] == "equal" and not r["equal"]) or (r["must"] == "explained" and not r["equal"] and not r["explanation"])]
raise RuleBroken(f"{len(bad)} check(s) are not equal and not explained: {'; '.join(bad[:5])}. Fix the cause, run the migration again, then sign")
repo.migration.sign(_who(caller), _now(), got["checks"])
got["signed"] = {"by": _who(caller), "at": _now().isoformat()}
return got