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.
@@ -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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ vaultburn/__init__.py
5
+ vaultburn.egg-info/PKG-INFO
6
+ vaultburn.egg-info/SOURCES.txt
7
+ vaultburn.egg-info/dependency_links.txt
8
+ vaultburn.egg-info/entry_points.txt
9
+ vaultburn.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ vaultburn = vaultburn:main
@@ -0,0 +1 @@
1
+ vaultburn