""" 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. "01–10 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