Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions bin/ftb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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 <business|personal> create the MyFTB account in headless Chrome
ftb activate <business|personal> log in and enter the PIN from FTB's letter
ftb register <business|personal|llc> create the MyFTB account in headless Chrome
ftb reissue <role> log in and ask FTB to mail a new PIN letter
ftb activate <role> log in and enter the PIN from FTB's letter
ftb status which accounts exist, and what each is waiting on

register options:
Expand All @@ -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
Expand Down Expand Up @@ -218,7 +223,7 @@ function chooseSecret<T extends BusinessSecret | PersonalSecret>(all: T[], year:
export async function main(argv: readonly string[]): Promise<number> {
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) {
Expand All @@ -241,13 +246,13 @@ export async function main(argv: readonly string[]): Promise<number> {
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);
Expand All @@ -256,23 +261,33 @@ export async function main(argv: readonly string[]): Promise<number> {
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.`);
Expand All @@ -298,9 +313,16 @@ export async function main(argv: readonly string[]): Promise<number> {
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 = {
Expand All @@ -324,8 +346,8 @@ export async function main(argv: readonly string[]): Promise<number> {
};

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'));
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion src/ftb-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export async function walk(session: Session, options: WalkOptions): Promise<Walk
}
const codeRetry = page.errors.length > 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 };

Expand Down
30 changes: 23 additions & 7 deletions src/ftb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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' }) },
Expand All @@ -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) },
];
Expand All @@ -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(
Expand Down Expand Up @@ -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';
}
Expand All @@ -458,6 +472,8 @@ export interface Account {
security: Record<string, string>;
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;
}

Expand Down
31 changes: 31 additions & 0 deletions test/ftb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading