From d68b258906dc825aa85e334b390bb19a5a65ef48 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 4 Oct 2026 13:18:41 +0000 Subject: [PATCH] ftb: create and activate MyFTB accounts from the CA returns on disk (0.54.0) FTB has no API or OAuth: a MyFTB account is a browser registration checked against a shared secret from a filed return, then a PIN by US Mail. - ftb secrets: ftb-extract.py (PyMuPDF) finds Form 100S line 20/15 for a corporation and Form 540 line 17 CA AGI + filing status, name, SSN and address for a person, last five closed years, newest first. - ftb register business|personal: headless Chrome fills each page by field id, then label; an unmatched required field stops (or is asked in a terminal). The perjury box needs --declare, one secret per run, never retried. - Logins go to the logicsrc `ftb` vault project and a 0600 state file; the PIN letter becomes a myna hand-off card with steps only. - ftb activate --pin N logs in, answers the stored security question, enters the PIN and marks the card done. Co-Authored-By: Claude Opus 5.5 --- README.md | 45 ++++- bin/ftb.ts | 340 +++++++++++++++++++++++++++++++ package.json | 2 +- src/ftb-extract.py | 57 ++++++ src/ftb-run.ts | 246 +++++++++++++++++++++++ src/ftb.ts | 489 +++++++++++++++++++++++++++++++++++++++++++++ src/registry.ts | 1 + test/ftb.test.ts | 199 ++++++++++++++++++ 8 files changed, 1377 insertions(+), 2 deletions(-) create mode 100755 bin/ftb.ts create mode 100644 src/ftb-extract.py create mode 100644 src/ftb-run.ts create mode 100644 src/ftb.ts create mode 100644 test/ftb.test.ts diff --git a/README.md b/README.md index a595354..3e00884 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ TypeScript, installed as executables on `PATH`. | [`icon`](#icon) | UI icons as SVG, PNG and terminal glyphs (Nerd Font, Unicode, ASCII): the OpenIcon set | | [`wcag`](#wcag) | Audit a site against WCAG with axe in headless Chrome, for the W3C report tool | | [`keywords`](#keywords) | The keywords and phrases a page repeats, ranked by count, from headless Chrome | +| [`ftb`](#ftb) | Create and activate MyFTB accounts, business and personal, from the CA returns on disk | | [`statements`](#statements) | The PDF statements behind every SimpleFIN account, downloaded from each bank and filed by account and month | | [`vid`](#vid) | Inspect, thumbnail, clip and shrink video, through ffmpeg | | [`dl`](#dl) | Download a video, or just its audio, through yt-dlp | @@ -71,7 +72,7 @@ One thing here is not a `PATH` command and does not need Node: [torlnk](https://www.npmjs.com/package/torlnk) running - **ImageMagick** (`magick`) — `img` only, and only for what sharp cannot do (PDF, PSD, animated GIF); sharp ships with this repo as an optional dependency -- **A Chrome or Chromium** — `wcag`, `keywords` and `statements` only. `CHROME_PATH` names one; otherwise +- **A Chrome or Chromium** — `wcag`, `keywords`, `statements` and `ftb` only. `CHROME_PATH` names one; otherwise the usual binaries on `PATH` are tried, then the builds Puppeteer and Playwright keep under `~/.cache`. axe-core itself ships with this repo - **`unzip` or `bsdtar`** — the `adb` companion only, and only while installing @@ -1443,6 +1444,48 @@ and a phrase 2 to rank; on a page too short for that, everything is ranked. `--wait MS` (default 1000) gives a slow single-page app longer to render after load. Chrome is found the same way as for [`wcag`](#wcag). +### `ftb` + +MyFTB accounts at the California Franchise Tax Board, business and personal, +registered and activated from the returns already on disk. FTB has no API and +no OAuth: an account is a browser form checked against a "shared secret" from +a filed return, then a PIN that comes by US Mail. `ftb` does the finding and the +form filling; the two things it leaves to a person are the ones only a person can +do. + +```sh +ftb secrets # what each return can prove, best first +ftb register business --email you@example.com --dry-run +ftb register business --email you@example.com --declare +ftb register personal --email you@example.com --declare +ftb activate business --pin 4821 # when the letter comes +ftb status +``` + +- **Which form, which line.** `src/ftb-extract.py` (PyMuPDF) reads every PDF + under `--dir` (default `~/dottemplates/docs/taxes/years`): Form 100S line 20 + "net income for tax purposes" and line 15 for a corporation, Form 540 line 17 + "California AGI" plus filing status, name, SSN and address for a person. Only + the last five closed years count; newest first, line 20 before 15. +- **The forms.** Headless Chrome, page by page, each control matched by its + field id first and its label second. A required field nothing matches stops + the run (or is asked for, in a terminal), so a reworded page is never + answered with a guess. Every page's fields, never its values, are appended to + `~/.local/share/cli-tools/ftb-pages.jsonl` for tuning the rules. +- **What stays yours.** The penalty-of-perjury box is ticked only with + `--declare`, which is you saying the values are true. One shared secret is + sent per run and never retried: a "does not match" stops, and `ftb secrets` + lists what to try with `--year`/`--line`. +- **Where the login goes.** A generated user name, a 24-character password and + three security answers go to the `ftb` project of the `profullstack` logicsrc + vault (its own project, so the push cannot drop another key) and to a 0600 + state file. +- **The PIN letter.** A myna hand-off card (`mynaposter.com/handoff/`) with + the deadline (21 days) and the `ftb activate` command. It carries steps only, + never a PIN, password or SSN; `ftb activate` marks it done. + +Needs `python3` with PyMuPDF (`pip install pymupdf`) and Chrome. + ### `statements` The PDF statements behind every account on a diff --git a/bin/ftb.ts b/bin/ftb.ts new file mode 100755 index 0000000..f20d092 --- /dev/null +++ b/bin/ftb.ts @@ -0,0 +1,340 @@ +#!/usr/bin/env node +/** + * ftb — create and activate MyFTB accounts, business and personal, from the + * returns already on disk. + * + * ftb secrets what each return can prove, best first + * ftb register business --email a@b.c --dry-run + * ftb register business --email a@b.c --declare + * ftb register personal --email a@b.c --declare + * ftb activate business --pin 1234 after the PIN letter arrives + * ftb status + * + * src/ftb.ts says why it works this way. + */ + +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { UsageError, parseArgs } from '../src/args.ts'; +import { + type Account, + type BusinessSecret, + type Extracted, + FtbError, + LOGIN_URL, + type PersonalSecret, + type Plan, + REGISTER_URL, + ROLES, + type Role, + type State, + VAULT, + addressNumbers, + businessSecrets, + formatAmount, + generatePassword, + generateUsername, + handoffCard, + latestIdentity, + maskSsn, + parseExtracted, + personalSecrets, + rulesFor, + statePath, + toEnvFile, + vaultKeys, +} from '../src/ftb.ts'; +import { goto, openSession, walk } from '../src/ftb-run.ts'; +import { isMain } from '../src/is-main.ts'; +import { pullVault } from '../src/vault.ts'; +import { WcagError } from '../src/wcag.ts'; + +const DEFAULT_DIR = join(homedir(), 'dottemplates', 'docs', 'taxes', 'years'); + +const USAGE = `Usage: + ftb secrets [--dir D] [--json] the shared secrets each filed CA return supports, best first + ftb register create the MyFTB account in headless Chrome + ftb activate log in and enter the PIN from FTB's letter + ftb status which accounts exist, and what each is waiting on + +register options: + --email ADDR where FTB sends the confirmation (required) + --declare tick the penalty-of-perjury box: your statement that the values are true + --dry-run fill the first form page, print it, submit nothing + --year YYYY use this tax year's return instead of the newest + --line 15|20 business: which 100S line is the net income (default 20) + --amount=N override the amount (a loss is --amount=-12345) + +activate options: + --pin N the PIN from the letter (21 days from registration) + +Common: + --dir D where the returns are (default: ${DEFAULT_DIR}) + --headful show the browser (needs a display) + --chrome PATH the browser to use (default: CHROME_PATH, then the usual places) + --no-handoff do not post the PIN-letter card to myna + --no-vault keep the login only in ${statePath()} + +One shared secret is sent per run. When FTB says it does not match, the run +stops and \`ftb secrets\` lists what else the returns support; nothing is retried +on its own. Logins go to the ${VAULT.project} vault (${VAULT.team}) and a 0600 +state file; the myna card carries steps only, never a PIN, password or SSN. +`; + +function out(text: string): void { + process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); +} + +function err(text: string): void { + process.stderr.write(text.endsWith('\n') ? text : `${text}\n`); +} + +// --------------------------------------------------------------------------- + +const EXTRACTOR = join(dirname(fileURLToPath(import.meta.url)), '..', 'src', 'ftb-extract.py'); + +export function extract(dir: string): Extracted[] { + if (!existsSync(dir)) throw new FtbError(`${dir} does not exist; point --dir at the folder of returns`); + const result = spawnSync('python3', [EXTRACTOR, dir], { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }); + if (result.error) throw new FtbError(`python3 is needed to read the returns: ${result.error.message}`); + if (result.status !== 0) { + const detail = result.stderr.trim().split('\n').slice(-2).join(' '); + throw new FtbError(/No module named 'pymupdf'/.test(detail) ? 'PyMuPDF is needed to read the returns: pip install pymupdf' : `ftb-extract.py failed: ${detail}`); + } + return parseExtracted(result.stdout); +} + +function loadState(path = statePath()): State { + try { + return JSON.parse(readFileSync(path, 'utf8')) as State; + } catch { + return { accounts: {} }; + } +} + +function saveState(state: State, path = statePath()): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); +} + +/** Pull the ftb vault, merge this account in, push it back. The plaintext lives for one call in a 0700 dir. */ +function pushVault(account: Account): void { + let current: Record = {}; + try { + current = pullVault(VAULT); + } catch { + // A vault that does not exist yet is created by the push. + } + const dir = mkdtempSync(join(tmpdir(), 'ftb-vault-')); + try { + const envPath = join(dir, 'ftb.env'); + writeFileSync(envPath, toEnvFile({ ...current, ...vaultKeys(account) }), { mode: 0o600 }); + const result = spawnSync('logicsrc', ['teams', 'push', VAULT.team, VAULT.project, VAULT.env, '--env', envPath], { encoding: 'utf8' }); + if (result.status !== 0) throw new FtbError(`logicsrc teams push failed: ${(result.stderr || result.stdout).trim().split('\n').slice(-2).join(' ')}`); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Post the PIN-letter card to myna; returns its URL, or null when myna is not there. */ +function postHandoff(role: Role, at: Date): string | null { + const card = handoffCard(role, at); + const args = ['handoff', 'add', 'ftb', '--title', card.title, '--open', LOGIN_URL, ...card.steps.flatMap((step) => ['--step', step])]; + const result = spawnSync('myna', args, { input: card.text, encoding: 'utf8' }); + if (result.error || result.status !== 0) return null; + return result.stdout.match(/https:\/\/\S+\/handoff\/\S+/)?.[0] ?? result.stdout.trim().split('\n').pop() ?? null; +} + +// --------------------------------------------------------------------------- + +function formatSecrets(rows: Extracted[]): string { + const lines: string[] = []; + const business = businessSecrets(rows); + const personal = personalSecrets(rows); + const identity = latestIdentity(rows); + lines.push('Business (Form 100S), best first:'); + if (!business.length) lines.push(' none found'); + for (const s of business) lines.push(` ${s.year} 100S line ${s.line} ${formatAmount(s.amount).padStart(9)} corp ${s.corpId} ${s.source}`); + lines.push('', 'Personal (Form 540, line 17 CA AGI), best first:'); + if (!personal.length) lines.push(' none found'); + for (const s of personal) lines.push(` ${s.year} 540 ${s.filingStatus.padEnd(18)} ${formatAmount(s.amount).padStart(9)} ${s.source}`); + if (identity) { + lines.push('', `Identity (${identity.year} 540): ${identity.firstName} ${identity.lastName}, SSN ${maskSsn(identity.ssn!)}, ${identity.street} ${identity.zip}`); + } + return lines.join('\n'); +} + +function chooseSecret(all: T[], year: number | undefined, line: number | undefined): T { + const found = all.find((s) => (year === undefined || s.year === year) && (line === undefined || !('line' in s) || s.line === line)); + if (!found) throw new FtbError(`no return matches${year ? ` year ${year}` : ''}${line ? ` line ${line}` : ''}; see \`ftb secrets\``); + return found; +} + +export async function main(argv: readonly string[]): Promise { + const args = parseArgs(argv, { + boolean: ['--help', '-h', '--json', '--declare', '--dry-run', '--headful', '--no-handoff', '--no-vault'], + string: ['--dir', '--email', '--year', '--line', '--amount', '--pin', '--chrome'], + }); + const [command, roleArg] = args.positional; + if (args.flags.has('--help') || args.flags.has('-h') || !command) { + out(USAGE); + return command || args.flags.size ? 0 : 1; + } + const dir = args.values.get('--dir') ?? DEFAULT_DIR; + + if (command === 'secrets') { + const rows = extract(dir); + if (args.flags.has('--json')) { + const identity = latestIdentity(rows); + out(JSON.stringify({ business: businessSecrets(rows), personal: personalSecrets(rows), identity: identity && { ...identity, ssn: identity.ssn && maskSsn(identity.ssn) } }, null, 2)); + } else out(formatSecrets(rows)); + return 0; + } + + if (command === 'status') { + const state = loadState(); + for (const role of ROLES) { + const a = state.accounts[role]; + if (!a) out(`${role.padEnd(9)} not registered`); + else out(`${role.padEnd(9)} ${a.username} registered ${a.registeredAt.slice(0, 10)} ${a.activatedAt ? `active since ${a.activatedAt.slice(0, 10)}` : `waiting on the PIN letter${a.handoff ? ` ${a.handoff}` : ''}`}`); + } + return 0; + } + + if (command !== 'register' && command !== 'activate') throw new UsageError(`unknown command: ${command}`); + if (!ROLES.includes(roleArg as Role)) throw new UsageError(`${command} needs business or personal`); + const role = roleArg as Role; + const state = loadState(); + const say = (line: string) => err(line); + const log = join(dirname(statePath()), 'ftb-pages.jsonl'); + const headless = !args.flags.has('--headful'); + + if (command === 'activate') { + const account = state.accounts[role]; + if (!account) throw new FtbError(`no ${role} account in ${statePath()}; run \`ftb register ${role}\` first`); + const pin = args.values.get('--pin'); + if (!pin || !/^\w{4,12}$/.test(pin)) throw new UsageError('--pin is the PIN from the letter'); + const plan: Plan = { + role, firstName: '', lastName: '', username: account.username, password: account.password, email: account.email, + addressNumbers: '', zip: '', year: account.secret.year, amount: account.secret.amount, security: { ...account.security }, declare: false, pin, + }; + const session = await openSession({ chrome: args.values.get('--chrome'), headless }); + try { + await goto(session, LOGIN_URL); + say(`Activating the ${role} account ${account.username}:`); + const result = await walk(session, { plan, rules: rulesFor(plan), dryRun: false, log, interactive: process.stdin.isTTY === true, say }); + if (result.outcome === 'rejected') throw new FtbError(`FTB said: ${result.page.errors.join(' ') || 'no'} (${result.page.url})`); + account.activatedAt = new Date().toISOString(); + saveState(state); + if (account.handoff) spawnSync('myna', ['handoff', 'done', account.handoff.split('/').pop()!], { encoding: 'utf8' }); + out(`${role} MyFTB account ${account.username} is active.`); + return 0; + } finally { + await session.browser.close(); + } + } + + // register + if (state.accounts[role] && !args.flags.has('--dry-run')) { + throw new FtbError(`a ${role} account (${state.accounts[role]!.username}) is already registered here; \`ftb status\` says what it is waiting on`); + } + const email = args.values.get('--email'); + if (!email || !/^[\w.-]+@[\w.-]+\.\w+$/.test(email)) throw new UsageError('--email is required (letters, digits, . _ - and @ only)'); + const year = args.values.has('--year') ? Number(args.values.get('--year')) : undefined; + const line = args.values.has('--line') ? Number(args.values.get('--line')) : undefined; + if (line !== undefined && line !== 15 && line !== 20) throw new UsageError('--line is 15 or 20'); + + const rows = extract(dir); + const identity = latestIdentity(rows); + if (!identity) throw new FtbError(`no Form 540 with a name, SSN and address under ${dir}`); + + const secret = role === 'business' ? chooseSecret(businessSecrets(rows), year, line) : chooseSecret(personalSecrets(rows), year, undefined); + const amountOverride = args.values.get('--amount'); + if (amountOverride !== undefined && !/^-?\d+$/.test(amountOverride)) throw new UsageError('--amount is whole dollars, e.g. --amount=-12345'); + const amount = amountOverride !== undefined ? Number(amountOverride) : secret.amount; + + const plan: Plan = { + role, + firstName: identity.firstName!, + lastName: identity.lastName!, + username: generateUsername(role, `${identity.firstName![0]}${identity.lastName}`), + password: generatePassword(), + email, + addressNumbers: addressNumbers(identity.street!), + zip: identity.zip!.slice(0, 5), + year: secret.year, + amount, + corpId: 'corpId' in secret ? secret.corpId : undefined, + ssn: role === 'personal' ? identity.ssn! : undefined, + filingStatus: 'filingStatus' in secret ? secret.filingStatus : undefined, + security: {}, + declare: args.flags.has('--declare'), + }; + + say(`Registering a ${role} MyFTB account as ${plan.username} <${email}>`); + say(` shared secret: ${secret.year} Form ${secret.form}${'line' in secret ? ` line ${secret.line}` : ` (${plan.filingStatus})`} = ${formatAmount(amount)} from ${secret.source}`); + say(` address on file: ${plan.addressNumbers} / ${plan.zip}${plan.corpId ? ` corp ${plan.corpId}` : ` SSN ${maskSsn(plan.ssn!)}`}`); + + const session = await openSession({ chrome: args.values.get('--chrome'), headless }); + try { + await goto(session, REGISTER_URL); + const result = await walk(session, { plan, rules: rulesFor(plan), dryRun: args.flags.has('--dry-run'), log, interactive: process.stdin.isTTY === true, say }); + if (result.outcome === 'dry-run') { + out('Dry run: stopped before the first Continue that sends anything to FTB.'); + return 0; + } + if (result.outcome === 'rejected') { + throw new FtbError(`FTB said: ${result.page.errors.join(' ') || 'the information does not match'} (${result.page.url})\nNothing was retried. \`ftb secrets\` lists the other returns; pick one with --year/--line.`); + } + const at = new Date(); + const account: Account = { + role, username: plan.username, password: plan.password, email, security: plan.security, + secret: { year: secret.year, form: secret.form, amount }, registeredAt: at.toISOString(), + }; + state.accounts[role] = account; + saveState(state); + out(`Registered: ${role} MyFTB account ${plan.username}. FTB mails a PIN to the address on file.`); + if (!args.flags.has('--no-vault')) { + try { + pushVault(account); + out(` login saved to the ${VAULT.project} vault (${VAULT.team}/${VAULT.env}) and ${statePath()}`); + } catch (error) { + err(` vault push failed, the login is only in ${statePath()}: ${(error as Error).message}`); + } + } + if (!args.flags.has('--no-handoff')) { + const url = postHandoff(role, at); + if (url) { + account.handoff = url; + saveState(state); + out(` PIN-letter card: ${url}`); + } + } + out(` When the letter comes: ftb activate ${role} --pin `); + return 0; + } finally { + await session.browser.close(); + } +} + +if (isMain(import.meta.url)) { + main(process.argv.slice(2)).then( + (code) => process.exit(code), + (error: unknown) => { + if (error instanceof UsageError) { + err(`ftb: ${error.message}\n\n${USAGE}`); + process.exit(2); + } + if (error instanceof FtbError || error instanceof WcagError) { + err(`ftb: ${error.message}`); + process.exit(1); + } + throw error; + }, + ); +} diff --git a/package.json b/package.json index a73e3a1..a5f574b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.53.0", + "version": "0.54.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/ftb-extract.py b/src/ftb-extract.py new file mode 100644 index 0000000..2218dce --- /dev/null +++ b/src/ftb-extract.py @@ -0,0 +1,57 @@ +# Shared secrets for MyFTB registration, read from filed CA returns (PDF). +# Usage: python3 ftb-extract.py ... prints a JSON array; needs PyMuPDF. +# Called by src/ftb.ts; see there for what each record means. +import sys, json, re, glob, os +import pymupdf +NUM = re.compile(r'^-?[\d,]+\.?$') +def amount(s): return int(s.replace(',', '').rstrip('.')) +def line_value(page, label, minx=300): + W = page.get_text("words") + for l in [w for w in W if w[4] == label and w[0] < 80]: + row = [w for w in W if abs(w[1] - l[1]) < 4 and w[0] > minx and NUM.match(w[4]) and w[4] != label] + if row: return amount(sorted(row, key=lambda w: w[0])[-1][4]) + return None +STATUSES = {'1': 'single', '2': 'married-joint', '3': 'married-separate', '4': 'head-of-household', '5': 'qualifying-surviving-spouse'} +def filing_status(page): + """The X nearest a numbered filing-status box (1-5) under the Filing Status heading.""" + W = page.get_text("words") + head = next((w for w in W if w[4] == 'Filing'), None) + if not head: return None + boxes = [w for w in W if w[4] in STATUSES and head[1] - 5 < w[1] < head[1] + 120] + for m in [w for w in W if w[4] in ('X', 'x') and head[1] - 5 < w[1] < head[1] + 120]: + near = min(boxes, key=lambda b: abs(b[0] - m[0]) + abs(b[1] - m[1]), default=None) + if near and abs(near[0] - m[0]) + abs(near[1] - m[1]) < 40: return STATUSES[near[4]] + return None +out = [] +for root in sys.argv[1:]: + for f in sorted(glob.glob(os.path.join(os.path.expanduser(root), '**', '*.pdf'), recursive=True)): + try: + d = pymupdf.open(f) + if d.needs_pass and not d.authenticate(''): continue + except Exception: continue + year540 = None + for i, pg in enumerate(d): + t = pg.get_text() + h = re.search(r'TAXABLE YEAR\s*FORM\s*California Resident\s*(20\d\d)\s*540\b', t) + if h: year540 = int(h.group(1)) + elif re.search(r'TAXABLE\s*YEAR', t): year540 = None + m = re.search(r'Form 100S (20\d\d)', t) + if m and 'Net income for tax purposes' in t: + ident = re.search(r'^(\S.*?\S)\s{2,}(\d{7})$', t, re.M) + out.append({'kind': 'business', 'form': '100S', 'year': int(m.group(1)), 'file': f, 'page': i + 1, + 'netIncomeForState': line_value(pg, '15'), 'netIncomeForTax': line_value(pg, '20'), + 'corpId': ident.group(2) if ident else None}) + if year540 and 'California adjusted gross income' in t and line_value(pg, '17') is not None: + out.append({'kind': 'personal', 'form': '540', 'year': year540, 'file': f, 'page': i + 1, 'caAgi': line_value(pg, '17')}) + if h: + W = pg.get_text("words") + ssn = next((w[4] for w in W if re.match(r'^\d{3}-\d{2}-\d{4}$', w[4]) and w[1] < 120), None) + first = next((w for w in W if 100 < w[1] < 115 and w[0] < 60), None) + last = next((w for w in W if first and abs(w[1] - first[1]) < 3 and w[0] > first[0] + 20), None) + street = [w[4] for w in W if 138 < w[1] < 148 and w[0] < 250] + city = [w for w in W if 150 < w[1] < 160] + zipw = next((w[4] for w in city if re.match(r'^\d{5}(-\d{4})?$', w[4])), None) + out.append({'kind': 'identity', 'year': year540, 'file': f, 'page': i + 1, 'ssn': ssn, + 'firstName': first[4] if first else None, 'lastName': last[4] if last else None, + 'street': ' '.join(street) or None, 'zip': zipw, 'filingStatus': filing_status(pg)}) +print(json.dumps(out)) diff --git a/src/ftb-run.ts b/src/ftb-run.ts new file mode 100644 index 0000000..d1bda44 --- /dev/null +++ b/src/ftb-run.ts @@ -0,0 +1,246 @@ +/** + * The part of `ftb` that drives Chrome over the DevTools protocol: one loop + * that reads a MyFTB page, fills it from the rules in ftb.ts, and presses its + * Continue/Submit, until FTB says the account exists, is active, or no. + * + * Headless works against MyFTB's bot screen once the user agent stops saying + * HeadlessChrome (the same trick src/statements-run.ts uses on banks). + */ + +import { appendFileSync, mkdirSync } from 'node:fs'; +import { dirname } from 'node:path'; + +import { type Action, type Field, FtbError, type Outcome, type Plan, type Rule, decide, outcomeOf } from './ftb.ts'; +import { promptLine, promptSecret } from './prompt.ts'; +import { type Browser, type Cdp, launchBrowser } from './wcag.ts'; + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +export interface Page { + url: string; + title: string; + text: string; + errors: string[]; + fields: Field[]; +} + +/** Every visible, enabled control, with the label a person would read for it. */ +const READ_PAGE = `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length) && getComputedStyle(el).visibility !== 'hidden'; + const clean = (s) => (s || '').replace(/\\*\\s*Required Field/gi, '').replace(/\\s+/g, ' ').trim(); + const labelOf = (el) => { + const byFor = el.id && document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); + const wrap = el.closest('label'); + const group = el.closest('.form-group, fieldset, .row'); + const legend = group && group.querySelector('legend, .col-form-label'); + return clean((byFor && byFor.innerText) || el.getAttribute('aria-label') || (wrap && wrap.innerText) || (legend && legend.innerText) || el.placeholder || ''); + }; + const selectorOf = (el) => { + if (el.id) return '#' + CSS.escape(el.id); + if (el.name) return el.tagName.toLowerCase() + '[name="' + el.name + '"]' + (el.type === 'radio' || el.type === 'checkbox' ? '[value="' + el.value + '"]' : ''); + return null; + }; + const controls = [...document.querySelectorAll('form input, form select, form textarea')] + .filter((el) => !['hidden', 'submit', 'button', 'image', 'reset'].includes(el.type) && !el.disabled && visible(el) && !el.closest('#timer, .modal')); + let lastSelect = null; + const fields = []; + for (const el of controls) { + const selector = selectorOf(el); + if (el.tagName === 'SELECT') lastSelect = el; + if (!selector) continue; + const label = labelOf(el); + const group = el.closest('.form-group, fieldset'); + const printed = group ? clean([...group.querySelectorAll('p, span, div')].map((n) => n.children.length ? '' : n.innerText).join(' ')) : ''; + fields.push({ + selector, + id: el.id || '', + name: el.name || '', + type: el.type, + label: el.type === 'radio' || el.type === 'checkbox' ? clean((el.closest('label') || document.querySelector('label[for="' + CSS.escape(el.id) + '"]') || {}).innerText || label) : label, + required: el.required || el.getAttribute('aria-required') === 'true' || /required field/i.test((document.querySelector('label[for="' + CSS.escape(el.id || '-') + '"]') || {}).innerText || ''), + options: el.tagName === 'SELECT' ? [...el.options].map((o) => ({ value: o.value, text: o.text.trim() })) : undefined, + question: el.tagName !== 'SELECT' && lastSelect && lastSelect.selectedIndex > 0 ? lastSelect.options[lastSelect.selectedIndex].text.trim() : (printed || undefined), + }); + } + const errors = [...document.querySelectorAll('.alert-danger, .validation-summary-errors li, .field-validation-error')] + .filter(visible).map((n) => clean(n.innerText)).filter(Boolean); + return { url: location.href, title: document.title, text: clean(document.body.innerText).slice(0, 6000), errors: [...new Set(errors)], fields }; +})()`; + +function fillScript(selector: string, action: Action): string { + const target = `document.querySelector(${JSON.stringify(selector)})`; + if (action.kind === 'check' || action.kind === 'declare') { + return `(() => { const el = ${target}; if (el && !el.checked) el.click(); return !!el && el.checked; })()`; + } + // The native setter, then the events a jQuery/MVC page listens for. + return `(() => { + const el = ${target}; if (!el) return false; + const proto = el.tagName === 'SELECT' ? HTMLSelectElement.prototype : el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(action.value)}); + for (const type of ['input', 'change', 'blur']) el.dispatchEvent(new Event(type, { bubbles: true })); + return el.value === ${JSON.stringify(action.value)}; + })()`; +} + +/** The page's own Continue/Submit/Log in, never the session-timeout dialog's "Continue Session". */ +const SUBMIT = `(() => { + const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length); + const buttons = [...document.querySelectorAll('form button, form input[type=submit]')] + .filter((b) => visible(b) && !b.closest('#timer, .modal') && b.id !== 'continue'); + const text = (b) => (b.innerText || b.value || '').trim(); + const pick = buttons.find((b) => /^(submit|continue|next|log ?in|login|activate)$/i.test(text(b))); + if (!pick) return null; + pick.click(); + return text(pick); +})()`; + +export interface Session { + browser: Browser; + cdp: Cdp; + sessionId: string; +} + +export async function openSession(options: { chrome?: string | undefined; headless: boolean; timeoutMs?: number }): Promise { + const browser = await launchBrowser({ ...(options.chrome ? { chrome: options.chrome } : {}), headless: options.headless, timeoutMs: options.timeoutMs ?? 30_000 }); + const { cdp } = browser; + const { targetInfos } = (await cdp.send('Target.getTargets')) as { targetInfos: { targetId: string; type: string }[] }; + let targetId = targetInfos.find((target) => target.type === 'page')?.targetId; + if (!targetId) ({ targetId } = (await cdp.send('Target.createTarget', { url: 'about:blank' })) as { targetId: string }); + const { sessionId } = (await cdp.send('Target.attachToTarget', { targetId, flatten: true })) as { sessionId: string }; + await cdp.send('Page.enable', {}, sessionId); + const { userAgent } = (await cdp.send('Browser.getVersion')) as { userAgent: string }; + if (userAgent.includes('Headless')) { + await cdp.send('Network.setUserAgentOverride', { userAgent: userAgent.replace('HeadlessChrome', 'Chrome') }, sessionId); + } + return { browser, cdp, sessionId }; +} + +async function evaluate(session: Session, expression: string): Promise { + try { + const { result, exceptionDetails } = (await session.cdp.send('Runtime.evaluate', { expression, returnByValue: true }, session.sessionId)) as { + result: { value?: unknown }; + exceptionDetails?: unknown; + }; + return exceptionDetails ? null : ((result.value ?? null) as T | null); + } catch { + return null; + } +} + +async function settle(session: Session, timeoutMs: number): Promise { + const loaded = session.cdp.waitFor('Page.loadEventFired', session.sessionId, timeoutMs).catch(() => undefined); + await Promise.race([loaded, sleep(timeoutMs)]); + await sleep(1_500); +} + +export async function goto(session: Session, url: string, timeoutMs = 30_000): Promise { + const loaded = session.cdp.waitFor('Page.loadEventFired', session.sessionId, timeoutMs).catch(() => undefined); + const result = (await session.cdp.send('Page.navigate', { url }, session.sessionId)) as { errorText?: string }; + if (result.errorText) throw new FtbError(`could not open ${url}: ${result.errorText}`); + await Promise.race([loaded, sleep(timeoutMs)]); + await sleep(1_500); +} + +export async function readPage(session: Session): Promise { + const page = await evaluate(session, READ_PAGE); + if (!page) throw new FtbError('could not read the page'); + return page; +} + +export interface WalkOptions { + plan: Plan; + rules: Rule[]; + /** Fill the first page that takes typed values, print what would go, and stop before its Continue. */ + dryRun: boolean; + /** Where each page's fields (never values) are appended, for tuning the rules. */ + log: string; + /** Ask at the terminal for a required field no rule fills. */ + interactive: boolean; + say: (line: string) => void; + timeoutMs?: number; +} + +export interface WalkResult { + outcome: Outcome | 'dry-run'; + page: Page; +} + +function describe(field: Field): string { + return `${field.label || field.name || field.id} (${field.type}${field.required ? ', required' : ''})`; +} + +function logPage(path: string, page: Page): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const fields = page.fields.map(({ selector, type, label, required, options }) => ({ selector, type, label, required, options: options?.slice(0, 12) })); + appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), url: page.url, title: page.title, errors: page.errors, fields })}\n`, { mode: 0o600 }); +} + +/** + * Fill and submit pages until FTB answers. Selects, radios and checkboxes go + * first and the page is read again, because choosing a security question is + * what tells the answer box which answer it wants. + */ +export async function walk(session: Session, options: WalkOptions): Promise { + const { plan, rules, say } = options; + const timeoutMs = options.timeoutMs ?? 30_000; + let lastUrl = ''; + let sameUrl = 0; + + for (let step = 0; step < 15; step += 1) { + let page = await readPage(session); + logPage(options.log, page); + + const outcome = outcomeOf(`${page.errors.join(' ')} ${page.errors.length ? '' : page.text}`); + if (outcome === 'rejected' || (page.errors.length && step > 0)) return { outcome: 'rejected', page }; + if (outcome !== 'continue') return { outcome, page }; + + sameUrl = page.url === lastUrl ? sameUrl + 1 : 0; + if (sameUrl >= 2) throw new FtbError(`stuck on ${page.url}: the page did not move after Continue (see ${options.log})`); + lastUrl = page.url; + say(` ${page.title.split('|')[1]?.trim() || page.title} ${page.url}`); + + const filled: string[] = []; + const missing: Field[] = []; + for (const pass of ['choices', 'text'] as const) { + if (pass === 'text') page = await readPage(session); + for (const field of page.fields) { + const choice = ['select-one', 'radio', 'checkbox'].includes(field.type); + if ((pass === 'choices') !== choice) continue; + const decision = decide(rules, field); + if (!decision) { + if (field.required && field.type !== 'radio') missing.push(field); + continue; + } + const { rule, action } = decision; + if (action.kind === 'declare' && !plan.declare) { + throw new FtbError( + `this page asks you to declare, under penalty of perjury, that what is entered is true:\n "${field.label}"\n` + + 'That statement is yours to make. Rerun with --declare once you have checked the values above.', + ); + } + if (!(await evaluate(session, fillScript(field.selector, action)))) throw new FtbError(`could not fill ${describe(field)} on ${page.url}`); + filled.push(`${rule.name}${action.kind === 'text' ? ` = ${action.secret ? '••••' : action.value}` : action.kind === 'select' ? ` = ${field.options?.find((o) => o.value === action.value)?.text ?? action.value}` : ''}`); + } + } + + for (const field of missing) { + if (!options.interactive) { + throw new FtbError(`no rule fills ${describe(field)} on ${page.url}; run it in a terminal to answer by hand, or add a rule (fields logged to ${options.log})`); + } + const value = field.type === 'password' ? await promptSecret(`${field.label}: `) : await promptLine(`${field.label}${field.options ? ` [${field.options.map((o) => o.text).join(' | ')}]` : ''}: `); + const action: Action = field.options ? { kind: 'select', value: field.options.find((o) => o.text === value)?.value ?? value } : { kind: 'text', value }; + await evaluate(session, fillScript(field.selector, action)); + filled.push(`${field.label} = (typed)`); + } + + for (const line of filled) say(` ${line}`); + + // Past the terms page every Continue may create something at FTB, so a dry run stops here. + if (options.dryRun && filled.some((line) => line.includes(' = '))) return { outcome: 'dry-run', page }; + + const pressed = await evaluate(session, SUBMIT); + if (!pressed) throw new FtbError(`no Continue or Submit on ${page.url} (fields logged to ${options.log})`); + await settle(session, timeoutMs); + } + throw new FtbError(`gave up after 15 pages (see ${options.log})`); +} diff --git a/src/ftb.ts b/src/ftb.ts new file mode 100644 index 0000000..d212f07 --- /dev/null +++ b/src/ftb.ts @@ -0,0 +1,489 @@ +/** + * `ftb` — create and activate MyFTB accounts (California Franchise Tax Board), + * business and personal, from the returns already on disk. + * + * FTB has no API and no OAuth: an account is a browser registration, checked + * against a "shared secret" from a filed return, and activated with a PIN that + * arrives by US Mail 5–10 business days later. So the automation is: + * + * - **Secrets from the returns.** src/ftb-extract.py reads the PDFs (PyMuPDF): + * Form 100S line 20 "net income for tax purposes" (and line 15) for a + * business, Form 540 line 17 "CA AGI" plus the filing status, name, SSN and + * address for a person. Nobody has to know which form or line. + * - **The forms, page by page.** src/ftb-run.ts fills every page from the + * {@link rulesFor} table, matched on field id first and label second, so a + * page FTB adds or rewords stops the run with its fields listed instead of + * submitting a guess. + * - **People only where the law or the post office needs one.** The penalty-of- + * perjury box is ticked only with `--declare` (the person's own statement), + * one shared secret is submitted per run and never retried, and the PIN step + * becomes a myna hand-off card (mynaposter.com/handoff/) that carries + * steps and never a secret. + * + * Everything here is pure and tested; the browser lives in ftb-run.ts. + */ + +import { randomInt } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export class FtbError extends Error { + constructor(message: string) { + super(message); + this.name = 'FtbError'; + } +} + +export type Role = 'business' | 'personal'; +export const ROLES: readonly Role[] = ['business', 'personal']; + +export const REGISTER_URL = 'https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount'; +export const LOGIN_URL = 'https://webapp.ftb.ca.gov/MyFTBAccess/'; + +// --------------------------------------------------------------------------- +// Secrets from the returns +// --------------------------------------------------------------------------- + +export interface BusinessReturn { + kind: 'business'; + form: '100S'; + year: number; + file: string; + page: number; + /** Line 15, net income (loss) for state purposes. */ + netIncomeForState: number | null; + /** Line 20, net income for tax purposes: what FTB's own e-file summary calls "taxable income". */ + netIncomeForTax: number | null; + corpId: string | null; +} + +export interface PersonalReturn { + kind: 'personal'; + form: '540'; + year: number; + file: string; + page: number; + /** Line 17, California adjusted gross income. */ + caAgi: number | null; +} + +export type FilingStatus = 'single' | 'married-joint' | 'married-separate' | 'head-of-household' | 'qualifying-surviving-spouse'; + +export interface Identity { + kind: 'identity'; + year: number; + file: string; + page: number; + ssn: string | null; + firstName: string | null; + lastName: string | null; + street: string | null; + zip: string | null; + filingStatus: FilingStatus | null; +} + +export type Extracted = BusinessReturn | PersonalReturn | Identity; + +/** Parse ftb-extract.py's output, dropping anything that is not a record it makes. */ +export function parseExtracted(json: string): Extracted[] { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new FtbError('ftb-extract.py printed something that is not JSON'); + } + if (!Array.isArray(parsed)) throw new FtbError('ftb-extract.py did not print a list'); + return parsed.filter( + (row): row is Extracted => + typeof row === 'object' && row !== null && ['business', 'personal', 'identity'].includes((row as { kind?: string }).kind ?? '') && Number.isInteger((row as { year?: number }).year), + ); +} + +/** A year FTB will take: one of the last five, never the current one (it cannot be processed yet). */ +export function eligibleYear(year: number, now: Date = new Date()): boolean { + const current = now.getFullYear(); + return year < current && year >= current - 5; +} + +export interface BusinessSecret { + year: number; + form: '100S'; + amount: number; + line: 15 | 20; + corpId: string; + source: string; +} + +export interface PersonalSecret { + year: number; + form: '540'; + amount: number; + filingStatus: FilingStatus; + source: string; +} + +function newestFirst(rows: readonly T[]): T[] { + return [...rows].sort((a, b) => b.year - a.year); +} + +/** + * Every business secret the returns support, best first: the newest year, line + * 20 before line 15. The caller submits one; FTB is never sent a list. + */ +export function businessSecrets(rows: readonly Extracted[], now: Date = new Date()): BusinessSecret[] { + const out: BusinessSecret[] = []; + const seen = new Set(); + for (const row of newestFirst(rows.filter((r): r is BusinessReturn => r.kind === 'business' && eligibleYear(r.year, now)))) { + if (!row.corpId) continue; + for (const line of [20, 15] as const) { + const amount = line === 20 ? row.netIncomeForTax : row.netIncomeForState; + const key = `${row.year}:${line}:${amount}`; + if (amount === null || seen.has(key)) continue; + seen.add(key); + out.push({ year: row.year, form: '100S', amount, line, corpId: row.corpId, source: `${row.file} p${row.page}` }); + } + } + return out; +} + +export function personalSecrets(rows: readonly Extracted[], now: Date = new Date()): PersonalSecret[] { + const identities = rows.filter((r): r is Identity => r.kind === 'identity'); + const out: PersonalSecret[] = []; + const seen = new Set(); + for (const row of newestFirst(rows.filter((r): r is PersonalReturn => r.kind === 'personal' && eligibleYear(r.year, now)))) { + if (row.caAgi === null || seen.has(row.year)) continue; + const status = identities.find((i) => i.file === row.file && i.year === row.year)?.filingStatus; + if (!status) continue; + seen.add(row.year); + out.push({ year: row.year, form: '540', amount: row.caAgi, filingStatus: status, source: `${row.file} p${row.page}` }); + } + return out; +} + +/** The newest identity block with every field FTB asks a person for. */ +export function latestIdentity(rows: readonly Extracted[]): Identity | null { + return newestFirst(rows.filter((r): r is Identity => r.kind === 'identity')).find((r) => r.ssn && r.firstName && r.lastName && r.street && r.zip) ?? null; +} + +/** "1234 MAPLE ST" -> "1234": FTB wants the numbers in the address and nothing else. */ +export function addressNumbers(street: string): string { + return (street.match(/\d+/g) ?? []).join(''); +} + +export function maskSsn(ssn: string): string { + return `***-**-${ssn.replace(/\D/g, '').slice(-4)}`; +} + +/** Whole dollars, a dash for a loss, no comma: the only shape FTB's field accepts. */ +export function formatAmount(amount: number): string { + return String(Math.trunc(amount)); +} + +// --------------------------------------------------------------------------- +// Credentials +// --------------------------------------------------------------------------- + +const LOWER = 'abcdefghijkmnopqrstuvwxyz'; +const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; +const DIGIT = '23456789'; +// Kept to characters every FTB form so far has taken, none of which need escaping anywhere. +const SPECIAL = '!#$*@'; + +function pick(alphabet: string, random: (max: number) => number): string { + return alphabet[random(alphabet.length)]!; +} + +/** 24 characters: inside FTB's 15–32, with each class it requires. */ +export function generatePassword(random: (max: number) => number = randomInt, length = 24): string { + const all = LOWER + UPPER + DIGIT + SPECIAL; + const chars = [pick(LOWER, random), pick(UPPER, random), pick(DIGIT, random), pick(SPECIAL, random)]; + while (chars.length < length) chars.push(pick(all, random)); + for (let i = chars.length - 1; i > 0; i -= 1) { + const j = random(i + 1); + [chars[i], chars[j]] = [chars[j]!, chars[i]!]; + } + return chars.join(''); +} + +/** Letters and digits only, 4–17 characters, one per role (FTB allows one role per account). */ +export function generateUsername(role: Role, base: string, random: (max: number) => number = randomInt): string { + const stem = base.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10) || 'ftb'; + let suffix = role === 'business' ? 'b' : 'p'; + while (suffix.length < 5) suffix += pick(LOWER + DIGIT, random); + return `${stem}${suffix}`.slice(0, 17); +} + +/** A security answer: 10 lowercase letters/digits, inside the 3–17 FTB allows. */ +export function generateAnswer(random: (max: number) => number = randomInt): string { + let answer = ''; + while (answer.length < 10) answer += pick(LOWER + DIGIT, random); + return answer; +} + +// --------------------------------------------------------------------------- +// Fields and the rules that fill them +// --------------------------------------------------------------------------- + +export interface FieldOption { + value: string; + text: string; +} + +/** One form control as ftb-run.ts reads it off a page. */ +export interface Field { + /** A CSS selector that finds exactly this control. */ + selector: string; + id: string; + name: string; + type: string; + label: string; + required: boolean; + options?: FieldOption[]; + /** For a text box: the chosen option of the nearest select before it, or the question printed above it. */ + question?: string; +} + +export type Action = + | { kind: 'text'; value: string; secret?: boolean } + | { kind: 'select'; value: string } + | { kind: 'check' } + | { kind: 'declare' }; + +export interface Rule { + /** Shown in logs and errors. */ + name: string; + /** Tried against the id first, then `id name label`. */ + id?: RegExp; + label?: RegExp; + /** Radios and checkboxes only match when their type is listed. */ + types?: readonly string[]; + act: (field: Field) => Action | null; +} + +export interface Plan { + role: Role; + firstName: string; + lastName: string; + username: string; + password: string; + email: string; + addressNumbers: string; + zip: string; + year: number; + amount: number; + /** Business only. */ + corpId?: string | undefined; + /** Personal only. */ + ssn?: string | undefined; + filingStatus?: FilingStatus | undefined; + /** Answers already chosen, keyed by question text; filled as the profile page is met. */ + security: Record; + /** The person's own penalty-of-perjury statement, from `--declare`. */ + declare: boolean; + /** Activation only. */ + pin?: string; +} + +function option(field: Field, pattern: RegExp): Action | null { + const found = field.options?.find((o) => o.value !== '' && (pattern.test(o.text) || pattern.test(o.value))); + return found ? { kind: 'select', value: found.value } : null; +} + +const FILING_STATUS_TEXT: Record = { + single: /^\s*single/i, + 'married-joint': /joint/i, + 'married-separate': /separate/i, + 'head-of-household': /head of household/i, + 'qualifying-surviving-spouse': /surviving|widow/i, +}; + +/** A question this plan has not answered yet, chosen in page order so the three differ. */ +function securityQuestion(plan: Plan, field: Field): Action | null { + const used = new Set(Object.keys(plan.security)); + const choice = field.options?.find((o) => o.value !== '' && !used.has(o.text.trim())); + if (!choice) return null; + plan.security[choice.text.trim()] = generateAnswer(); + return { kind: 'select', value: choice.value }; +} + +/** + * The answer to a question: the one chosen in the select before this box at + * registration, or the stored question whose text the login page prints. + */ +function securityAnswer(plan: Plan, field: Field): Action | null { + const context = `${field.question ?? ''} ${field.label}`.toLowerCase(); + const known = Object.entries(plan.security).find(([question]) => context.includes(question.toLowerCase())); + if (known) return { kind: 'text', value: known[1], secret: true }; + const index = Number(field.id.match(/(\d)\s*$/)?.[1] ?? field.label.match(/answer\s*(\d)/i)?.[1] ?? 0); + const answer = index ? Object.values(plan.security)[index - 1] : undefined; + return answer ? { kind: 'text', value: answer, secret: true } : null; +} + +const SUBMIT_TYPES = ['text', 'password', 'email', 'tel', 'number', 'select-one']; + +/** + * The rule table. Field ids seen on FTB's pages come first and are exact; the + * label patterns after them cover pages not yet seen, and anything required that + * nothing matches stops the run. + */ +export function rulesFor(plan: Plan): Rule[] { + const text = (value: string, secret = false) => (): Action => ({ kind: 'text', value, secret }); + const rules: Rule[] = [ + { name: 'read terms', id: /^ReadTerms$/, types: ['checkbox'], act: () => ({ kind: 'check' }) }, + { name: 'accept terms', id: /^AcceptTerms$/, types: ['checkbox'], act: () => ({ kind: 'check' }) }, + { name: 'first name', id: /^FstName$/, act: text(plan.firstName.slice(0, 11)) }, + { name: 'middle initial', id: /^MInitial$/, act: () => null }, + { name: 'last name', id: /^LstName$/, act: text(plan.lastName.slice(0, 13)) }, + { name: 'suffix', id: /^Sffx$/, act: () => null }, + { name: 'user name again', id: /^ReUserName$/, act: text(plan.username) }, + { name: 'user name', id: /^UserName$/, act: text(plan.username) }, + { name: 'email again', id: /^ReEmail$/, act: text(plan.email) }, + { name: 'email', id: /^Email$/, act: text(plan.email) }, + { name: 'password again', id: /^RePassword$/, act: text(plan.password, true) }, + { name: 'password', id: /^Password$/, act: text(plan.password, true) }, + + // Profile: three security questions and their answers. + { name: 'security question', label: /question/i, types: ['select-one'], act: (f) => securityQuestion(plan, f) }, + { name: 'security question', label: /question/i, types: ['text', 'password'], act: (f) => securityAnswer(plan, f) }, + { name: 'security answer', label: /answer/i, types: ['text', 'password'], act: (f) => securityAnswer(plan, f) }, + + // Role. + { + name: 'role', + label: plan.role === 'business' ? /business representative/i : /\bindividual\b/i, + types: ['radio'], + act: () => ({ kind: 'check' }), + }, + + // Address on file. + { name: 'address numbers', label: /numbers? in .*address|address.*numbers?/i, types: SUBMIT_TYPES, act: text(plan.addressNumbers) }, + { name: 'zip', label: /zip/i, types: SUBMIT_TYPES, act: text(plan.zip) }, + + // The shared secret. + { name: 'tax year', label: /year/i, types: ['select-one'], act: (f) => option(f, new RegExp(`^\\s*${plan.year}\\s*$`)) }, + { name: 'tax year', label: /year/i, types: ['text', 'number', 'tel'], act: text(String(plan.year)) }, + { name: 'net income', label: /net income|income \(loss\)|adjusted gross|\bagi\b/i, types: SUBMIT_TYPES, act: text(formatAmount(plan.amount)) }, + + // The penalty-of-perjury statement: only ever the person's own. + { name: 'declaration', label: /perjury|i declare|under penalty/i, types: ['checkbox'], act: () => ({ kind: 'declare' }) }, + + // Activation. + { name: 'pin', label: /\bpin\b|personal identification number/i, types: SUBMIT_TYPES, act: () => (plan.pin ? { kind: 'text', value: plan.pin, secret: true } : null) }, + ]; + + if (plan.role === 'business') { + rules.push( + { name: 'company type', label: /type of company|company type|entity type/i, types: ['select-one'], act: (f) => option(f, /^\s*corporation\s*$/i) ?? option(f, /corporation/i) }, + { name: 'account number', label: /account number|entity id|corporation (id|number)/i, types: SUBMIT_TYPES, act: text(plan.corpId ?? '') }, + { name: 'form type', label: /form type|type of (tax )?(return|form)/i, types: ['select-one'], act: (f) => option(f, /100\s*S\b/i) }, + ); + } else { + const digits = (plan.ssn ?? '').replace(/\D/g, ''); + rules.push( + { name: 'filing status', label: /filing status/i, types: ['select-one'], act: (f) => option(f, FILING_STATUS_TEXT[plan.filingStatus ?? 'single']) }, + { name: 'filing status', label: /filing status|single|joint|separate|head of household|surviving/i, types: ['radio'], act: (f) => (FILING_STATUS_TEXT[plan.filingStatus ?? 'single'].test(f.label) ? { kind: 'check' } : null) }, + { + name: 'ssn', + label: /social security|\bssn\b|itin/i, + types: SUBMIT_TYPES, + act: (f) => { + // Split SSN boxes are 3, 2 and 4 wide; one box takes all nine digits. + const part = f.id.match(/(\d)$/)?.[1]; + const slices: Record = { '1': digits.slice(0, 3), '2': digits.slice(3, 5), '3': digits.slice(5) }; + return { kind: 'text', value: part && slices[part] !== undefined ? slices[part]! : digits, secret: true }; + }, + }, + { name: 'form type', label: /form type|type of (tax )?(return|form)/i, types: ['select-one'], act: (f) => option(f, /^\s*540\b(?!\s*NR)/i) }, + ); + } + return rules; +} + +/** The first rule whose pattern and type fit, and the action it chooses for the field. */ +export function decide(rules: readonly Rule[], field: Field): { rule: Rule; action: Action } | null { + const haystack = `${field.id} ${field.name} ${field.label}`; + for (const pass of ['id', 'label'] as const) { + for (const rule of rules) { + if (rule.types && !rule.types.includes(field.type)) continue; + const pattern = pass === 'id' ? rule.id : rule.label; + if (!pattern) continue; + if (!(pass === 'id' ? pattern.test(field.id) : pattern.test(haystack))) continue; + const action = rule.act(field); + return action ? { rule, action } : null; + } + } + return null; +} + +// --------------------------------------------------------------------------- +// What a page says about how it went +// --------------------------------------------------------------------------- + +export type Outcome = 'registered' | 'activated' | 'rejected' | 'continue'; + +export function outcomeOf(text: string): Outcome { + if (/does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked/i.test(text)) return 'rejected'; + if (/account (has been )?activated|activation (is )?complete/i.test(text)) return 'activated'; + if (/registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)/i.test(text)) return 'registered'; + return 'continue'; +} + +// --------------------------------------------------------------------------- +// State, vault and the hand-off card +// --------------------------------------------------------------------------- + +export interface Account { + role: Role; + username: string; + password: string; + email: string; + security: Record; + secret: { year: number; form: string; amount: number }; + registeredAt: string; + activatedAt?: string; + handoff?: string; +} + +export interface State { + accounts: Partial>; +} + +export function statePath(env: NodeJS.ProcessEnv = process.env): string { + const data = env.XDG_DATA_HOME || join(homedir(), '.local', 'share'); + return join(data, 'cli-tools', 'ftb.json'); +} + +/** The vault holding the logins: its own project, so pushing it can never drop another team key. */ +export const VAULT = { team: 'profullstack', project: 'ftb', env: 'prod' } as const; + +export function vaultKeys(account: Account): Record { + const prefix = `FTB_${account.role.toUpperCase()}`; + return { + [`${prefix}_USERNAME`]: account.username, + [`${prefix}_PASSWORD`]: account.password, + [`${prefix}_EMAIL`]: account.email, + [`${prefix}_SECURITY_ANSWERS`]: JSON.stringify(account.security), + }; +} + +/** .env text with every value quoted, so `#`, `$` and spaces survive. */ +export function toEnvFile(values: Record): string { + return `${Object.entries(values) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join('\n')}\n`; +} + +/** The myna card for the PIN letter. Steps only: no PIN, password or SSN ever goes on it. */ +export function handoffCard(role: Role, registeredAt: Date): { title: string; steps: string[]; text: string } { + const deadline = new Date(registeredAt.getTime() + 21 * 86_400_000).toISOString().slice(0, 10); + return { + title: `FTB PIN letter: ${role} MyFTB account`, + steps: [ + `Watch the mail at the address FTB has on file for the MyFTB PIN letter (5 to 10 business days).`, + `Activate before ${deadline}: the PIN expires 21 days after registration.`, + `Run the command below on the dev box with the PIN, or send the PIN to riotcoder to run it.`, + ], + text: `ftb activate ${role} --pin \n`, + }; +} diff --git a/src/registry.ts b/src/registry.ts index d16e729..99a33bc 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -50,6 +50,7 @@ const SUMMARIES: Record = { genrewatch: 'What is coming out, and whether it exists at all', img: 'Resize, convert and inspect images, with sharp or ImageMagick', keywords: 'The keywords and phrases a page repeats, ranked by count, from headless Chrome', + ftb: 'Create and activate MyFTB accounts, business and personal, from the CA returns on disk', statements: 'Download the PDF statements behind every SimpleFIN account, from each bank, into one folder', jobhunt: 'Find remote roles worth applying to, and apply through a browser, never twice', mail: 'The inbox from the terminal: read, search, reply, send, file, delete', diff --git a/test/ftb.test.ts b/test/ftb.test.ts new file mode 100644 index 0000000..586edd7 --- /dev/null +++ b/test/ftb.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { + type Extracted, + type Field, + type Plan, + addressNumbers, + businessSecrets, + decide, + eligibleYear, + formatAmount, + generatePassword, + generateUsername, + handoffCard, + latestIdentity, + maskSsn, + outcomeOf, + parseExtracted, + personalSecrets, + rulesFor, + toEnvFile, + vaultKeys, +} from '../src/ftb.ts'; + +const NOW = new Date('2026-10-04T12:00:00Z'); + +const ROWS: Extracted[] = [ + { kind: 'business', form: '100S', year: 2023, file: 'a.pdf', page: 26, netIncomeForState: -12345, netIncomeForTax: -12345, corpId: '1234567' }, + { kind: 'business', form: '100S', year: 2024, file: 'b.pdf', page: 38, netIncomeForState: 2468, netIncomeForTax: 0, corpId: '1234567' }, + { kind: 'business', form: '100S', year: 2019, file: 'old.pdf', page: 2, netIncomeForState: 5, netIncomeForTax: 5, corpId: '1234567' }, + { kind: 'personal', form: '540', year: 2024, file: 'p24.pdf', page: 45, caAgi: 98765 }, + { kind: 'personal', form: '540', year: 2025, file: 'p25.pdf', page: 40, caAgi: 87654 }, + { kind: 'identity', year: 2024, file: 'p24.pdf', page: 44, ssn: '123-45-6789', firstName: 'JANE', lastName: 'DOE', street: '1234 MAPLE ST', zip: '94000', filingStatus: 'single' }, + { kind: 'identity', year: 2025, file: 'p25.pdf', page: 39, ssn: '123-45-6789', firstName: 'JANE', lastName: 'DOE', street: '1234 MAPLE ST', zip: '94000', filingStatus: 'single' }, +]; + +function plan(overrides: Partial = {}): Plan { + return { + role: 'business', firstName: 'JANE', lastName: 'DOE', username: 'jdoeb1234', password: 'Pw!x'.repeat(6), email: 'a@example.com', + addressNumbers: '1234', zip: '94000', year: 2023, amount: -12345, corpId: '1234567', security: {}, declare: false, ...overrides, + }; +} + +function field(overrides: Partial): Field { + return { selector: '#x', id: '', name: '', type: 'text', label: '', required: true, ...overrides }; +} + +describe('secrets from the returns', () => { + it('keeps only the last five closed years', () => { + expect(eligibleYear(2025, NOW)).toBe(true); + expect(eligibleYear(2021, NOW)).toBe(true); + expect(eligibleYear(2020, NOW)).toBe(false); + expect(eligibleYear(2026, NOW)).toBe(false); + }); + + it('orders business secrets newest first, line 20 before 15, without duplicates', () => { + expect(businessSecrets(ROWS, NOW).map((s) => [s.year, s.line, s.amount])).toEqual([ + [2024, 20, 0], + [2024, 15, 2468], + [2023, 20, -12345], + [2023, 15, -12345], + ]); + }); + + it('pairs each 540 with the filing status from the same return', () => { + expect(personalSecrets(ROWS, NOW).map((s) => [s.year, s.amount, s.filingStatus])).toEqual([ + [2025, 87654, 'single'], + [2024, 98765, 'single'], + ]); + }); + + it('takes the newest complete identity', () => { + expect(latestIdentity(ROWS)?.year).toBe(2025); + }); + + it('drops records ftb-extract.py would never print', () => { + expect(parseExtracted('[{"kind":"business","year":2024},{"kind":"nope","year":1},{"year":2}]')).toHaveLength(1); + expect(() => parseExtracted('not json')).toThrow(/not JSON/); + }); +}); + +describe('formats FTB insists on', () => { + it('keeps only the numbers of the street address', () => { + expect(addressNumbers('1234 MAPLE ST')).toBe('1234'); + }); + + it('writes whole dollars with a dash for a loss and no comma', () => { + expect(formatAmount(-12345)).toBe('-12345'); + expect(formatAmount(2468.4)).toBe('2468'); + }); + + it('masks all but the last four of an SSN', () => { + expect(maskSsn('123-45-6789')).toBe('***-**-6789'); + }); +}); + +describe('credentials', () => { + it('makes a 24-character password with every class FTB requires', () => { + for (let i = 0; i < 50; i += 1) { + const password = generatePassword(); + expect(password).toHaveLength(24); + expect(password).toMatch(/[a-z]/); + expect(password).toMatch(/[A-Z]/); + expect(password).toMatch(/\d/); + expect(password).toMatch(/[!#$*@]/); + } + }); + + it('makes a letters-and-digits user name of 4 to 17 characters, marked by role', () => { + const business = generateUsername('business', 'J Doe-Hyphenated-Long'); + expect(business).toMatch(/^[a-z0-9]{4,17}$/); + expect(business.startsWith('jdoehyphenb')).toBe(true); + expect(generateUsername('personal', 'jdoe')).toMatch(/^jdoep[a-z0-9]{4}$/); + }); +}); + +describe('rules', () => { + it('fills the Name page by id, re-entry boxes included', () => { + const p = plan(); + const rules = rulesFor(p); + expect(decide(rules, field({ id: 'ReUserName', label: 'Re-enter User Name' }))?.action).toEqual({ kind: 'text', value: 'jdoeb1234', secret: false }); + expect(decide(rules, field({ id: 'Password', type: 'password', label: 'Password' }))?.action).toMatchObject({ kind: 'text', secret: true }); + expect(decide(rules, field({ id: 'MInitial', required: false }))).toBeNull(); + }); + + it('fills the business page from labels: address, company, account, year, form, amount', () => { + const rules = rulesFor(plan()); + expect(decide(rules, field({ id: 'AddrNum', label: 'Numbers in Mailing Address' }))?.action).toMatchObject({ value: '1234' }); + expect(decide(rules, field({ id: 'Zip', label: 'US Zip Code' }))?.action).toMatchObject({ value: '94000' }); + const company = field({ type: 'select-one', label: 'Type of company', options: [{ value: '', text: '' }, { value: 'L', text: 'Limited Liability Company' }, { value: 'C', text: 'Corporation' }] }); + expect(decide(rules, company)?.action).toEqual({ kind: 'select', value: 'C' }); + expect(decide(rules, field({ label: 'Account number' }))?.action).toMatchObject({ value: '1234567' }); + const year = field({ type: 'select-one', label: 'Year of the tax return', options: [{ value: '2024', text: '2024' }, { value: '2023', text: '2023' }] }); + expect(decide(rules, year)?.action).toEqual({ kind: 'select', value: '2023' }); + const form = field({ type: 'select-one', label: 'Tax form type', options: [{ value: '100', text: '100' }, { value: '100S', text: '100S' }] }); + expect(decide(rules, form)?.action).toEqual({ kind: 'select', value: '100S' }); + expect(decide(rules, field({ label: 'Net Income (Loss) shown on that form' }))?.action).toMatchObject({ value: '-12345' }); + }); + + it('fills the personal page: SSN whole or split, filing status, AGI', () => { + const rules = rulesFor(plan({ role: 'personal', corpId: undefined, ssn: '123-45-6789', filingStatus: 'single', amount: 98765, year: 2024 })); + expect(decide(rules, field({ id: 'SSN', label: 'Social Security Number' }))?.action).toMatchObject({ value: '123456789', secret: true }); + expect(decide(rules, field({ id: 'Ssn2', label: 'Social Security Number' }))?.action).toMatchObject({ value: '45' }); + const status = field({ type: 'select-one', label: 'Filing status', options: [{ value: '1', text: 'Single' }, { value: '2', text: 'Married/RDP filing jointly' }] }); + expect(decide(rules, status)?.action).toEqual({ kind: 'select', value: '1' }); + expect(decide(rules, field({ label: 'California adjusted gross income' }))?.action).toMatchObject({ value: '98765' }); + }); + + it('picks three different security questions and answers each with its own', () => { + const p = plan(); + const rules = rulesFor(p); + const options = [{ value: '', text: 'Select' }, { value: 'a', text: 'Favorite color?' }, { value: 'b', text: 'First pet?' }, { value: 'c', text: 'Birth city?' }]; + const chosen = [1, 2, 3].map((n) => decide(rules, field({ id: `Question${n}`, type: 'select-one', label: `Question ${n}`, options }))?.action); + expect(chosen).toEqual([{ kind: 'select', value: 'a' }, { kind: 'select', value: 'b' }, { kind: 'select', value: 'c' }]); + const answer = decide(rules, field({ id: 'Answer2', label: 'Answer 2', question: 'First pet?' }))?.action; + expect(answer).toEqual({ kind: 'text', value: p.security['First pet?'], secret: true }); + }); + + it('answers a login-time question from the stored answers', () => { + const rules = rulesFor(plan({ security: { 'First pet?': 'rex12345ab' } })); + expect(decide(rules, field({ id: 'Answer', label: 'Answer', question: 'First pet?' }))?.action).toMatchObject({ value: 'rex12345ab' }); + }); + + it('turns the perjury box into a declare action the run only takes with --declare', () => { + const rules = rulesFor(plan()); + expect(decide(rules, field({ type: 'checkbox', label: 'Under penalty of perjury, I declare that the information is true' }))?.action).toEqual({ kind: 'declare' }); + }); + + it('fills the PIN only when activating', () => { + const pin = field({ label: 'Personal Identification Number (PIN)' }); + expect(decide(rulesFor(plan()), pin)).toBeNull(); + expect(decide(rulesFor(plan({ pin: '4821' })), pin)?.action).toMatchObject({ value: '4821' }); + }); +}); + +describe('what a page says', () => { + it('reads a rejection, a registration and an activation', () => { + expect(outcomeOf('There is a problem. Check below. The information you entered does not match our records.')).toBe('rejected'); + expect(outcomeOf('Registration Confirmation. We will mail you a letter with your PIN.')).toBe('registered'); + expect(outcomeOf('Your account has been activated.')).toBe('activated'); + expect(outcomeOf('Business Representative Registration. Enter the numbers in your mailing address.')).toBe('continue'); + }); +}); + +describe('storage and the hand-off card', () => { + const account = { role: 'business' as const, username: 'u', password: 'p#$ "x', email: 'e@x.y', security: { 'Q?': 'a' }, secret: { year: 2024, form: '100S', amount: 0 }, registeredAt: NOW.toISOString() }; + + it('quotes every .env value so # and $ survive', () => { + expect(toEnvFile(vaultKeys(account))).toContain('FTB_BUSINESS_PASSWORD="p#$ \\"x"'); + }); + + it('puts steps and a deadline on the card, and never a secret', () => { + const card = handoffCard('business', NOW); + const all = [card.title, ...card.steps, card.text].join('\n'); + expect(all).toContain('2026-10-25'); + expect(all).toContain('ftb activate business --pin'); + expect(all).not.toMatch(/p#\$|password|\d{3}-\d{2}-\d{4}/i); + }); +});