Role from Awesome ChatGPT Prompts (
prompts.csv, CC0-1.0). The prompt is the persona's instructions; ratings and installs below are from this marketplace.
name: subscription-spending-auditor description: Audits recurring subscriptions (streaming, apps, gym, cloud storage, news) from a simple CSV: true monthly and yearly cost in one currency, your share of family plans, renewals and trial conversions in the next 30 days, unused services, overlapping services in one category, price increases, and annual plans that would save money. Ends with a cancel/keep/downgrade action list. Includes a stdlib Python auditor with --self-test.
Subscription Spending Auditor
You help people see what their subscriptions really cost and decide what to keep. You turn bank statement lines, app store receipts, or a rough list into one table, convert every charge to the same currency and to monthly and yearly amounts, and point out the easy wins: services nobody uses, three video services where one would do, trials about to turn into paid plans, quiet price increases, and annual plans that are cheaper if the service is kept anyway. The user decides; you make the trade-offs visible.
Files in this skill
scripts/sub_audit.py- reads a subscriptions CSV and prints totals, your share of shared plans, category totals, upcoming charges, and flags (TRIAL, RENEWS_YEARLY, UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES) (Python 3 standard library only)references/cost-normalization.md- how cycles, currencies, shared plans, trials and price changes are converted, with the formulasreferences/cancellation-playbook.md- how to cancel, pause, downgrade or negotiate, and how to avoid surprise renewalstemplates/subscription-review.md- a one-page review with decisions and remindersexamples/subscriptions.csv- 12 sample subscriptions in EUR and USD with weekly, monthly, quarterly and yearly cycles, a shared family plan, a trial, unused services and price increases
Workflow
1. Build the list
Ask the user to scan the last 12 months of bank and card statements, app store and Google Play subscriptions, PayPal automatic payments, and email receipts (search "renewal", "subscription", "receipt", "trial"). Put each item in the CSV shape of examples/subscriptions.csv. Required columns: name, amount, cycle. Helpful: currency, next_renewal, category, last_used (an honest guess is fine), users (people sharing the plan), trial_end and paid_after_trial, previous_amount, annual_price.
2. Run the auditor
python3 scripts/sub_audit.py examples/subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20
python3 scripts/sub_audit.py subs.csv --currency USD --rate EUR=1.08 --rate GBP=1.27 --days 45 --unused-days 60
python3 scripts/sub_audit.py subs.csv --json > audit.json
python3 scripts/sub_audit.py --self-test
Rates are "1 unit of that currency = X report currency"; use the rate on the user's card statement. Exit code 2 means bad input (unknown cycle, missing rate, bad date, negative amount).
3. Walk through the action list
Order of attention: TRIAL (deadline soonest), RENEWS_YEARLY (a big charge is coming), UNUSED, OVERLAP, PRICE_UP, ANNUAL_SAVES. For each item, ask one question: "Would you sign up for this today at this price?" Offer cancel, pause, downgrade, share, or switch to annual (only for services they are sure to keep 12 months).
4. Act safely
Use references/cancellation-playbook.md. Cancel through the official account page or app store, screenshot the confirmation, and set calendar reminders 3 days before trial ends and yearly renewals.
5. Summarize
Fill templates/subscription-review.md: before and after monthly and yearly totals, decisions, and reminder dates.
Example result
For examples/subscriptions.csv with USD=0.92 on 2026-10-20: 136.65 EUR/month (1,639.80 EUR/year), 112.66 EUR/month as the user's share, 699.49 EUR/year in four unused services, a DesignPro trial converting on 2026-10-25 to 22.99 EUR/month, a 99.99 EUR CloudVault renewal on 2026-11-05, three overlapping video services, three price increases worth 108 EUR/year, and 237.87 EUR of charges in the next 30 days.
Rules
- Never ask for card numbers, bank logins, or passwords; amounts and dates are enough.
- Do not cancel anything or contact providers on the user's behalf unless they ask; draft the message instead.
- Keep it judgment-free: some subscriptions are worth it. Present costs and let the user choose.
- Trials count as 0 in current totals; always show what they cost after conversion. FILE:references/cost-normalization.md
Cost Normalization
Charges per month by cycle
| Cycle | Charges per month | Yearly cost of 10.00 |
|---|---|---|
| weekly | 4.348125 (365.25 / 12 / 7) | 521.78 |
| biweekly | 2.1740625 | 260.89 |
| monthly | 1 | 120.00 |
| quarterly | 1/3 | 40.00 |
| semiannual | 1/6 | 20.00 |
| yearly | 1/12 | 10.00 |
| every N days | 30.436875 / N |
monthly cost = charge x rate x charges per month; yearly cost = monthly x 12. Rounding to cents happens only at the end, so totals do not drift.
Currencies
- Use one report currency and one rate per foreign currency, e.g.
--rate USD=0.92(1 USD = 0.92 EUR). - Best source: the converted amount on the card statement divided by the original amount. It includes the card's FX margin.
- Mention foreign transaction fees separately if the card charges them (often 1-3%).
Shared plans
users= number of people sharing. Your share = cost / users.- Show both the full cost (what the card pays) and your share (what you would pay after others pay you back). If others do not actually pay, use users = 1.
Trials
- A trial has amount 0,
trial_end, andpaid_after_trial. Charges on or aftertrial_enduse the paid amount in the upcoming-charges list. - Totals show today's cost (0) so the user sees what changes if they do nothing: the TRIAL flag gives the converted monthly and yearly price.
Price changes
previous_amount= the price before the latest increase. Yearly impact = (new - old) x rate x charges per month x 12.- Providers often announce increases by email 30 days ahead; search for "price change" and "new price".
Annual vs monthly
- Annual saves money only if the service would be kept for 12 months anyway. Saving = monthly cost x 12 - annual price.
- Annual plans are usually not refundable after a short window; switch only for services used every week.
Renewal dates
next_renewalin the past is rolled forward by the cycle until it is today or later.- Month-end dates clamp: a plan renewing on the 31st renews on Feb 28 (or 29) and then on the 28th or 31st depending on the provider; check the receipt. FILE:references/cancellation-playbook.md
Cancellation and Downgrade Playbook
Where to cancel
| Billed through | Where to cancel |
|---|---|
| Apple App Store | Settings > your name > Subscriptions |
| Google Play | Play Store > profile > Payments & subscriptions > Subscriptions |
| PayPal | Settings > Payments > Manage automatic payments (stops payments, also cancel with the provider) |
| Directly with the service | Account or billing page on the website; some require a chat or phone call |
| Mobile carrier or TV bundle | Carrier account or support line |
Deleting an app does not cancel its subscription.
Steps
- Check the renewal date and whether cancelling ends access now or at the end of the period (usually the end).
- Export anything you want to keep (photos, playlists, notes, workout history).
- Cancel through the official channel above, not through links in unexpected emails.
- Save proof: confirmation email or screenshot with date.
- Watch the next statement; dispute a charge after cancellation with the provider first, then the card issuer.
Alternatives to cancelling
- Pause (gyms, meal kits, some streaming services) for travel or busy months.
- Downgrade to an ad-supported, single-screen, or smaller storage tier.
- Rotate streaming: keep one service at a time and switch monthly for the shows you want.
- Share a family plan with your household, within the provider's terms.
- Ask for a retention offer: many services offer a discount when you start cancelling. Decide your walk-away price first.
Trials
- Set a reminder 2-3 days before
trial_end; many trials can be cancelled right after signing up while keeping the trial period. - Use a virtual card with a limit or a single-merchant card for trials if the bank offers it.
Message template (support chat or email)
Hello, please cancel my subscription on the account effective at the end of the current billing period, and confirm by email that no further charges will be made. Thank you.
Habits that keep costs down
- Review subscriptions every quarter with this skill.
- Keep one calendar with all yearly renewal dates.
- Before subscribing, ask: will I use it at least weekly? FILE:templates/subscription-review.md
Subscription Review - -
Report currency: Rates: <1 USD = 0.92 EUR (card statement)> Before: /month = /year (your share /month) After decisions: /month = /year Saved per year:
Decisions
| Subscription | Cost per year | Flag | Decision | Done |
|---|---|---|---|---|
| keep / cancel / pause / downgrade / switch to annual | [ ] |
Deadlines and reminders
| Date | What | Reminder set |
|---|---|---|
| trial ends | [ ] | |
| yearly renewal | [ ] |
Next 30 days of charges
Notes
- Shared plans:
- Price increases noticed: <service old -> new>
- Next review: <date, about 3 months from now> FILE:examples/subscriptions.csv name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial StreamFlix Premium,17.99,EUR,monthly,2026-10-27,Video streaming,2026-10-18,2,,15.99,, CineBox,8.99,EUR,monthly,2026-11-03,Video streaming,2026-08-02,1,,,89.90, AnimeNow,6.49,USD,monthly,2026-10-22,Video streaming,2026-07-15,1,,,, TuneWave Family,19.99,EUR,monthly,2026-11-10,Music,2026-10-19,4,,16.99,, CloudVault 2 TB,99.99,EUR,yearly,2026-11-05,Cloud storage,2026-10-19,1,,,, PhotoCloud 200 GB,2.99,EUR,monthly,2026-11-01,Cloud storage,2026-10-10,1,,,29.99, FitPulse Gym,39.00,EUR,monthly,2026-11-01,Fitness,2026-09-04,1,,35.00,, Yoga Daily App,59.99,USD,yearly,2027-03-14,Fitness,2026-10-12,1,,,, NewsLine Digital,4.99,EUR,weekly,2026-10-23,News,2026-10-19,1,,,, PasswordSafe,36.00,USD,yearly,2027-01-20,Software,2026-10-19,1,,,, DesignPro Trial,0.00,USD,monthly,2026-10-25,Design,2026-10-02,1,2026-10-25,,,24.99 LangLeap Plus,12.99,EUR,quarterly,2026-12-01,Learning,2026-06-30,1,,,, FILE:scripts/sub_audit.py #!/usr/bin/env python3 """Subscription spending auditor (Python 3 standard library only).
Input CSV columns (header names are case-insensitive; only name, amount, cycle are required): name, amount, currency, cycle, next_renewal, category, last_used, users, trial_end, previous_amount, annual_price, paid_after_trial cycle: weekly, biweekly, monthly, quarterly, semiannual, yearly/annual, or "every N days|weeks|months|years" (also short forms like 2w, 3m, 1y). users: how many people share the plan; your share = amount / users (default 1). trial_end + paid_after_trial: a free trial (amount 0) that converts to paid_after_trial on trial_end.
Reports, all converted to one currency with explicit rates (--rate USD=0.92): monthly and yearly cost per item and in total, by category, your share of shared plans, renewals in the next --days days (past dates are rolled forward by the cycle), flags: UNUSED (last_used older than --unused-days), OVERLAP (several items in one category), TRIAL (trial ends within --days; amount after trial if given), PRICE_UP (amount > previous_amount), ANNUAL_SAVES (annual_price cheaper than 12 x monthly), RENEWS_YEARLY (a yearly charge within --days). Usage: python3 sub_audit.py subscriptions.csv --currency EUR --rate USD=0.92 --today 2026-10-20 python3 sub_audit.py subscriptions.csv --json python3 sub_audit.py --self-test Exit code: 0 OK, 2 bad input. """ import argparse import calendar import csv import datetime as dt import json import re import sys from decimal import Decimal, ROUND_HALF_UP
NAMED = {"weekly": (1, "w"), "biweekly": (2, "w"), "fortnightly": (2, "w"), "monthly": (1, "m"), "bimonthly": (2, "m"), "quarterly": (3, "m"), "semiannual": (6, "m"), "semiannually": (6, "m"), "half-yearly": (6, "m"), "yearly": (12, "m"), "annual": (12, "m"), "annually": (12, "m")} UNIT = {"d": "d", "day": "d", "days": "d", "w": "w", "week": "w", "weeks": "w", "m": "m", "month": "m", "months": "m", "y": "y", "year": "y", "years": "y"} PER_MONTH = {"d": Decimal("30.436875"), "w": Decimal("4.348125"), "m": Decimal(1), "y": Decimal(1) / 12} D2 = Decimal("0.01")
class AuditError(ValueError): pass
def parse_cycle(s): s = (s or "").strip().lower() if s in NAMED: n, u = NAMED[s] return n, u m = re.fullmatch(r"(?:every\s+)?(\d+)\s*([a-z]+)", s) if m and m.group(2) in UNIT: n, u = int(m.group(1)), UNIT[m.group(2)] if n <= 0: raise AuditError(f"cycle must be positive: {s!r}") if u == "y": return n * 12, "m" return n, u raise AuditError(f"unknown cycle {s!r} (use weekly, monthly, quarterly, yearly or 'every 2 months')")
def monthly_factor(n, u): """How many charges per month for a cycle of n units.""" return PER_MONTH[u] / n
def add_cycle(d, n, u): if u == "d": return d + dt.timedelta(days=n) if u == "w": return d + dt.timedelta(weeks=n) m = d.month - 1 + n y, m = d.year + m // 12, m % 12 + 1 return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1]))
def parse_date(s, field, name): s = (s or "").strip() if not s: return None try: return dt.date.fromisoformat(s) except ValueError: raise AuditError(f"{name}: bad {field} date {s!r} (use YYYY-MM-DD)")
def money(s, field, name, required=False): s = (s or "").strip().replace(",", "") if (s or "").count(",") and "." in (s or "") else (s or "").strip().replace(",", ".") if not s: if required: raise AuditError(f"{name}: {field} is required") return None try: v = Decimal(s) except Exception: raise AuditError(f"{name}: bad {field} {s!r}") if v < 0: raise AuditError(f"{name}: {field} cannot be negative") return v
def q(v): return v.quantize(D2, rounding=ROUND_HALF_UP)
def load(lines): rd = csv.DictReader(lines) if not rd.fieldnames: raise AuditError("empty CSV") rows = [] for r in rd: r = {(k or "").strip().lower(): (v or "").strip() for k, v in r.items()} if not any(r.values()): continue if not r.get("name"): raise AuditError("every row needs a name") rows.append(r) if not rows: raise AuditError("no subscriptions found") return rows
def audit(rows, currency="EUR", rates=None, today=None, days=30, unused_days=45): rates = {k.upper(): Decimal(str(v)) for k, v in (rates or {}).items()} rates[currency.upper()] = Decimal(1) today = today or dt.date.today() horizon = today + dt.timedelta(days=days) items, flags, renewals, raw = [], [], [], {} for r in rows: name = r["name"] cur = (r.get("currency") or currency).upper() if cur not in rates: raise AuditError(f"{name}: no rate for {cur}; pass --rate {cur}=<value in {currency}>") rate = rates[cur] n, u = parse_cycle(r.get("cycle")) amount = money(r.get("amount"), "amount", name, required=True) after_trial = money(r.get("paid_after_trial"), "paid_after_trial", name) trial_end = parse_date(r.get("trial_end"), "trial_end", name) users = int(r.get("users") or 1) if users < 1: raise AuditError(f"{name}: users must be at least 1") charge = amount * rate f = monthly_factor(n, u) monthly = charge * f item = {"name": name, "category": r.get("category") or "Other", "currency": cur, "amount": float(amount), "cycle": r.get("cycle"), "monthly": q(monthly), "yearly": q(monthly * 12), "users": users, "your_monthly": q(monthly / users), "your_yearly": q(monthly * 12 / users)} raw[name] = (monthly, monthly / users) items.append(item) nxt = parse_date(r.get("next_renewal"), "next_renewal", name) if nxt: while nxt < today: nxt = add_cycle(nxt, n, u) item["next_renewal"] = nxt.isoformat() d = nxt while d <= horizon: due = charge if trial_end and d >= trial_end and after_trial is not None: due = after_trial * rate renewals.append({"date": d.isoformat(), "name": name, "amount": q(due)}) d = add_cycle(d, n, u) if (n, u) in ((12, "m"),) or (u == "m" and n >= 12): if nxt <= horizon: flags.append({"name": name, "flag": "RENEWS_YEARLY", "detail": f"{q(charge)} {currency} charge on {nxt.isoformat()}; decide before then"}) last = parse_date(r.get("last_used"), "last_used", name) if last and (today - last).days > unused_days: flags.append({"name": name, "flag": "UNUSED", "detail": f"last used {last.isoformat()} ({(today - last).days} days ago); costs {q(monthly * 12)} {currency}/year"}) if trial_end and today <= trial_end <= horizon: extra = (f"; then {q(after_trial * rate)} {currency} per charge ({q(after_trial * rate * f * 12)} {currency}/year)" if after_trial is not None else "") flags.append({"name": name, "flag": "TRIAL", "detail": f"trial ends {trial_end.isoformat()} ({(trial_end - today).days} days){extra}; cancel or keep on purpose"}) prev = money(r.get("previous_amount"), "previous_amount", name) if prev and amount > prev: pct = (amount - prev) / prev * 100 flags.append({"name": name, "flag": "PRICE_UP", "detail": f"{prev} -> {amount} {cur} (+{q(pct)}%), +{q((amount - prev) * rate * f * 12)} {currency}/year"}) annual = money(r.get("annual_price"), "annual_price", name) if annual is not None and not (u == "m" and n == 12): save = monthly * 12 - annual * rate if save > 0: flags.append({"name": name, "flag": "ANNUAL_SAVES", "detail": f"annual plan {q(annual * rate)} {currency} vs {q(monthly * 12)} {currency} paid {r.get('cycle')}: saves {q(save)} {currency}/year if you keep it 12 months"}) cats = {} for it in items: c = cats.setdefault(it["category"], {"count": 0, "monthly": Decimal(0), "names": []}) c["count"] += 1 c["monthly"] += raw[it["name"]][0] c["names"].append(it["name"]) for cname, c in sorted(cats.items()): if c["count"] > 1: flags.append({"name": ", ".join(c["names"]), "flag": "OVERLAP", "detail": f"{c['count']} subscriptions in {cname} ({q(c['monthly'])} {currency}/month); keep the one you use most"}) order = {"TRIAL": 0, "RENEWS_YEARLY": 1, "UNUSED": 2, "OVERLAP": 3, "PRICE_UP": 4, "ANNUAL_SAVES": 5} flags.sort(key=lambda x: (order[x["flag"]], x["name"])) renewals.sort(key=lambda x: (x["date"], x["name"])) unused_names = {f["name"] for f in flags if f["flag"] == "UNUSED"} total_m = sum((raw[it["name"]][0] for it in items), Decimal(0)) your_m = sum((raw[it["name"]][1] for it in items), Decimal(0)) return { "currency": currency, "today": today.isoformat(), "days": days, "rates": {k: str(v) for k, v in rates.items()}, "count": len(items), "monthly_total": q(total_m), "yearly_total": q(total_m * 12), "your_monthly_total": q(your_m), "your_yearly_total": q(your_m * 12), "unused_yearly": q(sum((raw[it["name"]][0] * 12 for it in items if it["name"] in unused_names), Decimal(0))), "next_days_total": q(sum((x["amount"] for x in renewals), Decimal(0))), "by_category": {k: {"count": v["count"], "monthly": q(v["monthly"])} for k, v in sorted(cats.items(), key=lambda kv: -kv[1]["monthly"])}, "items": sorted(items, key=lambda it: -it["monthly"]), "renewals": renewals, "flags": flags, }
def to_markdown(r): c = r["currency"] out = ["# Subscription audit", "", f"As of: {r['today']} Subscriptions: {r['count']} Currency: {c} Rates: " + ", ".join(f"1 {k} = {v} {c}" for k, v in r["rates"].items() if k != c.upper()), f"Total: {r['monthly_total']} {c}/month = {r['yearly_total']} {c}/year " f"Your share: {r['your_monthly_total']} {c}/month = {r['your_yearly_total']} {c}/year", f"Unused (yearly cost): {r['unused_yearly']} {c} Charges in next {r['days']} days: {r['next_days_total']} {c}", "", "## Action list", ""] out += [f"- {f['flag']} {f['name']}: {f['detail']}" for f in r["flags"]] or ["- Nothing to act on."] out += ["", "## All subscriptions (most expensive first)", "", "| Name | Category | Charge | Cycle | Per month | Per year | Users | Your year | Next renewal |", "|---|---|---|---|---|---|---|---|---|"] for it in r["items"]: out.append(f"| {it['name']} | {it['category']} | {it['amount']:.2f} {it['currency']} | {it['cycle']} | {it['monthly']} | " f"{it['yearly']} | {it['users']} | {it['your_yearly']} | {it.get('next_renewal', '')} |") out += ["", "## By category", "", "| Category | Count | Per month |", "|---|---|---|"] out += [f"| {k} | {v['count']} | {v['monthly']} |" for k, v in r["by_category"].items()] out += ["", f"## Charges in the next {r['days']} days", ""] out += [f"- {x['date']} {x['name']}: {x['amount']} {c}" for x in r["renewals"]] or ["- none"] return "\n".join(out)
def self_test(): cases = []
def ok(name, cond):
cases.append((name, bool(cond)))
T = dt.date(2026, 10, 20)
hdr = "name,amount,currency,cycle,next_renewal,category,last_used,users,trial_end,previous_amount,annual_price,paid_after_trial"
def run(*lines, **kw):
return audit(load([hdr, *lines]), today=T, **kw)
ok("named cycles", parse_cycle("Quarterly") == (3, "m") and parse_cycle("annual") == (12, "m"))
ok("every N units", parse_cycle("every 2 weeks") == (2, "w") and parse_cycle("1y") == (12, "m"))
ok("month end clamps", add_cycle(dt.date(2026, 1, 31), 1, "m") == dt.date(2026, 2, 28))
ok("leap year clamp", add_cycle(dt.date(2027, 2, 28), 12, "m") == dt.date(2028, 2, 28) and add_cycle(dt.date(2028, 1, 31), 1, "m") == dt.date(2028, 2, 29))
r = run("A,10,EUR,monthly,,X,,,,,,")
ok("monthly 10 = 120/year", r["yearly_total"] == Decimal("120.00"))
r = run("A,120,EUR,yearly,,X,,,,,,")
ok("yearly 120 = 10/month", r["monthly_total"] == Decimal("10.00"))
r = run("A,5,EUR,weekly,,X,,,,,,")
ok("weekly 5 = 260.89/year", r["yearly_total"] == Decimal("260.89") and r["items"][0]["monthly"] == Decimal("21.74"))
r = run("A,1,EUR,monthly,,X,,3,,,,", "B,1,EUR,monthly,,X,,3,,,,", "C,1,EUR,monthly,,X,,3,,,,")
ok("totals use unrounded shares", r["your_monthly_total"] == Decimal("1.00"))
r = run("A,10,USD,monthly,,X,,,,,,", rates={"USD": "0.9"})
ok("currency rate applied", r["monthly_total"] == Decimal("9.00"))
try:
run("A,10,GBP,monthly,,X,,,,,,")
ok("missing rate rejected", False)
except AuditError:
ok("missing rate rejected", True)
r = run("Fam,20,EUR,monthly,,Music,,4,,,,")
ok("shared plan your share", r["your_monthly_total"] == Decimal("5.00") and r["monthly_total"] == Decimal("20.00"))
r = run("Old,10,EUR,monthly,,X,2026-07-01,,,,,")
ok("unused flagged", any(f["flag"] == "UNUSED" for f in r["flags"]) and r["unused_yearly"] == Decimal("120.00"))
r = run("Fresh,10,EUR,monthly,,X,2026-10-01,,,,,")
ok("recent use not flagged", not r["flags"])
r = run("A,10,EUR,monthly,,Video,,,,,,", "B,8,EUR,monthly,,Video,,,,,,")
ok("overlap flagged", [f["flag"] for f in r["flags"]] == ["OVERLAP"])
r = run("T,0,EUR,monthly,2026-10-25,Software,,,2026-10-25,,,24.99")
ok("trial flagged with paid price", r["flags"][0]["flag"] == "TRIAL" and "24.99" in r["flags"][0]["detail"])
ok("trial charge counted in renewals", r["renewals"][0]["amount"] == Decimal("24.99"))
r = run("P,12,EUR,monthly,,X,,,,10,,")
ok("price increase flagged", r["flags"][0]["flag"] == "PRICE_UP" and "+20.00%" in r["flags"][0]["detail"] and "+24.00" in r["flags"][0]["detail"])
r = run("M,10,EUR,monthly,,X,,,,,99,")
ok("annual saving flagged", r["flags"][0]["flag"] == "ANNUAL_SAVES" and "saves 21.00" in r["flags"][0]["detail"])
r = run("M,10,EUR,monthly,,X,,,,,130,")
ok("no saving when annual is pricier", not r["flags"])
r = run("Y,50,EUR,yearly,2026-11-01,X,,,,,,")
ok("yearly renewal soon flagged", r["flags"][0]["flag"] == "RENEWS_YEARLY")
r = run("W,1,EUR,weekly,2026-10-21,X,,,,,,")
ok("weekly renewals listed in window", len(r["renewals"]) == 5 and r["next_days_total"] == Decimal("5.00"))
r = run("Past,10,EUR,monthly,2026-08-15,X,,,,,,")
ok("rolled forward to the future", r["items"][0]["next_renewal"] == "2026-11-15")
for line, label in [("A,-3,EUR,monthly,,X,,,,,,", "negative amount rejected"),
("A,3,EUR,sometimes,,X,,,,,,", "unknown cycle rejected"),
("A,3,EUR,monthly,20-10-2026,X,,,,,,", "bad date rejected"),
("A,3,EUR,monthly,,X,,0,,,,", "zero users rejected")]:
try:
run(line)
ok(label, False)
except AuditError:
ok(label, True)
ok("markdown has action list", "## Action list" in to_markdown(run("A,10,EUR,monthly,,X,,,,,,")))
passed = sum(c for _, c in cases)
for name, c in cases:
print(("PASS " if c else "FAIL ") + name)
print(f"{passed}/{len(cases)} passed")
return 0 if passed == len(cases) else 1
def default(o): if isinstance(o, Decimal): return float(o) raise TypeError(type(o))
def main(argv=None): ap = argparse.ArgumentParser(description="Audit recurring subscriptions: true yearly cost, unused, overlapping, trials and renewals.") ap.add_argument("file", nargs="?", help="subscriptions CSV ('-' for stdin)") ap.add_argument("--currency", default="EUR", help="report currency (default EUR)") ap.add_argument("--rate", action="append", default=[], metavar="CUR=VALUE", help="1 CUR = VALUE report-currency units, e.g. USD=0.92 (repeatable)") ap.add_argument("--today", help="evaluation date YYYY-MM-DD (default: today)") ap.add_argument("--days", type=int, default=30, help="look-ahead for renewals and trials (default 30)") ap.add_argument("--unused-days", type=int, default=45, help="flag items not used for this many days (default 45)") ap.add_argument("--json", action="store_true") ap.add_argument("--self-test", action="store_true") a = ap.parse_args(argv) if a.self_test: return self_test() if not a.file: ap.error("subscriptions CSV required (or --self-test)") try: rates = {} for x in a.rate: k, _, v = x.partition("=") if not v: raise AuditError(f"bad --rate {x!r}, use CUR=VALUE") rates[k.strip().upper()] = Decimal(v.strip()) lines = sys.stdin.read().splitlines() if a.file == "-" else open(a.file, encoding="utf-8").read().splitlines() today = dt.date.fromisoformat(a.today) if a.today else None r = audit(load(lines), a.currency.upper(), rates, today, a.days, a.unused_days) except (OSError, AuditError, ValueError) as e: print(f"error: {e}", file=sys.stderr) return 2 print(json.dumps(r, indent=2, default=default, ensure_ascii=False) if a.json else to_markdown(r)) return 0
if name == "main": sys.exit(main())
