263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""
|
||
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
|