Build a tax extension
A full extension that adds a sales-tax line to invoices and exposes a tax-summary endpoint and dashboard page.
A complete extension that exercises hooks, custom entities, jobs, API routes, and pages.
Goal
- On invoice / sales-receipt save, compute and stamp a sales-tax line.
- Track each tax application as a
TaxRecordcustom entity. - Track jurisdiction addresses as a
NexusAddresscustom entity, manageable from a UI page. - Expose a
/tax-summaryAPI route for date-ranged reporting. - Run a weekly job that summarizes the prior week.
Manifest
// backfill.config.ts
import { defineExtension } from "@backfill-io/sdk";
export default defineExtension({
key: "backfill-extension-tax-calcs",
name: "Tax Calculations",
version: "1.1.0",
permissions: {
entities: {
Invoice: ["read", "write"],
SalesReceipt: ["read", "write"],
NexusAddress: ["read", "write"],
},
},
pages: [
{
id: "nexus-addresses",
title: "Nexus Addresses",
path: "nexus-addresses",
render: "src/pages/nexus-addresses.tsx",
kind: "list",
nav: { label: "Nexus Addresses", section: "Tax", order: 10 },
},
{
id: "nexus-address-form",
title: "Nexus Address",
path: "nexus-address-form",
render: "src/pages/nexus-address-form.tsx",
kind: "page",
},
],
ui: {
actions: [
{
id: "save-nexus-address",
label: "Save Nexus Address",
page: "nexus-address-form",
run: "src/pages/actions/save-nexus-address.ts",
},
],
},
config: {
taxRate: 8.25,
taxLabel: "Sales Tax",
enableTaxOnInvoices: true,
enableTaxOnSalesReceipts: true,
},
});
Custom entities
// src/entities/NexusAddress.ts
import { defineEntity } from "@backfill-io/sdk";
export default defineEntity({
name: "NexusAddress",
fields: {
nexusName: { type: "string", required: true },
street: { type: "string", required: true },
city: { type: "string", required: true },
state: { type: "string", required: true },
postalCode: { type: "string", required: true },
country: { type: "string", default: "US" },
active: { type: "boolean", default: true },
},
});
// src/entities/TaxRecord.ts
import { defineEntity } from "@backfill-io/sdk";
export default defineEntity({
name: "TaxRecord",
fields: {
entityType: { type: "string", required: true },
entityId: { type: "string", required: true },
subtotal: { type: "number", required: true },
taxRate: { type: "number", required: true },
taxAmount: { type: "number", required: true },
},
});
The before-save hook
// src/hooks/invoice/before-save.ts
import { Settings, Log } from "@backfill-io/sdk";
import type { HookContext } from "@backfill-io/sdk";
export default async function (ctx: HookContext) {
const settings = Settings.getAll();
if (!settings.enableTaxOnInvoices) return null;
const rate = settings.taxRate ?? 8.25;
const label = settings.taxLabel ?? "Sales Tax";
const lines: any[] = ctx.entity.line_items ?? [];
const subtotal = lines
.filter((li) => li.description !== label)
.reduce((sum, li) => sum + (Number(li.amount) || 0), 0);
if (subtotal <= 0) return null;
const taxAmount = Math.round(subtotal * (rate / 100) * 100) / 100;
const updatedLines = [
...lines.filter((li) => li.description !== label),
{ description: label, amount: taxAmount, quantity: 1 },
];
const totalAmount = Math.round((subtotal + taxAmount) * 100) / 100;
Log.info("Tax calculated for invoice", { subtotal, rate, taxAmount, totalAmount });
return { line_items: updatedLines, total_amount: totalAmount };
}
A near-identical hook lives at src/hooks/sales-receipt/before-save.ts.
The after-save hook
After save, persist a TaxRecord so we can summarize later:
// src/hooks/invoice/after-save.ts
declare const TaxRecord: any;
import type { HookContext } from "@backfill-io/sdk";
export default async function (ctx: HookContext) {
const lines: any[] = ctx.entity.line_items ?? [];
const taxLine = lines.find((li) => li.description?.toLowerCase().includes("tax"));
if (!taxLine) return;
const subtotal = lines
.filter((li) => li !== taxLine)
.reduce((sum, li) => sum + (Number(li.amount) || 0), 0);
TaxRecord.create({
entityType: "Invoice",
entityId: ctx.entity.id,
subtotal,
taxRate: 8.25,
taxAmount: Number(taxLine.amount),
});
}
The summary API route
// src/api/tax-summary.ts
declare const TaxRecord: any;
export const GET = async function (ctx: { searchParams: Record<string, string> }) {
const { start, end } = ctx.searchParams;
if (!start || !end) {
return { __api_response: true, status: 400, body: { error: "Missing start/end" } };
}
const records = TaxRecord.query({
where: { _created_at: { gte: start, lte: end } },
});
return { period: { start, end }, count: records.length };
};
The weekly job
// src/jobs/weekly-tax-report.ts
import { Log } from "@backfill-io/sdk";
declare const TaxRecord: any;
export const schedule = "0 8 * * 1";
export async function run() {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const startDate = weekAgo.toISOString().split("T")[0];
const records: any[] = TaxRecord.query({
where: { _created_at: { gte: startDate } },
});
const total = records.reduce((sum, r) => sum + r.taxAmount, 0);
Log.info("Weekly tax report", { count: records.length, total });
}
Nexus address pages
Three pieces wire up the admin UI: a list page, a form page, and an action handler that does the save.
List page
// src/pages/nexus-addresses.tsx
import {
Page, Section, Stack, Card, Text, Badge,
EmptyState, Toolbar, Link, Routes,
} from "@backfill-io/sdk/ui";
import type { ExtensionPageRenderContext } from "@backfill-io/sdk/ui";
declare const NexusAddress: any;
export default function render(ctx: ExtensionPageRenderContext) {
const rows = NexusAddress.query({
orderBy: [{ field: "nexusName", direction: "asc" }],
});
const newHref = Routes.page(ctx, "nexus-address-form");
return (
<Page
title="Nexus Addresses"
description="Jurisdictions where you have a tax nexus."
toolbar={<Toolbar><Link href={newHref}>Add Nexus Address</Link></Toolbar>}
>
{rows.length === 0 ? (
<Section>
<EmptyState
title="No nexus addresses yet"
description="Add your first address to get started."
/>
</Section>
) : (
<Section>
<Stack direction="column" gap="md">
{rows.map((row: any) => (
<Card key={row.id} title={row.nexusName}>
<Stack direction="column" gap="sm">
<Text>{row.street}</Text>
<Text>{`${row.city}, ${row.state} ${row.postalCode}`}</Text>
<Text tone="muted">{row.country || "US"}</Text>
{row.active === false && <Badge tone="warning">Inactive</Badge>}
<Link href={Routes.page(ctx, "nexus-address-form", { query: { id: row.id } })}>
Edit
</Link>
</Stack>
</Card>
))}
</Stack>
</Section>
)}
</Page>
);
}
Routes.page(ctx, "<page-id>", { query }) builds a link to another page in this extension, with optional query params. That’s how the list page hands a row id to the form page on edit.
Form page
The same nexus-address-form.tsx file handles both create and edit — the difference is whether ?id=... is in the URL. If it is, we load the existing record and pre-fill the fields.
// src/pages/nexus-address-form.tsx
import {
Page, Section, Stack, Grid, Form, Field,
TextInput, Select, Checkbox, SubmitButton, Routes,
} from "@backfill-io/sdk/ui";
import type { ExtensionPageRenderContext, SelectOption } from "@backfill-io/sdk/ui";
declare const NexusAddress: any;
const COUNTRY_OPTIONS: SelectOption[] = [
{ value: "US", label: "United States" },
{ value: "CA", label: "Canada" },
{ value: "GB", label: "United Kingdom" },
// …extend as needed
];
export default function render(ctx: ExtensionPageRenderContext) {
const id = typeof ctx.page.params?.id === "string" ? ctx.page.params.id : undefined;
const existing = id ? NexusAddress.get(id) : null;
const isEdit = existing != null;
const v = existing ?? {
nexusName: "", street: "", city: "", state: "",
postalCode: "", country: "US", active: true,
};
return (
<Page title={isEdit ? "Edit Nexus Address" : "New Nexus Address"}>
<Section>
<Form action="save-nexus-address" cancelHref={Routes.page(ctx, "nexus-addresses")}>
<Stack direction="column" gap="md">
<Field name="nexusName" label="Nexus Name" required>
<TextInput defaultValue={v.nexusName} placeholder="Company HQ" />
</Field>
<Field name="street" label="Street" required>
<TextInput defaultValue={v.street} />
</Field>
<Grid columns={2} gap="md">
<Field name="city" label="City" required>
<TextInput defaultValue={v.city} />
</Field>
<Field name="state" label="State / Province" required>
<TextInput defaultValue={v.state} />
</Field>
<Field name="postalCode" label="Postal Code" required>
<TextInput defaultValue={v.postalCode} />
</Field>
<Field name="country" label="Country">
<Select defaultValue={v.country ?? "US"} options={COUNTRY_OPTIONS} />
</Field>
</Grid>
<Field name="active" label="Active">
<Checkbox checked={v.active !== false} />
</Field>
<SubmitButton action="save-nexus-address">
{isEdit ? "Save Changes" : "Create Nexus Address"}
</SubmitButton>
</Stack>
</Form>
</Section>
</Page>
);
}
Form action="save-nexus-address" references the action declared in ui.actions in the manifest. The runtime invokes the corresponding handler script with the form’s values.
Action handler
// src/pages/actions/save-nexus-address.ts
import { redirect, validationError, Routes } from "@backfill-io/sdk/ui";
import type { ExtensionPageActionContext, UiActionFieldError } from "@backfill-io/sdk/ui";
declare const NexusAddress: any;
export default async function run(ctx: ExtensionPageActionContext) {
const id = typeof ctx.page.params?.id === "string" ? ctx.page.params.id : undefined;
const v = ctx.values ?? {};
const attrs = {
nexusName: String(v.nexusName ?? "").trim(),
street: String(v.street ?? "").trim(),
city: String(v.city ?? "").trim(),
state: String(v.state ?? "").trim(),
postalCode: String(v.postalCode ?? "").trim(),
country: String(v.country ?? "").trim() || "US",
// Checkbox may arrive as boolean true, "true", "on", or missing
active: v.active === true || v.active === "true" || v.active === "on",
};
const errors: UiActionFieldError[] = [];
if (!attrs.nexusName) errors.push({ field: "nexusName", message: "Nexus name is required." });
if (!attrs.street) errors.push({ field: "street", message: "Street is required." });
if (!attrs.city) errors.push({ field: "city", message: "City is required." });
if (!attrs.state) errors.push({ field: "state", message: "State is required." });
if (!attrs.postalCode) errors.push({ field: "postalCode", message: "Postal code is required." });
if (errors.length > 0) {
return validationError("Please correct the errors below.", errors);
}
if (id) {
NexusAddress.update(id, attrs);
} else {
NexusAddress.create(attrs);
}
return redirect(
Routes.page(ctx, "nexus-addresses"),
id ? "Nexus address updated." : "Nexus address created.",
);
}
Two things to note:
ctx.valuesare strings (or arrays for multi-selects, or whatever the input emits). Coerce explicitly — checkboxes in particular can arrive astrue,"true","on", or missing.validationError(message, fieldErrors)re-renders the form with field-level error messages.redirect(href, toast)navigates and flashes a toast. Both come from@backfill-io/sdk.
Deploy
pnpm tsc --noEmit
backfill build
backfill deploy
That’s it.