Cost Helpers¶
nautobot_contract_models.cost
¶
Cost-analytics helpers — burn rate, renewal forecast, vendor spend.
Phase 8 introduces Contract.billing_period so we can normalize across
contracts that bill at different cadences. These helpers do the per-contract
math (monthly_cost, annual_cost, total_contract_value) and the
fleet-wide aggregations (burn_rate_by_currency, renewal_cost_in_window,
spend_by_vendor).
Aggregations always group by Contract.currency. We do NOT do FX
conversion in v1 — summing $5,000 + €4,000 into a single number is wrong
in three different ways (which exchange rate? as of when? for which
purpose?), so the helpers return dict[currency_code, Decimal] and let
the dashboard render each currency on its own row.
The per-contract helpers are pure functions of one Contract instance — no queries — so they're cheap to call inside a template loop. The fleet-wide helpers each do exactly one queryset over Contract.
annual_cost(contract)
¶
Return contract.recurring_cost annualized.
For one_time contracts this is also 0, mirroring :func:monthly_cost.
burn_rate_by_currency(*, on_date=None)
¶
Sum :func:monthly_cost across active contracts, grouped by currency.
Returns a dict[currency_code, Decimal]. Currencies with no active
contracts simply don't appear in the dict. Dashboard rendering should
iterate over the dict and show one row per currency.
Source code in src/nautobot_contract_models/cost.py
coverage_gap_count()
¶
Lightweight count of Devices that have no direct ContractAssignment.
Used by CostReportJob's summary line. This is a direct-only count
(it does NOT walk the Tenant/Location ancestry) because the full
transitive walk requires per-Device Python iteration and is too slow
to embed in a recurring report. Operators wanting the transitive
answer should run CoverageGapJob instead.
Source code in src/nautobot_contract_models/cost.py
detect_anomalies(*, threshold=Decimal('0.20'), lookback_weeks=4, on_date=None)
¶
Compare today's snapshots to lookback_weeks ago, return spend anomalies.
Returns a list of dicts: {currency, metric, prev_value, current_value,
pct_change, direction}. direction is "up" or "down".
Only changes whose absolute pct_change exceeds threshold are
returned — a 5% week-over-week drift is noise, a 25% jump is news.
Compared metrics: monthly_burn and renewal_90d. We do NOT
compare active_contract_count because count changes are usually
intentional (operator added/removed a contract) — flagging them as
anomalies would generate false positives for routine fleet changes.
Comparisons are per-currency. A currency that exists today but not
in the lookback window gets reported as "up" from 0; the inverse
(existed then, gone now) is reported as "down" to 0.
Why a fixed lookback rather than rolling stats: at typical operator snapshot cadence (weekly), 4 weeks is ~enough history to smooth week-to-week noise without requiring sophisticated time-series machinery. Operators wanting more rigor can run their own analysis against the API.
Source code in src/nautobot_contract_models/cost.py
history(*, weeks=12, currency=None, on_date=None)
¶
Return CostSnapshot rows from the past weeks weeks, ordered oldest first.
Optionally filtered to one currency so callers driving a
sparkline don't have to post-filter. Caller can pass on_date to
anchor a different "today" (useful for tests).
Source code in src/nautobot_contract_models/cost.py
monthly_cost(contract)
¶
Return contract.recurring_cost normalized to a per-month figure.
A one_time contract returns 0 — its cost belongs in
:func:total_contract_value, not the burn rate. A blank/unknown
period falls back to monthly (the migration default).
Source code in src/nautobot_contract_models/cost.py
renewal_calendar(months=12, *, on_date=None)
¶
Forward-looking month-by-month renewal cost grid.
Returns a list of {year, month, label, totals, contract_count, contracts_by_currency}
dicts in chronological order. totals is a per-currency
dict[currency_code, Decimal]; contracts_by_currency maps the
same currency keys to a list of contract names — useful for hover
tooltips so operators can see WHICH contracts contribute to a
saturated cell. The list always has exactly months entries —
empty months appear with totals={} and contract_count=0 so
the calendar grid stays rectangular.
Used by the Renewal Calendar view to render a heat-map-style breakdown ("which month is the renewal cliff?"). Operators click a cell to drill into the underlying contract list filtered to that month.
The grid starts at the first day of the current month (or
on_date's month) so partial months don't hide near-term renewals.
Source code in src/nautobot_contract_models/cost.py
renewal_cost_in_window(window_days, *, on_date=None)
¶
Total contract value for contracts whose end_date falls in [on_date, on_date+window].
Per currency. We use :func:total_contract_value rather than
:func:monthly_cost because the procurement question is "how much
does it cost to renew these for another term?" — not "what's the
monthly equivalent?".
Source code in src/nautobot_contract_models/cost.py
spend_by_vendor(*, on_date=None, limit=10)
¶
Top vendors by current monthly spend.
Returns a list of (provider, monthly_total, currency) tuples,
sorted descending by monthly total within each currency. Different
currencies are NOT mixed — a vendor billing in EUR and a vendor billing
in USD appear as separate entries even if "the EUR one" objectively
costs more after FX conversion.
limit caps the result so dashboard panels stay scannable; pass
limit=None for the full sorted list.
Source code in src/nautobot_contract_models/cost.py
take_snapshot(*, on_date=None)
¶
Persist a CostSnapshot row per currency for on_date (default today).
Idempotent on (snapshot_date, currency): re-running the same day updates the existing row rather than creating a duplicate. The coverage_gap_count is captured on the alphabetically-first currency so the column carries one value per date — null on the others.
Returns the list of saved snapshots so callers (the Job) can log what was captured.
Source code in src/nautobot_contract_models/cost.py
total_contract_value(contract)
¶
Total cost over the contract's full term, including one-time fees.
Uses term_months if set; otherwise assumes a 12-month term for the
purpose of this estimate. The one_time_cost is always added.
Source code in src/nautobot_contract_models/cost.py
vendor_concentration(*, on_date=None)
¶
Per-currency top-vendor share — procurement concentration signal.
Returns dict[currency_code, dict] where each currency maps to::
{
"top_vendor": <ServiceProvider>,
"top_vendor_pct": Decimal("0.65"), # fraction, not percent
"total_burn": Decimal("3200.00"), # this currency's monthly burn
}
Composes :func:burn_rate_by_currency and :func:spend_by_vendor so
we don't reissue the queryset. Currencies with zero burn don't appear
in the result (no top vendor to identify).
Per-currency by design: a vendor concentrated in USD is genuinely
risky even when EUR spend is diversified. Mixing across currencies
would hide that asymmetry — the same reason burn_rate_by_currency
doesn't sum across FX.