From 4e7509cc6242c7153564a3a629e7bfb4e214a4d9 Mon Sep 17 00:00:00 2001 From: Robert Sesek Date: Fri, 21 Feb 2020 13:15:48 -0500 Subject: [PATCH] Strongly type Form lines. This completely rewrites Form so that getLine and getValue are fully type-checked. --- src/Form.test.ts | 141 ++++++++++++++++------------ src/Form.ts | 45 ++++----- src/Line.test.ts | 60 +++++------- src/Line.ts | 6 +- src/TaxReturn.test.ts | 15 ++- src/TaxReturn.ts | 10 +- src/fed2019/Form1040.test.ts | 6 +- src/fed2019/Form1040.ts | 177 +++++++++++++++++------------------ src/fed2019/FormW2.ts | 42 ++++----- 9 files changed, 248 insertions(+), 254 deletions(-) diff --git a/src/Form.test.ts b/src/Form.test.ts index 55078a7..5c2d732 100644 --- a/src/Form.test.ts +++ b/src/Form.test.ts @@ -6,14 +6,10 @@ import { InconsistencyError, NotFoundError } from './Errors'; test('add and get line', () => { const l = new ComputedLine('1', () => 42); - class TestForm extends Form { - get name(): string { - return 'Test Form'; - } - - protected getLines() { - return [l]; - } + class TestForm extends Form { + readonly name = 'Test Form'; + + protected readonly _lines = { '1': l }; }; const f = new TestForm(); @@ -21,53 +17,17 @@ test('add and get line', () => { }); test('get non-existent line', () => { - class TestForm extends Form { - get name(): string { - return 'Test Form'; - } - - protected getLines() { - return []; - } + class TestForm extends Form { + readonly name = 'Test'; + protected readonly _lines = {}; }; const f = new TestForm(); - expect(() => f.getLine('1')).toThrow(NotFoundError); -}); - -test('add duplicate line', () => { - const l1 = new ComputedLine('1', () => 42); - const l2 = new ComputedLine('1', () => 36); - - class TestForm extends Form { - get name(): string { - return 'Test Form'; - } - - protected getLines() { - return [l1, l2]; - } - }; - - expect(() => new TestForm()).toThrow(InconsistencyError); -}); + const fAsAny: Form = f; + expect(() => fAsAny.getLine('line')).toThrow(NotFoundError); -test('add line to two forms', () => { - const l = new ComputedLine('bad', () => 'bad'); - - class TestForm1 extends Form { - get name(): string { return '1'; } - - protected getLines() { return [ l ]; } - }; - class TestForm2 extends Form { - get name(): string { return '2'; } - - protected getLines() { return [ l ]; } - }; - - const f1 = new TestForm1(); - expect(() => new TestForm2()).toThrow(InconsistencyError); + //TYPEERROR: + //expect(() => f.getLine('line')).toThrow(NotFoundError); }); test('input', () => { @@ -75,10 +35,10 @@ test('input', () => { filingStatus: string; money: number; }; - class TestForm extends Form { - get name() { return '1040'; } + class TestForm extends Form { + readonly name = '1040'; - protected getLines() { return []; } + protected readonly _lines = null; }; const f = new TestForm({ filingStatus: 'S', money: 100.0 }); @@ -86,15 +46,78 @@ test('input', () => { }); test('get value', () => { - class TestForm extends Form { - get name() { return 'Form'; } + class TestForm extends Form { + readonly name = 'Form'; - protected getLines() { - return [ new ComputedLine('line', () => 42) ]; - } + protected readonly _lines = { + line: new ComputedLine('line', () => 42), + }; }; const f = new TestForm(); const tr = new TaxReturn(2019); expect(f.getValue(tr, 'line')).toBe(42); + + //TYPEERROR: + //let s: string = f.getValue(tr, 'line'); + + const fAsAny: Form = f; + expect(() => fAsAny.getValue(tr, 'other')).toThrow(NotFoundError); + //TYPEERROR: + //expect(() => f.getValue(tr, 'other')).toThrow(NotFoundError); +}); + +/* +abstract class Form2 } , I> { + abstract readonly name: string; + + protected abstract readonly _lines: L; + protected abstract readonly _input?: I; + + getLine(key: K): L[K] { + return this._lines[key]; + } + + getInput(key: K): I[K] { + return this._input[key]; + } + + getValue(tr: TaxReturn, key: K): T { + const line = this.getLine(key); + return line.value(tr); + } +}; + +class FormG extends Form2 { + readonly name = 'G'; + + protected readonly _lines = { + x: new ComputedLine('moo', () => 42), + z: new ComputedLine('moo', () => 36), + }; + protected readonly _input = null; + + private _moo = 42; + + getLineImpl(lines: T, k: K): T[K] { + return lines[k]; + } + + allLines(): FormG['_lines'] { + return this._lines; + } + + LINE = k => this.getLineImpl(this._lines, k); + + testLine>(k: K): any { + return this.getLineImpl(this._lines, k); + } +} + +test('testing', () => { + const g = new FormG(); + let v = g.testLine('x'); //g.getLineImpl(g._lines, 'x'); + let v2 = g.getValue(null, 'z'); + throw new Error(`v = ${v2}`); }); +*/ diff --git a/src/Form.ts b/src/Form.ts index 5fe5862..f68d975 100644 --- a/src/Form.ts +++ b/src/Form.ts @@ -2,45 +2,32 @@ import TaxReturn from './TaxReturn'; import { Line } from './Line'; import { InconsistencyError, NotFoundError } from './Errors'; -export default abstract class Form { - private _lines: Line[] = []; - private _input?: I; +export default abstract class Form }, + I = unknown> { + abstract readonly name: string; - abstract get name(): string; + protected abstract readonly _lines: L; readonly supportsMultipleCopies: boolean = false; + private readonly _input?: I; + constructor(input?: I) { this._input = input; - this.getLines().map(this.addLine.bind(this)); } - protected abstract getLines(): Line[]; - - private addLine(line: Line) { - if (line.form !== undefined) { - throw new InconsistencyError('Line is already in a Form'); - } - try { - this.getLine(line.id); - } catch { - line.form = this; - this._lines.push(line); - return; - } - throw new InconsistencyError('Cannot add a line with a duplicate identifier'); - } - - getLine(id: string): Line { - const lines = this._lines.filter(l => l.id === id); - if (lines.length == 0) { - throw new NotFoundError(id); - } - return lines[0]; + getLine(id: K): L[K] { + if (!(id in this._lines)) + throw new NotFoundError(`Form ${this.name} does not have line ${id}`); + const line = this._lines[id]; + // We cannot apply this to every line in the constructor because |_lines| + // is abstract, so do it on getLine(). + line.form = this; + return line; } - getValue(tr: TaxReturn, id: string): T { - const line: Line = this.getLine(id); + getValue(tr: TaxReturn, id: K): ReturnType { + const line: L[K] = this.getLine(id); return line.value(tr); } diff --git a/src/Line.test.ts b/src/Line.test.ts index 2c6558a..20e4d96 100644 --- a/src/Line.test.ts +++ b/src/Line.test.ts @@ -31,12 +31,11 @@ test('computed line', () => { }); test('reference line', () => { - class TestForm extends Form { - get name() { return 'Form 1'; } - - protected getLines() { - return [ new ConstantLine('6b', 12.34) ]; - } + class TestForm extends Form { + readonly name = 'Form 1'; + protected readonly _lines = { + '6b': new ConstantLine('6b', 12.34) + }; }; const tr = new TaxReturn(2019); @@ -57,15 +56,12 @@ test('input line', () => { key: string; key2?: string; } - class TestForm extends Form { - get name() { return 'F1'; } - - protected getLines() { - return [ - new InputLine('1', 'key'), - new InputLine('2', 'key2') - ]; - } + class TestForm extends Form { + readonly name = 'F1'; + protected readonly _lines = { + '1': new InputLine('1', 'key'), + '2': new InputLine('2', 'key2') + }; }; const tr = new TaxReturn(2019); const f = new TestForm({ 'key': 'value' }); @@ -77,24 +73,20 @@ test('input line', () => { }); test('line stack', () => { - class FormZ extends Form { - get name() { return 'Z'; } - - protected getLines() { - return [ new InputLine('3', 'input') ]; + class FormZ extends Form { + readonly name = 'Z'; + protected readonly _lines = { + '3': new InputLine('3', 'input') } }; - class FormZ2 extends Form { - get name() { return 'Z-2'; } - - protected getLines() { - return [ - new ComputedLine('2c', (tr: TaxReturn, l: Line): any => { + class FormZ2 extends Form { + readonly name = 'Z-2'; + protected readonly _lines = { + '2c': new ComputedLine('2c', (tr: TaxReturn, l: Line): any => { return tr.getForm('Z').getLine('3').value(tr) * 0.2; }) - ]; - } + }; }; const tr = new TaxReturn(2019); @@ -106,14 +98,12 @@ test('line stack', () => { }); test('accumulator line', () => { - class TestForm extends Form { - get name() { return 'Form B'; } - + class TestForm extends Form { + readonly name = 'Form B'; readonly supportsMultipleCopies = true; - - protected getLines() { - return [ new ConstantLine('g', 100.25) ] - } + protected readonly _lines = { + g: new ConstantLine('g', 100.25) + }; }; const tr = new TaxReturn(2019); diff --git a/src/Line.ts b/src/Line.ts index 6a7b791..1e6543c 100644 --- a/src/Line.ts +++ b/src/Line.ts @@ -5,7 +5,7 @@ export abstract class Line { private _id: string; private _description?: string; - form: Form; + form: Form; constructor(id: string, description?: string) { this._id = id; @@ -56,7 +56,7 @@ export class ReferenceLine extends Line { export class InputLine extends Line { private _input: T; - form: Form; + form: Form; constructor(id: string, input: T, description?: string) { super(id, description); @@ -80,7 +80,7 @@ export class AccumulatorLine extends Line { value(tr: TaxReturn): number { const forms = tr.getForms(this._form); - const reducer = (acc: number, curr: Form) => acc + curr.getValue(tr, this._line); + const reducer = (acc: number, curr: Form) => acc + (curr.getValue(tr, this._line) as number); return forms.reduce(reducer, 0); } }; diff --git a/src/TaxReturn.test.ts b/src/TaxReturn.test.ts index b327918..5914dcf 100644 --- a/src/TaxReturn.test.ts +++ b/src/TaxReturn.test.ts @@ -53,10 +53,9 @@ test('get non-existent person', () => { }); test('single-copy forms', () => { - class TestForm extends Form { - get name(): string { return 'Test Form'; } - - protected getLines() { return []; } + class TestForm extends Form { + readonly name = 'Test Form'; + protected readonly _lines = null; }; const tr = new TaxReturn(2019); @@ -67,12 +66,10 @@ test('single-copy forms', () => { }); test('multiple-copy forms', () => { - class TestForm extends Form { - get name(): string { return 'Test Form'; } - + class TestForm extends Form { + readonly name = 'Test Form'; readonly supportsMultipleCopies = true; - - protected getLines() { return []; } + protected readonly _lines = null; }; const tr = new TaxReturn(2019); diff --git a/src/TaxReturn.ts b/src/TaxReturn.ts index a8e64fb..375c629 100644 --- a/src/TaxReturn.ts +++ b/src/TaxReturn.ts @@ -6,7 +6,7 @@ export default class TaxReturn { private _year: number; private _people: Person[] = []; - private _forms: Form[] = []; + private _forms: Form[] = []; constructor(year: number) { this._year = year; @@ -37,7 +37,7 @@ export default class TaxReturn { return people[0]; } - addForm(form: Form) { + addForm(form: Form) { if (!form.supportsMultipleCopies) { const other = this.getForms(form.name); if (other.length > 0) { @@ -47,7 +47,7 @@ export default class TaxReturn { this._forms.push(form); } - maybeGetForm(name: string): T | null { + maybeGetForm>(name: string): T | null { const forms = this.getForms(name); if (forms.length == 0) { return null; @@ -58,14 +58,14 @@ export default class TaxReturn { return forms[0]; } - getForm(name: string): T { + getForm>(name: string): T { const form = this.maybeGetForm(name); if (!form) throw new NotFoundError(`No form named ${name}`); return form; } - getForms(name: string): T[] { + getForms>(name: string): T[] { return this._forms.filter(f => f.name == name) as T[]; } }; diff --git a/src/fed2019/Form1040.test.ts b/src/fed2019/Form1040.test.ts index ff607a2..f953311 100644 --- a/src/fed2019/Form1040.test.ts +++ b/src/fed2019/Form1040.test.ts @@ -8,8 +8,10 @@ test('w2 wages', () => { const pa = Person.self('A'); const pb = Person.spouse('B'); const tr = new TaxReturn(2019); - tr.addForm(new FormW2({ employer: 'AA', employee: pa, wages: 100.00 })); - tr.addForm(new FormW2({ employer: 'BB', employee: pb, wages: 36.32 })); + tr.addForm(new FormW2({ employer: 'AA', employee: pa, wages: 100.00, fedIncomeTax: 0 })); + tr.addForm(new FormW2({ employer: 'BB', employee: pb, wages: 36.32, fedIncomeTax: 0 })); const f1040 = new Form1040(); + tr.addForm(f1040); expect(f1040.getValue(tr, '1')).toBe(136.32); + f1040.getValue(tr, '23'); }); diff --git a/src/fed2019/Form1040.ts b/src/fed2019/Form1040.ts index 493beb3..8f9c87a 100644 --- a/src/fed2019/Form1040.ts +++ b/src/fed2019/Form1040.ts @@ -14,93 +14,92 @@ export interface Form1040Input { const reduceBySum = (list: number[]) => list.reduce((acc, curr) => acc + curr, 0); -export default class Form1040 extends Form { - get name(): string { return '1040'; } - - protected getLines(): Line[] { - return [ - new AccumulatorLine('1', 'W-2', '1', 'Wages, salaries, tips, etc.'), - new AccumulatorLine('2a', '1099-INT', '8', 'Tax-exempt interest'), - new AccumulatorLine('2b', '1009-INT', '1', 'Taxable interest'), - new AccumulatorLine('3a', '1099-DIV', '1b', 'Qualified dividends'), - new AccumulatorLine('3b', '1099-DIV', '1a', 'Ordinary dividends'), - // 4a and 4b are complex - // 4c and 4d are not supported - // 5a and 5b are not supported - // 6 - Sched D - new ReferenceLine('7a', 'Schedule 1', '9', 'Other income from Schedule 1'), - - new ComputedLine('7b', (tr: TaxReturn): number => { - const lineIds = ['1', '2b', '3b', '4b', '4d', '5b', '6', '7a']; - const lines = lineIds.map(l => this.getValue(tr, l)); - return reduceBySum(lines); - }, 'Total income'), - - new ReferenceLine('8a', 'Schedule 1', '22', 'Adjustments to income'), - - new ComputedLine('8b', (tr: TaxReturn): number => { - return this.getValue(tr, '7b') - this.getValue(tr, '8a'); - }, 'Adjusted gross income'), - - // 9 - Deduction - - new ComputedLine('10', (tr: TaxReturn): number => { - const taxableIncome = this.getValue(tr, '8b'); - let use8995a = false; - switch (this.getInput('filingStatus')) { - case FilingStatus.Single: use8995a = taxableIncome <= 160700; break; - case FilingStatus.MarriedFilingSingle: use8995a = taxableIncome <= 160725; break; - case FilingStatus.MarriedFilingJoint: use8995a = taxableIncome <= 321400; break; - }; - return 0; - }, 'Qualified business income deduction'), - - new ComputedLine('11a', (tr: TaxReturn) => { - return this.getValue(tr, '9') + this.getValue(tr, '10'); - }), - new ComputedLine('11b', (tr: TaxReturn) => { - const value = this.getValue(tr, '8b') - this.getValue(tr, '11a'); - return value < 0 ? 0 : value; - }, 'Taxable income'), - - new ComputedLine('16', (tr: TaxReturn) => { - return 0; - }, 'Total tax'), - - new ComputedLine('17', (tr: TaxReturn) => { - const fedTaxWithheldBoxes = [ - ['W-2', '2'], ['1099-R', '4'], ['1099-DIV', '4'], ['1099-INT', '4'] - ]; - const withholding = fedTaxWithheldBoxes.map(b => (new AccumulatorLine('F1040.L17+', b[0], b[1])).value(tr)); - - let additionalMedicare = 0; - const f8959 = tr.maybeGetForm('8595') - if (f8959) { - additionalMedicare = f8959.getValue(tr, '24'); - } - - return reduceBySum(withholding) + additionalMedicare; - }, 'Federal income tax withheld'), - - // 18 not supported - - new ReferenceLine('19', '1040', '17', 'Total payments'), - - new ComputedLine('20', (tr: TaxReturn) => { - const l16 = this.getValue(tr, '16'); - const l19 = this.getValue(tr, '19'); - if (l19 > l16) - return l19 - l16; - return 0; - }, 'Amount overpaid'), - - new ComputedLine('23', (tr: TaxReturn) => { - const l16 = this.getValue(tr, '16'); - const l19 = this.getValue(tr, '19'); - if (l19 < l16) - return l16 - l19; - return 0; - }, 'Amount you owe'), - ]; - } +export default class Form1040 extends Form { + readonly name = '1040'; + + protected readonly _lines = { + '1': new AccumulatorLine('1', 'W-2', '1', 'Wages, salaries, tips, etc.'), + '2a': new AccumulatorLine('2a', '1099-INT', '8', 'Tax-exempt interest'), + '2b': new AccumulatorLine('2b', '1009-INT', '1', 'Taxable interest'), + '3a': new AccumulatorLine('3a', '1099-DIV', '1b', 'Qualified dividends'), + '3b': new AccumulatorLine('3b', '1099-DIV', '1a', 'Ordinary dividends'), + // 4a and 4b are complex + // 4c and 4d are not supported + // 5a and 5b are not supported + // 6 - Sched D + '7a': new ReferenceLine('7a', 'Schedule 1', '9', 'Other income from Schedule 1'), + + '7b': new ComputedLine('7b', (tr: TaxReturn): number => { + const lineIds = ['1', '2b', '3b', '4b', '4d', '5b', '6', '7a']; + const lines: number[] = lineIds.map(l => this.getValue(tr, l as keyof Form1040['_lines'])); + return reduceBySum(lines); + }, 'Total income'), + + '8a': new ReferenceLine('8a', 'Schedule 1', '22', 'Adjustments to income'), + + '8b': new ComputedLine('8b', (tr: TaxReturn): number => { + return this.getValue(tr, '7b') - this.getValue(tr, '8a'); + }, 'Adjusted gross income'), + + // TODO - Deduction + '9': new ComputedLine('9', () => 0, 'Deduction'), + + '10': new ComputedLine('10', (tr: TaxReturn): number => { + const taxableIncome = this.getValue(tr, '8b'); + let use8995a = false; + switch (this.getInput('filingStatus')) { + case FilingStatus.Single: use8995a = taxableIncome <= 160700; break; + case FilingStatus.MarriedFilingSingle: use8995a = taxableIncome <= 160725; break; + case FilingStatus.MarriedFilingJoint: use8995a = taxableIncome <= 321400; break; + }; + return 0; + }, 'Qualified business income deduction'), + + '11a': new ComputedLine('11a', (tr: TaxReturn): number => { + return this.getValue(tr, '9') + this.getValue(tr, '10'); + }), + '11b': new ComputedLine('11b', (tr: TaxReturn): number => { + const value = this.getValue(tr, '8b') - this.getValue(tr, '11a'); + return value < 0 ? 0 : value; + }, 'Taxable income'), + + '16': new ComputedLine('16', (tr: TaxReturn): number => { + return 0; + }, 'Total tax'), + + '17': new ComputedLine('17', (tr: TaxReturn): number => { + const fedTaxWithheldBoxes = [ + ['W-2', '2'], ['1099-R', '4'], ['1099-DIV', '4'], ['1099-INT', '4'] + ]; + const withholding = fedTaxWithheldBoxes.map(b => (new AccumulatorLine('F1040.L17+', b[0], b[1])).value(tr)); + + let additionalMedicare = 0; + const f8959 = tr.maybeGetForm('8595') + if (f8959) { + additionalMedicare = f8959.getValue(tr, '24'); + } + + return reduceBySum(withholding) + additionalMedicare; + }, 'Federal income tax withheld'), + + // 18 not supported + + '19': new ReferenceLine('19', '1040', '17', 'Total payments'), + + '20': new ComputedLine('20', (tr: TaxReturn): number => { + const l16 = this.getValue(tr, '16'); + const l19 = this.getValue(tr, '19'); + if (l19 > l16) + return l19 - l16; + return 0; + }, 'Amount overpaid'), + + '23': new ComputedLine('23', (tr: TaxReturn): number => { + const l16 = this.getValue(tr, '16'); + const l19 = this.getValue(tr, '19'); + if (l19 < l16) + return l16 - l19; + return 0; + }, 'Amount you owe'), + }; }; diff --git a/src/fed2019/FormW2.ts b/src/fed2019/FormW2.ts index e61f83c..5a1cb15 100644 --- a/src/fed2019/FormW2.ts +++ b/src/fed2019/FormW2.ts @@ -33,30 +33,26 @@ export interface W2Input { class Input extends InputLine {}; -export default class W2 extends Form { - get name(): string { return 'W-2'; } +export default class W2 extends Form { + readonly name = 'W-2'; readonly supportsMultipleCopies = true; - aggregate(f: Form[]): this { return null; } - - protected getLines(): Line[] { - return [ - new Input('c', 'employer', 'Employer name'), - new Input('e', 'employee', 'Emplyee name'), - new Input('1', 'wages', 'Wages, tips, other compensation'), - new Input('2', 'fedIncomeTax', 'Federal income tax withheld'), - new Input('3', 'socialSecurityWages', 'Social security wages'), - new Input('4', 'socialSecuirtyTax', 'Social security tax withheld'), - new Input('5', 'medicareWages', 'Medicare wages and tips'), - new Input('6', 'medicareTax', 'Medicare tax withheld'), - new Input('7', 'socialSecurityTips', 'Social security tips'), - new Input('8', 'allocatedTips', 'Allocated tips'), - new Input('10', 'dependentCareBenefits', 'Dependent care benefits'), - new Input('11', 'nonqualifiedPlans','Nonqualified plans'), - new Input('12', 'box12', 'Box 12'), - new Input('13', 'box13', 'Box 13'), - new Input('14', 'box14', 'Other'), - ]; - } + protected readonly _lines = { + 'c': new Input('c', 'employer', 'Employer name'), + 'e': new Input('e', 'employee', 'Emplyee name'), + '1': new Input('1', 'wages', 'Wages, tips, other compensation'), + '2': new Input('2', 'fedIncomeTax', 'Federal income tax withheld'), + '3': new Input('3', 'socialSecurityWages', 'Social security wages'), + '4': new Input('4', 'socialSecuirtyTax', 'Social security tax withheld'), + '5': new Input('5', 'medicareWages', 'Medicare wages and tips'), + '6': new Input('6', 'medicareTax', 'Medicare tax withheld'), + '7': new Input('7', 'socialSecurityTips', 'Social security tips'), + '8': new Input('8', 'allocatedTips', 'Allocated tips'), + '10': new Input('10', 'dependentCareBenefits', 'Dependent care benefits'), + '11': new Input('11', 'nonqualifiedPlans','Nonqualified plans'), + '12': new Input('12', 'box12', 'Box 12'), + '13': new Input('13', 'box13', 'Box 13'), + '14': new Input('14', 'box14', 'Other'), + }; }; -- 2.22.5