From 80d3e9bc5cf93de93c88fc986b2312ec55cac6b3 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 22 Oct 2026 23:15:51 +0000 Subject: [PATCH] ftb: register LLCs (Form 568), always ask for the PIN letter, reissue (0.71.0) - New `llc` role: business representative with an entity id, Form 568, "Total income from line 1" (--entity-id, --year, --amount). - The Account Activation Options page (TransUnion quiz or PIN letter) now picks "Mail a PIN letter". Its text mentions a PIN letter, which outcomeOf read as "registered", so a personal registration could end with no letter ever requested. A page still asking a radio question is never a result. - `ftb reissue ` logs in to ask for a new letter; status shows it. Co-Authored-By: Claude Opus 5.5 --- bin/ftb.ts | 50 ++++++++++++++++++++++++++++++++++-------------- package.json | 2 +- src/ftb-run.ts | 2 +- src/ftb.ts | 30 ++++++++++++++++++++++------- test/ftb.test.ts | 31 ++++++++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 23 deletions(-) diff --git a/bin/ftb.ts b/bin/ftb.ts index 750f985..2b30a34 100755 --- a/bin/ftb.ts +++ b/bin/ftb.ts @@ -7,6 +7,8 @@ * 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 register llc --entity-id N --year 2024 --amount=0 --email a@b.c --declare + * ftb reissue personal log in and ask for a new PIN letter * ftb activate business --pin 1234 after the PIN letter arrives * ftb status * @@ -63,8 +65,9 @@ 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 register create the MyFTB account in headless Chrome + ftb reissue log in and ask FTB to mail a new PIN letter + 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: @@ -73,6 +76,8 @@ register options: --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) + --entity-id N llc: its entity id (required); llc also needs --year and --amount + (Form 568 line 1, total income), which are not read from the PDFs --amount=N override the amount (a loss is --amount=-12345) --phone N 10 digits FTB texts a verification code to (required) --call have FTB phone the code in instead of texting it @@ -218,7 +223,7 @@ function chooseSecret(all: T[], year: export async function main(argv: readonly string[]): Promise { const args = parseArgs(argv, { boolean: ['--help', '-h', '--json', '--declare', '--dry-run', '--headful', '--no-vault', '--call', '--force'], - string: ['--dir', '--email', '--year', '--line', '--amount', '--pin', '--chrome', '--phone'], + string: ['--dir', '--email', '--year', '--line', '--amount', '--pin', '--chrome', '--phone', '--entity-id'], }); const [command, roleArg] = args.positional; if (args.flags.has('--help') || args.flags.has('-h') || !command) { @@ -241,13 +246,13 @@ export async function main(argv: readonly string[]): Promise { 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, activate by ${activationDeadline(a.registeredAt)}`}`); + 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.pinRequestedAt ? ` asked for ${a.pinRequestedAt.slice(0, 10)}` : ''}, activate by ${activationDeadline(a.pinRequestedAt ?? a.registeredAt)}`}`); } 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`); + if (command !== 'register' && command !== 'activate' && command !== 'reissue') throw new UsageError(`unknown command: ${command}`); + if (!ROLES.includes(roleArg as Role)) throw new UsageError(`${command} needs ${ROLES.join(', ')}`); const role = roleArg as Role; const state = loadState(); const say = (line: string) => err(line); @@ -256,23 +261,33 @@ export async function main(argv: readonly string[]): Promise { const profile = join(dirname(statePath()), 'ftb-chrome'); const headless = !args.flags.has('--headful'); - if (command === 'activate') { + if (command === 'activate' || command === 'reissue') { const account = state.accounts[role]; if (!account) throw new FtbError(`no ${role} account in ${statePath()}; run \`ftb register ${role}\` first`); + if (account.activatedAt) throw new FtbError(`the ${role} account ${account.username} is already active`); + const reissue = command === 'reissue'; const pin = args.values.get('--pin'); - if (!pin || !/^\w{4,12}$/.test(pin)) throw new UsageError('--pin is the PIN from the letter'); + if (!reissue && (!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, + addressNumbers: '', zip: '', year: account.secret.year, amount: account.secret.amount, security: { ...account.security }, declare: false, + ...(pin && !reissue ? { pin } : {}), }; - gate(role, 'activate', args.flags.has('--force')); + gate(role, command, args.flags.has('--force')); const session = await openSession({ chrome: args.values.get('--chrome'), headless, profile }); try { await goto(session, LOGIN_URL); - say(`Activating the ${role} account ${account.username}:`); + say(`${reissue ? 'Asking for a new PIN letter for' : 'Activating'} the ${role} account ${account.username}:`); const result = await walk(session, { plan, rules: rulesFor(plan), dryRun: false, log, codeFile, interactive: process.stdin.isTTY === true, say }); noteResult(role, `${result.page.title} ${result.page.text}`); if (result.outcome === 'rejected') throw new FtbError(`FTB said: ${result.page.errors.join(' ') || pageMessage(result.page.text)} (${result.page.url})`); + if (reissue) { + if (result.outcome !== 'registered') throw new FtbError(`FTB did not say a letter is coming: ${pageMessage(result.page.text)} (${result.page.url})`); + account.pinRequestedAt = new Date().toISOString(); + saveState(state); + out(`New PIN letter requested for the ${role} account ${account.username}; activate by ${activationDeadline(account.pinRequestedAt)}.`); + return 0; + } account.activatedAt = new Date().toISOString(); saveState(state); out(`${role} MyFTB account ${account.username} is active.`); @@ -298,9 +313,16 @@ export async function main(argv: readonly string[]): Promise { 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 entityId = (args.values.get('--entity-id') ?? '').replace(/\D/g, ''); + if (role === 'llc' && (!entityId || year === undefined || amountOverride === undefined)) { + throw new UsageError('llc needs --entity-id, --year and --amount (Form 568 line 1, total income)'); + } + const secret = + role === 'llc' + ? { year: year!, form: '568', amount: Number(amountOverride), corpId: entityId, source: '--year/--amount' } + : role === 'business' ? chooseSecret(businessSecrets(rows), year, line) : chooseSecret(personalSecrets(rows), year, undefined); const amount = amountOverride !== undefined ? Number(amountOverride) : secret.amount; const plan: Plan = { @@ -324,8 +346,8 @@ export async function main(argv: readonly string[]): Promise { }; 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!)}`}`); + say(` shared secret: ${secret.year} Form ${secret.form}${'line' in secret ? ` line ${secret.line}` : 'filingStatus' in secret ? ` (${plan.filingStatus})` : ''} = ${formatAmount(amount)} from ${secret.source}`); + say(` address on file: ${plan.addressNumbers} / ${plan.zip}${plan.corpId ? ` ${role === 'llc' ? 'entity' : 'corp'} ${plan.corpId}` : ` SSN ${maskSsn(plan.ssn!)}`}`); // A dry run stops before the page that creates anything, so it is not an attempt. if (!args.flags.has('--dry-run')) gate(role, 'register', args.flags.has('--force')); diff --git a/package.json b/package.json index 79ea2b3..89bfe2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.70.0", + "version": "0.71.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/src/ftb-run.ts b/src/ftb-run.ts index 007b932..684567c 100644 --- a/src/ftb-run.ts +++ b/src/ftb-run.ts @@ -259,7 +259,7 @@ export async function walk(session: Session, options: WalkOptions): Promise 0 && page.errors.every((e) => /code you entered/i.test(e)) && page.fields.some((f) => CODE_LABEL.test(f.label)); if (codeRetry) say(` FTB: ${page.errors[0]}`); - const outcome = codeRetry ? 'continue' : outcomeOf(`${page.errors.join(' ')} ${page.errors.length ? '' : page.text}`); + const outcome = codeRetry ? 'continue' : outcomeOf(`${page.errors.join(' ')} ${page.errors.length ? '' : page.text}`, page.fields); if (outcome === 'rejected' || (page.errors.length && step > 0 && !codeRetry)) return { outcome: 'rejected', page }; if (outcome !== 'continue') return { outcome, page }; diff --git a/src/ftb.ts b/src/ftb.ts index 067617c..c9e20e6 100644 --- a/src/ftb.ts +++ b/src/ftb.ts @@ -33,8 +33,9 @@ export class FtbError extends Error { } } -export type Role = 'business' | 'personal'; -export const ROLES: readonly Role[] = ['business', 'personal']; +/** `llc`: a second business entity, an LLC that files Form 568, registered by its own representative account. */ +export type Role = 'business' | 'personal' | 'llc'; +export const ROLES: readonly Role[] = ['business', 'personal', 'llc']; export const REGISTER_URL = 'https://webapp.ftb.ca.gov/MyFTBAccess/Registration/NewAccount'; export const LOGIN_URL = 'https://webapp.ftb.ca.gov/MyFTBAccess/'; @@ -207,7 +208,7 @@ export function generatePassword(random: (max: number) => number = randomInt, le /** 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'; + let suffix = role === 'business' ? 'b' : role === 'llc' ? 'l' : 'p'; while (suffix.length < 5) suffix += pick(LOWER + DIGIT, random); return `${stem}${suffix}`.slice(0, 17); } @@ -270,7 +271,7 @@ export interface Plan { zip: string; year: number; amount: number; - /** Business only. */ + /** Business: the corporation number. LLC: the entity id (SOS file number or FTB's own). */ corpId?: string | undefined; /** Personal only. */ ssn?: string | undefined; @@ -359,7 +360,7 @@ export function rulesFor(plan: Plan): Rule[] { label: /individual|business representative/i, types: ['radio'], // By how the option starts: "Tax Professional - ... your individual or business entity client" names both. - act: (f) => ((plan.role === 'business' ? /^\s*business representative/i : /^\s*individual\b/i).test(f.label) ? { kind: 'check' } : null), + act: (f) => ((plan.role !== 'personal' ? /^\s*business representative/i : /^\s*individual\b/i).test(f.label) ? { kind: 'check' } : null), }, // Address on file. @@ -369,7 +370,7 @@ export function rulesFor(plan: Plan): Rule[] { // The shared secret. { name: 'tax year', label: /year (of|on) the tax return|tax year/i, types: ['select-one'], act: (f) => option(f, new RegExp(`^\\s*${plan.year}\\s*$`)) }, { name: 'tax year', label: /year (of|on) the tax return|tax 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)) }, + { name: 'net income', label: /net income|income \(loss\)|total income|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' }) }, @@ -384,6 +385,11 @@ export function rulesFor(plan: Plan): Rule[] { { name: 'user name', label: /^user name/i, types: ['text'], act: text(plan.username) }, { name: 'password', label: /^password/i, types: ['password'], act: text(plan.password, true) }, + // After the code: how to activate. The instant route is a TransUnion credit quiz only the person can + // answer, so the letter it is. Missing this left an account registered with no letter ever requested. + { name: 'mail a PIN letter', id: /^r_RegistrationCriteria\d$/, types: ['radio'], act: (f) => (/^\s*mail a pin letter/i.test(f.label) ? { kind: 'check' } : null) }, + { name: 'mail a PIN letter', label: /mail (me )?a pin letter|activate your account now/i, types: ['radio'], act: (f) => (/^\s*mail (me )?a pin letter/i.test(f.label) ? { kind: 'check' } : null) }, + // Activation. { name: 'pin', label: /\bpin\b|personal identification number/i, types: SUBMIT_TYPES, act: () => (plan.pin ? { kind: 'text', value: plan.pin, secret: true } : null) }, ]; @@ -394,6 +400,12 @@ export function rulesFor(plan: Plan): Rule[] { { 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 if (plan.role === 'llc') { + rules.push( + { name: 'company type', label: /type of company|company type|entity type/i, types: ['select-one'], act: (f) => option(f, /limited liability/i) ?? option(f, /\bllc\b/i) }, + { name: 'account number', label: /account number|entity id|secretary of state|file 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, /\b568\b/) }, + ); } else { const digits = (plan.ssn ?? '').replace(/\D/g, ''); rules.push( @@ -439,9 +451,11 @@ export function decide(rules: readonly Rule[], field: Field): { rule: Rule; acti export type Outcome = 'registered' | 'activated' | 'rejected' | 'continue'; -export function outcomeOf(text: string): Outcome { +export function outcomeOf(text: string, fields: readonly Field[] = []): Outcome { if (/does not match our records|there is a problem|unable to (verify|process) your|account (is|has been) locked|account locked|exceeded the allowed number of attempts/i.test(text)) return 'rejected'; if (/account (has been )?activated|activation (is )?complete/i.test(text)) return 'activated'; + // "Mail a PIN letter" is an option on a page still waiting for an answer, not news that one is coming. + if (fields.some((f) => f.type === 'radio')) return 'continue'; if (/registration confirmation|successfully (registered|created)|we will (mail|send) you a (letter|pin)|pin .*(mail|letter)/i.test(text)) return 'registered'; return 'continue'; } @@ -458,6 +472,8 @@ export interface Account { security: Record; secret: { year: number; form: string; amount: number }; registeredAt: string; + /** When `ftb reissue` last asked for a new letter: the 21 days run from here. */ + pinRequestedAt?: string; activatedAt?: string; } diff --git a/test/ftb.test.ts b/test/ftb.test.ts index 9471fbd..6227b10 100644 --- a/test/ftb.test.ts +++ b/test/ftb.test.ts @@ -178,6 +178,37 @@ describe('rules', () => { }); }); +describe('an LLC (Form 568)', () => { + it('picks Limited Liability Company, the entity id, Form 568 and the total income', () => { + const rules = rulesFor(plan({ role: 'llc', corpId: '200000000001', amount: 0, year: 2024 })); + 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: 'L' }); + expect(decide(rules, field({ label: 'Entity ID' }))?.action).toMatchObject({ value: '200000000001' }); + const form = field({ type: 'select-one', label: 'Tax form type', options: [{ value: '565', text: '565' }, { value: '568', text: '568' }] }); + expect(decide(rules, form)?.action).toEqual({ kind: 'select', value: '568' }); + expect(decide(rules, field({ label: 'Total income from Schedule IW' }))?.action).toMatchObject({ value: '0' }); + expect(decide(rules, field({ type: 'radio', label: 'Business Representative - I am the owner' }))?.action).toEqual({ kind: 'check' }); + expect(generateUsername('llc', 'jdoe', () => 0)).toBe('jdoelaaaa'); + }); +}); + +describe('account activation options', () => { + const options = [ + field({ selector: '#r_RegistrationCriteria0', id: 'r_RegistrationCriteria0', type: 'radio', required: false, label: 'Activate your account now: You will need to provide additional information and answer questions that will be validated by an external credit reporting agency (TransUnion).' }), + field({ selector: '#r_RegistrationCriteria1', id: 'r_RegistrationCriteria1', type: 'radio', required: false, label: 'Mail a PIN letter: We will mail you a Personal Identification Number (PIN) letter to activate your account (allow 5-7 business days).' }), + ]; + + it('asks for the letter, never the credit quiz', () => { + const rules = rulesFor(plan({ role: 'personal' })); + expect(decide(rules, options[0]!)).toBeNull(); + expect(decide(rules, options[1]!)?.action).toEqual({ kind: 'check' }); + }); + + it('is a question, not a registration, even though it says "PIN letter"', () => { + expect(outcomeOf('Account Activation Options. Mail a PIN letter: We will mail you a PIN letter', options)).toBe('continue'); + }); +}); + 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');