vaultburn 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vaultburn-1.0.0/LICENSE +21 -0
- vaultburn-1.0.0/PKG-INFO +76 -0
- vaultburn-1.0.0/README.md +61 -0
- vaultburn-1.0.0/pyproject.toml +26 -0
- vaultburn-1.0.0/setup.cfg +4 -0
- vaultburn-1.0.0/vaultburn/__init__.py +440 -0
- vaultburn-1.0.0/vaultburn.egg-info/PKG-INFO +76 -0
- vaultburn-1.0.0/vaultburn.egg-info/SOURCES.txt +9 -0
- vaultburn-1.0.0/vaultburn.egg-info/dependency_links.txt +1 -0
- vaultburn-1.0.0/vaultburn.egg-info/entry_points.txt +2 -0
- vaultburn-1.0.0/vaultburn.egg-info/top_level.txt +1 -0
vaultburn-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VaultBurn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
vaultburn-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaultburn
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Offline SaaS spend auditor. CSV in → decision report out. VaultBurn discovers decisions; it never makes them.
|
|
5
|
+
Author-email: VaultBurn <vaultburn@proton.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/neverlose-io/vaultburn
|
|
8
|
+
Project-URL: Repository, https://github.com/neverlose-io/vaultburn
|
|
9
|
+
Project-URL: Issues, https://github.com/neverlose-io/vaultburn/issues
|
|
10
|
+
Keywords: saas,audit,csv,offline,spend,decision-support
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# VaultBurn
|
|
17
|
+
|
|
18
|
+
Offline SaaS spend auditor. CSV in → decision report out.
|
|
19
|
+
|
|
20
|
+
**VaultBurn discovers decisions. It never makes them.**
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install vaultburn
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Use
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
vaultburn subs.csv # text report
|
|
32
|
+
vaultburn subs.csv --json r.json
|
|
33
|
+
vaultburn --init # generate template
|
|
34
|
+
vaultburn --help # full schema & philosophy
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Schema
|
|
38
|
+
|
|
39
|
+
Required: name, monthly_cost, seats, used_seats, last_used
|
|
40
|
+
|
|
41
|
+
Optional: renew_date, category, critical, reason, replacement_group, cost_model
|
|
42
|
+
|
|
43
|
+
cost_model
|
|
44
|
+
|
|
45
|
+
· per_seat (default): monthly_cost is the price per seat
|
|
46
|
+
· flat: monthly_cost is the total monthly price
|
|
47
|
+
|
|
48
|
+
Signals
|
|
49
|
+
|
|
50
|
+
· monthly_spend — Sum of all subscriptions
|
|
51
|
+
· unused_seat_cost — Idle seats × seat cost
|
|
52
|
+
· potentially_cancellable — used=0 AND stale AND not critical
|
|
53
|
+
· duplicate_candidate — From user-declared replacement groups
|
|
54
|
+
· renewals_7d — Subscriptions renewing within 7 days
|
|
55
|
+
|
|
56
|
+
Verdicts
|
|
57
|
+
|
|
58
|
+
· cancellable — used=0, stale, not critical
|
|
59
|
+
· review — needs human attention
|
|
60
|
+
· protected — critical, never suggested for cancellation
|
|
61
|
+
· ignored — user marked replacement_group=ignore
|
|
62
|
+
· ok — clean
|
|
63
|
+
|
|
64
|
+
What it does NOT do
|
|
65
|
+
|
|
66
|
+
· No cloud. No OAuth. No accounts. No telemetry.
|
|
67
|
+
· No auto-cancel. No auto-keep. No cheapest-wins.
|
|
68
|
+
· No Stripe (yet — will add only if 3 users request it).
|
|
69
|
+
|
|
70
|
+
Philosophy
|
|
71
|
+
|
|
72
|
+
VaultBurn discovers decisions. It never makes them.
|
|
73
|
+
|
|
74
|
+
License
|
|
75
|
+
|
|
76
|
+
MIT.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# VaultBurn
|
|
2
|
+
|
|
3
|
+
Offline SaaS spend auditor. CSV in → decision report out.
|
|
4
|
+
|
|
5
|
+
**VaultBurn discovers decisions. It never makes them.**
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install vaultburn
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Use
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
vaultburn subs.csv # text report
|
|
17
|
+
vaultburn subs.csv --json r.json
|
|
18
|
+
vaultburn --init # generate template
|
|
19
|
+
vaultburn --help # full schema & philosophy
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Schema
|
|
23
|
+
|
|
24
|
+
Required: name, monthly_cost, seats, used_seats, last_used
|
|
25
|
+
|
|
26
|
+
Optional: renew_date, category, critical, reason, replacement_group, cost_model
|
|
27
|
+
|
|
28
|
+
cost_model
|
|
29
|
+
|
|
30
|
+
· per_seat (default): monthly_cost is the price per seat
|
|
31
|
+
· flat: monthly_cost is the total monthly price
|
|
32
|
+
|
|
33
|
+
Signals
|
|
34
|
+
|
|
35
|
+
· monthly_spend — Sum of all subscriptions
|
|
36
|
+
· unused_seat_cost — Idle seats × seat cost
|
|
37
|
+
· potentially_cancellable — used=0 AND stale AND not critical
|
|
38
|
+
· duplicate_candidate — From user-declared replacement groups
|
|
39
|
+
· renewals_7d — Subscriptions renewing within 7 days
|
|
40
|
+
|
|
41
|
+
Verdicts
|
|
42
|
+
|
|
43
|
+
· cancellable — used=0, stale, not critical
|
|
44
|
+
· review — needs human attention
|
|
45
|
+
· protected — critical, never suggested for cancellation
|
|
46
|
+
· ignored — user marked replacement_group=ignore
|
|
47
|
+
· ok — clean
|
|
48
|
+
|
|
49
|
+
What it does NOT do
|
|
50
|
+
|
|
51
|
+
· No cloud. No OAuth. No accounts. No telemetry.
|
|
52
|
+
· No auto-cancel. No auto-keep. No cheapest-wins.
|
|
53
|
+
· No Stripe (yet — will add only if 3 users request it).
|
|
54
|
+
|
|
55
|
+
Philosophy
|
|
56
|
+
|
|
57
|
+
VaultBurn discovers decisions. It never makes them.
|
|
58
|
+
|
|
59
|
+
License
|
|
60
|
+
|
|
61
|
+
MIT.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaultburn"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Offline SaaS spend auditor. CSV in → decision report out. VaultBurn discovers decisions; it never makes them."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "VaultBurn", email = "vaultburn@proton.me" }]
|
|
14
|
+
keywords = ["saas", "audit", "csv", "offline", "spend", "decision-support"]
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
vaultburn = "vaultburn:main"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/neverlose-io/vaultburn"
|
|
22
|
+
Repository = "https://github.com/neverlose-io/vaultburn"
|
|
23
|
+
Issues = "https://github.com/neverlose-io/vaultburn/issues"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
packages = ["vaultburn"]
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# vaultburn.py — v1.0.0 | MIT | Python 3.9+
|
|
2
|
+
"""Offline SaaS spend auditor.
|
|
3
|
+
|
|
4
|
+
VaultBurn discovers decisions. It never makes them.
|
|
5
|
+
No cloud. No OAuth. No telemetry. No auto-selection.
|
|
6
|
+
"""
|
|
7
|
+
import csv, json, sys, argparse, re
|
|
8
|
+
from datetime import date, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, List, Dict, Any, Tuple
|
|
11
|
+
from decimal import Decimal, InvalidOperation
|
|
12
|
+
|
|
13
|
+
VERSION = "1.0.0"
|
|
14
|
+
DEFAULT_STALE_AFTER = 45
|
|
15
|
+
DEFAULT_DUP_THRESHOLD = Decimal("20")
|
|
16
|
+
RENEW_WINDOW = 7
|
|
17
|
+
GROUP_RE = re.compile(r"^([a-zA-Z0-9_\-]+)(\*?)$")
|
|
18
|
+
|
|
19
|
+
SCHEMA_REQUIRED = ["name", "monthly_cost", "seats", "used_seats", "last_used"]
|
|
20
|
+
SCHEMA_OPTIONAL = ["renew_date", "category", "critical", "reason",
|
|
21
|
+
"replacement_group", "cost_model"]
|
|
22
|
+
|
|
23
|
+
TEMPLATE = (
|
|
24
|
+
"name,monthly_cost,cost_model,seats,used_seats,last_used,renew_date,"
|
|
25
|
+
"category,critical,reason,replacement_group\n"
|
|
26
|
+
"ChatGPT Team,150,flat,5,2,2026-09-20,2026-10-03,ai-assistant,false,,ai-1*\n"
|
|
27
|
+
"Claude Pro,20,per_seat,1,1,2026-09-25,2026-10-10,ai-assistant,false,,ai-1\n"
|
|
28
|
+
"Figma,45,per_seat,2,1,2026-09-01,2026-10-05,design,false,,\n"
|
|
29
|
+
"Notion,80,flat,4,4,2026-09-28,2026-11-15,docs,true,team-wiki,\n"
|
|
30
|
+
"AWS Support,100,flat,10,7,2026-09-30,2026-10-01,infrastructure,true,"
|
|
31
|
+
"regulatory-compliance,ignore\n"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
HELP_EPILOG = """\
|
|
35
|
+
INPUT SCHEMA (required columns):
|
|
36
|
+
name, monthly_cost, seats, used_seats, last_used
|
|
37
|
+
OPTIONAL COLUMNS:
|
|
38
|
+
renew_date, category, critical, reason, replacement_group, cost_model
|
|
39
|
+
|
|
40
|
+
cost_model (optional, default: per_seat):
|
|
41
|
+
flat → monthly_cost = السعر الشهري الإجمالي للاشتراك
|
|
42
|
+
per_seat → monthly_cost = السعر الشهري لمقعد واحد (يُضرب في seats)
|
|
43
|
+
|
|
44
|
+
SIGNALS (conservative by default):
|
|
45
|
+
monthly_spend sum of all monthly_cost (مُوحَّد)
|
|
46
|
+
unused_seat_cost idle seats × seat cost (per cost_model)
|
|
47
|
+
potentially_cancellable used=0 AND stale AND !critical
|
|
48
|
+
duplicate_candidate same replacement_group, combined > threshold
|
|
49
|
+
renewals_7d subscriptions renewing within 7 days
|
|
50
|
+
|
|
51
|
+
DEFAULTS (not industry standards — override freely):
|
|
52
|
+
--stale-after 45 review threshold, not a policy
|
|
53
|
+
--duplicate-threshold 20 USD floor to suppress noise
|
|
54
|
+
|
|
55
|
+
replacement_group VALUES:
|
|
56
|
+
"" no claim of equivalence
|
|
57
|
+
"grp" user-declared equivalence group → REVIEW
|
|
58
|
+
"grp*" user-declared preferred candidate → USER-PREFERRED
|
|
59
|
+
"ignore" excluded from duplicate analysis
|
|
60
|
+
|
|
61
|
+
VERDICTS:
|
|
62
|
+
cancellable used=0 AND stale AND !critical
|
|
63
|
+
review needs human attention
|
|
64
|
+
protected critical=true — never suggested for cancellation
|
|
65
|
+
ignored replacement_group=ignore
|
|
66
|
+
ok clean
|
|
67
|
+
|
|
68
|
+
renewals_7d actionable = reported → fact, not recommendation
|
|
69
|
+
|
|
70
|
+
PHILOSOPHY:
|
|
71
|
+
VaultBurn discovers decisions. It never makes them.
|
|
72
|
+
No auto-keep. No auto-cancel. No cheapest-wins.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# ───────── Primitives ─────────
|
|
76
|
+
|
|
77
|
+
def _dec(v: Any, d: Optional[Decimal] = None) -> Optional[Decimal]:
|
|
78
|
+
if v is None or str(v).strip() == "": return d
|
|
79
|
+
try: return Decimal(str(v).strip())
|
|
80
|
+
except InvalidOperation: return None
|
|
81
|
+
|
|
82
|
+
def _int(v: Any) -> Optional[int]:
|
|
83
|
+
x = _dec(v); return None if x is None else int(x)
|
|
84
|
+
|
|
85
|
+
def _bool(v: Any) -> bool:
|
|
86
|
+
return str(v).strip().lower() in ("true", "1", "yes", "y")
|
|
87
|
+
|
|
88
|
+
def _date(s: str) -> Optional[date]:
|
|
89
|
+
if not s: return None
|
|
90
|
+
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
|
|
91
|
+
try: return datetime.strptime(s.strip(), fmt).date()
|
|
92
|
+
except ValueError: continue
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _days_since(d: Optional[date]) -> Optional[int]:
|
|
96
|
+
return None if d is None else (date.today() - d).days
|
|
97
|
+
|
|
98
|
+
def _days_until(d: Optional[date]) -> Optional[int]:
|
|
99
|
+
return None if d is None else (d - date.today()).days
|
|
100
|
+
|
|
101
|
+
def _parse_group(raw: str) -> Tuple[Optional[str], bool, bool]:
|
|
102
|
+
s = (raw or "").strip()
|
|
103
|
+
if s == "": return None, False, False
|
|
104
|
+
if s.lower() == "ignore": return None, False, True
|
|
105
|
+
m = GROUP_RE.match(s)
|
|
106
|
+
if not m: return None, False, False
|
|
107
|
+
return m.group(1), m.group(2) == "*", False
|
|
108
|
+
|
|
109
|
+
def _norm_rows(text: str) -> List[str]:
|
|
110
|
+
text = text.lstrip("\ufeff")
|
|
111
|
+
return [ln for ln in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
|
|
112
|
+
if ln.strip()]
|
|
113
|
+
|
|
114
|
+
# ───────── Row Parsing ─────────
|
|
115
|
+
|
|
116
|
+
def parse_row(r: Dict[str, str], idx: int) -> Optional[Dict[str, Any]]:
|
|
117
|
+
name = (r.get("name") or "").strip()
|
|
118
|
+
if not name:
|
|
119
|
+
print(f"⚠️ row {idx}: name missing", file=sys.stderr); return None
|
|
120
|
+
|
|
121
|
+
cost = _dec(r.get("monthly_cost"))
|
|
122
|
+
if cost is None or cost < 0:
|
|
123
|
+
print(f"⚠️ row {idx} ({name}): monthly_cost invalid",
|
|
124
|
+
file=sys.stderr); return None
|
|
125
|
+
|
|
126
|
+
seats, used = _int(r.get("seats")), _int(r.get("used_seats"))
|
|
127
|
+
if seats is None or seats <= 0 or used is None or used < 0 or used > seats:
|
|
128
|
+
print(f"⚠️ row {idx} ({name}): seats/used_seats invalid",
|
|
129
|
+
file=sys.stderr); return None
|
|
130
|
+
|
|
131
|
+
last = _date(r.get("last_used", ""))
|
|
132
|
+
if last is None:
|
|
133
|
+
print(f"⚠️ row {idx} ({name}): last_used missing or unparseable",
|
|
134
|
+
file=sys.stderr); return None
|
|
135
|
+
|
|
136
|
+
# التعديل #1: cost_model اختياري، default=per_seat، صريح
|
|
137
|
+
model = (r.get("cost_model") or "per_seat").strip().lower()
|
|
138
|
+
if model not in ("flat", "per_seat"):
|
|
139
|
+
print(f"⚠️ row {idx} ({name}): cost_model must be flat or per_seat",
|
|
140
|
+
file=sys.stderr); return None
|
|
141
|
+
|
|
142
|
+
gid, preferred, ignored = _parse_group(r.get("replacement_group", ""))
|
|
143
|
+
return {
|
|
144
|
+
"name": name, "cost": cost, "model": model,
|
|
145
|
+
"seats": seats, "used": used,
|
|
146
|
+
"last_used": last,
|
|
147
|
+
"renew": _date(r.get("renew_date", "")),
|
|
148
|
+
"category": (r.get("category") or "").strip().lower() or None,
|
|
149
|
+
"critical": _bool(r.get("critical", "false")),
|
|
150
|
+
"reason": (r.get("reason") or "").strip() or None,
|
|
151
|
+
"group_id": gid, "group_preferred": preferred, "group_ignored": ignored,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# ───────── Signals (reported / actionable) ─────────
|
|
155
|
+
|
|
156
|
+
def _pair(reported: Decimal, actionable: Decimal) -> Dict[str, float]:
|
|
157
|
+
return {"reported": float(round(reported, 2)),
|
|
158
|
+
"actionable": float(round(actionable, 2))}
|
|
159
|
+
|
|
160
|
+
def _unused_pair(row: Dict[str, Any]) -> Dict[str, float]:
|
|
161
|
+
"""التعديل #1: يفرّق flat عن per_seat بشكل صريح."""
|
|
162
|
+
idle = row["seats"] - row["used"]
|
|
163
|
+
if idle <= 0: return _pair(Decimal(0), Decimal(0))
|
|
164
|
+
if row["model"] == "per_seat":
|
|
165
|
+
# monthly_cost = سعر المقعد الواحد
|
|
166
|
+
amt = row["cost"] * idle
|
|
167
|
+
else:
|
|
168
|
+
# flat = السعر الكلي، التوزيع نسبي
|
|
169
|
+
amt = (row["cost"] / row["seats"]) * idle
|
|
170
|
+
return _pair(amt, Decimal(0) if row["critical"] else amt)
|
|
171
|
+
|
|
172
|
+
def _cancellable_pair(row: Dict[str, Any], stale_after: int) -> Dict[str, float]:
|
|
173
|
+
since = _days_since(row["last_used"])
|
|
174
|
+
stale = since is not None and since > stale_after
|
|
175
|
+
eligible = (row["used"] == 0 and stale and not row["critical"]
|
|
176
|
+
and not row["group_ignored"])
|
|
177
|
+
if eligible:
|
|
178
|
+
return _pair(row["cost"], row["cost"])
|
|
179
|
+
return _pair(Decimal(0), Decimal(0))
|
|
180
|
+
|
|
181
|
+
def _renew_pair(row: Dict[str, Any]) -> Dict[str, float]:
|
|
182
|
+
until = _days_until(row["renew"])
|
|
183
|
+
if until is None or not (0 <= until <= RENEW_WINDOW):
|
|
184
|
+
return _pair(Decimal(0), Decimal(0))
|
|
185
|
+
return _pair(row["cost"], row["cost"])
|
|
186
|
+
|
|
187
|
+
def classify(row: Dict[str, Any], stale_after: int) -> Dict[str, Any]:
|
|
188
|
+
idle = row["seats"] - row["used"]
|
|
189
|
+
since = _days_since(row["last_used"])
|
|
190
|
+
until = _days_until(row["renew"])
|
|
191
|
+
stale = since is not None and since > stale_after
|
|
192
|
+
|
|
193
|
+
reasons: List[str] = []
|
|
194
|
+
if row["reason"]: reasons.append(f"context: {row['reason']}")
|
|
195
|
+
|
|
196
|
+
cancellable = Decimal("0")
|
|
197
|
+
verdict = "ok"
|
|
198
|
+
|
|
199
|
+
# التعديل #2: protected حالة خامسة، ليست review
|
|
200
|
+
if row["group_ignored"]:
|
|
201
|
+
verdict = "ignored"
|
|
202
|
+
reasons.append("replacement_group=ignore - excluded from duplicate analysis")
|
|
203
|
+
elif row["critical"]:
|
|
204
|
+
verdict = "protected"
|
|
205
|
+
reasons.append("critical - never suggested for cancellation")
|
|
206
|
+
elif row["used"] == 0 and stale:
|
|
207
|
+
verdict = "cancellable"
|
|
208
|
+
cancellable = row["cost"]
|
|
209
|
+
reasons.append(f"no users - last used {since} days ago")
|
|
210
|
+
elif row["used"] == 0:
|
|
211
|
+
verdict = "review"
|
|
212
|
+
reasons.append(f"used_seats = 0 - last used {since} days ago")
|
|
213
|
+
elif stale:
|
|
214
|
+
verdict = "review"
|
|
215
|
+
reasons.append(f"last used {since} days ago - exceeds {stale_after}")
|
|
216
|
+
elif idle > 0:
|
|
217
|
+
verdict = "review"
|
|
218
|
+
reasons.append(f"{idle} idle seats")
|
|
219
|
+
|
|
220
|
+
if until is not None and 0 <= until <= RENEW_WINDOW:
|
|
221
|
+
reasons.append(f"renews in {until} days")
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
"name": row["name"], "verdict": verdict,
|
|
225
|
+
"monthly_spend": float(row["cost"]),
|
|
226
|
+
"cost_model": row["model"],
|
|
227
|
+
"signals": {
|
|
228
|
+
"unused_seat_cost": _unused_pair(row),
|
|
229
|
+
"potentially_cancellable": _pair(cancellable, cancellable),
|
|
230
|
+
"renewals_7d": _renew_pair(row),
|
|
231
|
+
},
|
|
232
|
+
"idle_seats": idle,
|
|
233
|
+
"days_since_used": since,
|
|
234
|
+
"days_until_renew": until,
|
|
235
|
+
"category": row["category"],
|
|
236
|
+
"critical": row["critical"],
|
|
237
|
+
"reason": row["reason"],
|
|
238
|
+
"replacement_group": row["group_id"],
|
|
239
|
+
"user_preferred": row["group_preferred"],
|
|
240
|
+
"reasons": reasons,
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# ───────── Duplicate Detection ─────────
|
|
244
|
+
|
|
245
|
+
def find_groups(rows: List[Dict[str, Any]],
|
|
246
|
+
threshold: Decimal) -> List[Dict[str, Any]]:
|
|
247
|
+
buckets: Dict[str, List[Dict[str, Any]]] = {}
|
|
248
|
+
for r in rows:
|
|
249
|
+
if r["group_ignored"] or not r["group_id"]: continue
|
|
250
|
+
buckets.setdefault(r["group_id"], []).append(r)
|
|
251
|
+
|
|
252
|
+
out = []
|
|
253
|
+
for gid, members in buckets.items():
|
|
254
|
+
if len(members) < 2: continue
|
|
255
|
+
combined = sum(m["cost"] for m in members)
|
|
256
|
+
if combined < threshold: continue
|
|
257
|
+
preferred = [m["name"] for m in members if m["group_preferred"]]
|
|
258
|
+
out.append({
|
|
259
|
+
"group_id": gid,
|
|
260
|
+
"candidates": [m["name"] for m in members],
|
|
261
|
+
"signals": {
|
|
262
|
+
"duplicate_candidate": _pair(combined, Decimal(0)),
|
|
263
|
+
},
|
|
264
|
+
"user_preferred": preferred or None,
|
|
265
|
+
"action": "USER-PREFERRED" if preferred else "REVIEW",
|
|
266
|
+
"note": ("explicit user preference - tool does not rank or choose"
|
|
267
|
+
if preferred else "similarity signal - decision for the user"),
|
|
268
|
+
})
|
|
269
|
+
return sorted(out, key=lambda g: -g["signals"]["duplicate_candidate"]["reported"])
|
|
270
|
+
|
|
271
|
+
# ───────── Analysis ─────────
|
|
272
|
+
|
|
273
|
+
def _sum_signal(items: List[Dict[str, Any]], key: str) -> Dict[str, float]:
|
|
274
|
+
r = sum(i["signals"][key]["reported"] for i in items)
|
|
275
|
+
a = sum(i["signals"][key]["actionable"] for i in items)
|
|
276
|
+
return _pair(Decimal(str(r)), Decimal(str(a)))
|
|
277
|
+
|
|
278
|
+
def analyze(rows: List[Dict[str, Any]], stale_after: int,
|
|
279
|
+
dup_threshold: Decimal) -> Dict[str, Any]:
|
|
280
|
+
items = [classify(r, stale_after) for r in rows]
|
|
281
|
+
groups = find_groups(rows, dup_threshold)
|
|
282
|
+
dup_reported = sum(g["signals"]["duplicate_candidate"]["reported"] for g in groups)
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
"generated": date.today().isoformat(),
|
|
286
|
+
"version": VERSION,
|
|
287
|
+
"thresholds": {
|
|
288
|
+
"stale_after_days": stale_after,
|
|
289
|
+
"renew_window_days": RENEW_WINDOW,
|
|
290
|
+
"duplicate_threshold_usd": float(dup_threshold),
|
|
291
|
+
},
|
|
292
|
+
"totals": {
|
|
293
|
+
"monthly_spend": float(round(sum(i["monthly_spend"] for i in items), 2)),
|
|
294
|
+
"signals": {
|
|
295
|
+
"unused_seat_cost": _sum_signal(items, "unused_seat_cost"),
|
|
296
|
+
"potentially_cancellable": _sum_signal(items, "potentially_cancellable"),
|
|
297
|
+
"duplicate_candidate": _pair(Decimal(str(dup_reported)), Decimal(0)),
|
|
298
|
+
"renewals_7d": _sum_signal(items, "renewals_7d"),
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
"counts": {
|
|
302
|
+
"cancellable": sum(1 for i in items if i["verdict"] == "cancellable"),
|
|
303
|
+
"review": sum(1 for i in items if i["verdict"] == "review"),
|
|
304
|
+
"ok": sum(1 for i in items if i["verdict"] == "ok"),
|
|
305
|
+
"protected": sum(1 for i in items if i["verdict"] == "protected"),
|
|
306
|
+
"ignored": sum(1 for i in items if i["verdict"] == "ignored"),
|
|
307
|
+
},
|
|
308
|
+
"items": sorted(items,
|
|
309
|
+
key=lambda x: -x["signals"]["potentially_cancellable"]["reported"]),
|
|
310
|
+
"replacement_groups": groups,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
# ───────── Report ─────────
|
|
314
|
+
|
|
315
|
+
def render(rep: Dict[str, Any]) -> None:
|
|
316
|
+
t, c, th = rep["totals"], rep["counts"], rep["thresholds"]
|
|
317
|
+
sig = t["signals"]
|
|
318
|
+
print(f"\n📋 VaultBurn v{VERSION} - {rep['generated']}")
|
|
319
|
+
print(f" stale-after={th['stale_after_days']}d · "
|
|
320
|
+
f"renew-window={th['renew_window_days']}d · "
|
|
321
|
+
f"dup-threshold=${th['duplicate_threshold_usd']:.0f}")
|
|
322
|
+
print("─" * 72)
|
|
323
|
+
print(f"💰 Monthly spend : ${t['monthly_spend']:.2f}")
|
|
324
|
+
print(f"🪑 Idle seat cost (reported / actionable): "
|
|
325
|
+
f"${sig['unused_seat_cost']['reported']:.2f} / "
|
|
326
|
+
f"${sig['unused_seat_cost']['actionable']:.2f}")
|
|
327
|
+
print(f"✂️ Cancellable (reported / actionable): "
|
|
328
|
+
f"${sig['potentially_cancellable']['reported']:.2f} / "
|
|
329
|
+
f"${sig['potentially_cancellable']['actionable']:.2f}")
|
|
330
|
+
print(f"🔁 Duplicate candid. (reported / actionable): "
|
|
331
|
+
f"${sig['duplicate_candidate']['reported']:.2f} / "
|
|
332
|
+
f"${sig['duplicate_candidate']['actionable']:.2f}")
|
|
333
|
+
print(f"⏰ Renewals in 7 days (reported / actionable): "
|
|
334
|
+
f"${sig['renewals_7d']['reported']:.2f} / "
|
|
335
|
+
f"${sig['renewals_7d']['actionable']:.2f}")
|
|
336
|
+
print(f"🔢 {c['cancellable']} cancellable · {c['review']} review · "
|
|
337
|
+
f"{c['ok']} ok · {c['protected']} protected · {c['ignored']} ignored")
|
|
338
|
+
print("─" * 72)
|
|
339
|
+
|
|
340
|
+
for i in rep["items"]:
|
|
341
|
+
if i["verdict"] == "ok" and not i["reasons"]: continue
|
|
342
|
+
icon = {"cancellable": "🔴", "review": "🟡", "ok": "🟢",
|
|
343
|
+
"protected": "🛡️", "ignored": "⚪"}[i["verdict"]]
|
|
344
|
+
line = f"{icon} {i['name']:<20} monthly ${i['monthly_spend']:.2f}"
|
|
345
|
+
u = i["signals"]["unused_seat_cost"]
|
|
346
|
+
if u["reported"] > 0:
|
|
347
|
+
line += f" - idle ${u['reported']:.2f}"
|
|
348
|
+
print(line)
|
|
349
|
+
for r in i["reasons"]: print(f" • {r}")
|
|
350
|
+
|
|
351
|
+
if rep["replacement_groups"]:
|
|
352
|
+
print("─" * 72)
|
|
353
|
+
print("⚠️ REPLACEMENT GROUPS - user-declared similarity, not a decision:")
|
|
354
|
+
for g in rep["replacement_groups"]:
|
|
355
|
+
d = g["signals"]["duplicate_candidate"]
|
|
356
|
+
print(f" group: {g['group_id']} → {g['action']}")
|
|
357
|
+
print(f" candidates: {', '.join(g['candidates'])}")
|
|
358
|
+
print(f" combined: ${d['reported']:.2f} (actionable: ${d['actionable']:.2f})")
|
|
359
|
+
if g["user_preferred"]:
|
|
360
|
+
print(f" user_preferred: {', '.join(g['user_preferred'])}")
|
|
361
|
+
print(f" note: {g['note']}")
|
|
362
|
+
|
|
363
|
+
print("─" * 72)
|
|
364
|
+
print("ℹ️ VaultBurn discovers decisions. It never makes them.")
|
|
365
|
+
|
|
366
|
+
# ───────── CLI ─────────
|
|
367
|
+
|
|
368
|
+
def cmd_init(path: Path) -> None:
|
|
369
|
+
if path.exists():
|
|
370
|
+
print(f"❌ {path} already exists"); sys.exit(1)
|
|
371
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
372
|
+
print(f"✅ Template: {path}\n Edit it, then: vaultburn {path}")
|
|
373
|
+
|
|
374
|
+
def load(path: Path) -> List[Dict[str, Any]]:
|
|
375
|
+
if not path.exists():
|
|
376
|
+
print(f"❌ File not found: {path}", file=sys.stderr); sys.exit(1)
|
|
377
|
+
text = path.read_text(encoding="utf-8-sig", errors="replace")
|
|
378
|
+
lines = _norm_rows(text)
|
|
379
|
+
if not lines:
|
|
380
|
+
print("❌ File is empty", file=sys.stderr); sys.exit(1)
|
|
381
|
+
|
|
382
|
+
header = [h.strip().lower() for h in next(csv.reader([lines[0]]))]
|
|
383
|
+
missing = [c for c in SCHEMA_REQUIRED if c not in header]
|
|
384
|
+
if missing:
|
|
385
|
+
print(f"❌ Missing columns: {', '.join(missing)}", file=sys.stderr)
|
|
386
|
+
print(f" Required: {', '.join(SCHEMA_REQUIRED)}", file=sys.stderr)
|
|
387
|
+
sys.exit(2)
|
|
388
|
+
|
|
389
|
+
reader = csv.DictReader(lines)
|
|
390
|
+
parsed = [parse_row(r, i) for i, r in enumerate(reader, 2)]
|
|
391
|
+
rows = [r for r in parsed if r]
|
|
392
|
+
if not rows:
|
|
393
|
+
print("❌ No valid rows", file=sys.stderr); sys.exit(1)
|
|
394
|
+
return rows
|
|
395
|
+
|
|
396
|
+
def main() -> None:
|
|
397
|
+
p = argparse.ArgumentParser(
|
|
398
|
+
prog="vaultburn",
|
|
399
|
+
description="Offline SaaS spend auditor. VaultBurn discovers decisions; it never makes them.",
|
|
400
|
+
epilog=HELP_EPILOG,
|
|
401
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
402
|
+
)
|
|
403
|
+
p.add_argument("csv", nargs="?", help="CSV file (see schema below)")
|
|
404
|
+
p.add_argument("--json", metavar="OUT", help="write JSON report")
|
|
405
|
+
p.add_argument("--init", metavar="CSV", nargs="?", const="subs.csv",
|
|
406
|
+
help="generate CSV template")
|
|
407
|
+
p.add_argument("--stale-after", type=int, default=DEFAULT_STALE_AFTER,
|
|
408
|
+
metavar="N",
|
|
409
|
+
help=f"review threshold in days (default: {DEFAULT_STALE_AFTER}) "
|
|
410
|
+
"— not an industry standard")
|
|
411
|
+
p.add_argument("--duplicate-threshold", type=Decimal,
|
|
412
|
+
default=DEFAULT_DUP_THRESHOLD, metavar="USD",
|
|
413
|
+
help=f"floor for duplicate groups (default: {DEFAULT_DUP_THRESHOLD})")
|
|
414
|
+
p.add_argument("--license", action="store_true")
|
|
415
|
+
p.add_argument("--version", action="version", version=VERSION)
|
|
416
|
+
a = p.parse_args()
|
|
417
|
+
|
|
418
|
+
if a.license:
|
|
419
|
+
print("MIT — VaultBurn v1.0.0. No server. No telemetry. No auto-decision.")
|
|
420
|
+
return
|
|
421
|
+
if a.init:
|
|
422
|
+
cmd_init(Path(a.init)); return
|
|
423
|
+
if not a.csv:
|
|
424
|
+
p.print_help(); return
|
|
425
|
+
if a.stale_after <= 0:
|
|
426
|
+
print("❌ --stale-after must be > 0", file=sys.stderr); sys.exit(2)
|
|
427
|
+
if a.duplicate_threshold < 0:
|
|
428
|
+
print("❌ --duplicate-threshold must be >= 0", file=sys.stderr); sys.exit(2)
|
|
429
|
+
|
|
430
|
+
rows = load(Path(a.csv))
|
|
431
|
+
rep = analyze(rows, a.stale_after, a.duplicate_threshold)
|
|
432
|
+
render(rep)
|
|
433
|
+
if a.json:
|
|
434
|
+
Path(a.json).write_text(
|
|
435
|
+
json.dumps(rep, ensure_ascii=False, indent=2, default=str),
|
|
436
|
+
encoding="utf-8")
|
|
437
|
+
print(f"✅ JSON: {a.json}")
|
|
438
|
+
|
|
439
|
+
if __name__ == "__main__":
|
|
440
|
+
main()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaultburn
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Offline SaaS spend auditor. CSV in → decision report out. VaultBurn discovers decisions; it never makes them.
|
|
5
|
+
Author-email: VaultBurn <vaultburn@proton.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/neverlose-io/vaultburn
|
|
8
|
+
Project-URL: Repository, https://github.com/neverlose-io/vaultburn
|
|
9
|
+
Project-URL: Issues, https://github.com/neverlose-io/vaultburn/issues
|
|
10
|
+
Keywords: saas,audit,csv,offline,spend,decision-support
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
# VaultBurn
|
|
17
|
+
|
|
18
|
+
Offline SaaS spend auditor. CSV in → decision report out.
|
|
19
|
+
|
|
20
|
+
**VaultBurn discovers decisions. It never makes them.**
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install vaultburn
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Use
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
vaultburn subs.csv # text report
|
|
32
|
+
vaultburn subs.csv --json r.json
|
|
33
|
+
vaultburn --init # generate template
|
|
34
|
+
vaultburn --help # full schema & philosophy
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Schema
|
|
38
|
+
|
|
39
|
+
Required: name, monthly_cost, seats, used_seats, last_used
|
|
40
|
+
|
|
41
|
+
Optional: renew_date, category, critical, reason, replacement_group, cost_model
|
|
42
|
+
|
|
43
|
+
cost_model
|
|
44
|
+
|
|
45
|
+
· per_seat (default): monthly_cost is the price per seat
|
|
46
|
+
· flat: monthly_cost is the total monthly price
|
|
47
|
+
|
|
48
|
+
Signals
|
|
49
|
+
|
|
50
|
+
· monthly_spend — Sum of all subscriptions
|
|
51
|
+
· unused_seat_cost — Idle seats × seat cost
|
|
52
|
+
· potentially_cancellable — used=0 AND stale AND not critical
|
|
53
|
+
· duplicate_candidate — From user-declared replacement groups
|
|
54
|
+
· renewals_7d — Subscriptions renewing within 7 days
|
|
55
|
+
|
|
56
|
+
Verdicts
|
|
57
|
+
|
|
58
|
+
· cancellable — used=0, stale, not critical
|
|
59
|
+
· review — needs human attention
|
|
60
|
+
· protected — critical, never suggested for cancellation
|
|
61
|
+
· ignored — user marked replacement_group=ignore
|
|
62
|
+
· ok — clean
|
|
63
|
+
|
|
64
|
+
What it does NOT do
|
|
65
|
+
|
|
66
|
+
· No cloud. No OAuth. No accounts. No telemetry.
|
|
67
|
+
· No auto-cancel. No auto-keep. No cheapest-wins.
|
|
68
|
+
· No Stripe (yet — will add only if 3 users request it).
|
|
69
|
+
|
|
70
|
+
Philosophy
|
|
71
|
+
|
|
72
|
+
VaultBurn discovers decisions. It never makes them.
|
|
73
|
+
|
|
74
|
+
License
|
|
75
|
+
|
|
76
|
+
MIT.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vaultburn
|