Skip to content

Commit

Permalink
chore: Update w9 to latest version (#1089)
Browse files Browse the repository at this point in the history
  • Loading branch information
Betree authored Jan 7, 2025
1 parent 84515da commit 0c7c325
Show file tree
Hide file tree
Showing 8 changed files with 110 additions and 46 deletions.
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,20 @@ jobs:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}

check-tax-forms-config:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup node
uses: actions/setup-node@v4
with:
node-version-file: 'package.json'
cache: 'npm'
- name: Install dependencies
run: npm ci --prefer-offline --no-audit
- run: npm run script scripts/check-tax-forms-config.ts

depcheck:
runs-on: ubuntu-latest
steps:
Expand Down
27 changes: 6 additions & 21 deletions lib/pdf-lib-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { get, isNil } from 'lodash';
import { isNil } from 'lodash';
import { scaleValue } from './math';
import fontkit from 'pdf-fontkit';
import { PDFDocument, PDFField, PDFFont, PDFForm, PDFHexString, PDFTextField, rgb, TextAlignment } from 'pdf-lib';
Expand Down Expand Up @@ -104,35 +104,25 @@ type FieldTypeSplitText = {
}>;
};

type FieldTypeNested = {
type: 'nested';
if?: (value, allValues) => boolean;
fields: Record<string, PDFFieldDefinition>;
};

type FieldTypeMulti = {
type: 'multi';
if?: (value, allValues) => boolean;
fields: PDFFieldDefinition[];
};

function isFieldTypeCombo(field: PDFFieldDefinition): field is FieldTypeCombo {
export function isFieldTypeCombo(field: PDFFieldDefinition): field is FieldTypeCombo {
return (field as FieldTypeCombo).type === 'combo';
}

function isFieldTypeSplitText(field: PDFFieldDefinition): field is FieldTypeSplitText {
export function isFieldTypeSplitText(field: PDFFieldDefinition): field is FieldTypeSplitText {
return (field as FieldTypeSplitText).type === 'split-text';
}

function isFieldTypeNested(field: PDFFieldDefinition): field is FieldTypeNested {
return (field as FieldTypeNested).type === 'nested';
}

function isFieldTypeMulti(field: PDFFieldDefinition): field is FieldTypeMulti {
export function isFieldTypeMulti(field: PDFFieldDefinition): field is FieldTypeMulti {
return (field as FieldTypeMulti).type === 'multi';
}

function isTextFormField(field: PDFField): field is PDFTextField {
export function isTextFormField(field: PDFField): field is PDFTextField {
return field.constructor.name === 'PDFTextField';
}

Expand All @@ -144,7 +134,6 @@ export type PDFFieldDefinition =
/** For multi-checkboxes where only one should be checked */
| FieldTypeCombo
| FieldTypeSplitText
| FieldTypeNested
| FieldTypeMulti;

/**
Expand Down Expand Up @@ -196,11 +185,7 @@ function fillValueForField<Values>(
}

// Render the field
if (isFieldTypeNested(field)) {
for (const [key, nestedField] of Object.entries(field.fields)) {
fillValueForField(form, nestedField, get(value, key), allValues, font);
}
} else if (isFieldTypeMulti(field)) {
if (isFieldTypeMulti(field)) {
for (const subField of field.fields) {
fillValueForField(form, subField, value, allValues, font);
}
Expand Down
11 changes: 8 additions & 3 deletions lib/tax-forms/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { fillW9TaxForm } from './w9';
import { fillW8BenTaxForm } from './w8-ben';
import { fillW9TaxForm, W9FieldsDefinition } from './w9';
import { fillW8BenTaxForm, W8BenFieldsDefinition } from './w8-ben';
import { PDFDocument, PDFFont } from 'pdf-lib';
import { fillW8BenETaxForm } from './w8-ben-e';
import { fillW8BenETaxForm, W8BenEFieldsDefinition } from './w8-ben-e';
import { readFileSyncFromPublicStaticFolder } from '../file-utils';
import { PDFFieldDefinition } from '../pdf-lib-utils';

type TaxFormType = 'W9' | 'W8_BEN' | 'W8_BEN_E';

Expand All @@ -14,19 +15,23 @@ export const TAX_FORMS: Record<
{
bytes: Uint8Array;
fillPDF: (pdfDoc: PDFDocument, values: Record<string, unknown>, font: PDFFont) => Promise<void>;
definition: Partial<Record<string, PDFFieldDefinition>>;
}
> = {
W9: {
bytes: readFileSyncFromPublicStaticFolder('tax-forms/fw9.pdf'),
fillPDF: fillW9TaxForm,
definition: W9FieldsDefinition,
},
W8_BEN: {
bytes: readFileSyncFromPublicStaticFolder('tax-forms/fw8ben.pdf'),
fillPDF: fillW8BenTaxForm,
definition: W8BenFieldsDefinition,
},
W8_BEN_E: {
bytes: readFileSyncFromPublicStaticFolder('tax-forms/fw8bene.pdf'),
fillPDF: fillW8BenETaxForm,
definition: W8BenEFieldsDefinition,
},
} as const;

Expand Down
2 changes: 1 addition & 1 deletion lib/tax-forms/w8-ben-e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isNil } from 'lodash';
import { W8BenETaxFormValues } from './frontend-types';
import { getCountryName } from '../i18n';

const W8BenEFieldsDefinition: Partial<Record<keyof W8BenETaxFormValues, PDFFieldDefinition>> = {
export const W8BenEFieldsDefinition: Partial<Record<keyof W8BenETaxFormValues, PDFFieldDefinition>> = {
businessName: 'topmostSubform[0].Page1[0].f1_1[0]',
disregardedBusinessName: 'topmostSubform[0].Page1[0].f1_3[0]',
businessCountryOfIncorporationOrOrganization: {
Expand Down
2 changes: 1 addition & 1 deletion lib/tax-forms/w8-ben.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getFullName } from './utils';
import { W8BenTaxFormValues } from './frontend-types';
import { getCountryName } from '../i18n';

const W8BenFieldsDefinition: Partial<Record<keyof W8BenTaxFormValues, PDFFieldDefinition>> = {
export const W8BenFieldsDefinition: Partial<Record<keyof W8BenTaxFormValues, PDFFieldDefinition>> = {
beneficialOwner: { formPath: 'topmostSubform[0].Page1[0].f_1[0]', transform: getFullName },
countryOfCitizenship: { formPath: 'topmostSubform[0].Page1[0].f_2[0]', transform: getCountryName },
residenceAddress: {
Expand Down
40 changes: 20 additions & 20 deletions lib/tax-forms/w9.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,26 @@ import { getFullName } from './utils';
import { W9TaxFormValues } from './frontend-types';
import { getCountryName } from '../i18n';

const W9FieldsDefinition: Partial<Record<keyof W9TaxFormValues, PDFFieldDefinition>> = {
signer: { formPath: 'topmostSubform[0].Page1[0].f1_1[0]', transform: getFullName },
businessName: 'topmostSubform[0].Page1[0].f1_2[0]',
export const W9FieldsDefinition: Partial<Record<keyof W9TaxFormValues, PDFFieldDefinition>> = {
signer: { formPath: 'topmostSubform[0].Page1[0].f1_01[0]', transform: getFullName },
businessName: 'topmostSubform[0].Page1[0].f1_02[0]',
accountNumbers: 'topmostSubform[0].Page1[0].f1_10[0]',
exemptPayeeCode: 'topmostSubform[0].Page1[0].Exemptions[0].f1_5[0]',
fatcaExemptionCode: 'topmostSubform[0].Page1[0].Exemptions[0].f1_6[0]',
exemptPayeeCode: 'topmostSubform[0].Page1[0].f1_05[0]',
fatcaExemptionCode: 'topmostSubform[0].Page1[0].f1_06[0]',
federalTaxClassification: {
type: 'combo',
values: {
Individual: 'topmostSubform[0].Page1[0].FederalClassification[0].c1_1[0]',
C_Corporation: 'topmostSubform[0].Page1[0].FederalClassification[0].c1_1[1]',
S_Corporation: 'topmostSubform[0].Page1[0].FederalClassification[0].c1_1[2]',
Partnership: 'topmostSubform[0].Page1[0].FederalClassification[0].c1_1[3]',
TrustEstate: 'topmostSubform[0].Page1[0].FederalClassification[0].c1_1[4]',
LimitedLiabilityCompany: 'topmostSubform[0].Page1[0].FederalClassification[0].c1_1[5]',
Other: 'topmostSubform[0].Page1[0].FederalClassification[0].c1_1[6]',
Individual: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0]',
C_Corporation: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[1]',

Check warning on line 18 in lib/tax-forms/w9.ts

View workflow job for this annotation

GitHub Actions / lint

Identifier 'C_Corporation' is not in camel case
S_Corporation: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[2]',

Check warning on line 19 in lib/tax-forms/w9.ts

View workflow job for this annotation

GitHub Actions / lint

Identifier 'S_Corporation' is not in camel case
Partnership: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[3]',
TrustEstate: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[4]',
LimitedLiabilityCompany: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[5]',
Other: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[6]',
},
},
federalTaxClassificationDetails: {
formPath: 'topmostSubform[0].Page1[0].FederalClassification[0].f1_4[0]',
formPath: 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].f1_04[0]',
if: (value, values) => values.federalTaxClassification === 'Other',
},
taxIdNumber: {
Expand All @@ -35,18 +35,18 @@ const W9FieldsDefinition: Partial<Record<keyof W9TaxFormValues, PDFFieldDefiniti
if: (value, values) => values.taxIdNumberType === 'SSN',
transform: (value) => value && value.replace(/-/g, '').trim(),
fields: [
{ formPath: 'topmostSubform[0].Page1[0].SSN[0].f1_11[0]', maxLength: 3 },
{ formPath: 'topmostSubform[0].Page1[0].SSN[0].f1_12[0]', maxLength: 2 },
{ formPath: 'topmostSubform[0].Page1[0].SSN[0].f1_13[0]', maxLength: 4 },
{ formPath: 'topmostSubform[0].Page1[0].f1_11[0]', maxLength: 3 },
{ formPath: 'topmostSubform[0].Page1[0].f1_12[0]', maxLength: 2 },
{ formPath: 'topmostSubform[0].Page1[0].f1_13[0]', maxLength: 4 },
],
},
{
type: 'split-text',
if: (value, values) => values.taxIdNumberType === 'EIN',
transform: (value) => value && value.replace(/-/g, '').trim(),
fields: [
{ formPath: 'topmostSubform[0].Page1[0].EmployerID[0].f1_14[0]', maxLength: 2 },
{ formPath: 'topmostSubform[0].Page1[0].EmployerID[0].f1_15[0]', maxLength: 7 },
{ formPath: 'topmostSubform[0].Page1[0].f1_14[0]', maxLength: 2 },
{ formPath: 'topmostSubform[0].Page1[0].f1_15[0]', maxLength: 7 },
],
},
],
Expand All @@ -55,12 +55,12 @@ const W9FieldsDefinition: Partial<Record<keyof W9TaxFormValues, PDFFieldDefiniti
type: 'multi',
fields: [
{
formPath: 'topmostSubform[0].Page1[0].Address[0].f1_7[0]',
formPath: 'topmostSubform[0].Page1[0].Address_ReadOrder[0].f1_07[0]',
transform: (value: W9TaxFormValues['location']) =>
[value?.structured?.address1, value?.structured?.address2].filter(Boolean).join(', '),
},
{
formPath: 'topmostSubform[0].Page1[0].Address[0].f1_8[0]',
formPath: 'topmostSubform[0].Page1[0].Address_ReadOrder[0].f1_08[0]',
transform: (value: W9TaxFormValues['location']) =>
[
value?.structured?.city,
Expand Down
Binary file modified public/static/tax-forms/fw9.pdf
Binary file not shown.
60 changes: 60 additions & 0 deletions scripts/check-tax-forms-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* This script ensures that all fields defined in the tax form configs are actually present in the PDFs.
*/

import { isFieldTypeCombo, isFieldTypeMulti, isFieldTypeSplitText, PDFFieldDefinition } from '../lib/pdf-lib-utils';
import { TAX_FORMS } from '../lib/tax-forms';

import { PDFDocument } from 'pdf-lib';

const checkField = (formFields, field: PDFFieldDefinition): string[] => {
if (typeof field === 'string') {
if (!formFields.find((f) => f.getName() === field)) {
return [field];
}
} else if (isFieldTypeMulti(field) || isFieldTypeSplitText(field)) {
return field.fields.map((subField) => checkField(formFields, subField)).flat();
} else if (isFieldTypeCombo(field)) {
return Object.values(field.values)
.map((subField) => checkField(formFields, subField))
.flat();
} else if (!formFields.find((f) => f.getName() === field.formPath)) {
return [field.formPath];
}

return [];
};

const main = async () => {
const errors = [];

for (const [formName, config] of Object.entries(TAX_FORMS)) {
const pdfDoc = await PDFDocument.load(config.bytes);
const form = pdfDoc.getForm();
const fields = form.getFields();

for (const field of Object.values(config.definition)) {
const missingFields = checkField(fields, field);
if (missingFields.length) {
missingFields.forEach((missingFieldName) => {
errors.push(`Field ${missingFieldName} is missing in ${formName}`);
});
}
}
}

if (errors.length > 0) {
console.error('Errors found:');
for (const error of errors) {
console.error(error);
}
throw new Error('Some fields are missing in the PDFs');
} else {
console.log('All good ✅');
}
};

// Entrypoint
if (!module.parent) {
main();
}

0 comments on commit 0c7c325

Please sign in to comment.