Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## [Unreleased]

## [1.1.2] - 2026-07-21

### Added
- **Expense category write API** — `POST /api/v1/categories` upserts expense categories (scope: `categories:write`). Matches by FinaBill `externalId` then by name; stores partner id on `externalAccountCode` / `externalSystem=finabill`. Income category type is rejected with a clear validation error until FF models income categories (map income via CoA revenue accounts).
- **tRPC parity** — `integrationFinabill.upsertCategory` thin wrapper over the same service method.
- **OpenAPI / Scalar** — POST `/categories`, `UpsertCategoryInput` / `UpsertCategoryResult`, and `categories:write` scope documented in `docs/api-reference/openapi.yaml` and `docs/authentication.md`.
- **Connect scopes** — `categories:write` included in `DEFAULT_CONNECT_SCOPES` and legacy `write` alias.

### Changed
- **Version bump to 1.1.2** — `package.json` and `src/lib/version.ts`.

### Fixed
- **CI integration test isolation** — Vitest 4 no longer honors `singleFork`, so API suites were running files in parallel against one Postgres DB. Test bootstrap also truncated `locations` and dropped budget plan tables on every setup pass, which raced with seeded data and produced `budget_plan_buckets does not exist`, `Location not found for business or inactive`, and missing location counters after business reset. Forced `fileParallelism: false` + `maxWorkers: 1`, made bootstrap once-only and non-destructive, and verified migration 0014 tables exist after apply (`vitest.config.ts`, `api/test/setup.ts`, `api/__tests__/budgets-router.test.ts`).

Expand All @@ -12,6 +23,7 @@
- `GET /api/v1/suppliers` — list suppliers (scope: `suppliers:read`)
- `POST /api/v1/suppliers` — upsert supplier (scope: `suppliers:write`)
- `GET /api/v1/categories` — list expense categories (scope: `categories:read`)
- `POST /api/v1/categories` — upsert expense category (scope: `categories:write`)
- `GET /api/v1/business/profile` — get business profile (scope: `business:read`)
- `GET /api/v1/locations` — list locations (scope: `locations:read`)
- `GET /api/v1/users` — list users (scope: `users:read`)
Expand All @@ -33,7 +45,7 @@
- **tRPC `.meta()` descriptions** on all `integrationFinabill` router procedures for future doc generation.

### Changed
- **Granular API scopes** — scopes now follow `resource:action` naming: `accounts:read`, `suppliers:read`, `suppliers:write`, `categories:read`, `business:read`, `locations:read`, `users:read`, `users:write`, `sales:write`, `journal:write`, `webhooks`. Legacy `read`/`write` scopes still work via alias resolution.
- **Granular API scopes** — scopes now follow `resource:action` naming: `accounts:read`, `suppliers:read`, `suppliers:write`, `categories:read`, `categories:write`, `business:read`, `locations:read`, `users:read`, `users:write`, `sales:write`, `journal:write`, `webhooks`. Legacy `read`/`write` scopes still work via alias resolution.
- **`DEFAULT_CONNECT_SCOPES` tightened** — removed `admin` (overly broad), `coa:read` and `supplier:read` (redundant). Now uses the shared registry from `api-scopes.ts`.
- **`integrationFinabillRouter` refactored** — tRPC router is now a thin wrapper over `integration-service.ts`. All business logic lives in the service layer.
- **`boot.ts` cleaned up** — inline webhook handler, `constantTimeCompare`, and daily-sales handler removed. All moved to proper modules.
Expand Down
30 changes: 30 additions & 0 deletions api/integration-finabill-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,36 @@ export const integrationFinabillRouter = createRouter({
return { data };
}),

upsertCategory: apiKeyProcedure
.meta({
description:
"Create or update an expense category. Match by externalId (FinaBill category id) or name. Income categories are not supported yet.",
})
.use(requireApiKey("categories:write"))
.input(
z.object({
externalId: z.string().optional(),
name: z.string().min(1).max(100),
categoryType: z.enum(["expense", "income"]).optional().default("expense"),
defaultAccountId: z.number().int().positive().optional().nullable(),
description: z.string().optional().nullable(),
isActive: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const businessId = getBusinessId(ctx);
if (!businessId) {
integrationService.logIntegration(ctx, "finabill.upsertCategory", "failed", { error: "No active business" });
throw new Error("No active business");
}
const result = await integrationService.upsertCategory(businessId, input);
integrationService.logIntegration(ctx, "finabill.upsertCategory", "success", {
categoryId: result.id,
created: result.created,
});
return result;
}),

upsertSupplier: apiKeyProcedure
.meta({ description: "Create or update a supplier. Match by externalId if provided, otherwise create new." })
.use(requireApiKey("suppliers:write"))
Expand Down
4 changes: 3 additions & 1 deletion api/lib/api-scopes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const API_SCOPES = {
"suppliers:read": "Read suppliers",
"suppliers:write": "Create and update suppliers",
"categories:read": "Read expense categories",
"categories:write": "Create and update expense categories",
"business:read": "Read business profile",
"locations:read": "Read locations/branches",
"users:read": "Read users and role templates",
Expand All @@ -28,6 +29,7 @@ export const DEFAULT_CONNECT_SCOPES: ApiScope[] = [
"suppliers:read",
"suppliers:write",
"categories:read",
"categories:write",
"business:read",
"locations:read",
"users:read",
Expand All @@ -48,7 +50,7 @@ export function isValidScope(scope: string): scope is ApiScope {
*/
export const SCOPE_ALIASES: Record<string, ApiScope[]> = {
read: ["accounts:read", "suppliers:read", "categories:read", "business:read", "locations:read", "users:read"],
write: ["suppliers:write"],
write: ["suppliers:write", "categories:write"],
};

/** Resolves a scope (including legacy aliases) to the set of granular scopes it grants. */
Expand Down
138 changes: 135 additions & 3 deletions api/lib/integration-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function logIntegration(
}
}

// ── Read operations ────────────────────────────────────────────────
// ── Read operations ────────────────────────────────────────────────

export async function listAccounts(businessId: number, accountType?: string) {
const db = getDb();
Expand Down Expand Up @@ -102,12 +102,24 @@ export async function listCategories(businessId: number) {
categoryType: expenseCategories.accountingClass,
defaultAccountId: expenseCategories.defaultAccountId,
externalAccountCode: expenseCategories.externalAccountCode,
externalSystem: expenseCategories.externalSystem,
isActive: expenseCategories.isActive,
})
.from(expenseCategories)
.where(and(eq(expenseCategories.businessId, businessId), isNull(expenseCategories.deletedAt)))
.orderBy(expenseCategories.name);
return data.map((c) => ({ ...c, categoryType: "expense" as const }));
return data.map((c) => ({
id: c.id,
name: c.name,
categoryType: "expense" as const,
defaultAccountId: c.defaultAccountId,
externalAccountCode: c.externalAccountCode,
externalId:
c.externalSystem === "finabill" && c.externalAccountCode
? c.externalAccountCode
: null,
isActive: c.isActive,
}));
}

export async function getBusinessProfile(businessId: number) {
Expand Down Expand Up @@ -221,7 +233,7 @@ export async function listRoleTemplates() {
.where(eq(rolePermissions.isActive, true));
}

// ── Write operations ───────────────────────────────────────────────
// ── Write operations ───────────────────────────────────────────────

export async function upsertSupplier(
businessId: number,
Expand Down Expand Up @@ -396,3 +408,123 @@ export async function upsertUser(

return { id: created.id, created: true };
}

export async function upsertCategory(
businessId: number,
input: {
externalId?: string;
name: string;
categoryType?: "expense" | "income";
defaultAccountId?: number | null;
description?: string | null;
isActive?: boolean;
},
) {
const db = getDb();
const categoryType = input.categoryType ?? "expense";
if (categoryType !== "expense") {
// FinaFlow currently models operational categories as expense_categories only.
// Income classification lives on the chart of accounts / revenue accounts.
throw new Error(
'categoryType "income" is not supported on FinaFlow yet. Map income via chart-of-accounts revenue accounts.',
);
}

if (input.defaultAccountId != null) {
const [account] = await db
.select({ id: accounts.id })
.from(accounts)
.where(
and(
eq(accounts.id, input.defaultAccountId),
eq(accounts.businessId, businessId),
isNull(accounts.deletedAt),
),
)
.limit(1);
if (!account) {
throw new Error("defaultAccountId must belong to this business");
}
}

const externalId = input.externalId?.trim() || null;

let existing =
externalId
? await db
.select()
.from(expenseCategories)
.where(
and(
eq(expenseCategories.businessId, businessId),
eq(expenseCategories.externalSystem, "finabill"),
eq(expenseCategories.externalAccountCode, externalId),
isNull(expenseCategories.deletedAt),
),
)
.limit(1)
: [];

if (!existing[0]) {
existing = await db
.select()
.from(expenseCategories)
.where(
and(
eq(expenseCategories.businessId, businessId),
eq(expenseCategories.name, input.name),
isNull(expenseCategories.deletedAt),
),
)
.limit(1);
}

if (existing[0]) {
const [updated] = await db
.update(expenseCategories)
.set({
name: input.name,
description: input.description ?? existing[0].description,
defaultAccountId:
input.defaultAccountId !== undefined
? input.defaultAccountId
: existing[0].defaultAccountId,
externalSystem: externalId ? "finabill" : existing[0].externalSystem,
externalAccountCode: externalId ?? existing[0].externalAccountCode,
isActive: input.isActive ?? existing[0].isActive,
updatedAt: new Date(),
deletedAt: null,
})
.where(eq(expenseCategories.id, existing[0].id))
.returning();
return {
id: updated.id,
created: false,
categoryType: "expense" as const,
name: updated.name,
defaultAccountId: updated.defaultAccountId,
};
}

const [created] = await db
.insert(expenseCategories)
.values({
businessId,
name: input.name,
description: input.description ?? null,
defaultAccountId: input.defaultAccountId ?? null,
externalSystem: externalId ? "finabill" : null,
externalAccountCode: externalId,
accountingClass: "operating_expense",
isActive: input.isActive ?? true,
})
.returning();

return {
id: created.id,
created: true,
categoryType: "expense" as const,
name: created.name,
defaultAccountId: created.defaultAccountId,
};
}
43 changes: 39 additions & 4 deletions api/routes/v1/categories.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// ABOUTME: GET /api/v1/categories — list expense categories for the authenticated business.
// ABOUTME: GET/POST /api/v1/categories — list and upsert expense categories for integrators.
import { Hono } from "hono";
import type { ApiKeyVariables } from "../../lib/api-key-middleware";
import { resolveApiKeyMiddleware } from "../../lib/api-key-middleware";
import { paginatedResponse, errorResponse } from "../../lib/api-response";
import { listCategories, logIntegration } from "../../lib/integration-service";
import { paginationQuerySchema } from "../../schemas";
import { successResponse, paginatedResponse, errorResponse } from "../../lib/api-response";
import { listCategories, upsertCategory, logIntegration } from "../../lib/integration-service";
import { paginationQuerySchema, upsertCategorySchema } from "../../schemas";

const categories = new Hono<{ Variables: ApiKeyVariables }>();

Expand All @@ -28,4 +28,39 @@ categories.get("/", resolveApiKeyMiddleware("categories:read"), async (c) => {
}
});

categories.post("/", resolveApiKeyMiddleware("categories:write"), async (c) => {
try {
const apiKey = c.get("apiKey");
const body = await c.req.json();
const parsed = upsertCategorySchema.safeParse(body);
if (!parsed.success) {
return errorResponse(
c,
400,
"VALIDATION_ERROR",
parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", "),
);
}

const result = await upsertCategory(apiKey.businessId, parsed.data);
logIntegration(
{ businessId: apiKey.businessId, apiKey },
"upsertCategory",
"success",
{ categoryId: result.id, created: result.created },
);
return successResponse(c, result, result.created ? 201 : 200);
} catch (err) {
const message = err instanceof Error ? err.message : "Internal server error";
const status = message.includes("not supported") || message.includes("must belong") ? 400 : 500;
logIntegration(
{ businessId: c.get("apiKey")?.businessId, apiKey: c.get("apiKey") },
"upsertCategory",
"failed",
{ error: message },
);
return errorResponse(c, status as 400 | 500, status === 400 ? "VALIDATION_ERROR" : "INTERNAL_ERROR", message);
}
});

export default categories;
11 changes: 11 additions & 0 deletions api/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ export const upsertSupplierSchema = z.object({
taxId: z.string().optional().nullable(),
});

// ── Integration: Categories ────────────────────────────────────────

export const upsertCategorySchema = z.object({
externalId: z.string().optional(),
name: z.string().min(1).max(100),
categoryType: z.enum(["expense", "income"]).optional().default("expense"),
defaultAccountId: z.number().int().positive().optional().nullable(),
description: z.string().optional().nullable(),
isActive: z.boolean().optional(),
});

// ── Integration: Users ──────────────────────────────────────────────

export const upsertUserSchema = z.object({
Expand Down
Loading
Loading