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 TaxRecord custom entity.
  • Track jurisdiction addresses as a NexusAddress custom entity, manageable from a UI page.
  • Expose a /tax-summary API 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.values are strings (or arrays for multi-selects, or whatever the input emits). Coerce explicitly — checkboxes in particular can arrive as true, "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.