Find invoices far above a client's usual
ivy.node.invoice-anomaly · v0.1.0
ivyx✓
Holds every invoice in a list against its client's usual (the median of the client's other invoices in the list, or the usual the caller gives per client) and names the ones above a threshold, with how far above. No model: a median and a percentage. What comes out is kind invoice-anomalies, which IVYX Agency turns into an Approve output decision per invoice.
Inputs
| Field | Type | Description |
|---|---|---|
| invoicesrequired | array | The invoices to check: client, number, date (YYYY-MM-DD), amount (a number). |
| usual | object | The usual amount per client, when the caller knows it; else the median of the client's other invoices in the list. |
| threshold_pct | number | How far above the usual counts as unusual; 30 when not given. |
Outputs
| Field | Type | Description |
|---|---|---|
| kindrequired | string | Always invoice-anomalies: tells a receiver what the output is. |
| checkedrequired | integer | How many invoices had a usual to be held against. |
| anomaliesrequired | array | The invoices above the threshold. |
Source
python
# Input preparation
inp = __ivy_ctx__["nodes"][__ivy_node_id__]["input"]
# Compute
class InvoiceAnomaly:
"Find invoices far above a client's usual"
def run(self, inp):
out = {}
# Every invoice held against its client's usual: a median and a percentage.
invoices = [i for i in (inp.get("invoices") or []) if isinstance(i, dict) and i.get("client")]
given = inp.get("usual") or {}
# 0 is a threshold (every invoice above its usual), so only a missing one is 30.
threshold = 30.0 if inp.get("threshold_pct") is None else float(inp["threshold_pct"])
if threshold < 0:
raise ValueError("threshold_pct is %g; give how far above the usual counts, zero or more." % threshold)
def amount(i):
try:
return float(i.get("amount"))
except (TypeError, ValueError):
return None
def median(xs):
xs = sorted(xs)
n = len(xs)
if n < 2:
return None
return xs[n // 2] if n % 2 else (xs[n // 2 - 1] + xs[n // 2]) / 2
def fmt(x):
return "{:,.2f}".format(x)
checked = 0
anomalies = []
for i in invoices:
a = amount(i)
if a is None:
continue
usual = given.get(i["client"])
if usual is None:
others = [amount(o) for o in invoices if o is not i and o.get("client") == i["client"] and amount(o) is not None]
usual = median(others)
if not usual:
continue
checked += 1
above = int(round((a - float(usual)) / float(usual) * 100))
if above >= threshold:
num = str(i.get("number") or "")
anomalies.append({"client": i["client"], "invoice": num, "date": str(i.get("date") or ""), "amount": a, "usual": float(usual), "above_pct": above, "detail": "%s invoice %s of %s is %d%% above usual (%s)." % (i["client"], num or "(no number)", fmt(a), above, fmt(float(usual)))})
out = {"kind": "invoice-anomalies", "checked": checked, "anomalies": anomalies}
return out
result = InvoiceAnomaly().run(inp)
# Output collection (the runner reads it here)
out = __ivy_ctx__["nodes"][__ivy_node_id__]["output"]
out.update(result)Tests
Requires: python:3.9
- one-above
Two usual invoices and one far above: the one is named with how far above the median of the other two.
- given-usual
With the usual given per client a lone invoice is checked too, and one 30% above a 40% threshold is not named.
- zero-threshold
A threshold of 0 names every invoice above its usual, rather than falling back to 30.
- negative-threshold
A negative threshold is refused, since it would name invoices below their usual.
- empty
No invoice checks nothing and names nothing.