Official fee schedules list every possible charge, but they rarely tell you which lines apply to you. That's the gap Passport Calculator fills: answer five or six questions and get an itemized estimate of what a US passport will actually cost.
Under the hood it's a small rules engine — a pattern that works for any "which fees apply to me?" problem (visas, permits, licenses, shipping). Here's how it's put together.
Fees as data, not if-statements
The first version of a calculator like this is usually a pile of nested ifs. That falls apart the moment fees change. Instead, each fee line is data with a condition:
type Answers = {
type: "first_time" | "renewal" | "child" | "lost_stolen";
product: "book" | "card" | "both";
expedite: boolean;
needsPhoto: boolean;
};
interface FeeLine {
id: string;
label: string;
amount: number; // in cents
official: boolean; // government fee vs estimate
applies: (a: Answers) => boolean;
}
Amounts are stored in cents to avoid floating-point surprises when adding lines together.
The fee table (as of the site's last update)
const inPerson = (a: Answers) => a.type !== "renewal"; // DS-11 applicants pay execution
export const FEES: FeeLine[] = [
{ id: "app_book_adult", label: "Application fee (adult book)", amount: 13000, official: true,
applies: (a) => a.type !== "child" && a.product !== "card" },
{ id: "app_book_child", label: "Application fee (child book)", amount: 10000, official: true,
applies: (a) => a.type === "child" && a.product !== "card" },
{ id: "app_card_adult", label: "Application fee (adult card)", amount: 3000, official: true,
applies: (a) => a.type !== "child" && a.product !== "book" },
{ id: "execution", label: "Execution fee", amount: 3500, official: true, applies: inPerson },
{ id: "expedite", label: "Expedited service", amount: 6000, official: true, applies: (a) => a.expedite },
{ id: "photo", label: "Passport photo (estimate)", amount: 1500, official: false, applies: (a) => a.needsPhoto },
];
Note that the execution fee is charged once per appointment, which is why a book and card together come to $195 rather than double-counting.
The engine is a filter and a sum
export function estimate(a: Answers) {
const lines = FEES.filter((f) => f.applies(a));
const total = lines.reduce((sum, f) => sum + f.amount, 0);
return { lines, total };
}
const usd = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
That's it. A first-time adult book comes out to $165.00 ($130 + $35), a mail renewal to $130.00, and adding expedite brings a first-time book to $225.00.
Test every published scenario
Every scenario shown on the site becomes a test, so a fee change that breaks a page is caught immediately:
test.each([
[{ type: "first_time", product: "book", expedite: false, needsPhoto: false }, 16500],
[{ type: "renewal", product: "book", expedite: false, needsPhoto: false }, 13000],
[{ type: "child", product: "book", expedite: false, needsPhoto: false }, 13500],
[{ type: "first_time", product: "card", expedite: false, needsPhoto: false }, 6500],
[{ type: "first_time", product: "both", expedite: false, needsPhoto: false }, 19500],
[{ type: "first_time", product: "book", expedite: true, needsPhoto: false }, 22500],
])("%o costs %i cents", (answers, cents) => {
expect(estimate(answers as Answers).total).toBe(cents);
});
Separate official fees from estimates
A photo isn't a government fee, so it's labeled as an estimate. Mixing the two quietly would make the total look more authoritative than it is. The official flag drives that label in the UI.
Scenario pages share the same engine
Pages like "first-time adult", "passport card" or "lost or stolen" all read from the same fee table and link back to the calculator with answers prefilled via query string. Update one number and every page, table and test stays consistent.
Be explicit about what you're not
For anything touching government services, clarity matters: the site states it isn't affiliated with the State Department, that results are unofficial estimates with an "as of" date, and that users should confirm current fees on travel.state.gov before paying. Nothing users enter is submitted anywhere.
Takeaways
- Model fees as data with conditions; keep the engine tiny.
- Store money in integer cents.
- Turn every published example into a test.
- Label estimates separately from official amounts, and date your data.
Try it at passportcalculator.com. Have you built rules engines for pricing or eligibility? I'd like to hear how you structured them.
Top comments (0)