SaaS subscriptions in card statements: a script that finds them
Grouping a card export by description found 11 of 15 subscriptions we planted in sample data. Our script found all 15; annual plans need 13 months.
The short read
Grouped by exact description, a made-up card export showed only 11 of the 15 subscriptions we planted in it.
- Our standard-library Python script found all 15 in 25 months of sample data, with two false alarms it marked for checking.
- With only the last 12 months it missed both annual plans, because a yearly charge appears once.
What you can do with this
Download at least 13 months of card transactions as CSV, run the script, import its calendar file, and decide on each annual plan 30 days before it renews.
In this article
- Statement names change between charges by design
- Visa’s rules ask for no reminder before a plain renewal
- Our test: 25 months of made-up card data
- Exact names missed four; one year of data missed both annual plans
- Step 1: download the card transactions as CSV
- Step 2: run the script
- The script
- Step 3: read the output, starting with the notes
- Step 4: put the renewals on a calendar
- Step 5: decide, then cancel at the vendor
- Without Python: a pivot table in a spreadsheet
- When a card export is the wrong place to look
- How we made this
A software subscription on a company card keeps charging until someone cancels it. If nobody remembers signing up, the card statement is where it surfaces. Finding them by eye is harder than it sounds. The merchant name changes between charges, two plans share one name, and an annual renewal appears once a year among hundreds of coffees.
We made a sample card export with 15 planted subscriptions and tried two ways of finding them. Sorting by exact description found 11. The script in this guide found all 15, with two false alarms it marked for checking.
It reads a CSV export, needs no extra packages, and writes a calendar of upcoming renewals. Vendor and card network documents were read on October 26, 2026.
Statement names change between charges by design
The text on a card statement is the merchant’s statement descriptor, and it is short. Stripe’s documentation sets a complete descriptor at 5 to 22 characters.
A merchant can add a “dynamic suffix” to each card charge, joined to a fixed prefix with an asterisk and a space. Stripe’s own example is RUNCLUB* OCT MARATHON, so one subscription can read differently every month. Stripe adds that “some [banks] might display it incorrectly or not at all.”
Google publishes its own list of descriptors on bank statements: more than 30 variants, from GOOGLE *Google Storage to GOOGLE WORKSPACE {first_7_letters_of_domain_name}. It warns that “some of these descriptors may appear shortened.”
Card network rules add one more change. Under Visa’s trial subscription rules, in force since April 18, 2020, the first charge after a free trial must carry an extra descriptor such as “trial” or “free trial”. So the first paid charge of a plan can read differently from every later one.
A script that groups charges by exact text misses all of these. Ours strips digits and month names before grouping, along with words that change between charges, such as TRIAL or INV.
Visa’s rules ask for no reminder before a plain renewal
Visa’s 2020 rules require a reminder “at least seven (7) days before initiating a recurring transaction” in two cases. One is a trial or introductory offer about to end; the other is a changed agreement, “for example, the price or billing period”. A renewal at the same price is not on that list. Visa says it “recommends” that all subscription merchants follow the principles.
At Stripe, a payment processor, renewal reminders are a setting the seller turns on. Its customer email page says to “turn on Send emails about upcoming renewals”. Stripe sends them only for subscriptions charged automatically, at a lead time the seller configures. Whether you hear before an annual plan renews depends on the vendor.
A new card number may not stop the charges either. Stripe’s card documentation says it “automatically attempts to update saved card details whenever a customer receives a new card”, including a card “reported lost or stolen”. It calls the feature “widely supported in the United States”, covering most American Express, Visa, Mastercard and Discover cards issued there, while “international support varies”. Stop a subscription at the vendor, not at the bank.
Our test: 25 months of made-up card data
We wrote a Python script that generated a card export for October 1, 2024 to October 25, 2026. It splits the data into two files that overlap by one month, the way two yearly downloads would. Every merchant name and amount is invented. It holds 976 transactions: coffee, groceries, fuel, office supplies, a team lunch on an irregular day each month, card payments and a refund.
Planted among them are 15 software subscriptions, each built to test one trap:
| Cadence | The trap |
|---|---|
| Monthly | none: a fixed price under a fixed name, the control |
| Monthly | the month and an invoice number in the descriptor (DRAWBOX* OCT INV4124) |
| Monthly | a price rise from $15 to $18 |
| Monthly | billed in euros, converted at a different rate each month |
| Monthly | billed per seat, growing from 3 to 5 seats |
| Monthly | billed on the 31st, so short months move it |
| Monthly | one failed payment, retried 4 days late |
| Monthly | canceled in February 2026 |
| Monthly | started in August 2026, so only three charges exist |
| Monthly, twice | two plans from one merchant under one descriptor, at $2.99 and $9.99 |
| Quarterly | none |
| Weekly | none |
| Annual | only two charges in 25 months |
| Annual | began as a free trial: the first charge reads VAULTLY TRIAL, the second VAULTLY ANNUAL |
Two ordinary bills also recur, an electricity bill that varies from month to month and a phone plan. They are not software, but a reader would want to see them, so we counted them separately.
| Method | Subscriptions found, of 15 | Other recurring bills, of 2 | False alarms |
|---|---|---|---|
| Group by exact description, 25 months | 11 | 2 | 0 |
| The script below, 25 months | 15 | 2 | 2, both marked “check” |
| The script below, last 12 months only | 13 | 2 | 1, marked “check” |
Exact names missed four; one year of data missed both annual plans
Grouping by exact description lost four: the descriptor that changed every month, the plan that started as a trial, and both plans under the shared name. The script’s cadence (weekly, monthly, quarterly, annual) and its active or ended status matched our answer key for all 15.
The two false alarms both came from one fuel station with 23 purchases. Two of them fell a year apart at similar amounts, and two others about three months apart. The script picked them out by amount, which is how it separates two plans under one name, and that step is where coincidences get in. It marks every series found that way with “check”.
The 12-month row is the lesson. An annual plan appears once in a year of data, and one charge is not a pattern, so both annual plans were missed. Export at least 13 months. Two years is safer: any 24-month window holds two charges of an annual plan, while 13 months leaves little room for a renewal charged a few days late.
Step 1: download the card transactions as CSV
Use the CSV or spreadsheet download in your card or bank portal, not the PDF statement. If you only have PDFs, our guide to bank statement PDFs turns them into rows first.
Download at least 13 months. If the portal offers a year at a time, download two years and pass both files. Rows that appear in both, such as the overlapping month, are counted once. Two identical charges on the same day in one file are both kept, because two seats bought at once is real.
The script finds the date, description and amount columns by their header names: Transaction Date, Date, Description, Merchant, Payee, Amount, or separate Debit and Credit columns. If your file uses other names, pass them with --date-col, --desc-col and --amount-col.
Step 2: run the script
It needs Python and nothing else; every module it uses ships with Python. We ran it on Python 3.11. Save it as find_subscriptions.py and run:
python3 find_subscriptions.py export-2025.csv export-2026.csv --out subscriptions.csv --ics renewals.ics
On Windows, the command is usually py or python instead of python3. Three options cover most exports from outside the US:
--date-format %d/%m/%Ywhen the dates are day first. The script refuses to guess when every date in the file fits both orders, as01/02/2026does.--decimal-commafor amounts like1.234,56. Without it, the script stops at the first such amount rather than reading it as 1.23.--charges positiveif purchases are positive numbers in your file and payments negative. By default the script assumes purchases carry the sign most rows have, and prints which one it used.
The script
"""Find recurring charges (subscriptions) in card or bank CSV exports.
Usage:
python find_subscriptions.py export-2025.csv export-2026.csv --out subscriptions.csv --ics renewals.ics
Reads one or more CSV files with a date, a description and an amount (or debit and
credit) column. Writes one row per recurring series it finds, and optionally a
calendar file with the next expected charge of each active one.
"""
import argparse
import calendar
import csv
import re
import sys
from collections import Counter, defaultdict
from datetime import date, datetime, timedelta, timezone
from statistics import median
DATE_FORMATS = ["%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%d.%m.%Y", "%m/%d/%y", "%d/%m/%y"]
DATE_COLS = ["transaction date", "trans. date", "date", "posted date", "post date", "posting date"]
DESC_COLS = ["description", "merchant", "payee", "name", "details", "narrative"]
# name, months per charge (0 = weekly), allowed gap in days, charges needed, charges per year
CADENCES = [("weekly", 0, (6, 8), 4, 52), ("monthly", 1, (26, 35), 3, 12),
("quarterly", 3, (84, 98), 2, 4), ("semiannual", 6, (170, 195), 2, 2),
("annual", 12, (350, 380), 2, 1)]
MONTHS = "JAN FEB MAR APR MAY JUN JUL AUG SEP SEPT OCT NOV DEC".split()
# words that change between charges of one subscription; removed before grouping
NOISE = set(MONTHS) | {"TRIAL", "FREE", "PERIOD", "END", "ANNUAL", "YEARLY", "MONTHLY",
"RENEWAL", "RENEW", "INV", "INVOICE", "REF", "RECURRING"}
def pick(header, wanted, override):
low = [h.strip().lower() for h in header]
if override:
if override.lower() not in low:
sys.exit(f"Column {override!r} not found; columns are {header}")
return low.index(override.lower())
for name in wanted:
if name in low:
return low.index(name)
return None
def parse_amount(text, decimal_comma):
t = text.strip().replace("\u00a0", "").replace(" ", "")
if not decimal_comma and re.search(r"\d,\d{2}\D*$", t):
sys.exit(f"Amount {text!r} looks like 1.234,56; run again with --decimal-comma")
neg = t.startswith("(") and t.endswith(")") or t.startswith("-") or t.endswith("-")
t = re.sub(r"[^0-9.,]", "", t)
if decimal_comma:
t = t.replace(".", "").replace(",", ".")
else:
t = t.replace(",", "")
if not t:
return None
return -float(t) if neg else float(t)
def read_files(paths, args):
"""Return [(date_text, description, amount)], counting a row seen in two overlapping files once."""
per_file = []
for path in paths:
counts = Counter()
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.reader(f)
header = next(reader, None)
if not header:
sys.exit(f"{path} is empty")
di = pick(header, DATE_COLS, args.date_col)
si = pick(header, DESC_COLS, args.desc_col)
ai = pick(header, ["amount"], args.amount_col)
dbi = pick(header, ["debit", "withdrawal", "withdrawals", "charge"], None)
cri = pick(header, ["credit", "deposit", "deposits", "payment"], None)
if di is None or si is None or (ai is None and dbi is None):
sys.exit(f"{path}: cannot find date, description and amount columns in {header}; "
"name them with --date-col, --desc-col and --amount-col")
for n, row in enumerate(reader, start=2):
if not any(c.strip() for c in row):
continue
try:
if ai is not None:
amt = parse_amount(row[ai], args.decimal_comma)
else: # separate columns: a debit is money out, written here as negative
out = parse_amount(row[dbi], args.decimal_comma) if row[dbi].strip() else None
inn = parse_amount(row[cri], args.decimal_comma) if cri is not None and row[cri].strip() else None
amt = -abs(out) if out else abs(inn) if inn else None
if amt is None:
raise ValueError("no amount")
counts[(row[di].strip(), row[si].strip(), amt)] += 1
except (IndexError, ValueError) as err:
print(f"Skipped {path} line {n}: {err}", file=sys.stderr)
per_file.append(counts)
best = Counter()
for counts in per_file: # identical rows in overlapping exports: keep the larger count, not the sum
for k, c in counts.items():
best[k] = max(best[k], c)
return [k for k, c in best.items() for _ in range(c)]
def parse_dates(texts, fmt):
"""Pick the date format that reads the most rows; refuse to guess between day and month."""
texts = set(texts)
fits = []
for f in [fmt] if fmt else DATE_FORMATS:
parsed = {}
for t in texts:
try:
parsed[t] = datetime.strptime(t, f).date()
except ValueError:
pass
if parsed:
fits.append((f, parsed))
if not fits:
sys.exit("Dates are in a format this script does not know; pass --date-format, e.g. %d/%m/%Y")
most = max(len(p) for _, p in fits)
fits = [(f, p) for f, p in fits if len(p) == most]
if len(fits) > 1 and len({tuple(sorted(p.items())) for _, p in fits}) > 1:
sys.exit(f"Dates fit more than one format ({', '.join(f for f, _ in fits)}); pass --date-format")
for t in sorted(texts - set(fits[0][1])):
print(f"Skipped rows dated {t!r}: not a date in {fits[0][0]}", file=sys.stderr)
return fits[0][1]
def merchant_key(desc):
words = re.sub(r"[^A-Z*. ]", " ", desc.upper()).replace("*", " * ").split()
words = [w for w in words if w not in NOISE and w.strip(".")]
return " ".join(words).replace(" * ", "* ").strip(" *.") or desc.upper()
def add_months(d, n, day):
y, m = divmod(d.month - 1 + n, 12)
return date(d.year + y, m + 1, min(day, calendar.monthrange(d.year + y, m + 1)[1]))
def cadence_of(charges):
"""charges: [(date, amount, desc)] sorted by date. Returns a CADENCES row or None."""
gaps = [(b[0] - a[0]).days for a, b in zip(charges, charges[1:])]
if not gaps:
return None
mid = median(gaps)
for c in CADENCES:
lo, hi = c[2]
if lo <= mid <= hi and len(charges) >= c[3]:
if sum(lo <= g <= hi for g in gaps) / len(gaps) >= 0.75:
return c
return None
def split_by_amount(charges, tol):
"""Cluster charges whose amounts are within tol of the cluster's smallest amount."""
clusters = []
for ch in sorted(charges, key=lambda c: c[1]):
if clusters and ch[1] <= clusters[-1][0][1] * (1 + tol):
clusters[-1].append(ch)
else:
clusters.append([ch])
return [sorted(c) for c in clusters]
def fold(line):
out, cur = [], ""
for ch in line:
if len((cur + ch).encode()) > 75:
out.append(cur)
cur = " "
cur += ch
return "\r\n".join(out + [cur])
def main():
ap = argparse.ArgumentParser()
ap.add_argument("files", nargs="+")
ap.add_argument("--out", default="subscriptions.csv")
ap.add_argument("--ics", help="also write the next expected charges as a calendar file")
ap.add_argument("--date-col")
ap.add_argument("--desc-col")
ap.add_argument("--amount-col")
ap.add_argument("--date-format", help="e.g. %%d/%%m/%%Y; tried automatically if left out")
ap.add_argument("--decimal-comma", action="store_true", help="amounts like 1.234,56")
ap.add_argument("--charges", choices=["negative", "positive"],
help="sign of a purchase in your file (default: the sign most rows have)")
ap.add_argument("--tolerance", type=float, default=0.10, help="amount spread within one plan")
args = ap.parse_args()
rows = read_files(args.files, args)
if not rows:
sys.exit("No transactions found.")
dates = parse_dates([r[0] for r in rows], args.date_format)
rows = [r for r in rows if r[0] in dates]
sign = args.charges or ("negative" if sum(r[2] < 0 for r in rows) * 2 >= len(rows) else "positive")
spend = [(dates[d], abs(a), s) for d, s, a in rows if (a < 0) == (sign == "negative") and a != 0]
last_day = max(dates.values())
print(f"{len(rows)} rows, {len(spend)} charges ({sign} amounts), "
f"{min(dates.values())} to {last_day}")
groups = defaultdict(list)
for ch in spend:
groups[merchant_key(ch[2])].append(ch)
found = []
for name, charges in groups.items():
charges.sort()
c = cadence_of(charges)
series = [(charges, c)] if c else [(s, cadence_of(s)) for s in split_by_amount(charges, args.tolerance)]
for s, c in series:
mixed = len(charges) - len(s)
if not c:
continue
cname, months, (lo, hi), _, per_year = c
first, last = s[0], s[-1]
# the usual day of the month, so a plan billed on the 31st is not moved to the 30th for good
day = max(Counter(d.day for d, _, _ in s).most_common(), key=lambda x: (x[1], x[0]))[0]
nxt = last[0] + timedelta(days=7) if months == 0 else add_months(last[0], months, day)
ended = (last_day - last[0]).days > hi + (7 if months == 0 else 10)
amounts = [a for _, a, _ in s]
notes = []
if abs(last[1] - first[1]) > 0.01 * first[1]:
notes.append(f"was {first[1]:.2f} at first")
if max(amounts) > 1.15 * min(amounts):
notes.append(f"ranged {min(amounts):.2f} to {max(amounts):.2f}")
if any("TRIAL" in d.upper() for _, _, d in s):
notes.append("started as a trial")
if mixed:
notes.append(f"picked by amount from {len(charges)} charges here; check")
found.append({
"merchant": name, "last_description": last[2], "cadence": cname,
"charges": len(s), "first_charge": first[0].isoformat(), "last_charge": last[0].isoformat(),
"last_amount": f"{last[1]:.2f}", "per_year": f"{last[1] * per_year:.2f}",
"status": "ended?" if ended else "active",
"next_expected": "" if ended else nxt.isoformat(), "notes": "; ".join(notes)})
if not found:
sys.exit("No recurring charges found. Annual plans need two charges, so export at least 13 months.")
found.sort(key=lambda r: (r["status"] != "active", -float(r["per_year"])))
with open(args.out, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=list(found[0]))
w.writeheader()
w.writerows(found)
active = [r for r in found if r["status"] == "active"]
print(f"{len(found)} recurring series written to {args.out}; {len(active)} active, "
f"{sum(float(r['per_year']) for r in active):,.2f} a year at their last amounts")
for r in found:
print(f" {r['status']:<7} {r['cadence']:<10} {r['per_year']:>9} {r['merchant']:<22} {r['notes']}")
if args.ics:
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//find_subscriptions//EN"]
for r in active:
d = date.fromisoformat(r["next_expected"])
warn = "-P30D" if r["cadence"] in ("annual", "semiannual") else "-P3D"
summary = re.sub(r"([\\;,])", r"\\\1", f"Renews: {r['merchant']} {r['last_amount']} ({r['cadence']})")
uid = re.sub(r"[^a-z0-9]+", "-", f"{r['merchant']} {r['last_amount']} {d}".lower())
lines += ["BEGIN:VEVENT", f"UID:{uid}@find-subscriptions", f"DTSTAMP:{stamp}",
f"DTSTART;VALUE=DATE:{d:%Y%m%d}", f"DTEND;VALUE=DATE:{d + timedelta(days=1):%Y%m%d}",
f"SUMMARY:{summary}", "BEGIN:VALARM", "ACTION:DISPLAY",
"DESCRIPTION:Keep or cancel?", f"TRIGGER:{warn}", "END:VALARM", "END:VEVENT"]
lines.append("END:VCALENDAR")
with open(args.ics, "w", newline="") as f:
# RFC 5545: lines longer than 75 octets continue on the next line after a space
f.write("".join(fold(line) + "\r\n" for line in lines))
print(f"{len(active)} expected charges written to {args.ics}")
if __name__ == "__main__":
main()
A few choices in it are deliberate. A series counts as monthly when the median gap between charges is 26 to 35 days and three quarters of the gaps fall in that range. So one late retry does not break it. Monthly needs three charges and annual needs two, because two charges a month apart happen by chance at any shop.
When a merchant’s charges as a whole are not regular, the script splits them into groups whose amounts lie within 10% of each other and tests each group. That is how it told the $2.99 and $9.99 plans apart, and also how the fuel station slipped in. Change the 10% with --tolerance. The output file starts with a byte-order mark: as our guide to CSV imports explains, Excel recognizes UTF-8 automatically only when the file has one.
Step 3: read the output, starting with the notes
On our sample, the script printed this. The amounts are what one year costs at the latest charge:
976 rows, 951 charges (negative amounts), 2024-10-01 to 2026-10-25
19 recurring series written to subscriptions.csv; 17 active, 7,009.71 a year at their last amounts
active monthly 2157.00 CITY POWER UTIL was 168.33 at first; ranged 81.72 to 204.00
active monthly 780.00 MOBILEONE WIRELESS
active monthly 600.00 CHATTERLY SEATS was 30.00 at first; ranged 30.00 to 50.00
active annual 528.00 VAULTLY was 480.00 at first; started as a trial
active weekly 519.48 NEWSWIRE WEEKLY
active quarterly 360.00 SECURESCAN QTR
active monthly 348.00 DRAWBOX
active annual 299.00 DOMAINDOCK
active monthly 288.00 PIXELPOST SCHED
active monthly 241.92 FORMKIT EU DUBLIN was 19.95 at first
active monthly 240.00 MEETNOTE AI
active monthly 216.00 SHEETSYNC.IO was 15.00 at first; ranged 15.00 to 18.00
active monthly 144.00 TASKLY
active monthly 119.88 CLOUDNEST STORAGE picked by amount from 45 charges here; check
active monthly 96.00 BACKUPHIVE
active annual 36.55 QUICKFUEL was 34.91 at first; picked by amount from 23 charges here; check
active monthly 35.88 CLOUDNEST STORAGE picked by amount from 45 charges here; check
ended? monthly 588.00 LEADMAGNET PRO
ended? quarterly 125.00 QUICKFUEL was 32.86 at first; picked by amount from 23 charges here; check
17 expected charges written to renewals.ics
Each note asks a question. “was … at first” marks an amount that moved, from a price rise, added seats or the exchange rate. That is worth knowing before a renewal, because the annual figure uses the latest amount. “started as a trial” is a plan that converted from a free trial, which is how many forgotten subscriptions begin.
“check” means the series was picked out of a busy merchant by amount. Here it was right twice, for the two storage plans, and wrong twice, for the fuel station. Open the charges for those lines before acting on them.
“ended?” means the last charge is older than one cycle plus a margin, counted from the last date in your export, not from today. LEADMAGNET stopped in February, as planned. If an “ended?” line is a tool you still use, it may now bill another card, or a new descriptor that the script lists on its own line.
subscriptions.csv has one row per series, with the first and last charge, the count, the last description as printed, and the next expected date. Add a column for the owner and one for keep or cancel, and it becomes the list a team reviews.
The script ran in under a tenth of a second on the 976 rows. We also fed it eight awkward files: empty, header only, unknown column names, dates that fit both orders, separate debit and credit columns, European amounts, positive purchases, and broken rows. Each one either worked or stopped with a message that names the fix. Broken rows are skipped and listed.
Step 4: put the renewals on a calendar
renewals.ics holds one all-day event per active series on its next expected date, titled like Renews: DOMAINDOCK 299.00 (annual). Each carries a reminder: 30 days ahead for annual and semiannual plans, 3 days for the rest. In the calendar format’s standard, RFC 5545, “negative durations are typically used to schedule an alarm to trigger before an associated time”, and that is how the reminders are written. The file keeps the standard’s limit that lines “SHOULD NOT be longer than 75 octets”.
Import the file into a shared calendar that the person who pays the bills can see. We checked that the file reads back with the icalendar 7.3.0 library: 17 events, each with its reminder. We did not import it into Google Calendar or Outlook.
The dates are estimates from the billing pattern. Vendors bill on their own schedule, and a plan billed on the 31st lands on the last day of shorter months. Run the script again each quarter on a fresh export and import the new file.
Step 5: decide, then cancel at the vendor
Go down the list by yearly cost. For each line, three questions settle it: who uses it, when they last did, and whether another tool on the list does the same job. Two storage plans from one vendor, as in our sample, is the classic case of a duplicate.
Cancel in the vendor’s billing settings and keep the confirmation email. As above, a new card number may not stop a subscription, because card details can follow the account to the new card. For annual plans, act on the 30-day reminder: once the renewal is charged, a refund depends on the vendor’s terms.
Without Python: a pivot table in a spreadsheet
A spreadsheet gets you most of the way for a single export. Open the CSV and add a column that copies the description with digits removed. Build a pivot table with that column as rows and the count and average of the amount as values. Sort by count and read every merchant with three or more charges.
This is the exact-description method from our test with digits stripped, so it shares its blind spots. It will not see an annual plan in 12 months of data or split two plans under one name, and it lists every shop you visit often. It takes longer than it sounds on a year of transactions, and it has to be redone each time.
When a card export is the wrong place to look
The script sees only what goes through the cards you export. Subscriptions paid by invoice and bank transfer, or charged to a personal card and expensed, never reach the export. And a merchant that bills many products under one descriptor shows up as one line; Google’s list alone has more than 30 descriptors.
It also finds recurring charges, not software. Electricity and phone bills show up, and so would a shop you visit on the same day every week; the notes about varying amounts usually give those away. And it cannot tell whether anyone uses a tool. That answer comes from the admin console of each tool, or from asking.
If the quarterly run becomes a habit, it can run on a schedule. Our piece on scheduled scripts that fail silently lists the checks to add before it runs without anyone watching.
How we made this
On October 26, 2026, we read Stripe’s documentation on statement descriptors, customer emails and card updates, Google’s list of statement descriptors, Visa’s 2020 summary of its trial subscription rules (not the full current rulebook) and RFC 5545. We generated 25 months of made-up card transactions with 15 planted subscriptions and an answer key. We ran the script on them with Python 3.11.17, and a version that groups by exact description on the same files. We did not use a real card export, whose layout and descriptors may differ from ours, and we did not import the calendar file into Google Calendar or Outlook.
Sources
- Stripe, Statement descriptors, read October 26, 2026
- Stripe, Automate customer emails, read October 26, 2026
- Stripe, How cards work, section “Automatic card updates”, read October 26, 2026
- Google Pay Help, Understand Google charges on your bank statement, read October 26, 2026
- Visa, Trial Subscription Updates, effective April 18, 2020, read October 26, 2026
- IETF, RFC 5545: Internet Calendaring and Scheduling Core Object Specification (iCalendar), sections 3.1 and 3.3.6, read October 26, 2026
- 28creation test, October 26, 2026: 976 generated card transactions from October 2024 to October 2026 in two overlapping files, 15 planted subscriptions and 2 recurring bills, scored against an answer key