317 lines
15 KiB
Python
317 lines
15 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 is_advertising_like, 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. "Amazon USA" in an account name is a PLACEHOLDER —
|
||
# to_dict() substitutes the journal's actual marketplace (Amazon Canada, Amazon UK, …).
|
||
# "FBA Fee" is the catch-all Amazon fee/adjustment bucket (fba_fees + promotional rebates +
|
||
# other transaction fees + non-storage/-shipping/-advertising "other"); advertising is broken
|
||
# out to its own line since Amazon only marks it in the description. 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"),
|
||
("Advertising Cost", "Advertising Expense:Amazon USA Ads"),
|
||
("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"
|
||
# The month-end accrual entry books revenue & fees with A/R as the balancing figure; bank
|
||
# receipts (Transfer) are posted separately from bank statements. The UI's journal therefore
|
||
# hides the Transfer line and balances to `receivable_accrual` (= net revenue, Dr A/R).
|
||
# The Transfer line itself must stay in the payload: movement.compute_movement derives net
|
||
# revenue by skipping it, and control C2 sums every line against the source `total` column.
|
||
ACCRUAL_DISPLAY_EXCLUDES = ("Transfer",)
|
||
|
||
# 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")
|
||
|
||
# Advertising has no transaction type of its own: Amazon books it as "Service Fee" with
|
||
# the intent only in the DESCRIPTION ("Cost of advertising" / localized), and the amount
|
||
# lands in a different column per marketplace — `other` in the North-America schema
|
||
# (Canada Jan-26: -36,948.43), `other transaction fees` in the UK/EU schema (UK Jan-26:
|
||
# -153,176.09). Testing the type alone left every one of these in a catch-all bucket
|
||
# with the Advertising line reading 0.00.
|
||
advertising = is_advertising_like(ttype, rec.get("description"))
|
||
otf = _amt(rec, "other_transaction_fees")
|
||
if otf:
|
||
comp["advertising" if advertising else "other_transaction_fees"] += otf
|
||
o = _amt(rec, "other")
|
||
if o:
|
||
if ttype == "FBA Inventory Fee":
|
||
comp["storage_fees"] += o
|
||
elif ttype == "Shipping Services":
|
||
comp["freight"] += o
|
||
elif advertising:
|
||
comp["advertising"] += o
|
||
elif ttype == "Adjustment":
|
||
comp["adjustments"] += 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")
|
||
# Advertising gets its own GL line. Amazon books it as "Service Fee" with the intent only
|
||
# in the description, and the amount column differs by region (`other` in North America,
|
||
# `other transaction fees` in UK/EU) — same detection as the Finance Summary component.
|
||
advertising = is_advertising_like(ttype, rec.get("description"))
|
||
otf = _amt(rec, "other_transaction_fees")
|
||
if advertising and otf:
|
||
acc["Advertising Cost"] += otf
|
||
otf = 0.0
|
||
# FBA Fee = fba fees + promotional rebates + remaining other transaction fees + regulatory.
|
||
acc["FBA Fee"] += (_amt(rec, "fba_fees") + _amt(rec, "promotional_rebates")
|
||
+ otf + _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
|
||
elif advertising:
|
||
acc["Advertising Cost"] += 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:
|
||
def gl(name: str) -> str:
|
||
# LINE_ACCOUNTS carries "Amazon USA" as a placeholder — substitute the journal's
|
||
# actual marketplace so Canada books to "…:Amazon Canada", UK to "…:Amazon UK", …
|
||
return name.replace("Amazon USA", f"Amazon {self.marketplace}")
|
||
|
||
# The accrual balancing figure: −Σ(lines except Transfer) = net revenue, i.e. the
|
||
# month's Dr to Accounts Receivable. Bank receipts are posted separately.
|
||
def accrual_of(p: JournalPeriod) -> float:
|
||
return -sum(v for k, v in p.lines.items() if k not in ACCRUAL_DISPLAY_EXCLUDES)
|
||
|
||
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": gl(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": gl("Accounts Receivable:Amazon USA"),
|
||
"values": [round(p.receivable, 2) for p in self.periods],
|
||
"total": round(self.receivable_total, 2)},
|
||
# Balancing figure of the month-end ACCRUAL entry (Transfer excluded): Dr A/R by
|
||
# net revenue. What the Journal Entry tab and the Accounts Summary display.
|
||
"receivable_accrual": {
|
||
"key": "Receivable", "gl_account": gl("Accounts Receivable:Amazon USA"),
|
||
"values": [round(accrual_of(p), 2) for p in self.periods],
|
||
"total": round(sum(accrual_of(p) for p in self.periods), 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 | tuple[str, str | None]],
|
||
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.
|
||
|
||
`files` accepts the same (path, marketplace_override) tuples the settlement pipeline takes.
|
||
Passing plain paths here while the pipeline forced a marketplace label would bucket the same
|
||
rows under different marketplaces in the two passes, so the roll-forward and the settlement
|
||
receivable would disagree for a reason that has nothing to do with timing.
|
||
"""
|
||
from .pipeline import iter_enriched_records
|
||
import os
|
||
results: dict[str, JournalResult] = {}
|
||
for entry in files:
|
||
path, override = entry if isinstance(entry, tuple) else (entry, None)
|
||
fname = os.path.basename(path)
|
||
periods: dict[str, JournalPeriod] = {}
|
||
for rec in iter_enriched_records(path, marketplace_override=override,
|
||
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 | tuple[str, str | None]],
|
||
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
|