dodil/erp-brick-ar
DODIL ERP brick: accounts receivable
tests/test_v6.py
"""Spec v6 (seam-gap PR 2): the order's pricing fields and net on a line, partner functions, deductions on a part invoice, billed-lines."""
from conftest import APPROVER, BILLER, BP, SYSTEM, api, billing, issue, line, must, raise_so
from test_v4a import deposit
OTHER = 7312
def test_ar_01_a_line_keeps_the_pricing_quantity_and_unit_the_order_priced_in(world):
billing()
inv = must(raise_so("so:SO-20:1", [line("Cable", "250", "2.4500", pricing_quantity=100, pricing_unit_code="C62", quantity_priced="250")]))
ln = inv["lines"][0]
assert (str(ln["pricing_quantity"]), ln["pricing_unit_code"], ln["quantity_priced"]) == ("100", "C62", "250")
def test_ar_01_v7_the_net_of_a_priced_line_is_quantity_priced_times_price_over_the_pricing_quantity_rounded_once(world):
"""2.45 per 100: 250 pieces cost 250 x 2.45 / 100 = 6.125, rounded once half up to 6.13 (reading it the other way gave 612.50)."""
billing()
inv = must(raise_so("so:SO-20:5", [line("Screw", "250", "2.4500", pricing_quantity=100, pricing_unit_code="C62", quantity_priced="250")]))
assert inv["lines"][0]["net_amount"] == "6.13" and inv["subtotal"] == "6.13"
disc = must(raise_so("so:SO-20:6", [line("Screw", "250", "2.4500", discount_pct="10", pricing_quantity=100, pricing_unit_code="C62",
quantity_priced="250")]))
assert disc["lines"][0]["net_amount"] == "5.51" # 6.125 x 0.9 = 5.5125, rounded once
s, b = raise_so("so:SO-20:7", [line("Screw", "250", "2.4500", pricing_quantity=100, pricing_unit_code="C62")])
assert s == 422 and b["gate"] == "quantity_priced" # the quantity in the pricing unit is required
default = must(raise_so("so:SO-20:8", [line("Screw", "3", "2.0000", pricing_unit_code="C62", quantity_priced="3")]))
assert default["lines"][0]["net_amount"] == "6.00" # pricing_quantity defaults to 1
def test_ar_01_v7_a_given_net_amount_is_kept_as_sent_even_when_it_is_not_quantity_times_price(world):
billing()
inv = must(raise_so("so:SO-20:2", [line("Cable", "3", "10.00", discount_pct="10", net_amount="25.00")])) # the order's conditions, not re-derivable
assert inv["subtotal"] == "25.00" and inv["lines"][0]["net_amount"] == "25.00"
assert must(raise_so("so:SO-20:3", [line("Cable", "3", "10.00", discount_pct="10", net_amount="27.00")]))["subtotal"] == "27.00"
assert must(raise_so("so:SO-20:4", [line("Cable", "3", "10.00")]))["subtotal"] == "30.00" # absent: ar computes it
def test_ar_01_partner_functions_are_stored_and_the_invoice_is_addressed_from_bill_to(world):
billing(bill_to_name="Payer Ltd", bill_to_address="1 Payer St")
billing(OTHER, bill_to_name="Head Office Ltd", bill_to_address="9 Head Rd", terms_id="NET30")
inv = must(raise_so("so:SO-21:1", sold_to_bp_id=OTHER, ship_to_bp_id=OTHER, bill_to_bp_id=OTHER))
assert (inv["bp_id"], inv["sold_to_bp_id"], inv["ship_to_bp_id"], inv["bill_to_bp_id"]) == (str(BP), str(OTHER), str(OTHER), str(OTHER))
done = issue(inv["invoice_id"], world=world)
assert "Head Office Ltd" in done["bill_to_json"] and "Payer" not in done["bill_to_json"] and done["bp_id"] == str(BP)
def test_ar_01_without_a_bill_to_the_payers_profile_addresses_the_invoice(world):
billing(bill_to_name="Payer Ltd")
inv = must(raise_so("so:SO-21:2"))
assert inv["bill_to_bp_id"] is None and "Payer Ltd" in issue(inv["invoice_id"], world=world)["bill_to_json"]
def test_ar_01_a_bill_to_with_no_billing_profile_blocks_issue(world):
billing()
inv = must(raise_so("so:SO-21:3", bill_to_bp_id=OTHER))
s, b = api("POST", f"/invoices/{inv['invoice_id']}/issue", BILLER, {})
assert s == 409
def test_ar_15_a_part_invoice_may_deduct_a_deposit_but_a_deposit_may_not(world):
dep = deposit(world)
part = must(raise_so("so:SO-9:2", [line("Phase 1", "1", "4000.00")], kind="regular",
deductions=[{"deposit_invoice_id": dep["invoice_id"], "net_amount": "2000.00", "tax_category": "standard"}]))
assert [(ln["net_amount"], ln["deducts_invoice_id"]) for ln in part["lines"]] == [("4000.00", ""), ("-2000.00", dep["invoice_id"])]
s, b = raise_so("so:SO-9:3", [line("Deposit 2", "1", "1000.00")], kind="deposit",
deductions=[{"deposit_invoice_id": dep["invoice_id"], "net_amount": "100.00"}])
assert s == 409 and "deposit invoice does not deduct" in b["detail"]
def test_ar_15_what_the_part_invoice_took_lowers_what_a_final_may_still_deduct(world):
dep = deposit(world)
issue(must(raise_so("so:SO-9:2", [line("Phase 1", "1", "4000.00")], kind="regular",
deductions=[{"deposit_invoice_id": dep["invoice_id"], "net_amount": "3000.00"}]))["invoice_id"], world=world)
fin = must(raise_so("so:SO-9:3", [line("Rest", "1", "6000.00")], kind="final",
deductions=[{"deposit_invoice_id": dep["invoice_id"], "net_amount": "2500.00"}]))
s, b = api("POST", f"/invoices/{fin['invoice_id']}/issue", BILLER, {})
assert s == 409 and "deposit_remaining" in str(b)
def test_ar_30_billed_lines_are_the_issued_lines_of_one_customer_and_drafts_only_on_request(world):
billing()
a = issue(must(raise_so("so:SO-30:1", [line("Hosting", "2", "100.00", product_id="HOST-M", sales_order_id="SO-30", so_line_no=1)]))["invoice_id"], world=world)
must(raise_so("so:SO-30:2", [line("Hosting", "1", "50.00", product_id="HOST-M", sales_order_id="SO-30", so_line_no=2)]))
s, rows = api("GET", f"/billed-lines?bp_id={BP}", SYSTEM)
assert s == 200 and [(r["document_id"], r["kind"], r["status"], r["net_amount"], r["category_code"]) for r in rows] == \
[(a["invoice_id"], "invoice", "issued", "200.00", "HOSTING")]
window = f"issued_from=2000-01-01&issued_to=2000-01-02"
assert api("GET", f"/billed-lines?bp_id={BP}&{window}", SYSTEM)[1] == []
_, rows = api("GET", f"/billed-lines?bp_id={BP}&include_drafts=true&{window}", SYSTEM)
assert [(r["status"], r["issue_date"], r["net_amount"], r["so_line_no"]) for r in rows] == [("draft", None, "50.00", 2)]
assert api("GET", f"/billed-lines?bp_id={OTHER}&include_drafts=true", SYSTEM)[1] == []
def test_ar_30_an_approved_credit_note_line_is_listed_as_a_credit_note(world):
inv = issue(must(raise_so("so:SO-31:1", [line("Hosting", "1", "100.00", sales_order_id="SO-31", so_line_no=1)]))["invoice_id"], world=world) if billing() else None
d = must(api("POST", "/credit-notes", BILLER, {"invoice_id": inv["invoice_id"], "reason": "again", "full": True}))
must(api("POST", f"/credit-notes/{d['credit_id']}/approve", APPROVER, {}))
_, rows = api("GET", f"/billed-lines?bp_id={BP}", SYSTEM)
assert sorted((r["kind"], r["net_amount"]) for r in rows) == [("credit_note", "100.00"), ("invoice", "100.00")]
def test_ar_30_v7_a_deposit_deduction_line_is_not_a_billed_line(world):
dep = deposit(world)
fin = issue(must(raise_so("so:SO-9:5", [line("Rest", "1", "6000.00", sales_order_id="SO-9", so_line_no=2)], kind="final",
deductions=[{"deposit_invoice_id": dep["invoice_id"], "net_amount": "2500.00"}]))["invoice_id"], world=world)
_, rows = api("GET", f"/billed-lines?bp_id={BP}", SYSTEM)
assert fin["lines"][-1]["deducts_invoice_id"] and all(r["net_amount"].replace(".", "").isdigit() for r in rows) # nothing negative
assert sorted(r["net_amount"] for r in rows) == ["5000.00", "6000.00"]
def test_ar_20_v7_expenses_and_the_till_raise_a_draft_through_the_same_use_case_and_a_repeat_answers_the_same(world):
billing()
exp = {"source_kind": "expense", "source_id": "exp:R-1:3", "bp_id": BP, "currency": "GBP", "lines": [line("Hotel re-bill", "1", "120.00")]}
a = must(api("POST", "/invoices/raise", SYSTEM, exp))
assert a["lifecycle"] == "draft" and a["source_kind"] == "expense" and must(api("POST", "/invoices/raise", SYSTEM, exp))["invoice_id"] == a["invoice_id"]
pos = {**exp, "source_kind": "pos", "source_id": "pos:LON-1-1-1-7", "lines": [line("Beans", "2", "9.50")]}
assert must(api("POST", "/invoices/raise", SYSTEM, pos))["source_kind"] == "pos"
s, b = api("POST", "/invoices/raise", SYSTEM, {**exp, "source_id": "pos:oops"})
assert s == 422 and "exp:" in str(b) # a source is keyed by its own prefix