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

317 lines
15 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 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. "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:
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