← All ivy-nodes
IVYXSTUDIO · IVY NODE
F

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.

#invoice#agency#check

Inputs

FieldTypeDescription
invoicesrequiredarrayThe invoices to check: client, number, date (YYYY-MM-DD), amount (a number).
usualobjectThe usual amount per client, when the caller knows it; else the median of the client's other invoices in the list.
threshold_pctnumberHow far above the usual counts as unusual; 30 when not given.

Outputs

FieldTypeDescription
kindrequiredstringAlways invoice-anomalies: tells a receiver what the output is.
checkedrequiredintegerHow many invoices had a usual to be held against.
anomaliesrequiredarrayThe 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.