Guide Work software

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.

Hands typing on a laptop at a wooden desk, a small handheld calculator and a red mug beside it.
Photo: Alabama Extension, CC0
In this article
  1. Statement names change between charges by design
  2. Visa’s rules ask for no reminder before a plain renewal
  3. Our test: 25 months of made-up card data
  4. Exact names missed four; one year of data missed both annual plans
  5. Step 1: download the card transactions as CSV
  6. Step 2: run the script
  7. The script
  8. Step 3: read the output, starting with the notes
  9. Step 4: put the renewals on a calendar
  10. Step 5: decide, then cancel at the vendor
  11. Without Python: a pivot table in a spreadsheet
  12. When a card export is the wrong place to look
  13. 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/%Y when the dates are day first. The script refuses to guess when every date in the file fits both orders, as 01/02/2026 does.
  • --decimal-comma for amounts like 1.234,56. Without it, the script stops at the first such amount rather than reading it as 1.23.
  • --charges positive if 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