export function bookkeepingServicesCalculator(form: FormTs) {
// Monthly bookkeeping tiers by transactions a month (lines on the bank and card statements)
const tiers = [
{ max: 100, monthly: 299 },
{ max: 250, monthly: 449 },
{ max: 500, monthly: 649 },
{ max: 1000, monthly: 949 }
];
const OVER_TOP_PER_100 = 75; // each started hundred transactions above the top tier
const ACCOUNTS_INCLUDED = 2; // bank and credit card accounts every plan reconciles
const ACCOUNT_MONTHLY = 35; // each additional account
const ACCRUAL_PCT = 25; // added for accrual-basis books
const QUARTERLY_OFF_PCT = 20; // taken off for a quarterly close instead of monthly
const DEDICATED_PCT = 20; // added for a dedicated bookkeeper instead of the pooled team
const RECEIPTS_APP_MONTHLY = 25; // the receipt capture app
// Monthly add-ons
const addonPrices: Record<string, number> = {
'ar': 100,
'ap': 125,
'inventory': 90,
'jobCosting': 110
};
const addonNames: Record<string, string> = {
'ar': 'Invoicing and A/R',
'ap': 'Bill pay (A/P)',
'inventory': 'Inventory tracking',
'jobCosting': 'Job costing and class tracking'
};
const CFO_HOURLY = 175;
// Payroll
const payrollPrices = {
baseMonthly: 60,
employeeMonthly: 12,
contractorMonthly: 6,
weeklyPct: 25, // added for weekly pay runs
w2Each: 15, // per W-2 at year-end
n1099Each: 20 // per 1099-NEC at year-end
};
// Sales tax filings, per state per month
const SALES_TAX_STATE_MONTHLY = 45;
// Annual tax returns (federal and one state)
const returnPrices = {
scheduleC: 550,
s1120: 1200,
p1065: 1200,
c1120: 1500,
n990n: 150,
n990ez: 750,
n990: 1800,
k1Each: 75, // each owner beyond the first on an 1120-S or 1065
estimatesYear: 300 // quarterly estimated taxes, per year
};
// One-time
const oneTimePrices = {
onboarding: 250,
newFileSetup: 400,
migration: 600,
catchupPct: 120 // one month of catch-up as % of the monthly bookkeeping fee
};
// Thresholds the law sets
const NEXUS_THRESHOLD = 100000; // economic nexus for remote sellers, most states
const N1099_THRESHOLD = 2000; // 1099-NEC reporting amount
const N990_POSTCARD_MAX = 50000; // gross receipts up to which a nonprofit files the 990-N
const N990_EZ_MAX = 200000; // gross receipts up to which a nonprofit may file the 990-EZ
const ANNUAL_PREPAY_PCT = 10; // cheaper per month when twelve months are paid up front
const PROMO_PCT = 0; // discount on the monthly fee; 0 hides the banner
const PROMO_TEXT = 'New client special';
const REPLY_HOURS = 24;
const CONSULT_MINUTES = 30;
// Entities: the return they file, whether a K-1 goes to each owner, and whether the owner is on payroll
type EntityId = 'sole' | 'scorp' | 'partnership' | 'ccorp' | 'nonprofit';
const entityNames: Record<EntityId, string> = {
'sole': 'Sole proprietor or single-member LLC',
'scorp': 'S corporation',
'partnership': 'Partnership or multi-member LLC',
'ccorp': 'C corporation',
'nonprofit': 'Nonprofit'
};
const entityK1: Record<EntityId, boolean> = { 'sole': false, 'scorp': true, 'partnership': true, 'ccorp': false, 'nonprofit': false };
const entityPassThrough: Record<EntityId, boolean> = { 'sole': true, 'scorp': true, 'partnership': true, 'ccorp': false, 'nonprofit': false };
const entityOwnerPayroll: Record<EntityId, 'required' | 'optional' | 'none'> = { 'sole': 'none', 'scorp': 'required', 'partnership': 'none', 'ccorp': 'optional', 'nonprofit': 'none' };
// Industries: whether sales tax is part of the job, and whether sales cross state lines
type IndustryId = 'contractor' | 'restaurant' | 'ecommerce' | 'professional' | 'realestate' | 'other';
const industryNames: Record<IndustryId, string> = {
'contractor': 'Contractor and trades',
'restaurant': 'Restaurant and food',
'ecommerce': 'E-commerce',
'professional': 'Professional services',
'realestate': 'Real estate',
'other': 'Something else'
};
const industrySalesTax: Record<IndustryId, 'yes' | 'no' | 'maybe'> = { 'contractor': 'maybe', 'restaurant': 'yes', 'ecommerce': 'yes', 'professional': 'no', 'realestate': 'no', 'other': 'maybe' };
type SoftwareStatusId = 'have' | 'new' | 'migrate';
const softwareStatusNames: Record<SoftwareStatusId, string> = { 'have': 'Already set up', 'new': 'Needs setup', 'migrate': 'Migrating' };
const softwareStatusFee: Record<SoftwareStatusId, number> = { 'have': 0, 'new': oneTimePrices.newFileSetup, 'migrate': oneTimePrices.migration };
const money = (value: number) => (value < 0 ? '-$' : '$') + Math.round(Math.abs(value)).toLocaleString('en-US');
const count = (value: number) => Math.round(value).toLocaleString('en-US');
const plural = (value: number, one: string, many: string) => value + ' ' + (value === 1 ? one : many);
form.addRow(row => {
row.addTextPanel('header', {
computedValue: () => 'Bookkeeping, Payroll & Tax Estimate',
customStyles: { 'font-size': '1.5rem', 'font-weight': '600', 'color': '#1e293b' }
});
});
form.addRow(row => {
row.addTextPanel('intro', {
computedValue: () => 'You do not need to know which return you file or whether sales tax applies - we work that out as you go. Beside the questions you will see the monthly fee, the one-time setup and what comes once a year. We reply within ' + REPLY_HOURS + ' hours.',
customStyles: { 'font-size': '0.95rem', 'color': '#475569' }
});
});
form.addSpacer({ height: 20 });
// Business Section
const businessSection = form.addSubform('business', { title: '🏢 Your Business' });
businessSection.addRow(row => {
row.addRadioButton('entity', {
label: 'How is the business set up?',
options: [
{ id: 'sole', name: 'Sole proprietor or single-member LLC - Schedule C on your personal return' },
{ id: 'scorp', name: 'S corporation - Form 1120-S, a K-1 per shareholder' },
{ id: 'partnership', name: 'Partnership or multi-member LLC - Form 1065, a K-1 per partner' },
{ id: 'ccorp', name: 'C corporation - Form 1120, pays its own tax' },
{ id: 'nonprofit', name: 'Nonprofit - Form 990, the version depends on size' }
],
defaultValue: 'sole',
orientation: 'vertical',
tooltip: 'Not sure? Pick what your bank paperwork says; we confirm it on the call',
isRequired: true
});
});
const getEntity = (): EntityId => {
const value = businessSection.radioButton('entity')?.value();
return value === 'scorp' || value === 'partnership' || value === 'ccorp' || value === 'nonprofit' ? value : 'sole';
};
const isK1Entity = () => entityK1[getEntity()];
businessSection.addRow(row => {
row.addInteger('owners', {
label: () => getEntity() === 'scorp' ? 'Shareholders' : 'Partners or members',
min: 1,
max: 20,
defaultValue: 1,
tooltip: 'Each one gets a K-1 with the annual return',
isVisible: () => isK1Entity()
}, '1fr');
row.addRadioButton('situation', {
label: 'Where are the books today?',
options: [
{ id: 'starting', name: 'Just starting out - no books yet' },
{ id: 'switching', name: 'Switching bookkeepers' },
{ id: 'diy', name: 'Doing it myself - spreadsheets or my own software' }
],
defaultValue: 'diy',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
businessSection.addRow(row => {
row.addDropdown('industry', {
label: 'What line of business?',
options: [
{ id: 'contractor', name: 'Contractor and trades' },
{ id: 'restaurant', name: 'Restaurant and food' },
{ id: 'ecommerce', name: 'E-commerce' },
{ id: 'professional', name: 'Professional services' },
{ id: 'realestate', name: 'Real estate' },
{ id: 'other', name: 'Something else' }
],
defaultValue: 'other',
tooltip: 'The industry tells us what to expect in the books, and whether sales tax is part of the job',
isRequired: true
}, '1fr');
});
const getOwners = () => isK1Entity() ? (businessSection.integer('owners')?.value() || 1) : 1;
const getSituation = () => businessSection.radioButton('situation')?.value() || 'diy';
const isStartingOut = () => getSituation() === 'starting';
const getIndustry = (): IndustryId => {
const value = businessSection.dropdown('industry')?.value();
return value === 'contractor' || value === 'restaurant' || value === 'ecommerce' || value === 'professional' || value === 'realestate' ? value : 'other';
};
// Books Section
const booksSection = form.addSubform('books', { title: '📒 Your Books' });
booksSection.addRow(row => {
row.addSlider('transactions', {
label: 'Transactions a month',
min: 10,
max: 1500,
step: 10,
defaultValue: 120,
unit: 'a month',
tooltip: 'Every line on your bank and credit card statements: deposits, card charges, checks, transfers. A ballpark is fine',
isRequired: true
});
});
booksSection.addRow(row => {
row.addInteger('accounts', {
label: 'Bank and credit card accounts (' + ACCOUNTS_INCLUDED + ' included)',
min: 1,
max: 30,
defaultValue: 2,
tooltip: 'Checking, savings, credit cards, loans, merchant accounts',
isRequired: true
}, '1fr');
row.addMoney('revenue', {
label: 'Annual revenue (ballpark)',
currency: '$',
min: 0,
max: 50000000,
step: 5000,
defaultValue: 350000,
tooltip: 'It decides sales tax across states and, for a nonprofit, which 990 you file',
isRequired: true
}, '1fr');
});
const getTransactions = () => booksSection.slider('transactions')?.value() || 120;
const getAccounts = () => booksSection.integer('accounts')?.value() || 1;
const getRevenue = () => booksSection.money('revenue')?.value() || 0;
// The plan tier the volume lands in, and what the volume past the top tier adds
const getTierIndex = () => {
for (let i = 0; i < tiers.length; i++) {
const tier = tiers[i];
if (tier && getTransactions() <= tier.max) return i;
}
return tiers.length - 1;
};
const getTierNumber = () => getTierIndex() + 1;
const getTierMax = () => tiers[getTierIndex()]?.max || 0;
const getTierMonthly = () => tiers[getTierIndex()]?.monthly || 0;
const getTopTierMax = () => tiers[tiers.length - 1]?.max || 0;
const getOverTop = () => Math.max(Math.ceil((getTransactions() - getTopTierMax()) / 100), 0);
booksSection.addRow(row => {
row.addRadioButton('basis', {
label: 'Accounting basis',
options: [
{ id: 'cash', name: 'Cash basis' },
{ id: 'accrual', name: 'Accrual basis (+' + ACCRUAL_PCT + '%)' }
],
defaultValue: 'cash',
tooltip: 'Most small businesses run on cash. Accrual matters when you carry inventory, invoice on terms or a lender asks for GAAP statements'
});
});
const isAccrual = () => booksSection.radioButton('basis')?.value() === 'accrual';
booksSection.addRow(row => {
row.addTextPanel('tierInfo', {
computedValue: () => 'Tier ' + getTierNumber() + ': up to ' + count(getTierMax()) + ' transactions a month. '
+ (getOverTop() > 0
? count(getTransactions()) + ' a month is past our largest tier, so every started hundred above ' + count(getTopTierMax()) + ' is priced on top at $' + OVER_TOP_PER_100 + '.'
: 'Every plan categorizes the transactions, reconciles ' + ACCOUNTS_INCLUDED + ' accounts and closes the month with a P&L and a balance sheet.')
+ (getAccounts() > ACCOUNTS_INCLUDED ? ' Your ' + getAccounts() + ' accounts mean ' + (getAccounts() - ACCOUNTS_INCLUDED) + ' more to reconcile, $' + ACCOUNT_MONTHLY + ' a month each.' : ''),
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
booksSection.addRow(row => {
row.addDropdown('software', {
label: 'Which software?',
options: [
{ id: 'qbo', name: 'QuickBooks Online' },
{ id: 'xero', name: 'Xero' },
{ id: 'wave', name: 'Wave' }
],
defaultValue: 'qbo',
tooltip: 'The subscription stays in your name; we work in your file',
isRequired: true
}, '1fr');
row.addRadioButton('softwareStatus', {
label: 'Where does that file stand?',
options: [
{ id: 'have', name: 'Already set up - you have a file we take over' },
{ id: 'new', name: 'Needs setup - a new file and chart of accounts (+$' + oneTimePrices.newFileSetup + ' once)' },
{ id: 'migrate', name: 'Migrating from another system or spreadsheets (+$' + oneTimePrices.migration + ' once)' }
],
defaultValue: 'have',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
booksSection.addRow(row => {
row.addInteger('monthsBehind', {
label: 'Months behind (each ' + oneTimePrices.catchupPct + '% of the monthly bookkeeping fee)',
min: 0,
max: 36,
defaultValue: 0,
tooltip: 'Months nobody has booked yet. Zero if the books are current',
isVisible: () => !isStartingOut()
}, '1fr');
});
const softwareNames: Record<string, string> = { 'qbo': 'QuickBooks Online', 'xero': 'Xero', 'wave': 'Wave' };
const getSoftware = () => softwareNames[booksSection.dropdown('software')?.value() || 'qbo'] || 'QuickBooks Online';
const getSoftwareStatus = (): SoftwareStatusId => {
const value = booksSection.radioButton('softwareStatus')?.value();
return value === 'new' || value === 'migrate' ? value : 'have';
};
const getMonthsBehind = () => isStartingOut() ? 0 : (booksSection.integer('monthsBehind')?.value() || 0);
booksSection.addRow(row => {
row.addTextPanel('catchupInfo', {
computedValue: () => 'Catch-up goes into the one-time cost. Bringing ' + plural(getMonthsBehind(), 'month', 'months') + ' current is work backwards: statements to pull, receipts to hunt down, and usually a late return or two to file.',
customStyles: { 'font-size': '0.9rem', 'color': '#b45309', 'background': '#fef3c7', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => getMonthsBehind() > 0
});
});
// Payroll Section
const payrollSection = form.addSubform('payroll', { title: '👥 Payroll' });
payrollSection.addRow(row => {
row.addInteger('employees', {
label: 'W-2 employees ($' + payrollPrices.employeeMonthly + '/mo each)',
min: 0,
max: 500,
defaultValue: 0,
tooltip: 'Not counting yourself'
}, '1fr');
row.addInteger('contractors', {
label: '1099 contractors ($' + payrollPrices.contractorMonthly + '/mo each)',
min: 0,
max: 500,
defaultValue: 0,
tooltip: 'Subs, freelancers, anyone you pay without withholding'
}, '1fr');
});
const getEmployees = () => payrollSection.integer('employees')?.value() || 0;
const getContractors = () => payrollSection.integer('contractors')?.value() || 0;
const getOwnerPayrollRule = () => entityOwnerPayroll[getEntity()];
payrollSection.addRow(row => {
row.addCheckbox('ownerPayroll', {
label: 'Owner on payroll - you draw a salary with withholding, like an employee',
defaultValue: true,
isVisible: () => getOwnerPayrollRule() === 'optional'
}, '1fr');
row.addRadioButton('frequency', {
label: 'How often do you pay?',
options: [
{ id: 'weekly', name: 'Weekly (+' + payrollPrices.weeklyPct + '%)' },
{ id: 'biweekly', name: 'Every two weeks' },
{ id: 'semimonthly', name: 'Twice a month' },
{ id: 'monthly', name: 'Monthly' }
],
defaultValue: 'biweekly',
isVisible: () => hasPayroll()
}, '1fr');
});
// The owner draws a salary when the law requires it, or when a C corp owner chose to
const isOwnerOnPayroll = () => getOwnerPayrollRule() === 'required'
|| (getOwnerPayrollRule() === 'optional' && payrollSection.checkbox('ownerPayroll')?.value() === true);
const getPayrollHeads = () => getEmployees() + (isOwnerOnPayroll() ? 1 : 0);
const hasPayroll = () => getPayrollHeads() > 0;
const frequencyNames: Record<string, string> = { 'weekly': 'weekly', 'biweekly': 'every two weeks', 'semimonthly': 'twice a month', 'monthly': 'monthly' };
const getFrequency = () => payrollSection.radioButton('frequency')?.value() || 'biweekly';
const isWeekly = () => hasPayroll() && getFrequency() === 'weekly';
payrollSection.addRow(row => {
row.addTextPanel('payrollInfo', {
computedValue: () => {
if (getOwnerPayrollRule() === 'required') {
return 'As an S corporation, you are on payroll: the IRS requires an S corp to pay its working shareholders a reasonable salary through payroll before any distributions, so we have added you to the headcount.';
}
if (getEntity() === 'partnership') {
return 'Partners are not on payroll: partners and LLC members take draws or guaranteed payments, never a W-2 paycheck. The tax comes through quarterly estimated payments instead.';
}
if (getEntity() === 'sole') {
return 'You are not on payroll: a sole proprietor takes owner\'s draws, never a W-2 paycheck. The tax comes through quarterly estimated payments instead.';
}
if (getContractors() > 0) {
return plural(getContractors(), 'contractor means a 1099-NEC', 'contractors mean 1099-NECs') + ' in January: every contractor paid $' + N1099_THRESHOLD.toLocaleString('en-US') + ' or more in the year gets one by January 31. We collect the W-9s during the year.';
}
return 'No payroll to run: the estimate leaves payroll out. Add employees whenever you hire and the fee follows.';
},
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' }
});
});
// Taxes Section
const taxesSection = form.addSubform('taxes', { title: '🧾 Taxes' });
const getSalesTaxRule = () => industrySalesTax[getIndustry()];
taxesSection.addRow(row => {
row.addCheckbox('salesTax', {
label: () => 'We collect sales tax (+$' + SALES_TAX_STATE_MONTHLY + '/state/mo)' + (getSalesTaxRule() === 'no' ? ' - unusual in your line of work, but some states tax certain services' : ' - retail sales, materials sold to customers, taxable services'),
defaultValue: false,
isVisible: () => getSalesTaxRule() !== 'yes'
}, '1fr');
});
const collectsSalesTax = () => getSalesTaxRule() === 'yes' || taxesSection.checkbox('salesTax')?.value() === true;
const isOverNexus = () => getIndustry() === 'ecommerce' && getRevenue() > NEXUS_THRESHOLD;
taxesSection.addRow(row => {
row.addInteger('states', {
label: 'States you file sales tax in ($' + SALES_TAX_STATE_MONTHLY + '/mo each)',
min: 1,
max: 50,
defaultValue: 1,
tooltip: 'Your home state counts as one',
isVisible: () => collectsSalesTax()
}, '1fr');
});
const getStates = () => collectsSalesTax() ? (taxesSection.integer('states')?.value() || 1) : 0;
taxesSection.addRow(row => {
row.addTextPanel('salesTaxInfo', {
computedValue: () => {
if (isOverNexus()) {
return 'You probably owe sales tax in more than one state. With about ' + money(getRevenue()) + ' in online sales you are likely past the economic nexus threshold of ' + money(NEXUS_THRESHOLD) + ' that most states use for out-of-state sellers. Count the states you already know about above; we map the rest on the call.';
}
if (getIndustry() === 'restaurant') {
return 'Restaurants collect sales tax on every ticket: prepared food is taxable in every state that has a sales tax, so the filings are part of your plan rather than a choice.';
}
return 'Online sales are taxable sales: what you sell online is taxed where the buyer is. At minimum you file in your home state; the bookkeeper maps the rest with you on the call.';
},
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => getSalesTaxRule() === 'yes'
});
});
// The return the business files: from the entity, or for a nonprofit from its size
const getReturn = (): { fee: number; name: string; note: string } => {
const entity = getEntity();
if (entity === 'sole') return { fee: returnPrices.scheduleC, name: 'Form 1040 with Schedule C', note: 'federal and one state' };
if (entity === 'scorp') return { fee: returnPrices.s1120, name: 'Form 1120-S', note: 'with a K-1 for each of ' + plural(getOwners(), 'owner', 'owners') };
if (entity === 'partnership') return { fee: returnPrices.p1065, name: 'Form 1065', note: 'with a K-1 for each of ' + plural(getOwners(), 'owner', 'owners') };
if (entity === 'ccorp') return { fee: returnPrices.c1120, name: 'Form 1120', note: 'federal and one state' };
if (getRevenue() <= N990_POSTCARD_MAX) return { fee: returnPrices.n990n, name: 'Form 990-N', note: 'the e-Postcard, up to ' + money(N990_POSTCARD_MAX) + ' in receipts' };
if (getRevenue() <= N990_EZ_MAX) return { fee: returnPrices.n990ez, name: 'Form 990-EZ', note: 'the short return, up to ' + money(N990_EZ_MAX) + ' in receipts' };
return { fee: returnPrices.n990, name: 'Form 990', note: 'the full return, above ' + money(N990_EZ_MAX) + ' in receipts' };
};
const getReturnFeeIfTaken = () => getReturn().fee + Math.max(getOwners() - 1, 0) * returnPrices.k1Each;
taxesSection.addRow(row => {
row.addCheckbox('taxReturn', {
label: () => 'Annual return: ' + getReturn().name + ', ' + getReturn().note + ' (+' + money(getReturnFeeIfTaken()) + '/yr)',
defaultValue: true
}, '1fr');
row.addCheckbox('estimates', {
label: () => 'Quarterly estimated taxes - ' + (entityPassThrough[getEntity()] ? 'the safe-harbor math and the vouchers four times a year' : 'the corporation\'s own estimated payments, four times a year') + ' (+$' + returnPrices.estimatesYear + '/yr)',
defaultValue: true,
isVisible: () => getEntity() !== 'nonprofit'
}, '1fr');
});
const takesTaxReturn = () => taxesSection.checkbox('taxReturn')?.value() === true;
const takesEstimates = () => getEntity() !== 'nonprofit' && taxesSection.checkbox('estimates')?.value() === true;
taxesSection.addRow(row => {
row.addTextPanel('returnInfo', {
computedValue: () => getEntity() === 'nonprofit'
? 'Your size puts you on ' + getReturn().name + '. The IRS sizes the 990 by gross receipts: the 990-N e-Postcard up to ' + money(N990_POSTCARD_MAX) + ', the 990-EZ up to ' + money(N990_EZ_MAX) + ' (with assets under $500,000), the full 990 above that.'
: 'One return, ' + getOwners() + ' K-1s. A' + (getEntity() === 'scorp' ? 'n S corporation' : ' partnership') + ' pays no income tax itself: the ' + getReturn().name + ' hands each owner a K-1 and the tax lands on their personal returns. Each owner past the first is $' + returnPrices.k1Each + ' a year on the return.',
customStyles: { 'font-size': '0.9rem', 'color': '#0369a1', 'background': '#e0f2fe', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => getEntity() === 'nonprofit' || (isK1Entity() && getOwners() > 1)
});
});
// Service Section
const serviceSection = form.addSubform('service', { title: '🤝 Working Together' });
serviceSection.addRow(row => {
row.addRadioButton('level', {
label: 'Who keeps your books?',
options: [
{ id: 'team', name: 'Bookkeeping team - a pooled team works your file; questions by e-mail and the client portal' },
{ id: 'dedicated', name: 'Dedicated bookkeeper - one person who knows your business and picks up the phone (+' + DEDICATED_PCT + '%)' }
],
defaultValue: 'dedicated',
orientation: 'vertical',
isRequired: true
}, '1fr');
row.addRadioButton('cadence', {
label: 'How often do you want statements?',
options: [
{ id: 'monthly', name: 'Monthly close - P&L and balance sheet by the 15th of the next month' },
{ id: 'quarterly', name: 'Quarterly close - statements four times a year (-' + QUARTERLY_OFF_PCT + '%)' }
],
defaultValue: 'monthly',
orientation: 'vertical',
isRequired: true
}, '1fr');
});
serviceSection.addRow(row => {
row.addRadioButton('docs', {
label: 'How do receipts and statements reach us?',
options: [
{ id: 'app', name: 'Snap receipts in the app - we match them to the bank feed (+$' + RECEIPTS_APP_MONTHLY + '/mo)' },
{ id: 'feeds', name: 'Bank feeds and e-mail - your accounts sync on their own; you forward whatever else comes in' },
{ id: 'paper', name: 'Drop off the box - bring the receipts and statements once a month, we scan and file them' }
],
defaultValue: 'feeds',
orientation: 'vertical',
isRequired: true
});
});
serviceSection.addRow(row => {
row.addCheckboxList('addons', {
label: 'What else should we take off your plate?',
options: Object.keys(addonNames).map(id => ({ id, name: (addonNames[id] || id) + ' (+$' + (addonPrices[id] || 0) + '/mo)' })),
orientation: 'vertical',
tooltip: 'Pick only what you actually have - nothing is added just in case'
}, '1fr');
row.addInteger('cfoHours', {
label: 'CFO advisory hours a month ($' + CFO_HOURLY + '/hour)',
min: 0,
max: 40,
defaultValue: 0,
tooltip: 'Budgets, cash-flow forecasts and a review call with a senior accountant. Zero if the statements are enough'
}, '1fr');
});
serviceSection.addRow(row => {
row.addDropdown('timing', {
label: 'When would we start?',
options: [
{ id: 'asap', name: 'As soon as possible' },
{ id: 'nextMonth', name: 'Next month' },
{ id: 'newYear', name: 'January 1' },
{ id: 'comparing', name: 'Just comparing quotes' }
],
defaultValue: 'nextMonth',
isRequired: true
}, '1fr');
});
const isDedicated = () => serviceSection.radioButton('level')?.value() !== 'team';
const isQuarterly = () => serviceSection.radioButton('cadence')?.value() === 'quarterly';
const docsNames: Record<string, string> = { 'app': 'receipt capture app', 'feeds': 'bank feeds and e-mail', 'paper': 'paper drop-off' };
const getDocs = () => serviceSection.radioButton('docs')?.value() || 'feeds';
const usesApp = () => getDocs() === 'app';
const getAddons = () => (serviceSection.checkboxList('addons')?.value() || []).filter(id => id in addonNames);
const getCfoHours = () => serviceSection.integer('cfoHours')?.value() || 0;
const timingNames: Record<string, string> = { 'asap': 'as soon as possible', 'nextMonth': 'from next month', 'newYear': 'from January 1', 'comparing': 'just comparing quotes' };
const getTiming = () => timingNames[serviceSection.dropdown('timing')?.value() || 'nextMonth'] || 'from next month';
form.addSpacer({ height: 20, showLine: true, lineStyle: 'dashed' });
// Pricing helpers: every month, once at the start, once a year
const getBooksFee = () => {
const tierBase = getTierMonthly() + getOverTop() * OVER_TOP_PER_100;
const extraAccounts = Math.max(getAccounts() - ACCOUNTS_INCLUDED, 0) * ACCOUNT_MONTHLY;
return (tierBase + extraAccounts) * (1 + (isAccrual() ? ACCRUAL_PCT / 100 : 0)) * (1 - (isQuarterly() ? QUARTERLY_OFF_PCT / 100 : 0));
};
const getSalesTaxFee = () => getStates() * SALES_TAX_STATE_MONTHLY;
const getPayrollFee = () => hasPayroll()
? (payrollPrices.baseMonthly + getPayrollHeads() * payrollPrices.employeeMonthly + getContractors() * payrollPrices.contractorMonthly) * (1 + (isWeekly() ? payrollPrices.weeklyPct / 100 : 0))
: 0;
const getAddonsFee = () => getAddons().reduce((sum, id) => sum + (addonPrices[id] || 0), 0) + (usesApp() ? RECEIPTS_APP_MONTHLY : 0);
const getCfoFee = () => getCfoHours() * CFO_HOURLY;
const getDedicatedFee = () => isDedicated() ? (getBooksFee() + getAddonsFee()) * DEDICATED_PCT / 100 : 0;
const getBeforePromo = () => getBooksFee() + getSalesTaxFee() + getPayrollFee() + getAddonsFee() + getCfoFee() + getDedicatedFee();
const getPromo = () => getBeforePromo() * PROMO_PCT / 100;
const getMonthly = () => Math.round(getBeforePromo() - getPromo());
const getMonthlyPrepaid = () => Math.round(getMonthly() * (1 - ANNUAL_PREPAY_PCT / 100));
const getSetupFee = () => softwareStatusFee[getSoftwareStatus()];
const getCatchupFee = () => getMonthsBehind() * getBooksFee() * oneTimePrices.catchupPct / 100;
const getOneTime = () => Math.round(oneTimePrices.onboarding + getSetupFee() + getCatchupFee());
const getReturnFee = () => takesTaxReturn() ? getReturnFeeIfTaken() : 0;
const getEstimatesFee = () => takesEstimates() ? returnPrices.estimatesYear : 0;
const getYearEndForms = () => (hasPayroll() ? getPayrollHeads() * payrollPrices.w2Each : 0) + getContractors() * payrollPrices.n1099Each;
const getAnnual = () => Math.round(getReturnFee() + getEstimatesFee() + getYearEndForms());
// Descriptions the bookkeeper can quote from
const describeBooks = () => 'Tier ' + getTierNumber() + ', ' + count(getTransactions()) + ' transactions a month, ' + getAccounts() + ' accounts, ' + (isAccrual() ? 'accrual' : 'cash') + ' basis, ' + (isQuarterly() ? 'quarterly' : 'monthly') + ' close';
const describePayroll = () => hasPayroll()
? getPayrollHeads() + ' on payroll (' + getEmployees() + ' employees' + (isOwnerOnPayroll() ? ' plus the owner' : '') + '), ' + (frequencyNames[getFrequency()] || '') + (getContractors() > 0 ? ', ' + getContractors() + ' 1099 contractors' : '')
: getContractors() > 0 ? 'no payroll, ' + getContractors() + ' 1099 contractors' : 'no payroll';
const describeTaxes = () => [
collectsSalesTax() ? 'sales tax in ' + plural(getStates(), 'state', 'states') + (isOverNexus() ? ' (nexus check needed)' : '') : 'no sales tax',
takesTaxReturn() ? getReturn().name + (isK1Entity() ? ', ' + plural(getOwners(), 'K-1', 'K-1s') : '') : 'no annual return',
takesEstimates() ? 'quarterly estimates' : ''
].filter(part => part !== '').join('; ');
const describeService = () => (isDedicated() ? 'dedicated bookkeeper' : 'bookkeeping team') + ', documents: ' + (docsNames[getDocs()] || '')
+ (getAddons().length > 0 ? '; ' + getAddons().map(id => (addonNames[id] || id).toLowerCase()).join(', ') : '')
+ (getCfoHours() > 0 ? '; CFO advisory ' + getCfoHours() + ' h/mo' : '');
const describeStart = () => [
getTiming(),
getSituation() === 'switching' ? 'taking over from the previous bookkeeper' : '',
getMonthsBehind() > 0 ? getMonthsBehind() + ' months of catch-up' : '',
getSetupFee() > 0 ? softwareStatusNames[getSoftwareStatus()].toLowerCase() + ' in ' + getSoftware() : ''
].filter(part => part !== '').join('; ');
// Estimate Section
const summarySection = form.addSubform('summary', { title: '💰 Your Estimate', isCollapsible: false });
summarySection.addRow(row => {
row.addTextPanel('promoBanner', {
computedValue: () => PROMO_TEXT + ': ' + PROMO_PCT + '% off is already in the monthly fee below.',
customStyles: { 'font-size': '0.95rem', 'font-weight': '500', 'color': '#166534', 'background': '#dcfce7', 'padding': '10px', 'border-radius': '6px' },
isVisible: () => getPromo() > 0
});
});
summarySection.addRow(row => {
row.addPriceDisplay('booksFee', {
label: () => 'Bookkeeping, tier ' + getTierNumber(),
computedValue: () => Math.round(getBooksFee()),
decimals: 0,
variant: 'default',
suffix: '/mo'
}, '1fr');
row.addPriceDisplay('salesTaxFee', {
label: () => 'Sales tax filings, ' + plural(getStates(), 'state', 'states'),
computedValue: () => getSalesTaxFee(),
decimals: 0,
variant: 'default',
prefix: '+',
suffix: '/mo',
isVisible: () => getSalesTaxFee() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('payrollFee', {
label: () => 'Payroll, ' + plural(getPayrollHeads() + getContractors(), 'person', 'people'),
computedValue: () => Math.round(getPayrollFee()),
decimals: 0,
variant: 'default',
prefix: '+',
suffix: '/mo',
isVisible: () => getPayrollFee() > 0
}, '1fr');
row.addPriceDisplay('addonsFee', {
label: 'Add-ons',
computedValue: () => getAddonsFee(),
decimals: 0,
variant: 'default',
prefix: '+',
suffix: '/mo',
isVisible: () => getAddonsFee() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('cfoFee', {
label: () => 'CFO advisory, ' + getCfoHours() + ' h',
computedValue: () => getCfoFee(),
decimals: 0,
variant: 'default',
prefix: '+',
suffix: '/mo',
isVisible: () => getCfoFee() > 0
}, '1fr');
row.addPriceDisplay('dedicatedFee', {
label: 'Dedicated bookkeeper (+' + DEDICATED_PCT + '%)',
computedValue: () => Math.round(getDedicatedFee()),
decimals: 0,
variant: 'default',
prefix: '+',
suffix: '/mo',
isVisible: () => getDedicatedFee() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addPriceDisplay('promoDiscount', {
label: PROMO_TEXT + ' (-' + PROMO_PCT + '%)',
computedValue: () => -Math.round(getPromo()),
decimals: 0,
variant: 'success',
suffix: '/mo',
isVisible: () => getPromo() > 0
}, '1fr');
});
summarySection.addSpacer({ showLine: true, lineStyle: 'solid', lineColor: '#e2e8f0' });
summarySection.addRow(row => {
row.addPriceDisplay('monthlyTotal', {
label: 'Every month',
computedValue: () => getMonthly(),
decimals: 0,
variant: 'large',
suffix: 'per month, flat'
}, '1fr');
row.addPriceDisplay('oneTimeTotal', {
label: 'Once at the start',
computedValue: () => getOneTime(),
decimals: 0,
variant: 'highlight',
suffix: 'one-time'
}, '1fr');
row.addPriceDisplay('annualTotal', {
label: 'Once a year',
computedValue: () => getAnnual(),
decimals: 0,
variant: 'highlight',
suffix: 'per year',
isVisible: () => getAnnual() > 0
}, '1fr');
});
summarySection.addRow(row => {
row.addTextPanel('prepayNote', {
computedValue: () => money(getMonthlyPrepaid()) + '/mo when twelve months are paid up front (' + ANNUAL_PREPAY_PCT + '% off).',
customStyles: { 'font-size': '0.9rem', 'color': '#059669', 'font-weight': '500' },
isVisible: () => ANNUAL_PREPAY_PCT > 0
});
});
// Sticky Summary Section
const finalSection = form.addSubform('final', {
title: '🧾 Summary',
isCollapsible: false,
sticky: 'bottom'
});
finalSection.addRow(row => {
row.addPriceDisplay('monthlyFee', {
label: 'Monthly fee',
computedValue: () => getMonthly(),
decimals: 0,
variant: 'large',
suffix: '/month'
}, '1fr');
row.addPriceDisplay('oneTimeFee', {
label: 'One-time',
computedValue: () => getOneTime(),
decimals: 0,
variant: 'default'
}, '1fr');
row.addPriceDisplay('annualFee', {
label: 'Per year',
computedValue: () => getAnnual(),
decimals: 0,
variant: 'default'
}, '1fr');
});
finalSection.addRow(row => {
row.addTextPanel('disclaimer', {
computedValue: () => 'Estimate only. The engagement letter after the call sets the final fee and scope.',
customStyles: { 'font-size': '0.85rem', 'color': '#94a3b8', 'font-style': 'italic' }
});
});
// Contact Section
const contactSection = form.addSubform('contact', { title: '📋 Your Contact Details' });
contactSection.addRow(row => {
row.addTextbox('businessName', {
label: 'Business Name',
placeholder: 'Or "not formed yet"',
tooltip: 'Leave it blank if the business does not exist yet'
}, '2fr');
row.addTextbox('ein', {
label: 'EIN (optional)',
placeholder: '12-3456789'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('name', {
label: 'Your Name',
placeholder: 'John Smith',
isRequired: true
}, '1fr');
row.addTextbox('city', {
label: 'City and State',
placeholder: 'Columbus, OH',
tooltip: 'We work remotely, but the state decides a lot of the tax picture',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addTextbox('phone', {
label: 'Phone',
placeholder: '(555) 123-4567',
isRequired: true
}, '1fr');
row.addEmail('email', {
label: 'Email Address',
placeholder: 'john@example.com',
tooltip: 'For the estimate and the engagement letter',
isRequired: true
}, '1fr');
});
contactSection.addRow(row => {
row.addRadioButton('callTime', {
label: 'Best time to call',
options: [
{ id: 'anytime', name: 'Anytime' },
{ id: 'morning', name: 'Mornings' },
{ id: 'afternoon', name: 'Afternoons' }
],
defaultValue: 'anytime'
}, '1fr');
});
contactSection.addRow(row => {
row.addTextarea('notes', {
label: 'Anything else we should know?',
placeholder: 'E.g. an IRS notice on the desk, a second location, sales into Canada, plans to hire, a lender asking for statements',
rows: 3,
maxLength: 1500
});
});
const whenWeCall = () => {
const when = contactSection.radioButton('callTime')?.value();
return when === 'morning' ? 'in the morning' : when === 'afternoon' ? 'in the afternoon' : 'shortly';
};
// PDF estimate: the business described and the three numbers
form.configurePdf('estimate', pdf => {
pdf.configure({
filename: 'bookkeeping-estimate',
pageSize: 'Letter',
allowUserDownload: true,
downloadButtonLabel: 'Download the estimate (PDF)',
header: {
title: 'Bookkeeping Estimate',
subtitle: entityNames[getEntity()] + ' | ' + count(getTransactions()) + ' transactions a month | ' + (hasPayroll() ? getPayrollHeads() + ' on payroll' : 'no payroll')
},
footer: {
text: 'Estimate only. The engagement letter after the call sets the final fee and scope.',
showPageNumbers: true
}
});
pdf.addSection('Your Business', section => {
section.addRow(row => {
row.addField('Business', contactSection.textbox('businessName')?.value() || 'not formed yet');
row.addField('Entity', entityNames[getEntity()]);
row.addField('Industry', industryNames[getIndustry()]);
row.addField('Annual revenue', 'about ' + money(getRevenue()));
});
section.addRow(row => {
row.addField('Books', describeBooks());
row.addField('Software', getSoftware() + ', ' + softwareStatusNames[getSoftwareStatus()].toLowerCase());
});
section.addRow(row => {
row.addField('Payroll', describePayroll());
row.addField('Taxes', describeTaxes());
});
section.addRow(row => {
row.addField('Service', describeService());
row.addField('Start', describeStart());
});
});
pdf.addSection('Every Month', section => {
const rows: string[][] = [['Bookkeeping, tier ' + getTierNumber() + ': ' + describeBooks(), money(getBooksFee())]];
if (getSalesTaxFee() > 0) rows.push(['Sales tax filings, ' + plural(getStates(), 'state', 'states'), money(getSalesTaxFee())]);
if (getPayrollFee() > 0) rows.push(['Payroll: ' + describePayroll(), money(getPayrollFee())]);
if (getAddonsFee() > 0) rows.push(['Add-ons: ' + [usesApp() ? 'receipt capture app' : ''].concat(getAddons().map(id => (addonNames[id] || id).toLowerCase())).filter(part => part !== '').join(', '), money(getAddonsFee())]);
if (getCfoFee() > 0) rows.push(['CFO advisory, ' + getCfoHours() + ' hours', money(getCfoFee())]);
if (getDedicatedFee() > 0) rows.push(['Dedicated bookkeeper (+' + DEDICATED_PCT + '%)', money(getDedicatedFee())]);
if (getPromo() > 0) rows.push([PROMO_TEXT + ' (-' + PROMO_PCT + '%)', '-' + money(getPromo())]);
rows.push(['Monthly fee', money(getMonthly()) + '/mo']);
section.addTable(['Item', 'Amount'], rows);
if (ANNUAL_PREPAY_PCT > 0) section.addText(money(getMonthlyPrepaid()) + '/mo when twelve months are paid up front.');
});
pdf.addSection('Once at the Start and Once a Year', section => {
const rows: string[][] = [['Onboarding', money(oneTimePrices.onboarding)]];
if (getSetupFee() > 0) rows.push([softwareStatusNames[getSoftwareStatus()] + ': ' + getSoftware(), money(getSetupFee())]);
if (getMonthsBehind() > 0) rows.push(['Catch-up bookkeeping, ' + getMonthsBehind() + ' months', money(getCatchupFee())]);
if (getReturnFee() > 0) rows.push([getReturn().name + (isK1Entity() && getOwners() > 1 ? ' with ' + getOwners() + ' K-1s' : '') + ' (once a year)', money(getReturnFee())]);
if (getEstimatesFee() > 0) rows.push(['Quarterly estimated taxes (once a year)', money(getEstimatesFee())]);
if (getYearEndForms() > 0) rows.push(['Year-end forms: ' + [hasPayroll() ? getPayrollHeads() + ' W-2s' : '', getContractors() > 0 ? getContractors() + ' 1099-NECs' : ''].filter(part => part !== '').join(', ') + ' (once a year)', money(getYearEndForms())]);
section.addTable(['Item', 'Amount'], rows);
});
pdf.addSection('What Happens Next', section => {
section.addText('A bookkeeper calls you ' + whenWeCall() + ', within ' + REPLY_HOURS + ' hours' + (CONSULT_MINUTES > 0 ? '; the first ' + CONSULT_MINUTES + ' minutes are free.' : '.'));
section.addText('We confirm the entity, the return you file, sales tax and payroll, and the scope of the plan. You receive the engagement letter with the final fee and a list of what we need to get started.');
const notes = contactSection.textarea('notes')?.value();
if (notes) {
section.addDivider();
section.addText('Notes: ' + notes);
}
section.addDivider();
section.addText('This estimate is not an offer to contract and not tax advice. What the form says about your return, payroll and sales tax follows from the answers you gave and the thresholds in force today; the bookkeeper confirms it on the call. Software subscriptions are billed by the vendor in your name and are not included.');
});
});
form.configureSubmitButton({
label: 'Request My Estimate'
});
form.configureCompletionScreen({
type: 'text',
title: 'Your request is with our bookkeeper',
message: 'We will call within ' + REPLY_HOURS + ' hours to confirm the scope. The engagement letter follows the call.'
});
}