Finance-Accounts/ar-aging-app/backend/app/core/journal.py

263 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
Month-end journal entry: decompose all Amazon activity into GL-account lines per period.
Reverse-engineered from the manual sheet (verified against the Jan-2026 files). Each source
column is assigned to a line so the lines always reconcile:
Σ(all lines, incl. Transfer) = Σ(total over every row) = uploaded_total
Receivable = uploaded_total (per period and in total)
Lines matched exactly to the manual sheet: FBA Selling Fee (selling_fees), FBA Storage
(FBA Inventory Fee rows), Outward Freight (Shipping Services rows), Transfer, Sales+Refunds
(product sales + shipping + gift wrap), Tax (tax columns net ~0), and Receivable.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date
from typing import Iterable
from .readers import make_reader
from .regions import region_for
from .i18n import normalize_type
# Refund-like transaction types (their sales/shipping land in the Refunds line).
REFUND_TYPES = {"refund", "refund_retrocharge", "chargeback refund", "a-to-z guarantee claim"}
# (key, GL account) in display order — matches the manual journal-entry sheet exactly.
# "FBA Fee" is the catch-all Amazon fee/adjustment bucket (fba_fees + promotional rebates +
# other transaction fees + all non-storage/-shipping "other" transactions), which is how the
# Finance sheet books it. Receivable is derived, not accumulated.
LINE_ACCOUNTS: list[tuple[str, str]] = [
("Sales", "Sales:All Platforms Sales:Amazon USA"),
("Refunds", "Sales:Refunds Given on Amazon:Amazon USA"),
("Tax", "Sales Tax (net of marketplace-withheld)"),
("FBA Selling Fee", "Selling Fees and Commissions:Amazon USA"),
("FBA Storage", "FBA Fees:Amazon USA Storage Fees"),
("FBA Fee", "FBA Fees:Amazon USA FBA Fees"),
("Inventory Adjustment", "Sales:Inventory Adjustments:Amazon USA"),
("Outward Freight / Shipping", "Outward Freight/ Shipping Expense"),
("Transfer", "Amazon USA (bank clearing)"),
]
LINE_KEYS = [k for k, _ in LINE_ACCOUNTS]
RECEIVABLE_KEY = "Receivable"
# Granular revenue/fee components for the Finance Summary (req #3): the journal's 9 GL lines
# fold several of these together, so we accumulate them separately as well. They sum to the
# same net revenue. (key, label, group)
COMPONENTS: list[tuple[str, str, str]] = [
("product_sales", "Order / product sales", "revenue"),
("shipping_credits", "Shipping credits", "revenue"),
("gift_wrap_credits", "Gift-wrap credits", "revenue"),
("other_sales_credits", "Other sales credits", "revenue"),
("refunds", "Refunds", "revenue"),
("promotional_rebates", "Promotional rebates", "fee"),
("tax_net", "Tax (net of marketplace-withheld)", "fee"),
("selling_fees", "Amazon selling fees", "fee"),
("fba_fees", "FBA fees", "fee"),
("storage_fees", "Storage fees", "fee"),
("advertising", "Advertising charges", "fee"),
("other_transaction_fees", "Other transaction fees", "fee"),
("adjustments", "Adjustments", "fee"),
("freight", "Outward freight / shipping", "fee"),
("other_service_charges", "Other service charges", "fee"),
("transfers", "Transfer / disbursements", "payout"),
("liquidations", "Liquidations (memo)", "memo"),
]
# Amazon liquidation transaction types (present in DE / UK / USA data).
LIQUIDATION_TYPES = {"Liquidations", "Liquidations Adjustments"}
COMPONENT_KEYS = [k for k, _, _ in COMPONENTS]
GROSS_KEYS = ("product_sales", "shipping_credits", "gift_wrap_credits", "other_sales_credits")
def _amt(rec: dict, f: str) -> float:
v = rec.get(f)
return float(v) if isinstance(v, (int, float)) else 0.0
def _tax_sum(rec: dict) -> float:
"""All tax columns net together (incl. the compact schemas' single collected column)."""
return (_amt(rec, "product_sales_tax") + _amt(rec, "shipping_credits_tax")
+ _amt(rec, "giftwrap_credits_tax") + _amt(rec, "tax_on_regulatory_fee")
+ _amt(rec, "promotional_rebates_tax") + _amt(rec, "marketplace_withheld_tax")
+ _amt(rec, "sales_tax_collected"))
def _contribute_components(rec: dict, comp: dict[str, float], ttype: str) -> None:
"""ttype = canonical English transaction type."""
tl = ttype.lower()
if tl == "transfer":
comp["transfers"] += _amt(rec, "total")
return
if ttype in LIQUIDATION_TYPES:
# Memo only — the amounts below are still booked to their normal buckets.
comp["liquidations"] += _amt(rec, "total")
if tl in REFUND_TYPES:
comp["refunds"] += (_amt(rec, "product_sales") + _amt(rec, "shipping_credits")
+ _amt(rec, "gift_wrap_credits"))
else:
comp["product_sales"] += _amt(rec, "product_sales")
comp["shipping_credits"] += _amt(rec, "shipping_credits")
comp["gift_wrap_credits"] += _amt(rec, "gift_wrap_credits")
comp["tax_net"] += _tax_sum(rec)
comp["other_sales_credits"] += _amt(rec, "regulatory_fee")
comp["promotional_rebates"] += _amt(rec, "promotional_rebates")
comp["selling_fees"] += _amt(rec, "selling_fees")
comp["fba_fees"] += _amt(rec, "fba_fees")
comp["other_transaction_fees"] += _amt(rec, "other_transaction_fees")
o = _amt(rec, "other")
if o:
if ttype == "FBA Inventory Fee":
comp["storage_fees"] += o
elif ttype == "Shipping Services":
comp["freight"] += o
elif ttype == "Adjustment":
comp["adjustments"] += o
elif "advertis" in tl:
comp["advertising"] += o
else:
comp["other_service_charges"] += o
def _contribute(rec: dict, acc: dict[str, float], ttype: str) -> None:
"""ttype = canonical English transaction type."""
tl = ttype.lower()
total = _amt(rec, "total")
if tl == "transfer":
acc["Transfer"] += total
return
gross = _amt(rec, "product_sales") + _amt(rec, "shipping_credits") + _amt(rec, "gift_wrap_credits")
acc["Refunds" if tl in REFUND_TYPES else "Sales"] += gross
acc["Tax"] += _tax_sum(rec)
acc["FBA Selling Fee"] += _amt(rec, "selling_fees")
# FBA Fee = fba fees + promotional rebates + other transaction fees + non-storage/-shipping "other".
acc["FBA Fee"] += (_amt(rec, "fba_fees") + _amt(rec, "promotional_rebates")
+ _amt(rec, "other_transaction_fees") + _amt(rec, "regulatory_fee"))
o = _amt(rec, "other")
if o:
if ttype == "FBA Inventory Fee":
acc["FBA Storage"] += o
elif ttype == "Shipping Services":
acc["Outward Freight / Shipping"] += o
else:
acc["FBA Fee"] += o
@dataclass
class JournalPeriod:
key: str # source filename
label: str # e.g. "0110 Jan 2026"
min_date: date | None = None
max_date: date | None = None
lines: dict[str, float] = field(default_factory=lambda: {k: 0.0 for k in LINE_KEYS})
components: dict[str, float] = field(default_factory=lambda: {k: 0.0 for k in COMPONENT_KEYS})
@property
def receivable(self) -> float:
return -sum(self.lines.values())
@dataclass
class JournalResult:
marketplace: str = "USA"
periods: list[JournalPeriod] = field(default_factory=list)
def line_total(self, key: str) -> float:
return sum(p.lines[key] for p in self.periods)
def component_total(self, key: str) -> float:
return sum(p.components[key] for p in self.periods)
@property
def receivable_total(self) -> float:
return sum(p.receivable for p in self.periods)
def to_dict(self) -> dict:
return {
"marketplace": self.marketplace,
"periods": [{"key": p.key, "label": p.label,
"min_date": p.min_date.isoformat() if p.min_date else None,
"max_date": p.max_date.isoformat() if p.max_date else None}
for p in self.periods],
"lines": [
{"key": k, "gl_account": acc,
"values": [round(p.lines[k], 2) for p in self.periods],
"total": round(self.line_total(k), 2)}
for k, acc in LINE_ACCOUNTS
],
"receivable": {"key": RECEIVABLE_KEY, "gl_account": "Accounts Receivable:Amazon USA",
"values": [round(p.receivable, 2) for p in self.periods],
"total": round(self.receivable_total, 2)},
"components": [
{"key": k, "label": label, "group": group,
"values": [round(p.components[k], 2) for p in self.periods],
"total": round(self.component_total(k), 2)}
for k, label, group in COMPONENTS
],
}
def _period_label(mind: date | None, maxd: date | None, fallback: str) -> str:
if mind and maxd:
if mind.month == maxd.month:
return f"{mind.day:02d}{maxd.day:02d} {mind:%b %Y}"
return f"{mind:%d %b} {maxd:%d %b %Y}"
return fallback
def compute_journals(files: Iterable[str], saved_overrides: dict[str, str] | None = None,
) -> dict[str, JournalResult]:
"""
Re-read each file (= one period) and sum the journal lines, bucketed per marketplace.
A file containing several marketplaces (e.g. a combined Belgium+Germany report) is split
per region automatically via the report's own marketplace column.
"""
from .pipeline import iter_enriched_records
import os
results: dict[str, JournalResult] = {}
for path in files:
fname = os.path.basename(path)
periods: dict[str, JournalPeriod] = {}
for rec in iter_enriched_records(path, saved_overrides=saved_overrides):
region = rec["_marketplace"]
p = periods.get(region)
if p is None:
p = JournalPeriod(key=fname, label=fname)
periods[region] = p
type_en = rec["_type_en"]
_contribute(rec, p.lines, type_en)
_contribute_components(rec, p.components, type_en)
d = rec.get("_date")
if d:
p.min_date = d if p.min_date is None or d < p.min_date else p.min_date
p.max_date = d if p.max_date is None or d > p.max_date else p.max_date
for region, p in periods.items():
p.label = _period_label(p.min_date, p.max_date, fname)
results.setdefault(region, JournalResult(marketplace=region)).periods.append(p)
for r in results.values():
r.periods.sort(key=lambda p: (p.min_date or date.max))
return results
def compute_journal(files: Iterable[str], saved_overrides: dict[str, str] | None = None,
default_marketplace: str = "USA") -> JournalResult:
"""Primary-marketplace journal (largest gross sales). See compute_journals for all."""
multi = compute_journals(files, saved_overrides)
if not multi:
return JournalResult(marketplace=default_marketplace)
return max(multi.values(), key=lambda r: abs(r.line_total("Sales")))
def journal_payload(files: Iterable[str], saved_overrides: dict[str, str] | None = None) -> dict:
"""JSON payload for storage: primary journal at the top level (backward-compatible),
plus every marketplace under `per_marketplace`."""
multi = compute_journals(files, saved_overrides)
if not multi:
return JournalResult().to_dict()
primary = max(multi.values(), key=lambda r: abs(r.line_total("Sales")))
payload = primary.to_dict()
payload["marketplaces_included"] = sorted(multi.keys())
if len(multi) > 1:
payload["per_marketplace"] = {mkt: jr.to_dict() for mkt, jr in multi.items()}
return payload