diff --git a/AGENTS.md b/AGENTS.md index cf379c5..a17b120 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ - All multi-step financial operations use db.transaction() - JWT stored in httpOnly cookies (not localStorage) - CSRF protection on all mutation endpoints -- Rate limiting on all endpoints (login: 10/min, API: 100/min) +- Rate limiting on all endpoints (login: 10/min, API: 500/min, integration: 100/min, connect: 30/min) - All list endpoints filter by location/business context - All tables have indexes on locationId, businessId, userId, deletedAt, status - Error boundaries wrap every route in App.tsx @@ -57,11 +57,17 @@ - Audit logging for sensitive operations ## Integrations -- FinaFlow exposes integration endpoints under `api/integration/*` mounted in `api/boot.ts`. -- Incoming webhooks are handled by `api/lib/webhook-handlers.ts` and verified in `api/boot.ts` using `X-Fina-Signature`. +- External REST API lives under `/api/v1/` (versioned). All routes use API key auth (`Authorization: Bearer fna_...`). +- Unified response envelope: `{ data, meta: { requestId } }` for success, `{ error: { code, message }, meta: { requestId } }` for errors. +- API key scopes are defined in `api/lib/api-scopes.ts`. Shared business logic lives in `api/lib/integration-service.ts`. +- v1 routes are mounted in `api/routes/v1/index.ts` and wired in `api/boot.ts` via `app.route("/api/v1", v1)`. +- Incoming webhooks are handled by `api/routes/v1/webhooks.ts` (FinaBill) and `api/lib/webhook-handlers.ts` (providers), verified using `X-Fina-Signature`. - Outgoing webhooks are dispatched by `api/lib/webhook-dispatcher.ts` and recorded in `webhookDeliveries`. - API-key auth for integration endpoints is implemented in `api/lib/api-key-auth.ts` and `api/lib/api-key-middleware.ts`. -- To add a new incoming webhook provider: extend `handleProviderWebhook` in `api/lib/webhook-handlers.ts` and mount the route before the catch-all in `api/boot.ts`. +- Legacy tRPC integration endpoints (`integrationFinabill.*`) are thin wrappers over `integration-service.ts` — kept for backward compat. +- Old paths `/api/integration/daily-sales` and `/api/webhooks/finabill` redirect to v1 with deprecation headers. +- Fina Connect pairing endpoints (`/api/connect/*`) remain in `boot.ts` — they're M2M protocol, not CRUD. +- To add a new incoming webhook provider: extend `handleProviderWebhook` in `api/lib/webhook-handlers.ts` and mount the route in `api/routes/v1/webhooks.ts`. - Run integration tests: `npx vitest run api/__tests__/webhook-dispatcher.test.ts api/__tests__/integration-finabill.test.ts` - Dev server: `npm run dev` (Portless) or `npm run dev:app` (no Portless). diff --git a/CHANGELOG.md b/CHANGELOG.md index 15fa35b..24265d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ ## [Unreleased] +### 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`). + +### Added +- **Unified REST API v1** (`/api/v1/`) — all external integration endpoints are now available as standard REST routes with a consistent `{ data, meta }` / `{ error, meta }` response envelope. No more tRPC wire format required for external consumers. + - `GET /api/v1/verify` — verify API key + - `GET /api/v1/accounts` — list accounts (scope: `accounts:read`) + - `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`) + - `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`) + - `POST /api/v1/users` — upsert user (scope: `users:write`) + - `GET /api/v1/roles` — list role templates (scope: `users:read`) + - `POST /api/v1/daily-sales` — ingest daily sales (scope: `sales:write`) + - `POST /api/v1/webhooks/finabill` — incoming FinaBill webhook with HMAC verification + - `POST /api/v1/wallet-webhooks/:provider` — incoming mobile wallet webhook (mpesa, airtel_money, sasapay) +- **API documentation at `/docs`** — Scalar-powered interactive API reference served from the Hono app, reading from `docs/api-reference/openapi.yaml`. +- **OpenAPI 3.1 spec** (`docs/api-reference/openapi.yaml`) — covers all 12 v1 endpoints, 6 outgoing webhook events, authentication, scopes, pagination, and error contracts. +- **Documentation pages** — `docs/authentication.md` (API keys, scopes, rate limits, error codes) and `docs/webhooks.md` (incoming/outgoing webhook contract, signature verification, retry policy, event catalog). +- **Request ID tracing** — every v1 response includes `X-Request-Id` header and `meta.requestId` in the JSON body. +- **Centralized scope registry** (`api/lib/api-scopes.ts`) — granular `resource:action` scopes with legacy alias resolution so existing `read`/`write` keys keep working. +- **Shared zod schemas** (`api/schemas/index.ts`) — single source of truth for request validation, webhook payload schemas, and pagination. Includes all 6 outgoing webhook event payload schemas. +- **Pagination on all v1 list endpoints** — `?offset=0&limit=20` query params with `{ page, limit, total, totalPages }` in response meta. +- **Service layer** (`api/lib/integration-service.ts`) — shared business logic extracted from the tRPC router, used by both REST and tRPC paths. +- **Rate limiting on connect endpoints** — `/api/connect/*` endpoints now have a 30 req/min limiter to prevent brute-force pairing attacks. +- **Rate limiting on integration endpoints** — all `/api/v1/` routes have a 100 req/min limiter. +- **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. +- **`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. +- **`/debug-sentry` gated** — only available when `NODE_ENV !== "production"`. +- **`constantTimeCompare` moved** to `api/lib/crypto.ts` alongside other crypto utilities. +- **Old paths deprecated** — `/api/integration/daily-sales` and `/api/webhooks/finabill` now return `307` redirects to their v1 equivalents with `Deprecation: true` header. +- **AGENTS.md Integrations section** updated to reflect the new v1 API surface, scope model, and architecture. +- **Removed unused `Sentry` import** from `api/middleware.ts`. + +### Fixed +- **Webhook catch-all parses body** — `/api/webhooks/:provider` now actually reads the request body instead of passing an empty `{}`. +- **CSRF exemption for v1** — `/api/v1` routes are properly exempted from CSRF (machine-to-machine via API key). +- **AGENTS.md rate limits corrected** — updated from stale 100/min to actual 500/min, with integration (100/min) and connect (30/min) limits documented. + ## [1.1.1] - 2026-07-11 ### Changed diff --git a/api/__tests__/budgets-router.test.ts b/api/__tests__/budgets-router.test.ts index 15ce692..6adad75 100644 --- a/api/__tests__/budgets-router.test.ts +++ b/api/__tests__/budgets-router.test.ts @@ -39,6 +39,19 @@ describe("Budgets Router", () => { const slugPrefix = "BGT_"; + async function cleanupBudgetPlansForBusiness(businessId: number) { + // Budget plan tables come from migration 0014. If bootstrap is incomplete, + // skip plan cleanup rather than failing the whole suite setup/teardown. + try { + await db.delete(bbl).where(sql`${bbl.bucketId} IN (SELECT id FROM ${bpb} WHERE ${bpb.planId} IN (SELECT id FROM ${bp} WHERE ${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${businessId})))`); + await db.delete(bpb).where(sql`${bpb.planId} IN (SELECT id FROM ${bp} WHERE ${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${businessId}))`); + await db.delete(bp).where(sql`${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${businessId})`); + } catch (error: unknown) { + const msg = String((error as { message?: string })?.message ?? error); + if (!/relation .* does not exist/i.test(msg)) throw error; + } + } + beforeAll(async () => { db = getDb(); @@ -46,9 +59,7 @@ describe("Budgets Router", () => { const existingBiz = await db.select({ id: businesses.id }).from(businesses) .where(sql`${businesses.slug} LIKE ${slugPrefix + "%"}`); for (const b of existingBiz) { - await db.delete(bbl).where(sql`${bbl.bucketId} IN (SELECT id FROM ${bpb} WHERE ${bpb.planId} IN (SELECT id FROM ${bp} WHERE ${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${b.id})))`); - await db.delete(bpb).where(sql`${bpb.planId} IN (SELECT id FROM ${bp} WHERE ${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${b.id}))`); - await db.delete(bp).where(sql`${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${b.id})`); + await cleanupBudgetPlansForBusiness(b.id); await db.delete(expenseCategories).where(eq(expenseCategories.businessId, b.id)); const locs = await db.select({ id: locations.id }).from(locations).where(eq(locations.businessId, b.id)); for (const l of locs) { @@ -129,12 +140,10 @@ describe("Budgets Router", () => { afterAll(async () => { if (!biz) return; - await db.delete(bbl).where(sql`${bbl.bucketId} IN (SELECT id FROM ${bpb} WHERE ${bpb.planId} IN (SELECT id FROM ${bp} WHERE ${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${biz.id})))`); - await db.delete(bpb).where(sql`${bpb.planId} IN (SELECT id FROM ${bp} WHERE ${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${biz.id}))`); - await db.delete(bp).where(sql`${bp.locationId} IN (SELECT id FROM ${locations} WHERE ${locations.businessId} = ${biz.id})`); + await cleanupBudgetPlansForBusiness(biz.id); await db.delete(expenseCategories).where(eq(expenseCategories.businessId, biz.id)); - await db.delete(accounts).where(eq(accounts.locationId, loc.id)); - await db.delete(locations).where(eq(locations.id, loc.id)); + if (loc) await db.delete(accounts).where(eq(accounts.locationId, loc.id)); + if (loc) await db.delete(locations).where(eq(locations.id, loc.id)); await db.delete(userBusinesses).where(eq(userBusinesses.businessId, biz.id)); await db.delete(businesses).where(eq(businesses.id, biz.id)); if (user) await db.delete(users).where(eq(users.id, user.id)); diff --git a/api/__tests__/docs-pages.test.ts b/api/__tests__/docs-pages.test.ts new file mode 100644 index 0000000..0480e53 --- /dev/null +++ b/api/__tests__/docs-pages.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +let app: Awaited["default"]>; + +beforeAll(async () => { + process.env.NODE_ENV = "development"; + const mod = await import("../boot"); + app = mod.default; +}, 120_000); + +describe("documentation pages", () => { + describe("GET /docs", () => { + it("returns HTML with the navigation header", async () => { + const res = await app.fetch(new Request("http://localhost/docs")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const html = await res.text(); + expect(html).toContain("FinaFlow Documentation"); + expect(html).toContain('href="/"'); + expect(html).toContain('href="/docs"'); + expect(html).toContain('href="/docs/api"'); + expect(html).toContain('href="/dashboard"'); + }); + + it("links to the API reference, authentication, and webhooks guides", async () => { + const res = await app.fetch(new Request("http://localhost/docs")); + const html = await res.text(); + expect(html).toContain('href="/docs/api"'); + expect(html).toContain('href="/docs/authentication"'); + expect(html).toContain('href="/docs/webhooks"'); + }); + + it("has a documentation card structure", async () => { + const res = await app.fetch(new Request("http://localhost/docs")); + const html = await res.text(); + expect(html).toContain("API Reference"); + expect(html).toContain("Authentication"); + expect(html).toContain("Webhooks"); + expect(html).toContain("Interactive"); + }); + }); + + describe("GET /docs/api", () => { + it("returns HTML with Scalar API reference", async () => { + const res = await app.fetch(new Request("http://localhost/docs/api")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const html = await res.text(); + expect(html).toContain("FinaFlow API Reference"); + expect(html).toContain("api-reference"); + expect(html).toContain("cdn.jsdelivr.net/npm/@scalar/api-reference"); + }); + + it("includes the navigation header with correct links", async () => { + const res = await app.fetch(new Request("http://localhost/docs/api")); + const html = await res.text(); + expect(html).toContain('href="/"'); + expect(html).toContain('href="/docs"'); + expect(html).toContain('href="/docs/api"'); + expect(html).toContain('href="/dashboard"'); + }); + + it("references the openapi.yaml spec", async () => { + const res = await app.fetch(new Request("http://localhost/docs/api")); + const html = await res.text(); + expect(html).toContain("/openapi.yaml"); + }); + + it("has the API link highlighted as active", async () => { + const res = await app.fetch(new Request("http://localhost/docs/api")); + const html = await res.text(); + expect(html).toContain("color:#C73E1D"); + }); + }); + + describe("GET /docs/authentication", () => { + it("returns HTML with authentication content", async () => { + const res = await app.fetch(new Request("http://localhost/docs/authentication")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const html = await res.text(); + expect(html).toContain("Authentication"); + expect(html).toContain("API key"); + expect(html).toContain("Scopes"); + }); + + it("includes the navigation header", async () => { + const res = await app.fetch(new Request("http://localhost/docs/authentication")); + const html = await res.text(); + expect(html).toContain('href="/"'); + expect(html).toContain('href="/docs"'); + expect(html).toContain('href="/docs/api"'); + expect(html).toContain('href="/dashboard"'); + }); + + it("has a breadcrumb back to documentation", async () => { + const res = await app.fetch(new Request("http://localhost/docs/authentication")); + const html = await res.text(); + expect(html).toContain('href="/docs"'); + expect(html).toContain("Documentation"); + }); + }); + + describe("GET /docs/webhooks", () => { + it("returns HTML with webhooks content", async () => { + const res = await app.fetch(new Request("http://localhost/docs/webhooks")); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + + const html = await res.text(); + expect(html).toContain("Webhooks"); + expect(html).toContain("HMAC"); + expect(html).toContain("X-Fina-Signature"); + }); + + it("includes the navigation header", async () => { + const res = await app.fetch(new Request("http://localhost/docs/webhooks")); + const html = await res.text(); + expect(html).toContain('href="/"'); + expect(html).toContain('href="/docs"'); + expect(html).toContain('href="/docs/api"'); + expect(html).toContain('href="/dashboard"'); + }); + + it("documents all webhook events", async () => { + const res = await app.fetch(new Request("http://localhost/docs/webhooks")); + const html = await res.text(); + expect(html).toContain("sale.recorded"); + expect(html).toContain("expense.created"); + expect(html).toContain("bill.paid"); + expect(html).toContain("coa.updated"); + expect(html).toContain("supplier.updated"); + expect(html).toContain("journal.created"); + }); + }); + + describe("GET /openapi.yaml", () => { + it("returns the OpenAPI spec", async () => { + const res = await app.fetch(new Request("http://localhost/openapi.yaml")); + expect(res.status).toBe(200); + + const text = await res.text(); + expect(text).toContain("openapi: 3.1.0"); + expect(text).toContain("FinaFlow Integration API"); + expect(text).toContain("/verify"); + expect(text).toContain("/accounts"); + expect(text).toContain("/suppliers"); + expect(text).toContain("/daily-sales"); + expect(text).toContain("/webhooks/finabill"); + }); + + it("documents the webhook event catalog", async () => { + const res = await app.fetch(new Request("http://localhost/openapi.yaml")); + const text = await res.text(); + expect(text).toContain("saleRecorded"); + expect(text).toContain("expenseCreated"); + expect(text).toContain("billPaid"); + }); + }); + + describe("navigation consistency", () => { + it("all doc pages share the same nav structure", async () => { + const paths = ["/docs", "/docs/api", "/docs/authentication", "/docs/webhooks"]; + const navChecks = ['href="/"', 'href="/docs"', 'href="/docs/api"', 'href="/dashboard"']; + + for (const path of paths) { + const res = await app.fetch(new Request(`http://localhost${path}`)); + expect(res.status).toBe(200); + const html = await res.text(); + for (const check of navChecks) { + expect(html, `${path} missing nav link: ${check}`).toContain(check); + } + } + }); + }); +}); diff --git a/api/__tests__/integration-finabill.test.ts b/api/__tests__/integration-finabill.test.ts index 8a2682a..a3fbcd6 100644 --- a/api/__tests__/integration-finabill.test.ts +++ b/api/__tests__/integration-finabill.test.ts @@ -300,7 +300,7 @@ describe("integration API key authentication", () => { caller.integrationFinabill.upsertSupplier({ name: "Test Supplier", }) - ).rejects.toThrow(/API key missing required scope: write/); + ).rejects.toThrow(/API key missing required scope: suppliers:write/); }); it("rejects expired API keys", async () => { diff --git a/api/__tests__/webhook-dispatcher.test.ts b/api/__tests__/webhook-dispatcher.test.ts index c67f991..3caeb73 100644 --- a/api/__tests__/webhook-dispatcher.test.ts +++ b/api/__tests__/webhook-dispatcher.test.ts @@ -172,7 +172,7 @@ describe("incoming FinaBill webhook route", () => { const app = await loadApp(); const res = await app.fetch( - new Request("http://localhost/api/webhooks/finabill", { + new Request("http://localhost/api/v1/webhooks/finabill", { method: "POST", headers: { "Content-Type": "application/json", @@ -184,8 +184,8 @@ describe("incoming FinaBill webhook route", () => { expect(res.status).toBe(200); const json = await res.json(); - expect(json.received).toBe(true); - expect(json.event).toBe("supplier.updated"); + expect(json.data.received).toBe(true); + expect(json.data.event).toBe("supplier.updated"); }, 120_000); it("rejects an invalid X-Fina-Signature", async () => { @@ -207,7 +207,7 @@ describe("incoming FinaBill webhook route", () => { const app = await loadApp(); const res = await app.fetch( - new Request("http://localhost/api/webhooks/finabill", { + new Request("http://localhost/api/v1/webhooks/finabill", { method: "POST", headers: { "Content-Type": "application/json", @@ -219,7 +219,7 @@ describe("incoming FinaBill webhook route", () => { expect(res.status).toBe(401); const json = await res.json(); - expect(json.error).toMatch(/Invalid signature/); + expect(json.error.message).toMatch(/Invalid signature/); }); it("returns 501 for unimplemented providers", async () => { diff --git a/api/boot.ts b/api/boot.ts index aaa407c..9d92342 100644 --- a/api/boot.ts +++ b/api/boot.ts @@ -5,6 +5,8 @@ import { Sentry } from "./instrument"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { bodyLimit } from "hono/body-limit"; +import { readFileSync } from "fs"; +import { resolve } from "path"; import type { HttpBindings } from "@hono/node-server"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "./router"; @@ -12,9 +14,9 @@ import { createContext } from "./context"; import { env } from "./lib/env"; import { securityHeaders } from "./lib/security-headers"; import { csrfProtection } from "./lib/csrf"; -import { apiLimiter, loginLimiter, lookupAccountLimiter } from "./lib/rate-limit"; +import { apiLimiter, loginLimiter, lookupAccountLimiter, connectLimiter } from "./lib/rate-limit"; import { getDb, closePool } from "./queries/connection"; -import { sql, eq, and, isNull } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { processTrialLifecycle, TRIAL_JOB_INTERVAL_MS } from "./lib/subscriptions"; import { shouldStartStandaloneServer } from "./lib/server-runtime"; import { walletRegistry } from "./lib/mobile-wallet/provider-registry"; @@ -24,21 +26,15 @@ import { SasapayProvider } from "./lib/mobile-wallet/providers/sasapay-provider" import { startExchangeRateSync, validateEnvConfig } from "./lib/exchange-rate-sync"; import { seedSupportedCurrencies, seedDefaultExchangeRates } from "./lib/seed-currencies"; import { seedWalletProviders } from "./lib/seed-wallet-providers"; -import { resolveApiKeyMiddleware, type ApiKeyVariables } from "./lib/api-key-middleware"; -import { ingestDailySales } from "./lib/daily-sales-ingestion"; -import { - signWebhookPayload, - decryptWebhookSecret, -} from "./lib/webhook-dispatcher"; -import { handleProviderWebhook, type FinabillWebhookPayload } from "./lib/webhook-handlers"; -import { integrationConnections } from "@db/schema"; -import crypto from "crypto"; +import { type ApiKeyVariables } from "./lib/api-key-middleware"; +import { handleProviderWebhook } from "./lib/webhook-handlers"; import { partnerApproveSession, completeReverseConnection, getConnectSessionPublic, resolvePairingCodeOnInitiator, } from "./lib/integrations/connect-service"; +import v1 from "./routes/v1"; // import { ensureDatabaseReady } from "./lib/db-startup"; // await ensureDatabaseReady(env.databaseUrl); @@ -79,11 +75,211 @@ app.get("/health", async (c) => { } }); -app.get("/debug-sentry", () => { - throw new Error("Sentry backend test error!"); +// ── API Documentation ───────────────────────────────────────────── +app.get("/openapi.yaml", (c) => { + try { + const specPath = resolve(process.cwd(), "docs/api-reference/openapi.yaml"); + const spec = readFileSync(specPath, "utf-8"); + return c.text(spec, 200, { "Content-Type": "text/yaml" }); + } catch { + return c.text("OpenAPI spec not found", 404); + } +}); + +// Documentation hub — human-facing landing page +const docsHeader = ``; + +app.get("/docs", (c) => { + return c.html(` + + + + + FinaFlow Documentation + + + + ${docsHeader} +
+

FinaFlow Documentation

+

Guides and references for using FinaFlow and its integrations.

+
+
+

API Reference Interactive

+

Complete REST API documentation with live request builder. Covers all v1 endpoints, authentication, scopes, webhooks, and error codes.

+ Open API Reference → +
+
+

Authentication

+

How API keys work, the scope model, cookie vs Bearer auth, rate limits, and error response format.

+ Read guide → +
+
+

Webhooks

+

Incoming and outgoing webhook contract: event catalog, HMAC signature verification, retry policy, and payload schemas.

+ Read guide → +
+
+
+

FinaFlow — Financial management for multi-location businesses.

+
+
+ +`); +}); + +// Serve markdown docs as styled HTML pages +function renderDocPage(title: string, filename: string): string { + try { + const md = readFileSync(resolve(process.cwd(), `docs/${filename}`), "utf-8"); + // Simple markdown → HTML: handle headings, bold, code, tables, lists, paragraphs + const html = md + .replace(/^### (.+)$/gm, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1') + .replace(/^\| (.+)$/gm, (match) => { + const cells = match.split('|').filter(c => c.trim()); + return '' + cells.map(c => `${c.trim()}`).join('') + ''; + }) + .replace(/^(- .+)$/gm, '
  • $1
  • ') + .replace(/(
  • .*<\/li>\n?)+/g, (match) => ``) + .replace(/(.*<\/tr>\n?)+/g, (match) => `${match}
    `) + .replace(/^(?!<[hutlol])(.+)$/gm, '

    $1

    ') + .replace(/

    <\/p>/g, '') + .replace(/```(\w*)\n([\s\S]*?)```/g, '

    $2
    '); + + return ` + + +${title} — FinaFlow + +${docsHeader} +`; + } catch { + return `

    ${title}

    Documentation file not found.

    `; + } +} + +app.get("/docs/authentication", (c) => { + return c.html(renderDocPage("Authentication", "authentication.md")); +}); + +app.get("/docs/webhooks", (c) => { + return c.html(renderDocPage("Webhooks", "webhooks.md")); }); -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// Scalar docs need CDN scripts — relax CSP for this route only +app.use("/docs/api", async (c, next) => { + await next(); + c.res.headers.set( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob: https://cdn.jsdelivr.net; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self' https: https://cdn.jsdelivr.net;", + ); +}); + +const scalarConfig = JSON.stringify({ + spec: { url: "/openapi.yaml" }, + theme: "kepler", + layout: "modern", + pageTitle: "FinaFlow API Reference", +}); + +app.get("/docs/api", (c) => { + return c.html(` + + + FinaFlow API Reference + + + + + + + + +`); +}); + +if (process.env.NODE_ENV !== "production") { + app.get("/debug-sentry", () => { + throw new Error("Sentry backend test error!"); + }); +} + async function trpcRateLimiter(c: any, next: any) { if (c.req.method === "POST" && c.req.path.startsWith("/api/trpc")) { try { @@ -93,7 +289,6 @@ async function trpcRateLimiter(c: any, next: any) { .catch(() => null); if (body && typeof body === "object") { const paths = new Set(); -// eslint-disable-next-line @typescript-eslint/no-explicit-any const walk = (obj: any) => { if (!obj || typeof obj !== "object") return; if (obj.path && typeof obj.path === "string") paths.add(obj.path); @@ -120,7 +315,7 @@ app.use("/*", csrfProtection); app.use("/api/trpc/*", trpcRateLimiter, apiLimiter); // Fina Connect machine-to-machine endpoints (CSRF-exempt via csrf.ts) -app.post("/api/connect/partner-approve", async (c) => { +app.post("/api/connect/partner-approve", connectLimiter, async (c) => { try { const body = await c.req.json(); const result = await partnerApproveSession({ @@ -144,7 +339,7 @@ app.post("/api/connect/partner-approve", async (c) => { } }); -app.post("/api/connect/complete", async (c) => { +app.post("/api/connect/complete", connectLimiter, async (c) => { try { const body = await c.req.json(); const businessId = Number(body.partnerBusinessId); @@ -171,13 +366,13 @@ app.post("/api/connect/complete", async (c) => { } }); -app.get("/api/connect/sessions/:sessionPublicId", async (c) => { +app.get("/api/connect/sessions/:sessionPublicId", connectLimiter, async (c) => { const session = await getConnectSessionPublic(c.req.param("sessionPublicId")); if (!session) return c.json({ error: "Not found" }, 404); return c.json(session); }); -app.post("/api/connect/resolve-pairing", async (c) => { +app.post("/api/connect/resolve-pairing", connectLimiter, async (c) => { try { const body = await c.req.json(); const pairingCode = String(body.pairingCode ?? "").trim().toUpperCase(); @@ -193,55 +388,14 @@ app.post("/api/connect/resolve-pairing", async (c) => { } }); -app.post("/api/integration/daily-sales", resolveApiKeyMiddleware("sales:write"), async (c) => { - try { - const apiKey = c.get("apiKey"); - - const body = await c.req.json(); - if (body.locationId == null || Number.isNaN(Number(body.locationId))) { - return c.json({ error: "locationId is required" }, 400); - } - if (!body.sourceBatchId || typeof body.sourceBatchId !== "string") { - return c.json({ error: "sourceBatchId is required" }, 400); - } - const result = await ingestDailySales({ - businessId: apiKey.businessId, - locationId: Number(body.locationId), - saleDate: body.saleDate, - sourceSystem: body.sourceSystem ?? "finabill", - sourceBatchId: body.sourceBatchId, - payments: body.payments ?? [], - discountAmount: body.discountAmount, - voidAmount: body.voidAmount, - unpaidAmount: body.unpaidAmount, - ticketCount: body.ticketCount, - orderCount: body.orderCount, - notes: body.notes, - }); +// ── External REST API v1 ────────────────────────────────────────── +app.route("/api/v1", v1); - if (!result.success) { - return c.json({ error: result.error, warnings: result.warnings }, 400); - } - - const status = result.warnings.length > 0 ? 202 : 200; - return c.json( - { - success: true, - dailySaleId: result.dailySaleId, - netSales: result.netSales, - warnings: result.warnings, - created: result.created, - }, - status - ); - } catch (err) { - console.error("[daily-sales-ingestion] error:", err); - Sentry.captureException(err); - return c.json( - { error: err instanceof Error ? err.message : "Internal server error" }, - 500 - ); - } +// Backward-compat: redirect old daily-sales path to v1 +app.post("/api/integration/daily-sales", (c) => { + c.header("Deprecation", "true"); + c.header("X-API-Deprecation-Notice", "Use POST /api/v1/daily-sales instead"); + return c.redirect("/api/v1/daily-sales", 307); }); app.use("/api/trpc/*", async (c) => { @@ -268,78 +422,21 @@ app.use("/api/trpc/*", async (c) => { return c.json({ error: "Internal server error" }, 500); } }); -function constantTimeCompare(a: string, b: string): boolean { - if (a.length !== b.length) return false; - try { - return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); - } catch { - return false; - } -} -app.post("/api/webhooks/finabill", async (c) => { - try { - const rawBody = await c.req.text(); - let payload: FinabillWebhookPayload; - try { - payload = JSON.parse(rawBody); - } catch { - return c.json({ error: "Invalid JSON body" }, 400); - } - - const businessId = - typeof payload.businessId === "number" ? payload.businessId : null; - if (!businessId) { - return c.json({ error: "Missing businessId" }, 400); - } - - const db = getDb(); - const [connection] = await db - .select() - .from(integrationConnections) - .where( - and( - eq(integrationConnections.businessId, businessId), - eq(integrationConnections.targetSystem, "finabill"), - eq(integrationConnections.isActive, true), - isNull(integrationConnections.deletedAt) - ) - ) - .limit(1); - - if (!connection?.webhookSecret) { - return c.json({ error: "Webhook secret not configured" }, 401); - } - - const secret = decryptWebhookSecret(connection.webhookSecret); - if (!secret) { - return c.json({ error: "Invalid webhook secret" }, 401); - } - - const expectedSignature = signWebhookPayload(rawBody, secret); - const providedSignature = c.req.header("X-Fina-Signature") ?? ""; - if (!constantTimeCompare(providedSignature, expectedSignature)) { - return c.json({ error: "Invalid signature" }, 401); - } - - const result = await handleProviderWebhook("finabill", payload as Record); - return c.json(result.body, result.status as 200 | 400 | 500); - } catch (err) { - console.error("[webhooks/finabill] error:", err); - Sentry.captureException(err); - return c.json( - { error: err instanceof Error ? err.message : "Internal server error" }, - 500 - ); - } +// Backward-compat: redirect old FinaBill webhook to v1 +app.post("/api/webhooks/finabill", (c) => { + c.header("Deprecation", "true"); + c.header("X-API-Deprecation-Notice", "Use POST /api/v1/webhooks/finabill instead"); + return c.redirect("/api/v1/webhooks/finabill", 307); }); app.post("/api/webhooks/:provider", async (c) => { const provider = c.req.param("provider"); if (provider === "finabill") { - return c.json({ error: "Use /api/webhooks/finabill" }, 404); + return c.json({ error: "Use /api/v1/webhooks/finabill" }, 404); } - const result = await handleProviderWebhook(provider, {}); + const body = await c.req.json().catch(() => ({})); + const result = await handleProviderWebhook(provider, body); return c.json(result.body, result.status as 200 | 501); }); diff --git a/api/integration-finabill-router.ts b/api/integration-finabill-router.ts index 82ad893..51b4349 100644 --- a/api/integration-finabill-router.ts +++ b/api/integration-finabill-router.ts @@ -1,72 +1,24 @@ +// ABOUTME: Legacy tRPC router for FinaBill integration (API-key authenticated). +// ABOUTME: Thin wrapper over integration-service.ts — kept for backward compat with tRPC clients. +// ABOUTME: New consumers should use the REST API at /api/v1/ instead. import { z } from "zod"; -import { eq, and, isNull, inArray } from "drizzle-orm"; import { createRouter, apiKeyProcedure, requireApiKey, } from "./middleware"; -import { getDb } from "./queries/connection"; -import { hashPassword } from "./lib/password"; -import { - accounts, - suppliers, - expenseCategories, - businesses, - locations, - users, - userBusinesses, - userLocations, - rolePermissions, -} from "@db/schema"; +import * as integrationService from "./lib/integration-service"; function getBusinessId(ctx: { businessId?: number | null }): number | null { return ctx.businessId ?? null; } -function logIntegration( - ctx: { businessId?: number | null; apiKey?: { id?: number | null } | null }, - operation: string, - status: string, - extras?: Record -) { - const payload: Record = { - businessId: ctx.businessId ?? null, - sourceSystem: "finabill", - operation, - status, - ...extras, - }; - if (ctx.apiKey?.id) { - payload.apiKeyId = ctx.apiKey.id; - } - const message = `[integration] ${JSON.stringify(payload)}`; - if (status === "failed") { - console.error(message); - } else { - console.log(message); - } -} - -const INTEGRATION_ALLOWED_ROLES = new Set([ - "manager", - "employee", - "accountant", - "viewer", - "cashier", -]); - -function assertAllowedIntegrationRole(role: string) { - if (!INTEGRATION_ALLOWED_ROLES.has(role)) { - throw new Error( - `Role "${role}" is not allowed via integration API. Allowed: ${Array.from(INTEGRATION_ALLOWED_ROLES).join(", ")}`, - ); - } -} - export const integrationFinabillRouter = createRouter({ - verify: apiKeyProcedure.query(async ({ ctx }) => { + verify: apiKeyProcedure + .meta({ description: "Verify that an API key is valid and return its business identity." }) + .query(async ({ ctx }) => { const businessId = getBusinessId(ctx); - logIntegration(ctx, "finabill.verify", "success"); + integrationService.logIntegration(ctx, "finabill.verify", "success"); return { ok: true, businessId, @@ -75,101 +27,51 @@ export const integrationFinabillRouter = createRouter({ }), listAccounts: apiKeyProcedure - .use(requireApiKey("read")) + .meta({ description: "List all accounts for the authenticated business. Optionally filter by accountType." }) + .use(requireApiKey("accounts:read")) .input(z.object({ accountType: z.string().optional() }).optional()) .query(async ({ ctx, input }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.listAccounts", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.listAccounts", "failed", { error: "No active business" }); return { data: [] }; } - - const conditions = [ - eq(accounts.businessId, businessId), - isNull(accounts.deletedAt), - ]; - if (input?.accountType) { - conditions.push(eq(accounts.accountType, input.accountType as any)); - } - - const data = await db - .select({ - id: accounts.id, - name: accounts.name, - accountCode: accounts.accountCode, - accountType: accounts.accountType, - accountSubType: accounts.accountSubType, - isActive: accounts.isActive, - }) - .from(accounts) - .where(and(...conditions)) - .orderBy(accounts.name); - - logIntegration(ctx, "finabill.listAccounts", "success", { count: data.length }); + const data = await integrationService.listAccounts(businessId, input?.accountType); + integrationService.logIntegration(ctx, "finabill.listAccounts", "success", { count: data.length }); return { data }; }), listSuppliers: apiKeyProcedure - .use(requireApiKey("read")) + .meta({ description: "List all suppliers for the authenticated business." }) + .use(requireApiKey("suppliers:read")) .query(async ({ ctx }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.listSuppliers", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.listSuppliers", "failed", { error: "No active business" }); return { data: [] }; } - - const data = await db - .select({ - id: suppliers.id, - name: suppliers.name, - email: suppliers.email, - phone: suppliers.phone, - taxId: suppliers.kraPin, - }) - .from(suppliers) - .where(and(eq(suppliers.businessId, businessId), isNull(suppliers.deletedAt))) - .orderBy(suppliers.name); - - logIntegration(ctx, "finabill.listSuppliers", "success", { count: data.length }); + const data = await integrationService.listSuppliers(businessId); + integrationService.logIntegration(ctx, "finabill.listSuppliers", "success", { count: data.length }); return { data }; }), listCategories: apiKeyProcedure - .use(requireApiKey("read")) + .meta({ description: "List all expense categories for the authenticated business." }) + .use(requireApiKey("categories:read")) .query(async ({ ctx }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.listCategories", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.listCategories", "failed", { error: "No active business" }); return { data: [] }; } - - const data = await db - .select({ - id: expenseCategories.id, - name: expenseCategories.name, - categoryType: expenseCategories.accountingClass, - defaultAccountId: expenseCategories.defaultAccountId, - externalAccountCode: expenseCategories.externalAccountCode, - isActive: expenseCategories.isActive, - }) - .from(expenseCategories) - .where( - and( - eq(expenseCategories.businessId, businessId), - isNull(expenseCategories.deletedAt) - ) - ) - .orderBy(expenseCategories.name); - - logIntegration(ctx, "finabill.listCategories", "success", { count: data.length }); - return { data: data.map((c) => ({ ...c, categoryType: "expense" as const })) }; + const data = await integrationService.listCategories(businessId); + integrationService.logIntegration(ctx, "finabill.listCategories", "success", { count: data.length }); + return { data }; }), upsertSupplier: apiKeyProcedure - .use(requireApiKey("write")) + .meta({ description: "Create or update a supplier. Match by externalId if provided, otherwise create new." }) + .use(requireApiKey("suppliers:write")) .input( z.object({ externalId: z.string().optional(), @@ -177,221 +79,77 @@ export const integrationFinabillRouter = createRouter({ email: z.string().email().optional().nullable(), phone: z.string().optional().nullable(), taxId: z.string().optional().nullable(), - }) + }), ) .mutation(async ({ ctx, input }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.upsertSupplier", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.upsertSupplier", "failed", { error: "No active business" }); throw new Error("No active business"); } - - const existing = input.externalId - ? await db - .select() - .from(suppliers) - .where( - and( - eq(suppliers.businessId, businessId), - eq(suppliers.id, Number(input.externalId)), - isNull(suppliers.deletedAt) - ) - ) - .limit(1) - : []; - - if (existing[0]) { - const [updated] = await db - .update(suppliers) - .set({ - name: input.name, - email: input.email ?? existing[0].email, - phone: input.phone ?? existing[0].phone, - kraPin: input.taxId ?? existing[0].kraPin, - updatedAt: new Date(), - }) - .where(eq(suppliers.id, existing[0].id)) - .returning(); - logIntegration(ctx, "finabill.upsertSupplier", "success", { supplierId: updated.id, created: false }); - return { id: updated.id, created: false }; - } - - const [created] = await db - .insert(suppliers) - .values({ - businessId, - name: input.name, - email: input.email ?? null, - phone: input.phone ?? null, - kraPin: input.taxId ?? null, - }) - .returning(); - logIntegration(ctx, "finabill.upsertSupplier", "success", { supplierId: created.id, created: true }); - return { id: created.id, created: true }; + const result = await integrationService.upsertSupplier(businessId, input); + integrationService.logIntegration(ctx, "finabill.upsertSupplier", "success", { supplierId: result.id, created: result.created }); + return result; }), getBusinessProfile: apiKeyProcedure - .use(requireApiKey("read")) + .meta({ description: "Get the business profile (name, email, phone, address, country, tax ID)." }) + .use(requireApiKey("business:read")) .query(async ({ ctx }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.getBusinessProfile", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.getBusinessProfile", "failed", { error: "No active business" }); return { data: null }; } - - const [business] = await db - .select({ - id: businesses.id, - name: businesses.name, - email: businesses.email, - phone: businesses.phone, - address: businesses.address, - country: businesses.country, - fiscalYearStartMonth: businesses.fiscalYearStartMonth, - registrationNumber: businesses.businessRegNumber, - taxId: businesses.kraPin, - }) - .from(businesses) - .where(and(eq(businesses.id, businessId), isNull(businesses.deletedAt))) - .limit(1); - - // FinaFlow's business table does not carry every FinaBill field; normalize nulls. - const normalized = business - ? { - ...business, - timezone: null, - dateFormat: null, - currency: null, - accentColor: null, - logoUrl: null, - } - : null; - - logIntegration(ctx, "finabill.getBusinessProfile", "success", { found: Boolean(normalized) }); - return { data: normalized }; + const data = await integrationService.getBusinessProfile(businessId); + integrationService.logIntegration(ctx, "finabill.getBusinessProfile", "success", { found: Boolean(data) }); + return { data }; }), listLocations: apiKeyProcedure - .use(requireApiKey("read")) + .meta({ description: "List all locations/branches for the authenticated business." }) + .use(requireApiKey("locations:read")) .query(async ({ ctx }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.listLocations", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.listLocations", "failed", { error: "No active business" }); return { data: [] }; } - - const data = await db - .select({ - id: locations.id, - name: locations.name, - slug: locations.slug, - isActive: locations.isActive, - address: locations.address, - phone: locations.phone, - email: locations.email, - defaultIncomeAccountId: locations.defaultCashAccountId, - defaultBankAccountId: locations.defaultMpesaAccountId, - }) - .from(locations) - .where(and(eq(locations.businessId, businessId), isNull(locations.deletedAt))) - .orderBy(locations.name); - - logIntegration(ctx, "finabill.listLocations", "success", { count: data.length }); + const data = await integrationService.listLocations(businessId); + integrationService.logIntegration(ctx, "finabill.listLocations", "success", { count: data.length }); return { data }; }), listUsers: apiKeyProcedure - .use(requireApiKey("read")) + .meta({ description: "List all users assigned to the authenticated business, with role and location assignments." }) + .use(requireApiKey("users:read")) .query(async ({ ctx }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.listUsers", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.listUsers", "failed", { error: "No active business" }); return { data: [] }; } - - const userRows = await db - .select({ - id: users.id, - name: users.name, - email: users.email, - phone: users.phone, - role: userBusinesses.role, - isActive: users.isActive, - }) - .from(users) - .innerJoin(userBusinesses, eq(users.id, userBusinesses.userId)) - .where( - and( - eq(userBusinesses.businessId, businessId), - eq(userBusinesses.isActive, true), - isNull(users.deletedAt) - ) - ); - - const businessLocationRows = await db - .select({ id: locations.id }) - .from(locations) - .where(and(eq(locations.businessId, businessId), isNull(locations.deletedAt))); - const businessLocationIds = new Set(businessLocationRows.map((r) => r.id)); - - const locationRows = businessLocationIds.size - ? await db - .select({ - userId: userLocations.userId, - locationId: userLocations.locationId, - }) - .from(userLocations) - .where(inArray(userLocations.locationId, Array.from(businessLocationIds))) - : []; - - const locationsByUser = new Map(); - for (const row of locationRows) { - const list = locationsByUser.get(row.userId) ?? []; - list.push(row.locationId); - locationsByUser.set(row.userId, list); - } - - const data = userRows.map((u) => ({ - id: u.id, - name: u.name, - email: u.email, - phone: u.phone, - role: u.role, - isActive: u.isActive, - assignedLocationIds: locationsByUser.get(u.id) ?? [], - })); - - logIntegration(ctx, "finabill.listUsers", "success", { count: data.length }); + const data = await integrationService.listUsers(businessId); + integrationService.logIntegration(ctx, "finabill.listUsers", "success", { count: data.length }); return { data }; }), listRoleTemplates: apiKeyProcedure - .use(requireApiKey("read")) + .meta({ description: "List all active role templates with their permission sets." }) + .use(requireApiKey("users:read")) .query(async ({ ctx }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.listRoleTemplates", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.listRoleTemplates", "failed", { error: "No active business" }); return { data: [] }; } - - const data = await db - .select({ - role: rolePermissions.roleKey, - permissions: rolePermissions.permissions, - }) - .from(rolePermissions) - .where(eq(rolePermissions.isActive, true)); - - logIntegration(ctx, "finabill.listRoleTemplates", "success", { count: data.length }); + const data = await integrationService.listRoleTemplates(); + integrationService.logIntegration(ctx, "finabill.listRoleTemplates", "success", { count: data.length }); return { data }; }), upsertUser: apiKeyProcedure + .meta({ description: "Create or update a user and assign to the business. Match by externalId if provided." }) .use(requireApiKey("users:write")) .input( z.object({ @@ -402,130 +160,16 @@ export const integrationFinabillRouter = createRouter({ role: z.string().min(1), isActive: z.boolean().optional().default(true), locationIds: z.array(z.number()).optional().default([]), - }) + }), ) .mutation(async ({ ctx, input }) => { - const db = getDb(); const businessId = getBusinessId(ctx); if (!businessId) { - logIntegration(ctx, "finabill.upsertUser", "failed", { error: "No active business" }); + integrationService.logIntegration(ctx, "finabill.upsertUser", "failed", { error: "No active business" }); throw new Error("No active business"); } - - assertAllowedIntegrationRole(input.role); - - if (input.locationIds.length > 0) { - const validLocations = await db - .select({ id: locations.id }) - .from(locations) - .where( - and( - eq(locations.businessId, businessId), - inArray(locations.id, input.locationIds), - isNull(locations.deletedAt), - ), - ); - if (validLocations.length !== input.locationIds.length) { - throw new Error("One or more locationIds do not belong to this business"); - } - } - - const existing = input.externalId - ? await db - .select({ user: users }) - .from(users) - .innerJoin(userBusinesses, eq(users.id, userBusinesses.userId)) - .where( - and( - eq(userBusinesses.businessId, businessId), - eq(users.id, Number(input.externalId)), - isNull(users.deletedAt) - ) - ) - .limit(1) - : []; - - if (existing[0]) { - const [updated] = await db - .update(users) - .set({ - name: input.name, - email: input.email, - phone: input.phone ?? existing[0].user.phone, - role: input.role as any, - isActive: input.isActive, - updatedAt: new Date(), - }) - .where(eq(users.id, existing[0].user.id)) - .returning(); - - const [membership] = await db - .select() - .from(userBusinesses) - .where( - and( - eq(userBusinesses.userId, updated.id), - eq(userBusinesses.businessId, businessId) - ) - ) - .limit(1); - - if (membership) { - await db - .update(userBusinesses) - .set({ - role: input.role as any, - isActive: input.isActive, - }) - .where(eq(userBusinesses.id, membership.id)); - } else { - await db.insert(userBusinesses).values({ - userId: updated.id, - businessId, - role: input.role as any, - isActive: input.isActive, - }); - } - - logIntegration(ctx, "finabill.upsertUser", "success", { userId: updated.id, created: false }); - return { id: updated.id, created: false }; - } - - const passwordHash = await hashPassword(crypto.randomUUID()); - const username = input.email.toLowerCase().trim(); - - const [created] = await db - .insert(users) - .values({ - name: input.name, - email: input.email, - phone: input.phone ?? null, - username, - passwordHash, - role: input.role as any, - isActive: input.isActive, - }) - .returning(); - - await db.insert(userBusinesses).values({ - userId: created.id, - businessId, - role: input.role as any, - isActive: input.isActive, - }); - - if (input.locationIds.length > 0) { - await db.insert(userLocations).values( - input.locationIds.map((locationId, idx) => ({ - userId: created.id, - locationId, - isPrimary: idx === 0, - isActive: true, - })) - ); - } - - logIntegration(ctx, "finabill.upsertUser", "success", { userId: created.id, created: true }); - return { id: created.id, created: true }; + const result = await integrationService.upsertUser(businessId, input); + integrationService.logIntegration(ctx, "finabill.upsertUser", "success", { userId: result.id, created: result.created }); + return result; }), }); diff --git a/api/lib/api-key-middleware.ts b/api/lib/api-key-middleware.ts index 96fca9c..ed9841b 100644 --- a/api/lib/api-key-middleware.ts +++ b/api/lib/api-key-middleware.ts @@ -1,22 +1,24 @@ import { createMiddleware } from "hono/factory"; import type { ResolvedApiKey } from "./api-key-auth"; import { resolveApiKey } from "./api-key-auth"; +import { hasScope, type ApiScope } from "./api-scopes"; export type ApiKeyVariables = { apiKey: ResolvedApiKey; }; -function hasRequiredScope(apiKey: ResolvedApiKey, scope?: string): boolean { +function hasRequiredScope(apiKey: ResolvedApiKey, scope?: ApiScope): boolean { if (!scope) return true; - return apiKey.scopes.includes(scope) || apiKey.scopes.includes("admin"); + return hasScope(apiKey.scopes, scope); } /** * Hono middleware that resolves a Fina API key from the Authorization header * and stores the resolved key on the Hono context under "apiKey". * Pass `scope` to require a specific capability (admin always satisfies). + * Supports legacy coarse scopes (read, write) via scope aliases in api-scopes.ts. */ -export function resolveApiKeyMiddleware(scope?: string) { +export function resolveApiKeyMiddleware(scope?: ApiScope) { return createMiddleware<{ Variables: ApiKeyVariables }>(async (c, next) => { const authHeader = c.req.header("authorization"); if (!authHeader?.startsWith("Bearer ")) { diff --git a/api/lib/api-response.ts b/api/lib/api-response.ts new file mode 100644 index 0000000..e24f266 --- /dev/null +++ b/api/lib/api-response.ts @@ -0,0 +1,71 @@ +// ABOUTME: Unified response envelope for the external REST API (v1). +// ABOUTME: Every v1 endpoint returns { data, meta } on success or { error, meta } on failure. +import type { Context } from "hono"; + +export interface ApiMeta { + requestId?: string; + pagination?: { + page: number; + limit: number; + total: number; + totalPages: number; + }; +} + +export interface ApiSuccessBody { + data: T; + meta?: ApiMeta; +} + +export interface ApiErrorBody { + error: { + code: string; + message: string; + }; + meta?: ApiMeta; +} + +function getRequestId(c: Context): string | undefined { + return (c.get("requestId") as string) || undefined; +} + +function buildMeta(c: Context, extra?: Partial): ApiMeta | undefined { + const requestId = getRequestId(c); + if (!requestId && !extra?.pagination) return undefined; + return { ...(requestId ? { requestId } : {}), ...extra }; +} + +/** 200 success response with unified envelope. */ +export function successResponse(c: Context, data: T, status: 200 | 201 | 202 = 200) { + return c.json({ data, meta: buildMeta(c) } satisfies ApiSuccessBody, status); +} + +/** Error response with unified envelope. */ +export function errorResponse( + c: Context, + status: 400 | 401 | 403 | 404 | 409 | 429 | 500, + code: string, + message: string +) { + return c.json( + { error: { code, message }, meta: buildMeta(c) } satisfies ApiErrorBody, + status + ); +} + +/** Paginated success response. */ +export function paginatedResponse( + c: Context, + data: T[], + pagination: { page: number; limit: number; total: number } +) { + return c.json({ + data, + meta: buildMeta(c, { + pagination: { + ...pagination, + totalPages: Math.ceil(pagination.total / pagination.limit), + }, + }), + } satisfies ApiSuccessBody); +} diff --git a/api/lib/api-scopes.ts b/api/lib/api-scopes.ts new file mode 100644 index 0000000..0482e0d --- /dev/null +++ b/api/lib/api-scopes.ts @@ -0,0 +1,71 @@ +// ABOUTME: Centralized registry of API key scopes for the external API. +// ABOUTME: Single source of truth — used by connect-service, middleware, and docs. + +/** + * Every scope available to external API keys. + * The key is the scope string stored in the DB; the value is a human-readable description. + * Naming convention: `resource:action` (aligned with RBAC permissions in middleware.ts). + */ +export const API_SCOPES = { + "accounts:read": "Read accounts, chart of accounts", + "suppliers:read": "Read suppliers", + "suppliers:write": "Create and update suppliers", + "categories:read": "Read expense categories", + "business:read": "Read business profile", + "locations:read": "Read locations/branches", + "users:read": "Read users and role templates", + "users:write": "Create and update users", + "sales:write": "Ingest daily sales batches", + "journal:write": "Create and post journal entries", + "webhooks": "Manage webhook subscriptions", +} as const; + +export type ApiScope = keyof typeof API_SCOPES; + +/** Scopes granted by default during Fina Connect pairing. */ +export const DEFAULT_CONNECT_SCOPES: ApiScope[] = [ + "accounts:read", + "suppliers:read", + "suppliers:write", + "categories:read", + "business:read", + "locations:read", + "users:read", + "users:write", + "sales:write", + "journal:write", + "webhooks", +]; + +/** Returns true if `scope` is a recognized API scope. */ +export function isValidScope(scope: string): scope is ApiScope { + return scope in API_SCOPES; +} + +/** + * Legacy scope aliases — maps old coarse scopes to the new granular ones. + * Used during migration to avoid breaking existing API keys. + */ +export const SCOPE_ALIASES: Record = { + read: ["accounts:read", "suppliers:read", "categories:read", "business:read", "locations:read", "users:read"], + write: ["suppliers:write"], +}; + +/** Resolves a scope (including legacy aliases) to the set of granular scopes it grants. */ +export function resolveScopes(scopes: string[]): Set { + const resolved = new Set(); + for (const scope of scopes) { + if (scope in SCOPE_ALIASES) { + for (const alias of SCOPE_ALIASES[scope]) resolved.add(alias); + } else { + resolved.add(scope); + } + } + return resolved; +} + +/** Checks whether the given scopes grant access to the required scope (including legacy aliases). */ +export function hasScope(granted: string[], required: ApiScope): boolean { + const resolved = resolveScopes(granted); + return resolved.has(required) || granted.includes("admin"); +} diff --git a/api/lib/crypto.ts b/api/lib/crypto.ts index da6d4a7..710649f 100644 --- a/api/lib/crypto.ts +++ b/api/lib/crypto.ts @@ -34,7 +34,7 @@ export function decryptString(ciphertext: string): string { throw new Error("Invalid encrypted value"); } - const salt = combined.subarray(0, SALT_LENGTH); + const _salt = combined.subarray(0, SALT_LENGTH); const iv = combined.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); const authTag = combined.subarray( SALT_LENGTH + IV_LENGTH, @@ -57,3 +57,13 @@ export function looksEncrypted(value: string): boolean { return false; } } + +/** Timing-safe string comparison — prevents timing attacks on webhook signatures. */ +export function constantTimeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return crypto.timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); + } catch { + return false; + } +} diff --git a/api/lib/csrf.ts b/api/lib/csrf.ts index b46f16b..5ef515e 100644 --- a/api/lib/csrf.ts +++ b/api/lib/csrf.ts @@ -17,7 +17,8 @@ export const csrfProtection = async (c: Context, next: Next) => { path.startsWith("/api/trpc") || path.startsWith("/api/webhooks") || path.startsWith("/api/integration/") || - path.startsWith("/api/connect") + path.startsWith("/api/connect") || + path.startsWith("/api/v1") ) { return next(); } diff --git a/api/lib/integration-service.ts b/api/lib/integration-service.ts new file mode 100644 index 0000000..6d2162e --- /dev/null +++ b/api/lib/integration-service.ts @@ -0,0 +1,398 @@ +// ABOUTME: Shared business logic for external API integration endpoints. +// ABOUTME: Called by both the v1 REST routes and the legacy tRPC integrationFinabill router. +import { eq, and, isNull, inArray } from "drizzle-orm"; +import { getDb } from "../queries/connection"; +import { hashPassword } from "./password"; +import { + accounts, + suppliers, + expenseCategories, + businesses, + locations, + users, + userBusinesses, + userLocations, + rolePermissions, +} from "@db/schema"; +import crypto from "crypto"; + +const INTEGRATION_ALLOWED_ROLES = new Set([ + "manager", + "employee", + "accountant", + "viewer", + "cashier", +]); + +function assertAllowedIntegrationRole(role: string) { + if (!INTEGRATION_ALLOWED_ROLES.has(role)) { + throw new Error( + `Role "${role}" is not allowed via integration API. Allowed: ${Array.from(INTEGRATION_ALLOWED_ROLES).join(", ")}`, + ); + } +} + +export function logIntegration( + ctx: { businessId?: number | null; apiKey?: { id?: number | null } | null }, + operation: string, + status: string, + extras?: Record, +) { + const payload: Record = { + businessId: ctx.businessId ?? null, + sourceSystem: "integration", + operation, + status, + ...extras, + }; + if (ctx.apiKey?.id) { + payload.apiKeyId = ctx.apiKey.id; + } + const message = `[integration] ${JSON.stringify(payload)}`; + if (status === "failed") { + console.error(message); + } else { + console.log(message); + } +} + +// ── Read operations ──────────────────────────────────────────────── + +export async function listAccounts(businessId: number, accountType?: string) { + const db = getDb(); + const conditions = [eq(accounts.businessId, businessId), isNull(accounts.deletedAt)]; + if (accountType) { + conditions.push(eq(accounts.accountType, accountType as any)); + } + return db + .select({ + id: accounts.id, + name: accounts.name, + accountCode: accounts.accountCode, + accountType: accounts.accountType, + accountSubType: accounts.accountSubType, + isActive: accounts.isActive, + }) + .from(accounts) + .where(and(...conditions)) + .orderBy(accounts.name); +} + +export async function listSuppliers(businessId: number) { + const db = getDb(); + return db + .select({ + id: suppliers.id, + name: suppliers.name, + email: suppliers.email, + phone: suppliers.phone, + taxId: suppliers.kraPin, + }) + .from(suppliers) + .where(and(eq(suppliers.businessId, businessId), isNull(suppliers.deletedAt))) + .orderBy(suppliers.name); +} + +export async function listCategories(businessId: number) { + const db = getDb(); + const data = await db + .select({ + id: expenseCategories.id, + name: expenseCategories.name, + categoryType: expenseCategories.accountingClass, + defaultAccountId: expenseCategories.defaultAccountId, + externalAccountCode: expenseCategories.externalAccountCode, + 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 })); +} + +export async function getBusinessProfile(businessId: number) { + const db = getDb(); + const [business] = await db + .select({ + id: businesses.id, + name: businesses.name, + email: businesses.email, + phone: businesses.phone, + address: businesses.address, + country: businesses.country, + fiscalYearStartMonth: businesses.fiscalYearStartMonth, + registrationNumber: businesses.businessRegNumber, + taxId: businesses.kraPin, + }) + .from(businesses) + .where(and(eq(businesses.id, businessId), isNull(businesses.deletedAt))) + .limit(1); + + if (!business) return null; + return { + ...business, + timezone: null, + dateFormat: null, + currency: null, + accentColor: null, + logoUrl: null, + }; +} + +export async function listLocations(businessId: number) { + const db = getDb(); + return db + .select({ + id: locations.id, + name: locations.name, + slug: locations.slug, + isActive: locations.isActive, + address: locations.address, + phone: locations.phone, + email: locations.email, + defaultIncomeAccountId: locations.defaultCashAccountId, + defaultBankAccountId: locations.defaultMpesaAccountId, + }) + .from(locations) + .where(and(eq(locations.businessId, businessId), isNull(locations.deletedAt))) + .orderBy(locations.name); +} + +export async function listUsers(businessId: number) { + const db = getDb(); + const userRows = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + phone: users.phone, + role: userBusinesses.role, + isActive: users.isActive, + }) + .from(users) + .innerJoin(userBusinesses, eq(users.id, userBusinesses.userId)) + .where( + and( + eq(userBusinesses.businessId, businessId), + eq(userBusinesses.isActive, true), + isNull(users.deletedAt), + ), + ); + + const businessLocationRows = await db + .select({ id: locations.id }) + .from(locations) + .where(and(eq(locations.businessId, businessId), isNull(locations.deletedAt))); + const businessLocationIds = new Set(businessLocationRows.map((r) => r.id)); + + const locationRows = businessLocationIds.size + ? await db + .select({ userId: userLocations.userId, locationId: userLocations.locationId }) + .from(userLocations) + .where(inArray(userLocations.locationId, Array.from(businessLocationIds))) + : []; + + const locationsByUser = new Map(); + for (const row of locationRows) { + const list = locationsByUser.get(row.userId) ?? []; + list.push(row.locationId); + locationsByUser.set(row.userId, list); + } + + return userRows.map((u) => ({ + id: u.id, + name: u.name, + email: u.email, + phone: u.phone, + role: u.role, + isActive: u.isActive, + assignedLocationIds: locationsByUser.get(u.id) ?? [], + })); +} + +export async function listRoleTemplates() { + const db = getDb(); + return db + .select({ + role: rolePermissions.roleKey, + permissions: rolePermissions.permissions, + }) + .from(rolePermissions) + .where(eq(rolePermissions.isActive, true)); +} + +// ── Write operations ─────────────────────────────────────────────── + +export async function upsertSupplier( + businessId: number, + input: { + externalId?: string; + name: string; + email?: string | null; + phone?: string | null; + taxId?: string | null; + }, +) { + const db = getDb(); + const existing = input.externalId + ? await db + .select() + .from(suppliers) + .where( + and( + eq(suppliers.businessId, businessId), + eq(suppliers.id, Number(input.externalId)), + isNull(suppliers.deletedAt), + ), + ) + .limit(1) + : []; + + if (existing[0]) { + const [updated] = await db + .update(suppliers) + .set({ + name: input.name, + email: input.email ?? existing[0].email, + phone: input.phone ?? existing[0].phone, + kraPin: input.taxId ?? existing[0].kraPin, + updatedAt: new Date(), + }) + .where(eq(suppliers.id, existing[0].id)) + .returning(); + return { id: updated.id, created: false }; + } + + const [created] = await db + .insert(suppliers) + .values({ + businessId, + name: input.name, + email: input.email ?? null, + phone: input.phone ?? null, + kraPin: input.taxId ?? null, + }) + .returning(); + return { id: created.id, created: true }; +} + +export async function upsertUser( + businessId: number, + input: { + externalId?: string; + name: string; + email: string; + phone?: string | null; + role: string; + isActive?: boolean; + locationIds?: number[]; + }, +) { + const db = getDb(); + assertAllowedIntegrationRole(input.role); + const locationIds = input.locationIds ?? []; + + if (locationIds.length > 0) { + const validLocations = await db + .select({ id: locations.id }) + .from(locations) + .where( + and( + eq(locations.businessId, businessId), + inArray(locations.id, locationIds), + isNull(locations.deletedAt), + ), + ); + if (validLocations.length !== locationIds.length) { + throw new Error("One or more locationIds do not belong to this business"); + } + } + + const existing = input.externalId + ? await db + .select({ user: users }) + .from(users) + .innerJoin(userBusinesses, eq(users.id, userBusinesses.userId)) + .where( + and( + eq(userBusinesses.businessId, businessId), + eq(users.id, Number(input.externalId)), + isNull(users.deletedAt), + ), + ) + .limit(1) + : []; + + if (existing[0]) { + const [updated] = await db + .update(users) + .set({ + name: input.name, + email: input.email, + phone: input.phone ?? existing[0].user.phone, + role: input.role as any, + isActive: input.isActive ?? true, + updatedAt: new Date(), + }) + .where(eq(users.id, existing[0].user.id)) + .returning(); + + const [membership] = await db + .select() + .from(userBusinesses) + .where(and(eq(userBusinesses.userId, updated.id), eq(userBusinesses.businessId, businessId))) + .limit(1); + + if (membership) { + await db + .update(userBusinesses) + .set({ role: input.role as any, isActive: input.isActive ?? true }) + .where(eq(userBusinesses.id, membership.id)); + } else { + await db.insert(userBusinesses).values({ + userId: updated.id, + businessId, + role: input.role as any, + isActive: input.isActive ?? true, + }); + } + + return { id: updated.id, created: false }; + } + + const passwordHash = await hashPassword(crypto.randomUUID()); + const username = input.email.toLowerCase().trim(); + + const [created] = await db + .insert(users) + .values({ + name: input.name, + email: input.email, + phone: input.phone ?? null, + username, + passwordHash, + role: input.role as any, + isActive: input.isActive ?? true, + }) + .returning(); + + await db.insert(userBusinesses).values({ + userId: created.id, + businessId, + role: input.role as any, + isActive: input.isActive ?? true, + }); + + if (locationIds.length > 0) { + await db.insert(userLocations).values( + locationIds.map((locationId, idx) => ({ + userId: created.id, + locationId, + isPrimary: idx === 0, + isActive: true, + })), + ); + } + + return { id: created.id, created: true }; +} diff --git a/api/lib/integrations/connect-service.ts b/api/lib/integrations/connect-service.ts index ee46962..1ccc1a5 100644 --- a/api/lib/integrations/connect-service.ts +++ b/api/lib/integrations/connect-service.ts @@ -14,17 +14,7 @@ import { import { env } from "../env"; import { encryptString, decryptString } from "../crypto"; import { generateApiKey, getKeyPrefix, hashApiKey } from "../api-key-auth"; - -export const DEFAULT_CONNECT_SCOPES = [ - "read", - "write", - "journal:write", - "coa:read", - "supplier:read", - "webhooks", - "admin", - "sales:write", -] as const; +import { DEFAULT_CONNECT_SCOPES } from "../api-scopes"; const SESSION_TTL_MS = 10 * 60 * 1000; const SYSTEM = "finaflow" as const; diff --git a/api/lib/rate-limit.ts b/api/lib/rate-limit.ts index 50c2c11..82f286f 100644 --- a/api/lib/rate-limit.ts +++ b/api/lib/rate-limit.ts @@ -72,6 +72,12 @@ export const loginLimiter = createRateLimiter(60 * 1000, 10); export const apiLimiter = createRateLimiter(60 * 1000, 500); export const lookupAccountLimiter = createEndpointRateLimiter("lookupAccount", { windowMs: 60 * 1000, max: 120 }); +/** Rate limiter for the external /api/v1/ integration endpoints (100 req/min per IP). */ +export const integrationLimiter = createEndpointRateLimiter("integration", { windowMs: 60 * 1000, max: 100 }); + +/** Rate limiter for Fina Connect pairing endpoints (30 req/min per IP — prevents brute-force). */ +export const connectLimiter = createEndpointRateLimiter("connect", { windowMs: 60 * 1000, max: 30 }); + export function clearRateLimitStore(): void { globalStore.clear(); endpointStores.clear(); diff --git a/api/lib/request-id.ts b/api/lib/request-id.ts new file mode 100644 index 0000000..14e395a --- /dev/null +++ b/api/lib/request-id.ts @@ -0,0 +1,21 @@ +// ABOUTME: Hono middleware that generates a unique request ID per request. +// ABOUTME: The ID is stored on context as "requestId" and added to the X-Request-Id response header. +import { createMiddleware } from "hono/factory"; +import { createId } from "@paralleldrive/cuid2"; + +export type RequestIdVariables = { + requestId: string; +}; + +/** + * Generates a `req_` prefixed unique ID and attaches it to the Hono context. + * Also echoes it back in the X-Request-Id response header. + */ +export const requestIdMiddleware = createMiddleware<{ Variables: RequestIdVariables }>( + async (c, next) => { + const id = `req_${createId()}`; + c.set("requestId", id); + c.header("X-Request-Id", id); + await next(); + } +); diff --git a/api/lib/vite.ts b/api/lib/vite.ts index ee8ea1c..a004410 100644 --- a/api/lib/vite.ts +++ b/api/lib/vite.ts @@ -1,5 +1,4 @@ import type { Hono } from "hono"; -import type { HttpBindings } from "@hono/node-server"; import { serveStatic } from "@hono/node-server/serve-static"; import fs from "fs"; import path from "path"; @@ -7,9 +6,21 @@ import path from "path"; export function serveStaticFiles(app: Hono) { const distPath = path.resolve(import.meta.dirname, "../dist/public"); - app.use("*", serveStatic({ root: "./dist/public" })); + // Serve static files, but skip /docs and /openapi.yaml (handled by dedicated routes) + app.use("*", async (c, next) => { + const pathname = new URL(c.req.url).pathname; + if (pathname.startsWith("/docs") || pathname === "/openapi.yaml") { + return next(); + } + return serveStatic({ root: "./dist/public" })(c, next); + }); app.notFound((c) => { + const pathname = new URL(c.req.url).pathname; + // Don't SPA-fallback for docs routes + if (pathname.startsWith("/docs") || pathname === "/openapi.yaml") { + return c.json({ error: "Not Found" }, 404); + } const accept = c.req.header("accept") ?? ""; if (!accept.includes("text/html")) { return c.json({ error: "Not Found" }, 404); diff --git a/api/middleware.ts b/api/middleware.ts index 2c94f1a..c71af01 100644 --- a/api/middleware.ts +++ b/api/middleware.ts @@ -3,12 +3,13 @@ import { TRPCError, initTRPC } from "@trpc/server"; import { ZodError } from "zod"; import SuperJSON from "superjson"; -import { Sentry } from "./instrument"; import { getDb } from "./queries/connection"; import { businesses, locations, users, userBusinesses, userLocations, appSettings, rolePermissions, type Business } from "@db/schema"; import { eq, and, sql, isNull, type AnyColumn, type AnyTable } from "drizzle-orm"; import type { RightsProfile } from "./lib/partner-allocations"; import type { ResolvedApiKey } from "./lib/api-key-auth"; +import { hasScope } from "./lib/api-scopes"; +import type { ApiScope } from "./lib/api-scopes"; import { env } from "./lib/env"; export const ErrorMessages = { @@ -282,7 +283,7 @@ const requireAuth = t.middleware(async (opts) => { return opts.next({ ctx: { ...opts.ctx, user } }); }); -export const requireApiKey = (scope?: string) => +export const requireApiKey = (scope?: ApiScope) => t.middleware(async (opts) => { const apiKey = opts.ctx.apiKey; if (!apiKey) { @@ -291,7 +292,7 @@ export const requireApiKey = (scope?: string) => message: "Valid API key required", }); } - if (scope && !apiKey.scopes.includes(scope) && !apiKey.scopes.includes("admin")) { + if (scope && !hasScope(apiKey.scopes, scope)) { throw new TRPCError({ code: "FORBIDDEN", message: `API key missing required scope: ${scope}`, @@ -442,7 +443,7 @@ const requireAccountManageOrApiKey = t.middleware(async (opts) => { } if ( opts.ctx.apiKey && - (opts.ctx.apiKey.scopes.includes("journal:write") || opts.ctx.apiKey.scopes.includes("admin")) + hasScope(opts.ctx.apiKey.scopes, "journal:write") ) { return opts.next({ ctx: opts.ctx }); } diff --git a/api/routes/v1/accounts.ts b/api/routes/v1/accounts.ts new file mode 100644 index 0000000..1f1def4 --- /dev/null +++ b/api/routes/v1/accounts.ts @@ -0,0 +1,32 @@ +// ABOUTME: GET /api/v1/accounts — list accounts for the authenticated business. +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 { listAccounts, logIntegration } from "../../lib/integration-service"; +import { listAccountsQuerySchema } from "../../schemas"; + +const accounts = new Hono<{ Variables: ApiKeyVariables }>(); + +accounts.get("/", resolveApiKeyMiddleware("accounts:read"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const parsed = listAccountsQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams)); + if (!parsed.success) { + return errorResponse(c, 400, "VALIDATION_ERROR", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")); + } + + const { offset, limit, accountType } = parsed.data; + const all = await listAccounts(apiKey.businessId, accountType); + const total = all.length; + const data = all.slice(offset, offset + limit); + + logIntegration({ businessId: apiKey.businessId, apiKey }, "listAccounts", "success", { count: data.length, total }); + return paginatedResponse(c, data, { page: Math.floor(offset / limit) + 1, limit, total }); + } catch (err) { + logIntegration({ businessId: c.get("apiKey")?.businessId, apiKey: c.get("apiKey") }, "listAccounts", "failed", { error: String(err) }); + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default accounts; diff --git a/api/routes/v1/business.ts b/api/routes/v1/business.ts new file mode 100644 index 0000000..752051d --- /dev/null +++ b/api/routes/v1/business.ts @@ -0,0 +1,21 @@ +// ABOUTME: GET /api/v1/business/profile — get business profile for the authenticated business. +import { Hono } from "hono"; +import type { ApiKeyVariables } from "../../lib/api-key-middleware"; +import { resolveApiKeyMiddleware } from "../../lib/api-key-middleware"; +import { successResponse, errorResponse } from "../../lib/api-response"; +import { getBusinessProfile, logIntegration } from "../../lib/integration-service"; + +const business = new Hono<{ Variables: ApiKeyVariables }>(); + +business.get("/profile", resolveApiKeyMiddleware("business:read"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const data = await getBusinessProfile(apiKey.businessId); + logIntegration({ businessId: apiKey.businessId, apiKey }, "getBusinessProfile", "success", { found: Boolean(data) }); + return successResponse(c, data); + } catch (err) { + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default business; diff --git a/api/routes/v1/categories.ts b/api/routes/v1/categories.ts new file mode 100644 index 0000000..2f2f29a --- /dev/null +++ b/api/routes/v1/categories.ts @@ -0,0 +1,31 @@ +// ABOUTME: GET /api/v1/categories — list expense categories for the authenticated business. +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"; + +const categories = new Hono<{ Variables: ApiKeyVariables }>(); + +categories.get("/", resolveApiKeyMiddleware("categories:read"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const parsed = paginationQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams)); + if (!parsed.success) { + return errorResponse(c, 400, "VALIDATION_ERROR", parsed.error.issues.map((i) => i.message).join(", ")); + } + + const { offset, limit } = parsed.data; + const all = await listCategories(apiKey.businessId); + const total = all.length; + const data = all.slice(offset, offset + limit); + + logIntegration({ businessId: apiKey.businessId, apiKey }, "listCategories", "success", { count: data.length, total }); + return paginatedResponse(c, data, { page: Math.floor(offset / limit) + 1, limit, total }); + } catch (err) { + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default categories; diff --git a/api/routes/v1/daily-sales.ts b/api/routes/v1/daily-sales.ts new file mode 100644 index 0000000..924b1a2 --- /dev/null +++ b/api/routes/v1/daily-sales.ts @@ -0,0 +1,48 @@ +// ABOUTME: POST /api/v1/daily-sales — ingest daily sales batches. +import { Hono } from "hono"; +import { Sentry } from "../../instrument"; +import type { ApiKeyVariables } from "../../lib/api-key-middleware"; +import { resolveApiKeyMiddleware } from "../../lib/api-key-middleware"; +import { successResponse, errorResponse } from "../../lib/api-response"; +import { ingestDailySales } from "../../lib/daily-sales-ingestion"; +import { dailySalesIngestSchema } from "../../schemas"; + +const dailySales = new Hono<{ Variables: ApiKeyVariables }>(); + +dailySales.post("/", resolveApiKeyMiddleware("sales:write"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const body = await c.req.json(); + const parsed = dailySalesIngestSchema.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 ingestDailySales({ + businessId: apiKey.businessId, + ...parsed.data, + }); + + if (!result.success) { + return errorResponse(c, 400, "INGESTION_FAILED", result.error ?? "Ingestion failed"); + } + + const status = result.warnings.length > 0 ? 202 : 200; + return successResponse( + c, + { + dailySaleId: result.dailySaleId, + netSales: result.netSales, + warnings: result.warnings, + created: result.created, + }, + status as 200 | 202, + ); + } catch (err) { + console.error("[v1/daily-sales] error:", err); + Sentry.captureException(err); + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default dailySales; diff --git a/api/routes/v1/index.ts b/api/routes/v1/index.ts new file mode 100644 index 0000000..a68466a --- /dev/null +++ b/api/routes/v1/index.ts @@ -0,0 +1,48 @@ +// ABOUTME: Mounts all v1 external REST API sub-routers and applies shared middleware. +// ABOUTME: Mounted at /api/v1/ in boot.ts. +import { Hono } from "hono"; +import type { ApiKeyVariables } from "../../lib/api-key-middleware"; +import { requestIdMiddleware } from "../../lib/request-id"; +import { integrationLimiter } from "../../lib/rate-limit"; + +import verify from "./verify"; +import accounts from "./accounts"; +import suppliersRouter from "./suppliers"; +import categories from "./categories"; +import business from "./business"; +import locations from "./locations"; +import usersRouter from "./users"; +import roles from "./roles"; +import dailySales from "./daily-sales"; +import webhooks from "./webhooks"; + +const v1 = new Hono<{ Variables: ApiKeyVariables }>(); + +// Shared middleware for all v1 routes +v1.use("*", requestIdMiddleware); +v1.use("*", integrationLimiter); + +// Mount sub-routers +v1.route("/verify", verify); +v1.route("/accounts", accounts); +v1.route("/suppliers", suppliersRouter); +v1.route("/categories", categories); +v1.route("/business", business); +v1.route("/locations", locations); +v1.route("/users", usersRouter); +v1.route("/roles", roles); +v1.route("/daily-sales", dailySales); +v1.route("/webhooks", webhooks); + +// 404 catch-all for unmatched v1 routes +v1.all("/*", (c) => { + return c.json( + { + error: { code: "NOT_FOUND", message: `Route not found: ${c.req.method} ${c.req.path}` }, + meta: { requestId: c.get("requestId") }, + }, + 404, + ); +}); + +export default v1; diff --git a/api/routes/v1/locations.ts b/api/routes/v1/locations.ts new file mode 100644 index 0000000..3d347c8 --- /dev/null +++ b/api/routes/v1/locations.ts @@ -0,0 +1,31 @@ +// ABOUTME: GET /api/v1/locations — list locations for the authenticated business. +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 { listLocations, logIntegration } from "../../lib/integration-service"; +import { paginationQuerySchema } from "../../schemas"; + +const locations = new Hono<{ Variables: ApiKeyVariables }>(); + +locations.get("/", resolveApiKeyMiddleware("locations:read"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const parsed = paginationQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams)); + if (!parsed.success) { + return errorResponse(c, 400, "VALIDATION_ERROR", parsed.error.issues.map((i) => i.message).join(", ")); + } + + const { offset, limit } = parsed.data; + const all = await listLocations(apiKey.businessId); + const total = all.length; + const data = all.slice(offset, offset + limit); + + logIntegration({ businessId: apiKey.businessId, apiKey }, "listLocations", "success", { count: data.length, total }); + return paginatedResponse(c, data, { page: Math.floor(offset / limit) + 1, limit, total }); + } catch (err) { + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default locations; diff --git a/api/routes/v1/roles.ts b/api/routes/v1/roles.ts new file mode 100644 index 0000000..8d1766d --- /dev/null +++ b/api/routes/v1/roles.ts @@ -0,0 +1,31 @@ +// ABOUTME: GET /api/v1/roles — list role templates for the authenticated business. +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 { listRoleTemplates, logIntegration } from "../../lib/integration-service"; +import { paginationQuerySchema } from "../../schemas"; + +const roles = new Hono<{ Variables: ApiKeyVariables }>(); + +roles.get("/", resolveApiKeyMiddleware("users:read"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const parsed = paginationQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams)); + if (!parsed.success) { + return errorResponse(c, 400, "VALIDATION_ERROR", parsed.error.issues.map((i) => i.message).join(", ")); + } + + const { offset, limit } = parsed.data; + const all = await listRoleTemplates(); + const total = all.length; + const data = all.slice(offset, offset + limit); + + logIntegration({ businessId: apiKey.businessId, apiKey }, "listRoleTemplates", "success", { count: data.length, total }); + return paginatedResponse(c, data, { page: Math.floor(offset / limit) + 1, limit, total }); + } catch (err) { + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default roles; diff --git a/api/routes/v1/suppliers.ts b/api/routes/v1/suppliers.ts new file mode 100644 index 0000000..df13e04 --- /dev/null +++ b/api/routes/v1/suppliers.ts @@ -0,0 +1,49 @@ +// ABOUTME: GET /api/v1/suppliers and POST /api/v1/suppliers — list and upsert suppliers. +import { Hono } from "hono"; +import type { ApiKeyVariables } from "../../lib/api-key-middleware"; +import { resolveApiKeyMiddleware } from "../../lib/api-key-middleware"; +import { successResponse, paginatedResponse, errorResponse } from "../../lib/api-response"; +import { listSuppliers, upsertSupplier, logIntegration } from "../../lib/integration-service"; +import { upsertSupplierSchema, paginationQuerySchema } from "../../schemas"; + +const suppliers = new Hono<{ Variables: ApiKeyVariables }>(); + +suppliers.get("/", resolveApiKeyMiddleware("suppliers:read"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const parsed = paginationQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams)); + if (!parsed.success) { + return errorResponse(c, 400, "VALIDATION_ERROR", parsed.error.issues.map((i) => i.message).join(", ")); + } + + const { offset, limit } = parsed.data; + const all = await listSuppliers(apiKey.businessId); + const total = all.length; + const data = all.slice(offset, offset + limit); + + logIntegration({ businessId: apiKey.businessId, apiKey }, "listSuppliers", "success", { count: data.length, total }); + return paginatedResponse(c, data, { page: Math.floor(offset / limit) + 1, limit, total }); + } catch (err) { + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +suppliers.post("/", resolveApiKeyMiddleware("suppliers:write"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const body = await c.req.json(); + const parsed = upsertSupplierSchema.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 upsertSupplier(apiKey.businessId, parsed.data); + logIntegration({ businessId: apiKey.businessId, apiKey }, "upsertSupplier", "success", { supplierId: result.id, created: result.created }); + return successResponse(c, result, result.created ? 201 : 200); + } catch (err) { + logIntegration({ businessId: c.get("apiKey")?.businessId, apiKey: c.get("apiKey") }, "upsertSupplier", "failed", { error: String(err) }); + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default suppliers; diff --git a/api/routes/v1/users.ts b/api/routes/v1/users.ts new file mode 100644 index 0000000..63d0d88 --- /dev/null +++ b/api/routes/v1/users.ts @@ -0,0 +1,53 @@ +// ABOUTME: GET /api/v1/users and POST /api/v1/users — list and upsert users. +import { Hono } from "hono"; +import type { ApiKeyVariables } from "../../lib/api-key-middleware"; +import { resolveApiKeyMiddleware } from "../../lib/api-key-middleware"; +import { successResponse, paginatedResponse, errorResponse } from "../../lib/api-response"; +import { listUsers, upsertUser, logIntegration } from "../../lib/integration-service"; +import { upsertUserSchema, paginationQuerySchema } from "../../schemas"; + +const usersRouter = new Hono<{ Variables: ApiKeyVariables }>(); + +usersRouter.get("/", resolveApiKeyMiddleware("users:read"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const parsed = paginationQuerySchema.safeParse(Object.fromEntries(new URL(c.req.url).searchParams)); + if (!parsed.success) { + return errorResponse(c, 400, "VALIDATION_ERROR", parsed.error.issues.map((i) => i.message).join(", ")); + } + + const { offset, limit } = parsed.data; + const all = await listUsers(apiKey.businessId); + const total = all.length; + const data = all.slice(offset, offset + limit); + + logIntegration({ businessId: apiKey.businessId, apiKey }, "listUsers", "success", { count: data.length, total }); + return paginatedResponse(c, data, { page: Math.floor(offset / limit) + 1, limit, total }); + } catch (err) { + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +usersRouter.post("/", resolveApiKeyMiddleware("users:write"), async (c) => { + try { + const apiKey = c.get("apiKey"); + const body = await c.req.json(); + const parsed = upsertUserSchema.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 upsertUser(apiKey.businessId, parsed.data); + logIntegration({ businessId: apiKey.businessId, apiKey }, "upsertUser", "success", { userId: result.id, created: result.created }); + return successResponse(c, result, result.created ? 201 : 200); + } catch (err) { + logIntegration({ businessId: c.get("apiKey")?.businessId, apiKey: c.get("apiKey") }, "upsertUser", "failed", { error: String(err) }); + const message = err instanceof Error ? err.message : "Internal server error"; + if (message.includes("not allowed via integration")) { + return errorResponse(c, 400, "INVALID_ROLE", message); + } + return errorResponse(c, 500, "INTERNAL_ERROR", message); + } +}); + +export default usersRouter; diff --git a/api/routes/v1/verify.ts b/api/routes/v1/verify.ts new file mode 100644 index 0000000..4ae744d --- /dev/null +++ b/api/routes/v1/verify.ts @@ -0,0 +1,18 @@ +// ABOUTME: Verifies that an API key is valid and returns its identity. +import { Hono } from "hono"; +import type { ApiKeyVariables } from "../../lib/api-key-middleware"; +import { resolveApiKeyMiddleware } from "../../lib/api-key-middleware"; +import { successResponse } from "../../lib/api-response"; + +const verify = new Hono<{ Variables: ApiKeyVariables }>(); + +verify.get("/", resolveApiKeyMiddleware(), async (c) => { + const apiKey = c.get("apiKey"); + return successResponse(c, { + ok: true, + businessId: apiKey.businessId, + authMethod: "api_key", + }); +}); + +export default verify; diff --git a/api/routes/v1/webhooks.ts b/api/routes/v1/webhooks.ts new file mode 100644 index 0000000..97b52c8 --- /dev/null +++ b/api/routes/v1/webhooks.ts @@ -0,0 +1,101 @@ +// ABOUTME: POST /api/v1/webhooks/finabill — incoming webhook handler with HMAC signature verification. +import { Hono } from "hono"; +import { Sentry } from "../../instrument"; +import { eq, and, isNull } from "drizzle-orm"; +import { getDb } from "../../queries/connection"; +import { integrationConnections } from "@db/schema"; +import type { ApiKeyVariables } from "../../lib/api-key-middleware"; +import { successResponse, errorResponse } from "../../lib/api-response"; +import { signWebhookPayload, decryptWebhookSecret } from "../../lib/webhook-dispatcher"; +import { handleProviderWebhook } from "../../lib/webhook-handlers"; +import { handleWalletWebhook } from "../../lib/mobile-wallet/webhook-handler"; +import { constantTimeCompare } from "../../lib/crypto"; +import { finabillWebhookPayloadSchema } from "../../schemas"; + +const webhooks = new Hono<{ Variables: ApiKeyVariables }>(); + +webhooks.post("/finabill", async (c) => { + try { + const rawBody = await c.req.text(); + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + return errorResponse(c, 400, "INVALID_JSON", "Invalid JSON body"); + } + + const parsed = finabillWebhookPayloadSchema.safeParse(payload); + if (!parsed.success) { + return errorResponse(c, 400, "VALIDATION_ERROR", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")); + } + + const { businessId } = parsed.data; + + const db = getDb(); + const [connection] = await db + .select() + .from(integrationConnections) + .where( + and( + eq(integrationConnections.businessId, businessId), + eq(integrationConnections.targetSystem, "finabill"), + eq(integrationConnections.isActive, true), + isNull(integrationConnections.deletedAt), + ), + ) + .limit(1); + + if (!connection?.webhookSecret) { + return errorResponse(c, 401, "WEBHOOK_NOT_CONFIGURED", "Webhook secret not configured"); + } + + const secret = decryptWebhookSecret(connection.webhookSecret); + if (!secret) { + return errorResponse(c, 401, "INVALID_WEBHOOK_SECRET", "Invalid webhook secret"); + } + + const expectedSignature = signWebhookPayload(rawBody, secret); + const providedSignature = c.req.header("X-Fina-Signature") ?? ""; + if (!constantTimeCompare(providedSignature, expectedSignature)) { + return errorResponse(c, 401, "INVALID_SIGNATURE", "Invalid signature"); + } + + const result = await handleProviderWebhook("finabill", parsed.data as Record); + return successResponse(c, result.body); + } catch (err) { + console.error("[v1/webhooks/finabill] error:", err); + Sentry.captureException(err); + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +/** + * POST /api/v1/wallet-webhooks/:provider + * Incoming webhook for mobile wallet providers (mpesa, airtel_money, sasapay). + * Routes the raw request to the registered provider's processWebhook handler. + */ +webhooks.post("/wallet-webhooks/:provider", async (c) => { + try { + const provider = c.req.param("provider"); + const rawBody = await c.req.text(); + const headers: Record = {}; + c.req.raw.headers.forEach((value, key) => { + headers[key] = value; + }); + + const result = await handleWalletWebhook({ + provider, + rawBody, + headers, + signature: c.req.header("X-Signature") ?? c.req.header("X-Mpesa-Signature") ?? undefined, + }); + + return c.json(JSON.parse(result.body), result.status as 200 | 400 | 404 | 405 | 500); + } catch (err) { + console.error("[v1/wallet-webhooks] error:", err); + Sentry.captureException(err); + return errorResponse(c, 500, "INTERNAL_ERROR", err instanceof Error ? err.message : "Internal server error"); + } +}); + +export default webhooks; diff --git a/api/schemas/index.ts b/api/schemas/index.ts new file mode 100644 index 0000000..ea5dac7 --- /dev/null +++ b/api/schemas/index.ts @@ -0,0 +1,125 @@ +// ABOUTME: Shared zod schemas for the external API request/response validation. +// ABOUTME: Single source of truth for integration, connect, and webhook payloads. +import { z } from "zod"; + +// ── Pagination ───────────────────────────────────────────────────── + +export const paginationQuerySchema = z.object({ + offset: z.coerce.number().int().min(0).default(0), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +export type PaginationQuery = z.infer; + +// ── Integration: Accounts ─────────────────────────────────────────── + +export const listAccountsQuerySchema = z.object({ + accountType: z.string().optional(), + ...paginationQuerySchema.shape, +}); + +// ── Integration: Suppliers ────────────────────────────────────────── + +export const upsertSupplierSchema = z.object({ + externalId: z.string().optional(), + name: z.string().min(1), + email: z.string().email().optional().nullable(), + phone: z.string().optional().nullable(), + taxId: z.string().optional().nullable(), +}); + +// ── Integration: Users ────────────────────────────────────────────── + +export const upsertUserSchema = z.object({ + externalId: z.string().optional(), + name: z.string().min(1), + email: z.string().email(), + phone: z.string().optional().nullable(), + role: z.string().min(1), + isActive: z.boolean().optional().default(true), + locationIds: z.array(z.number()).optional().default([]), +}); + +// ── Integration: Daily Sales ──────────────────────────────────────── + +export const dailySalesPaymentSchema = z.object({ + channel: z.string(), + amount: z.string(), +}); + +export const dailySalesIngestSchema = z.object({ + locationId: z.number(), + saleDate: z.string().optional(), + sourceSystem: z.string().optional().default("finabill"), + sourceBatchId: z.string().min(1), + payments: z.array(dailySalesPaymentSchema).optional().default([]), + discountAmount: z.string().optional(), + voidAmount: z.string().optional(), + unpaidAmount: z.string().optional(), + ticketCount: z.number().optional(), + orderCount: z.number().optional(), + notes: z.string().optional(), +}); + +// ── Webhook: Incoming payload ─────────────────────────────────────── + +export const finabillWebhookPayloadSchema = z.object({ + event: z.string(), + businessId: z.number(), + data: z.object({}).passthrough(), +}); + +// ── Webhook: Outgoing event payload schemas ───────────────────────── + +export const saleRecordedPayloadSchema = z.object({ + dailySaleId: z.number(), + saleDate: z.string(), + netSales: z.string(), +}); + +export const expenseCreatedPayloadSchema = z.object({ + expenseId: z.number(), + amount: z.string(), + accountId: z.number().nullable(), +}); + +export const billPaidPayloadSchema = z.object({ + billId: z.number(), + paymentId: z.number(), + amount: z.string(), +}); + +export const coaUpdatedPayloadSchema = z.object({ + name: z.string(), + externalId: z.string().optional(), + accountCode: z.string().optional(), + accountType: z.string().optional(), + accountSubType: z.string().optional(), +}); + +export const supplierUpdatedPayloadSchema = z.object({ + name: z.string(), + externalId: z.string().optional(), + email: z.string().optional(), + phone: z.string().optional(), + taxId: z.string().optional(), +}); + +export const journalCreatedPayloadSchema = z.object({ + journalEntryId: z.number(), + entryDate: z.string(), + totalDebit: z.string(), + totalCredit: z.string(), +}); + +/** Map of event name → payload schema for outgoing webhooks. */ +export const WEBHOOK_EVENT_SCHEMAS = { + "sale.recorded": saleRecordedPayloadSchema, + "expense.created": expenseCreatedPayloadSchema, + "bill.paid": billPaidPayloadSchema, + "coa.updated": coaUpdatedPayloadSchema, + "supplier.updated": supplierUpdatedPayloadSchema, + "journal.created": journalCreatedPayloadSchema, +} as const; + +export type WebhookEvent = keyof typeof WEBHOOK_EVENT_SCHEMAS; diff --git a/api/test/setup.ts b/api/test/setup.ts index 6cd3b90..e329f53 100644 --- a/api/test/setup.ts +++ b/api/test/setup.ts @@ -1,15 +1,19 @@ // ABOUTME: Boots the isolated PostgreSQL test database and applies required SQL migrations for integration tests. -// ABOUTME: Keeps test setup idempotent by only loading migrations whose tables are still missing. +// ABOUTME: Runs once per process, applies migrations idempotently, and never wipes shared seed data between suites. import { beforeAll } from "vitest"; import fs from "node:fs"; import path from "node:path"; import pg from "pg"; -// Set test environment variables +// Set test environment variables before any app modules resolve DATABASE_URL. +// Always pin to the dedicated test DB (override Vite/.env DATABASE_URL which +// often points at the developer `finaflow` database). CI uses the same URL. process.env.NODE_ENV = "test"; -process.env.APP_ID = "test-app"; -process.env.APP_SECRET = "test-secret-key-not-for-production"; -process.env.DATABASE_URL = "postgresql://postgres:postgres@127.0.0.1:5432/finaflow_test"; +process.env.APP_ID = process.env.APP_ID || "test-app"; +process.env.APP_SECRET = process.env.APP_SECRET || "test-secret-key-not-for-production"; +process.env.DATABASE_URL = + process.env.TEST_DATABASE_URL || + "postgresql://postgres:postgres@127.0.0.1:5432/finaflow_test"; process.env.NHIF_RATE = "2.75"; process.env.BCRYPT_ROUNDS = "4"; @@ -24,6 +28,10 @@ const skipTestDatabaseBootstrap = process.env.SKIP_API_TEST_DB === "1"; // Increase timeout for database bootstrapping since it involves DDL operations const BOOTSTRAP_TIMEOUT = 120_000; +// setupFiles run once per worker process. Guard so repeated beforeAll hooks +// (and accidental multi-worker configs) do not re-apply destructive DDL. +let bootstrapPromise: Promise | null = null; + async function tableExists(testPool: pg.Pool, tableName: string): Promise { const result = await testPool.query( ` @@ -150,9 +158,27 @@ async function applyMigrationFile(testPool: pg.Pool, filePath: string): Promise< for (const stmt of statements) { try { await testPool.query(stmt); - } catch { - // Individual DDL statements may fail if already applied; - // continue with the next statement for idempotent setup. + } catch (error: unknown) { + // Idempotent DDL/seed may fail when re-applied on an existing test DB. + // Ignore known already-applied conflicts; rethrow everything else so real + // schema breakage still fails the suite instead of being swallowed. + const err = error as { code?: string; message?: string }; + const msg = (err.message ?? "").toLowerCase(); + const ignorable = + err.code === "42P07" || // duplicate_table + err.code === "42710" || // duplicate_object + err.code === "42P16" || // invalid_table_definition (already exists variants) + err.code === "42701" || // duplicate_column + err.code === "42723" || // duplicate_function + err.code === "23505" || // unique_violation (seed/backfill re-run) + err.code === "23503" || // foreign_key_violation on optional seed rows + msg.includes("already exists") || + msg.includes("duplicate key"); + if (!ignorable) { + throw new Error( + `Migration ${path.basename(filePath)} failed: ${err.message ?? String(error)}`, + ); + } } } } @@ -184,15 +210,10 @@ async function ensureTestDatabase(): Promise { }); try { - // Defensive cleanup of stale test data before migrations. Leftover rows in - // location-related tables can cause duplicate-key failures during migration - // 0013; truncate them so each suite starts from a clean migration baseline. - if (await tableExists(testPool, "locations")) { - await testPool.query('TRUNCATE TABLE "locations" CASCADE'); - } - if (await tableExists(testPool, "user_locations")) { - await testPool.query('TRUNCATE TABLE "user_locations" CASCADE'); - } + // Never TRUNCATE/DROP shared tables here. setupFiles can run once per worker, + // and wiping locations/budget tables races with other suites' beforeAll seeds + // (integration-finabill location lookups, budgets-router inserts, etc.). + // Migrations below are idempotent (IF NOT EXISTS / ADD COLUMN IF NOT EXISTS). const baseSchemaPath = path.resolve( import.meta.dirname, @@ -202,14 +223,17 @@ async function ensureTestDatabase(): Promise { await applyMigrationFile(testPool, baseSchemaPath); } - // Drop stale budget tables from previous implementation so 0014 recreates them fresh - const stalePool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); - try { - await stalePool.query(`DROP TABLE IF EXISTS "budget_bucket_lines" CASCADE`); - await stalePool.query(`DROP TABLE IF EXISTS "budget_plan_buckets" CASCADE`); - await stalePool.query(`DROP TABLE IF EXISTS "budget_plans" CASCADE`); - } finally { - await stalePool.end(); + // Only rebuild budget plan tables when the 0014 schema is incomplete. + // Unconditional DROP raced with parallel suites and left budgets-router + // failing with "relation budget_plan_buckets does not exist". + const budgetSchemaReady = + (await tableExists(testPool, "budget_plans")) && + (await tableExists(testPool, "budget_plan_buckets")) && + (await tableExists(testPool, "budget_bucket_lines")); + if (!budgetSchemaReady) { + await testPool.query(`DROP TABLE IF EXISTS "budget_bucket_lines" CASCADE`); + await testPool.query(`DROP TABLE IF EXISTS "budget_plan_buckets" CASCADE`); + await testPool.query(`DROP TABLE IF EXISTS "budget_plans" CASCADE`); } for (const file of [ @@ -244,6 +268,19 @@ async function ensureTestDatabase(): Promise { await applyMigrationFile(testPool, p); } } + + // Fail fast if the budget plan model is still missing after migrations. + for (const requiredTable of [ + "budget_plans", + "budget_plan_buckets", + "budget_bucket_lines", + ]) { + if (!(await tableExists(testPool, requiredTable))) { + throw new Error( + `Test DB bootstrap incomplete: required table "${requiredTable}" is missing after migrations`, + ); + } + } } finally { await testPool.end(); } @@ -259,21 +296,32 @@ beforeAll(async () => { return; } - // Retry bootstrap once if a transient connection race occurs. - let lastError: unknown; - for (let attempt = 1; attempt <= 2; attempt++) { - try { - await ensureTestDatabase(); - return; - } catch (error) { - lastError = error; - if (attempt === 1) { - console.warn("Test database bootstrap failed, retrying once:", (error as Error)?.message); - await new Promise((resolve) => setTimeout(resolve, 500)); + if (!bootstrapPromise) { + bootstrapPromise = (async () => { + // Retry bootstrap once if a transient connection race occurs. + let lastError: unknown; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + await ensureTestDatabase(); + return; + } catch (error) { + lastError = error; + if (attempt === 1) { + console.warn( + "Test database bootstrap failed, retrying once:", + (error as Error)?.message, + ); + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } } - } + // Allow a later suite to retry bootstrap if this process-level attempt failed. + bootstrapPromise = null; + throw lastError; + })(); } - throw lastError; + + await bootstrapPromise; }, BOOTSTRAP_TIMEOUT); // Pool is not explicitly closed here because this is a shared setup file loaded for every test suite. diff --git a/docs/api-reference/openapi.yaml b/docs/api-reference/openapi.yaml new file mode 100644 index 0000000..29f9415 --- /dev/null +++ b/docs/api-reference/openapi.yaml @@ -0,0 +1,1001 @@ +openapi: 3.1.0 +info: + title: FinaFlow Integration API + description: | + REST API for external integrators. All endpoints require an API key + (`Authorization: Bearer fna_...`) unless noted otherwise. + + **Response envelope** — every response uses: + - Success: `{ "data": , "meta": { "requestId": "req_..." } }` + - Error: `{ "error": { "code": "ERROR_CODE", "message": "Human text" }, "meta": { "requestId": "req_..." } }` + + **Pagination** — list endpoints accept `?offset=0&limit=20` (max 100). + Responses include `meta.pagination: { page, limit, total, totalPages }`. + + **Rate limits** — 100 requests/minute per IP. Headers: `X-RateLimit-Limit`, + `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`. + version: 1.0.0 + contact: + name: FinaFlow Support + +servers: + - url: /api/v1 + description: v1 API (relative to host) + +security: + - BearerAuth: [] + +tags: + - name: Verify + description: API key verification + - name: Accounts + description: Chart of accounts + - name: Suppliers + description: Supplier management + - name: Categories + description: Expense categories + - name: Business + description: Business profile + - name: Locations + description: Branches / locations + - name: Users + description: User management and roles + - name: Daily Sales + description: Sales ingestion + - name: Webhooks + description: Incoming webhook receivers + +paths: + /verify: + get: + operationId: verifyApiKey + summary: Verify API key + description: Returns the business identity for the authenticated API key. + tags: [Verify] + security: + - BearerAuth: [] + responses: + "200": + description: Key is valid + content: + application/json: + schema: + $ref: "#/components/schemas/VerifyResponse" + + /accounts: + get: + operationId: listAccounts + summary: List accounts + description: List all accounts for the authenticated business. + tags: [Accounts] + security: + - BearerAuth: [] + - ScopeAuth: ["accounts:read"] + parameters: + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/Limit" + - name: accountType + in: query + schema: + type: string + description: Filter by account type (e.g. asset, liability, revenue, expense) + responses: + "200": + description: Paginated list of accounts + content: + application/json: + schema: + $ref: "#/components/schemas/AccountListResponse" + + /suppliers: + get: + operationId: listSuppliers + summary: List suppliers + tags: [Suppliers] + security: + - BearerAuth: [] + - ScopeAuth: ["suppliers:read"] + parameters: + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Paginated list of suppliers + content: + application/json: + schema: + $ref: "#/components/schemas/SupplierListResponse" + post: + operationId: upsertSupplier + summary: Create or update a supplier + description: | + If `externalId` is provided and matches an existing supplier, it is updated. + Otherwise a new supplier is created. + tags: [Suppliers] + security: + - BearerAuth: [] + - ScopeAuth: ["suppliers:write"] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertSupplierInput" + responses: + "200": + description: Supplier updated + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertResult" + "201": + description: Supplier created + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertResult" + "400": + $ref: "#/components/responses/ValidationError" + + /categories: + get: + operationId: listCategories + summary: List expense categories + tags: [Categories] + security: + - BearerAuth: [] + - ScopeAuth: ["categories:read"] + parameters: + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Paginated list of expense categories + content: + application/json: + schema: + $ref: "#/components/schemas/CategoryListResponse" + + /business/profile: + get: + operationId: getBusinessProfile + summary: Get business profile + tags: [Business] + security: + - BearerAuth: [] + - ScopeAuth: ["business:read"] + responses: + "200": + description: Business profile + content: + application/json: + schema: + type: object + properties: + data: + $ref: "#/components/schemas/BusinessProfile" + meta: + $ref: "#/components/schemas/Meta" + + /locations: + get: + operationId: listLocations + summary: List locations / branches + tags: [Locations] + security: + - BearerAuth: [] + - ScopeAuth: ["locations:read"] + parameters: + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Paginated list of locations + content: + application/json: + schema: + $ref: "#/components/schemas/LocationListResponse" + + /users: + get: + operationId: listUsers + summary: List users + description: List all users assigned to the business, including role and location assignments. + tags: [Users] + security: + - BearerAuth: [] + - ScopeAuth: ["users:read"] + parameters: + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Paginated list of users + content: + application/json: + schema: + $ref: "#/components/schemas/UserListResponse" + post: + operationId: upsertUser + summary: Create or update a user + description: | + If `externalId` is provided and matches an existing user, it is updated. + Otherwise a new user is created with a random password (the user must + reset it via the normal flow). + tags: [Users] + security: + - BearerAuth: [] + - ScopeAuth: ["users:write"] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertUserInput" + responses: + "200": + description: User updated + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertResult" + "201": + description: User created + content: + application/json: + schema: + $ref: "#/components/schemas/UpsertResult" + "400": + $ref: "#/components/responses/ValidationError" + + /roles: + get: + operationId: listRoleTemplates + summary: List role templates + description: List all active role templates with their permission sets. + tags: [Users] + security: + - BearerAuth: [] + - ScopeAuth: ["users:read"] + parameters: + - $ref: "#/components/parameters/Offset" + - $ref: "#/components/parameters/Limit" + responses: + "200": + description: Paginated list of role templates + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + role: + type: string + permissions: + type: array + items: + type: string + meta: + $ref: "#/components/schemas/PaginatedMeta" + + /daily-sales: + post: + operationId: ingestDailySales + summary: Ingest a daily sales batch + description: | + Idempotent by `sourceBatchId`. Re-submitting the same batch returns + the existing record without creating a duplicate. + tags: [Daily Sales] + security: + - BearerAuth: [] + - ScopeAuth: ["sales:write"] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/DailySalesInput" + responses: + "200": + description: Sales batch created + content: + application/json: + schema: + $ref: "#/components/schemas/DailySalesResult" + "202": + description: Sales batch created with warnings (unmapped channels) + content: + application/json: + schema: + $ref: "#/components/schemas/DailySalesResult" + "400": + $ref: "#/components/responses/ValidationError" + + /webhooks/finabill: + post: + operationId: receiveFinabillWebhook + summary: Receive a FinaBill webhook + description: | + Incoming webhook from FinaBill. The request body must be signed with + `X-Fina-Signature: sha256=` (HMAC-SHA256 of the raw body using + the shared webhook secret). + tags: [Webhooks] + security: [] + parameters: + - name: X-Fina-Signature + in: header + required: true + schema: + type: string + description: "HMAC-SHA256 signature: `sha256=`" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FinabillWebhookPayload" + responses: + "200": + description: Webhook processed + content: + application/json: + schema: + $ref: "#/components/schemas/WebhookAck" + "400": + $ref: "#/components/responses/ErrorResponse" + "401": + $ref: "#/components/responses/ErrorResponse" + + /wallet-webhooks/{provider}: + post: + operationId: receiveWalletWebhook + summary: Receive a mobile wallet webhook + description: | + Incoming webhook for mobile wallet providers (mpesa, airtel_money, sasapay). + Routes the raw request to the registered provider's handler. + tags: [Webhooks] + security: [] + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: [mpesa, airtel_money, sasapay] + responses: + "200": + description: Webhook processed + "400": + $ref: "#/components/responses/ErrorResponse" + "404": + description: Unknown provider + +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + description: "API key prefixed with `fna_`. Pass as `Authorization: Bearer fna_...`" + ScopeAuth: + type: oauth2 + description: "Scopes required for this operation. Granted when the API key is created." + flows: + implicit: + authorizationUrl: "about:blank" + scopes: + "accounts:read": Read accounts + "suppliers:read": Read suppliers + "suppliers:write": Create/update suppliers + "categories:read": Read expense categories + "business:read": Read business profile + "locations:read": Read locations + "users:read": Read users and roles + "users:write": Create/update users + "sales:write": Ingest daily sales + "journal:write": Create/post journal entries + "webhooks": Manage webhooks + + parameters: + Offset: + name: offset + in: query + schema: + type: integer + minimum: 0 + default: 0 + description: Number of items to skip + Limit: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + description: Maximum items to return + + responses: + ValidationError: + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + ErrorResponse: + description: Error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + schemas: + Meta: + type: object + properties: + requestId: + type: string + example: req_abc123 + + PaginatedMeta: + allOf: + - $ref: "#/components/schemas/Meta" + - type: object + properties: + pagination: + type: object + properties: + page: + type: integer + limit: + type: integer + total: + type: integer + totalPages: + type: integer + + ErrorResponse: + type: object + properties: + error: + type: object + properties: + code: + type: string + example: VALIDATION_ERROR + message: + type: string + example: "locationId is required" + meta: + $ref: "#/components/schemas/Meta" + + VerifyResponse: + type: object + properties: + data: + type: object + properties: + ok: + type: boolean + businessId: + type: integer + authMethod: + type: string + meta: + $ref: "#/components/schemas/Meta" + + Account: + type: object + properties: + id: + type: integer + name: + type: string + accountCode: + type: string + nullable: true + accountType: + type: string + nullable: true + accountSubType: + type: string + nullable: true + isActive: + type: boolean + + AccountListResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Account" + meta: + $ref: "#/components/schemas/PaginatedMeta" + + Supplier: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + nullable: true + phone: + type: string + nullable: true + taxId: + type: string + nullable: true + + SupplierListResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Supplier" + meta: + $ref: "#/components/schemas/PaginatedMeta" + + UpsertSupplierInput: + type: object + required: [name] + properties: + externalId: + type: string + description: Match existing supplier by ID for update + name: + type: string + minLength: 1 + email: + type: string + format: email + nullable: true + phone: + type: string + nullable: true + taxId: + type: string + nullable: true + + UpsertResult: + type: object + properties: + data: + type: object + properties: + id: + type: integer + created: + type: boolean + meta: + $ref: "#/components/schemas/Meta" + + Category: + type: object + properties: + id: + type: integer + name: + type: string + categoryType: + type: string + defaultAccountId: + type: integer + nullable: true + externalAccountCode: + type: string + nullable: true + isActive: + type: boolean + + CategoryListResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Category" + meta: + $ref: "#/components/schemas/PaginatedMeta" + + BusinessProfile: + type: object + nullable: true + properties: + id: + type: integer + name: + type: string + email: + type: string + nullable: true + phone: + type: string + nullable: true + address: + type: string + nullable: true + country: + type: string + nullable: true + fiscalYearStartMonth: + type: integer + nullable: true + registrationNumber: + type: string + nullable: true + taxId: + type: string + nullable: true + + Location: + type: object + properties: + id: + type: integer + name: + type: string + slug: + type: string + nullable: true + isActive: + type: boolean + address: + type: string + nullable: true + phone: + type: string + nullable: true + email: + type: string + nullable: true + + LocationListResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/Location" + meta: + $ref: "#/components/schemas/PaginatedMeta" + + User: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + phone: + type: string + nullable: true + role: + type: string + isActive: + type: boolean + assignedLocationIds: + type: array + items: + type: integer + + UserListResponse: + type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/User" + meta: + $ref: "#/components/schemas/PaginatedMeta" + + UpsertUserInput: + type: object + required: [name, email, role] + properties: + externalId: + type: string + name: + type: string + minLength: 1 + email: + type: string + format: email + phone: + type: string + nullable: true + role: + type: string + minLength: 1 + description: "One of: manager, employee, accountant, viewer, cashier" + isActive: + type: boolean + default: true + locationIds: + type: array + items: + type: integer + default: [] + + DailySalesInput: + type: object + required: [locationId, sourceBatchId] + properties: + locationId: + type: integer + saleDate: + type: string + format: date + description: Defaults to today if omitted + sourceSystem: + type: string + default: finabill + sourceBatchId: + type: string + minLength: 1 + description: Idempotency key — same batch ID won't create duplicates + payments: + type: array + items: + type: object + properties: + channel: + type: string + amount: + type: string + description: Decimal string (e.g. "12500.00") + discountAmount: + type: string + voidAmount: + type: string + unpaidAmount: + type: string + ticketCount: + type: integer + orderCount: + type: integer + notes: + type: string + + DailySalesResult: + type: object + properties: + data: + type: object + properties: + dailySaleId: + type: integer + netSales: + type: string + warnings: + type: array + items: + type: string + created: + type: boolean + meta: + $ref: "#/components/schemas/Meta" + + FinabillWebhookPayload: + type: object + required: [event, businessId, data] + properties: + event: + type: string + enum: [coa.updated, supplier.updated] + businessId: + type: integer + data: + type: object + + WebhookAck: + type: object + properties: + data: + type: object + properties: + received: + type: boolean + event: + type: string + + WebhookEventCatalog: + description: | + **Outgoing webhook events** (dispatched by FinaFlow to subscriber URLs): + + | Event | Description | Data fields | + |---|---|---| + | `sale.recorded` | Daily sales batch created | `dailySaleId`, `saleDate`, `netSales` | + | `expense.created` | Expense recorded | `expenseId`, `amount`, `accountId` | + | `bill.paid` | Bill payment recorded | `billId`, `paymentId`, `amount` | + | `coa.updated` | Chart of accounts entry synced | `name`, `externalId`, `accountCode`, `accountType` | + | `supplier.updated` | Supplier record synced | `name`, `externalId`, `email`, `phone`, `taxId` | + | `journal.created` | Journal entry posted | `journalEntryId`, `entryDate`, `totalDebit`, `totalCredit` | + + **Signature**: Each delivery includes `X-Fina-Signature: sha256=` + (HMAC-SHA256 of the raw JSON body using the webhook secret). + + **Retry policy**: Up to 3 attempts with exponential backoff (1s, 2s). + All attempts recorded in `webhookDeliveries`. + +webhooks: + saleRecorded: + description: Fired when a daily sales batch is recorded + post: + summary: sale.recorded + requestBody: + content: + application/json: + schema: + type: object + properties: + event: + const: sale.recorded + timestamp: + type: string + format: date-time + data: + type: object + properties: + dailySaleId: + type: integer + saleDate: + type: string + netSales: + type: string + responses: + "200": + description: Acknowledged + expenseCreated: + description: Fired when an expense is recorded + post: + summary: expense.created + requestBody: + content: + application/json: + schema: + type: object + properties: + event: + const: expense.created + timestamp: + type: string + format: date-time + data: + type: object + properties: + expenseId: + type: integer + amount: + type: string + accountId: + type: integer + nullable: true + responses: + "200": + description: Acknowledged + billPaid: + description: Fired when a bill payment is recorded + post: + summary: bill.paid + requestBody: + content: + application/json: + schema: + type: object + properties: + event: + const: bill.paid + timestamp: + type: string + format: date-time + data: + type: object + properties: + billId: + type: integer + paymentId: + type: integer + amount: + type: string + responses: + "200": + description: Acknowledged + coaUpdated: + description: Fired when a chart of accounts entry is synced from FinaBill + post: + summary: coa.updated + requestBody: + content: + application/json: + schema: + type: object + properties: + event: + const: coa.updated + timestamp: + type: string + format: date-time + data: + type: object + properties: + name: + type: string + externalId: + type: string + accountCode: + type: string + accountType: + type: string + responses: + "200": + description: Acknowledged + supplierUpdated: + description: Fired when a supplier record is synced from FinaBill + post: + summary: supplier.updated + requestBody: + content: + application/json: + schema: + type: object + properties: + event: + const: supplier.updated + timestamp: + type: string + format: date-time + data: + type: object + properties: + name: + type: string + externalId: + type: string + email: + type: string + phone: + type: string + taxId: + type: string + responses: + "200": + description: Acknowledged + journalCreated: + description: Fired when a journal entry is posted + post: + summary: journal.created + requestBody: + content: + application/json: + schema: + type: object + properties: + event: + const: journal.created + timestamp: + type: string + format: date-time + data: + type: object + properties: + journalEntryId: + type: integer + entryDate: + type: string + totalDebit: + type: string + totalCredit: + type: string + responses: + "200": + description: Acknowledged diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..eba7c80 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,98 @@ +# Authentication + +FinaFlow's external API uses **API key authentication**. All requests to `/api/v1/*` endpoints must include a valid API key in the `Authorization` header. + +## API Keys + +API keys are prefixed with `fna_` and are generated from the FinaFlow UI under **Settings > Integrations > API Keys**, or programmatically via the Fina Connect pairing flow. + +``` +Authorization: Bearer fna_ABCdef123456789... +``` + +API keys are scoped to a single business. All operations performed with a key are attributed to that business. + +## Scopes + +Each API key is granted a set of **scopes** that control which endpoints it can access. Scopes follow the `resource:action` naming convention: + +| Scope | Description | +|---|---| +| `accounts:read` | Read accounts, chart of accounts | +| `suppliers:read` | Read suppliers | +| `suppliers:write` | Create and update suppliers | +| `categories:read` | Read expense categories | +| `business:read` | Read business profile | +| `locations:read` | Read locations / branches | +| `users:read` | Read users and role templates | +| `users:write` | Create and update users | +| `sales:write` | Ingest daily sales batches | +| `journal:write` | Create and post journal entries | +| `webhooks` | Manage webhook subscriptions | + +### Legacy scopes + +Older API keys may use coarse scopes (`read`, `write`). These are automatically resolved to the granular equivalents: + +- `read` → `accounts:read`, `suppliers:read`, `categories:read`, `business:read`, `locations:read`, `users:read` +- `write` → `suppliers:write` + +This ensures backward compatibility without requiring key rotation. + +## Cookie vs Bearer auth + +FinaFlow uses two authentication mechanisms depending on the client type: + +| Client | Auth method | Token format | +|---|---|---| +| **Web app (React)** | httpOnly cookie (`finaflow_token`) | JWT | +| **External integrator** | `Authorization: Bearer` header | API key (`fna_...`) | +| **tRPC internal** | Cookie or Bearer | JWT or API key | + +The web app never sees API keys. External integrators never need cookies or CSRF tokens — all `/api/v1/*` and `/api/connect/*` routes are exempt from CSRF protection. + +## Rate limits + +| Endpoint group | Limit | Window | +|---|---|---| +| `/api/v1/*` (integration) | 100 requests | 1 minute | +| `/api/trpc/*` (app API) | 500 requests | 1 minute | +| `/api/connect/*` (pairing) | 30 requests | 1 minute | +| Login endpoints | 10 requests | 1 minute | + +Rate limit headers are included on every response: + +``` +X-RateLimit-Limit: 100 +X-RateLimit-Remaining: 97 +X-RateLimit-Reset: 1720000000 +``` + +When the limit is exceeded, the response includes `Retry-After` and returns HTTP 429. + +## Error responses + +All error responses use a consistent envelope: + +```json +{ + "error": { + "code": "UNAUTHORIZED", + "message": "Invalid API key" + }, + "meta": { + "requestId": "req_abc123" + } +} +``` + +Common error codes: + +| Code | HTTP Status | Meaning | +|---|---|---| +| `UNAUTHORIZED` | 401 | Missing or invalid API key | +| `FORBIDDEN` | 403 | API key lacks the required scope | +| `NOT_FOUND` | 404 | Route or resource not found | +| `VALIDATION_ERROR` | 400 | Request body failed validation | +| `INGESTION_FAILED` | 400 | Daily sales ingestion failed | +| `INTERNAL_ERROR` | 500 | Unexpected server error | diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..914bf59 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,102 @@ +# Webhooks + +FinaFlow supports both **incoming** and **outgoing** webhooks for real-time integration. + +## Incoming webhooks + +FinaFlow receives webhooks from partner systems (FinaBill, mobile wallet providers) to sync master data and transactions. + +### FinaBill webhooks + +**Endpoint**: `POST /api/v1/webhooks/finabill` + +The request must include an `X-Fina-Signature` header containing an HMAC-SHA256 signature of the raw JSON body, computed with the shared webhook secret: + +``` +X-Fina-Signature: sha256= +``` + +**Supported events**: + +| Event | Description | Data fields | +|---|---|---| +| `coa.updated` | Chart of accounts entry synced | `name`, `externalId`, `accountCode`, `accountType`, `accountSubType` | +| `supplier.updated` | Supplier record synced | `name`, `externalId`, `email`, `phone`, `taxId` | + +**Payload format**: +```json +{ + "event": "supplier.updated", + "businessId": 123, + "data": { + "name": "Acme Supplies", + "externalId": "SUP-001", + "email": "acme@example.com" + } +} +``` + +### Mobile wallet webhooks + +**Endpoint**: `POST /api/v1/wallet-webhooks/{provider}` + +Where `{provider}` is one of: `mpesa`, `airtel_money`, `sasapay`. + +These endpoints receive transaction callbacks from mobile money providers. The raw request body and headers are forwarded to the registered provider handler for processing. + +## Outgoing webhooks + +FinaFlow dispatches webhooks to subscriber URLs when business events occur. Configure webhooks under **Settings > Integrations > Webhooks**. + +### Event catalog + +| Event | Trigger | Payload `data` fields | +|---|---|---| +| `sale.recorded` | Daily sales batch created | `dailySaleId`, `saleDate`, `netSales` | +| `expense.created` | Expense recorded | `expenseId`, `amount`, `accountId` | +| `bill.paid` | Bill payment recorded | `billId`, `paymentId`, `amount` | +| `coa.updated` | Account synced from partner | `name`, `externalId`, `accountCode`, `accountType` | +| `supplier.updated` | Supplier synced from partner | `name`, `externalId`, `email`, `phone`, `taxId` | +| `journal.created` | Journal entry posted | `journalEntryId`, `entryDate`, `totalDebit`, `totalCredit` | + +### Delivery format + +Each delivery is a POST request with: + +```json +{ + "event": "sale.recorded", + "timestamp": "2026-07-17T14:30:00.000Z", + "data": { + "dailySaleId": 42, + "saleDate": "2026-07-17", + "netSales": "21250.00" + } +} +``` + +### Signature verification + +Each delivery includes an `X-Fina-Signature` header: + +``` +X-Fina-Signature: sha256= +``` + +To verify, compute `HMAC-SHA256(rawBody, webhookSecret)` and compare with the provided signature using a timing-safe comparison. + +### Retry policy + +Failed deliveries (non-2xx response or timeout) are retried up to **3 times** with exponential backoff: + +| Attempt | Delay | +|---|---| +| 1 | Immediate | +| 2 | 1 second | +| 3 | 2 seconds | + +All attempts (success and failure) are recorded in the `webhookDeliveries` table and visible in the UI under **Settings > Integrations > Webhooks > Deliveries**. + +### Responding to webhooks + +Return HTTP 200 to acknowledge receipt. Any non-2xx status code triggers a retry. Your handler should be idempotent — the same event may be delivered multiple times if retries occur. diff --git a/package-lock.json b/package-lock.json index a5ab02e..29701af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@scalar/hono-api-reference": "^0.11.11", "@sentry/node": "^10.58.0", "@sentry/react": "^10.58.0", "@tanstack/react-query": "^5.90.16", @@ -4843,6 +4844,81 @@ "win32" ] }, + "node_modules/@scalar/client-side-rendering": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@scalar/client-side-rendering/-/client-side-rendering-0.3.4.tgz", + "integrity": "sha512-kb3B+FGjvAUr2DU0fe9dVKBLwot1TjOO+iHCTmY8r8FUJySmYKGQYCicRzzilOyTjvKspiZBCEr03PWaWW+1gw==", + "license": "MIT", + "dependencies": { + "@scalar/schemas": "0.7.4", + "@scalar/types": "0.16.4", + "@scalar/validation": "0.6.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.9.2.tgz", + "integrity": "sha512-hjyMpMZjTBZQhyByZmz5oUgRKUQJO5V5AOiJxsVEGbUmgA7sJRQeTrXLB+BEwzaKS5nm2opJeNyMBYLFNK4hiQ==", + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/hono-api-reference": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/@scalar/hono-api-reference/-/hono-api-reference-0.11.11.tgz", + "integrity": "sha512-yvJ2oqyG9MC4C55/Xvi/Jny5qBYDxDvoVleABhgNG24A93u9ar17qsdSppli94sQOUdX31Qw/yCTm89LHbBRiQ==", + "license": "MIT", + "dependencies": { + "@scalar/client-side-rendering": "0.3.4" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "hono": "^4.12.5" + } + }, + "node_modules/@scalar/schemas": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@scalar/schemas/-/schemas-0.7.4.tgz", + "integrity": "sha512-Or31zxR+ceGGhkVU5XBO2Zv7oyGDtjsJOjM2Rr0WRZ1/tRsQc2/FsVv+9kPmCA7sqFL8BKWlNfTXcPITI7WRHw==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "@scalar/validation": "0.6.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/types": { + "version": "0.16.4", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.16.4.tgz", + "integrity": "sha512-fLf0ANAC3iQq0sIVdmH6aGM+pFSLyD8GfGBxSWcLpI0jE0iFAzX2A3p0qVZm7vqBT4TQnn0fSwg5U+h+FZ52BA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.9.2", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@scalar/validation": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@scalar/validation/-/validation-0.6.2.tgz", + "integrity": "sha512-Sc1TkcwGV6aVCO51AyKeaGiP8gpwAHxEtO5d3tZzPV+KsnlC/YokQxFxwBrbIXw73k9hmcExnJyGu3k5i6n6VA==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/@sentry/babel-plugin-component-annotate": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz", @@ -11466,7 +11542,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -11732,7 +11807,6 @@ "version": "5.6.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", - "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" diff --git a/package.json b/package.json index e666fd4..caf27af 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", + "@scalar/hono-api-reference": "^0.11.11", "@sentry/node": "^10.58.0", "@sentry/react": "^10.58.0", "@tanstack/react-query": "^5.90.16", diff --git a/src/components/QuickSupplierDialog.tsx b/src/components/QuickSupplierDialog.tsx index cd649e6..e4099de 100644 --- a/src/components/QuickSupplierDialog.tsx +++ b/src/components/QuickSupplierDialog.tsx @@ -3,6 +3,7 @@ import { trpc } from "@/providers/trpc"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; @@ -73,9 +74,9 @@ export function QuickSupplierDialog({
    - setForm((p) => ({ ...p, phone: e.target.value }))} + onChange={(value) => setForm((p) => ({ ...p, phone: value }))} placeholder="+254..." />
    diff --git a/src/components/partner/LeadFormDialog.tsx b/src/components/partner/LeadFormDialog.tsx index dcff984..0a9ac15 100644 --- a/src/components/partner/LeadFormDialog.tsx +++ b/src/components/partner/LeadFormDialog.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Label } from "@/components/ui/label"; type LeadStatus = "new" | "contacted" | "converted" | "declined"; @@ -106,10 +107,10 @@ export function LeadFormDialog({
    - updateField("phone", event.target.value)} + onChange={(value) => updateField("phone", value)} placeholder="+2547..." />
    diff --git a/src/components/ui/country-combobox.tsx b/src/components/ui/country-combobox.tsx new file mode 100644 index 0000000..1a9be4c --- /dev/null +++ b/src/components/ui/country-combobox.tsx @@ -0,0 +1,76 @@ +import { useState, useCallback } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { COUNTRIES, type CountryInfo } from "@/lib/countries"; + +interface CountryComboboxProps { + value: string; + onValueChange: (countryCode: string) => void; + onSelect?: (country: CountryInfo) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + id?: string; +} + +export function CountryCombobox({ + value, onValueChange, onSelect, placeholder = "Select country...", + disabled = false, className, id, +}: CountryComboboxProps) { + const [open, setOpen] = useState(false); + const selected = COUNTRIES.find((c) => c.code === value); + + const handleSelect = useCallback((code: string) => { + onValueChange(code); + const country = COUNTRIES.find((c) => c.code === code); + if (country) onSelect?.(country); + setOpen(false); + }, [onValueChange, onSelect]); + + return ( + + + + + + + + + No country found. + + {COUNTRIES.map((country) => ( + handleSelect(country.code)} + > + + {country.flag} + {country.name} + {country.dialCode} + + ))} + + + + + + ); +} diff --git a/src/components/ui/currency-combobox.tsx b/src/components/ui/currency-combobox.tsx new file mode 100644 index 0000000..abe11c8 --- /dev/null +++ b/src/components/ui/currency-combobox.tsx @@ -0,0 +1,107 @@ +import { useState, useCallback, useMemo } from "react"; +import { Check, ChevronsUpDown, Lock } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { SUPPORTED_CURRENCIES, getCurrencyInfo, type CurrencyInfo } from "@/lib/currency"; + +const EA_CODES = ["KES","TZS","UGX","RWF","BIF","SSP","ETB","SOS","DJF","ERN"]; + +interface CurrencyComboboxProps { + value: string; + onValueChange: (currencyCode: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + id?: string; + multiCurrency?: boolean; + allowedCurrencies?: string[]; +} + +export function CurrencyCombobox({ + value, onValueChange, placeholder = "Select currency...", + disabled = false, className, id, multiCurrency = false, allowedCurrencies, +}: CurrencyComboboxProps) { + const [open, setOpen] = useState(false); + const selected = getCurrencyInfo(value); + + const visibleCurrencies = useMemo(() => { + if (!multiCurrency) { + const cur = SUPPORTED_CURRENCIES.find((c) => c.code === value); + return cur ? [cur] : []; + } + if (allowedCurrencies?.length) { + const allowed = new Set(allowedCurrencies.map((c) => c.toUpperCase())); + allowed.add(value.toUpperCase()); + return SUPPORTED_CURRENCIES.filter((c) => allowed.has(c.code)); + } + return SUPPORTED_CURRENCIES; + }, [multiCurrency, value, allowedCurrencies]); + + const handleSelect = useCallback((code: string) => { + onValueChange(code); + setOpen(false); + }, [onValueChange]); + + const ea = visibleCurrencies.filter((c) => EA_CODES.includes(c.code)); + const intl = visibleCurrencies.filter((c) => !EA_CODES.includes(c.code)); + + return ( + + + + + + + + + No currency found. + {ea.length > 0 && ( + + {ea.map((c) => ( + handleSelect(c.code)}> + + {c.code} + {c.name} + {c.symbol} + + ))} + + )} + {ea.length > 0 && intl.length > 0 && } + {intl.length > 0 && ( + + {intl.map((c) => ( + handleSelect(c.code)}> + + {c.code} + {c.name} + {c.symbol} + + ))} + + )} + + + + + ); +} diff --git a/src/components/ui/phone-input.tsx b/src/components/ui/phone-input.tsx new file mode 100644 index 0000000..67ccb69 --- /dev/null +++ b/src/components/ui/phone-input.tsx @@ -0,0 +1,140 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { Check, ChevronDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { + Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { COUNTRIES, getDialCode, getCountryByCode, getCountryFromPhone, type CountryInfo } from "@/lib/countries"; + +interface PhoneInputProps { + value: string; + onChange: (value: string) => void; + countryCode?: string; + onCountryChange?: (country: CountryInfo) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + id?: string; +} + +function stripLocal(fullNumber: string, dialCode: string): string { + if (!fullNumber) return ""; + const cleaned = fullNumber.replace(/[\s\-()]/g, ""); + if (cleaned.startsWith(dialCode)) return cleaned.slice(dialCode.length); + if (cleaned.startsWith("+")) { + const detected = getCountryFromPhone(cleaned); + if (detected) return cleaned.slice(detected.dialCode.length); + return cleaned; + } + return cleaned; +} + +export function PhoneInput({ + value, onChange, countryCode, onCountryChange, + placeholder = "712 244 244", disabled = false, className, id, +}: PhoneInputProps) { + const [selectedCode, setSelectedCode] = useState(countryCode ?? ""); + useEffect(() => { if (countryCode && countryCode !== selectedCode) setSelectedCode(countryCode); }, [countryCode]); + + const selectedCountry = selectedCode ? getCountryByCode(selectedCode) : undefined; + const dialCode = selectedCountry?.dialCode; + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + + const uniqueCountries = useMemo(() => { + const seen = new Map(); + for (const c of COUNTRIES) { if (!seen.has(c.dialCode)) seen.set(c.dialCode, c); } + return [...seen.values()]; + }, []); + + const [localNumber, setLocalNumber] = useState(() => dialCode ? stripLocal(value, dialCode) : value); + useEffect(() => { setLocalNumber(dialCode ? stripLocal(value, dialCode) : value); }, [value, dialCode]); + + const emitFull = useCallback((local: string, dial: string | undefined) => { + if (!local) onChange(""); + else if (dial) onChange(dial + local.replace(/[^\d]/g, "")); + else onChange(local); + }, [onChange]); + + const handleCountrySelect = useCallback((country: CountryInfo) => { + setSelectedCode(country.code); + setOpen(false); + const digits = localNumber.replace(/[^\d+]/g, "").replace(/^\+/, ""); + onChange(country.dialCode + digits); + onCountryChange?.(country); + setTimeout(() => inputRef.current?.focus(), 0); + }, [localNumber, onChange, onCountryChange]); + + const handleChange = useCallback((e: React.ChangeEvent) => { + const raw = e.target.value; + if (!raw) { setLocalNumber(""); onChange(""); return; } + if (raw.startsWith("+")) { + const cleaned = raw.replace(/[\s\-()]/g, ""); + setLocalNumber(cleaned); + const detected = getCountryFromPhone(cleaned); + if (detected) { setSelectedCode(detected.code); onCountryChange?.(detected); } + onChange(cleaned); + return; + } + const digitsOnly = raw.replace(/[^\d]/g, ""); + setLocalNumber(digitsOnly); + emitFull(digitsOnly, dialCode); + }, [dialCode, emitFull, onChange, onCountryChange]); + + return ( +
    + + + + + e.preventDefault()} + > + + + + No country found. + + {uniqueCountries.map((country) => ( + handleCountrySelect(country)} + > + + {country.flag} + {country.name} + {country.dialCode} + + ))} + + + + + + +
    + ); +} diff --git a/src/components/ui/timezone-combobox.tsx b/src/components/ui/timezone-combobox.tsx new file mode 100644 index 0000000..d768b27 --- /dev/null +++ b/src/components/ui/timezone-combobox.tsx @@ -0,0 +1,81 @@ +import { useState, useCallback, useMemo } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { TIMEZONES, type TimezoneInfo } from "@/lib/timezones"; + +interface TimezoneComboboxProps { + value: string; + onValueChange: (timezoneId: string) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + id?: string; +} + +export function TimezoneCombobox({ + value, onValueChange, placeholder = "Select timezone...", + disabled = false, className, id, +}: TimezoneComboboxProps) { + const [open, setOpen] = useState(false); + const selected = TIMEZONES.find((tz) => tz.id === value); + + const uniqueTimezones = useMemo(() => { + const seen = new Set(); + return TIMEZONES.filter((tz) => { if (seen.has(tz.id)) return false; seen.add(tz.id); return true; }); + }, []); + + const grouped = useMemo(() => { + const map = new Map(); + for (const tz of uniqueTimezones) { + const list = map.get(tz.region) ?? []; + list.push(tz); + map.set(tz.region, list); + } + return map; + }, [uniqueTimezones]); + + const handleSelect = useCallback((id: string) => { + onValueChange(id); + setOpen(false); + }, [onValueChange]); + + return ( + + + + + + + + + No timezone found. + {Array.from(grouped.entries()).map(([region, zones]) => ( + + {zones.map((tz) => ( + handleSelect(tz.id)}> + + {tz.label} + + ))} + + ))} + + + + + ); +} diff --git a/src/lib/countries.ts b/src/lib/countries.ts new file mode 100644 index 0000000..5c01766 --- /dev/null +++ b/src/lib/countries.ts @@ -0,0 +1,105 @@ +/** + * Country definitions with ISO 3166-1 alpha-2 codes and dial codes. + * East African countries listed first, followed by commonly used markets. + */ + +export interface CountryInfo { + code: string; + name: string; + currencyCode: string; + dialCode: string; + flag: string; +} + +export const COUNTRIES: readonly CountryInfo[] = [ + // ── East Africa ─────────────────────────────────────────────────────── + { code: "KE", name: "Kenya", currencyCode: "KES", dialCode: "+254", flag: "🇰🇪" }, + { code: "TZ", name: "Tanzania", currencyCode: "TZS", dialCode: "+255", flag: "🇹🇿" }, + { code: "UG", name: "Uganda", currencyCode: "UGX", dialCode: "+256", flag: "🇺🇬" }, + { code: "RW", name: "Rwanda", currencyCode: "RWF", dialCode: "+250", flag: "🇷🇼" }, + { code: "BI", name: "Burundi", currencyCode: "BIF", dialCode: "+257", flag: "🇧🇮" }, + { code: "SS", name: "South Sudan", currencyCode: "SSP", dialCode: "+211", flag: "🇸🇸" }, + { code: "ET", name: "Ethiopia", currencyCode: "ETB", dialCode: "+251", flag: "🇪🇹" }, + { code: "SO", name: "Somalia", currencyCode: "SOS", dialCode: "+252", flag: "🇸🇴" }, + { code: "DJ", name: "Djibouti", currencyCode: "DJF", dialCode: "+253", flag: "🇩🇯" }, + { code: "ER", name: "Eritrea", currencyCode: "ERN", dialCode: "+291", flag: "🇪🇷" }, + + // ── Major International ─────────────────────────────────────────────── + { code: "US", name: "United States", currencyCode: "USD", dialCode: "+1", flag: "🇺🇸" }, + { code: "GB", name: "United Kingdom", currencyCode: "GBP", dialCode: "+44", flag: "🇬🇧" }, + { code: "DE", name: "Germany", currencyCode: "EUR", dialCode: "+49", flag: "🇩🇪" }, + { code: "FR", name: "France", currencyCode: "EUR", dialCode: "+33", flag: "🇫🇷" }, + { code: "IT", name: "Italy", currencyCode: "EUR", dialCode: "+39", flag: "🇮🇹" }, + { code: "ES", name: "Spain", currencyCode: "EUR", dialCode: "+34", flag: "🇪🇸" }, + { code: "NL", name: "Netherlands", currencyCode: "EUR", dialCode: "+31", flag: "🇳🇱" }, + { code: "CH", name: "Switzerland", currencyCode: "CHF", dialCode: "+41", flag: "🇨🇭" }, + { code: "JP", name: "Japan", currencyCode: "JPY", dialCode: "+81", flag: "🇯🇵" }, + { code: "CN", name: "China", currencyCode: "CNY", dialCode: "+86", flag: "🇨🇳" }, + { code: "IN", name: "India", currencyCode: "INR", dialCode: "+91", flag: "🇮🇳" }, + { code: "CA", name: "Canada", currencyCode: "CAD", dialCode: "+1", flag: "🇨🇦" }, + { code: "AU", name: "Australia", currencyCode: "AUD", dialCode: "+61", flag: "🇦🇺" }, + + // ── Africa ──────────────────────────────────────────────────────────── + { code: "ZA", name: "South Africa", currencyCode: "ZAR", dialCode: "+27", flag: "🇿🇦" }, + { code: "NG", name: "Nigeria", currencyCode: "NGN", dialCode: "+234", flag: "🇳🇬" }, + { code: "GH", name: "Ghana", currencyCode: "GHS", dialCode: "+233", flag: "🇬🇭" }, + { code: "MA", name: "Morocco", currencyCode: "MAD", dialCode: "+212", flag: "🇲🇦" }, + { code: "EG", name: "Egypt", currencyCode: "EGP", dialCode: "+20", flag: "🇪🇬" }, + { code: "SN", name: "Senegal", currencyCode: "XOF", dialCode: "+221", flag: "🇸🇳" }, + { code: "CM", name: "Cameroon", currencyCode: "XAF", dialCode: "+237", flag: "🇨🇲" }, + { code: "MW", name: "Malawi", currencyCode: "MWK", dialCode: "+265", flag: "🇲🇼" }, + { code: "ZM", name: "Zambia", currencyCode: "ZMW", dialCode: "+260", flag: "🇿🇲" }, + { code: "MZ", name: "Mozambique", currencyCode: "MZN", dialCode: "+258", flag: "🇲🇿" }, + { code: "BW", name: "Botswana", currencyCode: "BWP", dialCode: "+267", flag: "🇧🇼" }, + + // ── Middle East / Asia ──────────────────────────────────────────────── + { code: "AE", name: "United Arab Emirates", currencyCode: "AED", dialCode: "+971", flag: "🇦🇪" }, + { code: "SA", name: "Saudi Arabia", currencyCode: "SAR", dialCode: "+966", flag: "🇸🇦" }, + { code: "QA", name: "Qatar", currencyCode: "QAR", dialCode: "+974", flag: "🇶🇦" }, + { code: "KW", name: "Kuwait", currencyCode: "KWD", dialCode: "+965", flag: "🇰🇼" }, + { code: "BH", name: "Bahrain", currencyCode: "BHD", dialCode: "+973", flag: "🇧🇭" }, + { code: "SG", name: "Singapore", currencyCode: "SGD", dialCode: "+65", flag: "🇸🇬" }, + { code: "MY", name: "Malaysia", currencyCode: "MYR", dialCode: "+60", flag: "🇲🇾" }, + { code: "TH", name: "Thailand", currencyCode: "THB", dialCode: "+66", flag: "🇹🇭" }, + { code: "KR", name: "South Korea", currencyCode: "KRW", dialCode: "+82", flag: "🇰🇷" }, + { code: "PK", name: "Pakistan", currencyCode: "PKR", dialCode: "+92", flag: "🇵🇰" }, + { code: "BD", name: "Bangladesh", currencyCode: "BDT", dialCode: "+880", flag: "🇧🇩" }, + + // ── Americas ────────────────────────────────────────────────────────── + { code: "BR", name: "Brazil", currencyCode: "BRL", dialCode: "+55", flag: "🇧🇷" }, + { code: "MX", name: "Mexico", currencyCode: "MXN", dialCode: "+52", flag: "🇲🇽" }, +] as const; + +export function getCountryByCode(code: string): CountryInfo | undefined { + return COUNTRIES.find((c) => c.code === code.toUpperCase()); +} + +export function searchCountries(query: string): readonly CountryInfo[] { + if (!query) return COUNTRIES; + const lower = query.toLowerCase(); + return COUNTRIES.filter( + (c) => + c.name.toLowerCase().includes(lower) || + c.code.toLowerCase().includes(lower) || + c.dialCode.includes(query) + ); +} + +export function getDialCode(countryCode: string): string | undefined { + return COUNTRIES.find((c) => c.code === countryCode.toUpperCase())?.dialCode; +} + +export function getCountryFromPhone(phone: string): CountryInfo | undefined { + const stripped = phone.replace(/[\s\-()]/g, ""); + if (!stripped.startsWith("+")) return undefined; + const sorted = [...COUNTRIES].sort((a, b) => b.dialCode.length - a.dialCode.length); + return sorted.find((c) => stripped.startsWith(c.dialCode)); +} + +/** Map ISO country code → default currency code. */ +export const COUNTRY_CURRENCY_MAP: Readonly> = + Object.fromEntries(COUNTRIES.map((c) => [c.code, c.currencyCode])); + +export function getDefaultCurrencyForCountry(countryCode: string): string | undefined { + return COUNTRY_CURRENCY_MAP[countryCode.toUpperCase()]; +} diff --git a/src/lib/currency.ts b/src/lib/currency.ts index f9e6241..34778f7 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -9,34 +9,71 @@ export interface CurrencyInfo { } export const SUPPORTED_CURRENCIES: CurrencyInfo[] = [ + // ── East Africa ───────────────────────────────────────────────────── { code: "KES", name: "Kenyan Shilling", symbol: "KSh", decimalPlaces: 2 }, - { code: "USD", name: "US Dollar", symbol: "$", decimalPlaces: 2 }, - { code: "UGX", name: "Ugandan Shilling", symbol: "USh", decimalPlaces: 0 }, { code: "TZS", name: "Tanzanian Shilling", symbol: "TSh", decimalPlaces: 2 }, - { code: "EUR", name: "Euro", symbol: "EUR", decimalPlaces: 2 }, - { code: "GBP", name: "British Pound", symbol: "GBP", decimalPlaces: 2 }, - { code: "JPY", name: "Japanese Yen", symbol: "JPY", decimalPlaces: 0 }, - { code: "KWD", name: "Kuwaiti Dinar", symbol: "KWD", decimalPlaces: 3 }, - { code: "MWK", name: "Malawian Kwacha", symbol: "MK", decimalPlaces: 2 }, - { code: "ZMW", name: "Zambian Kwacha", symbol: "ZK", decimalPlaces: 2 }, + { code: "UGX", name: "Ugandan Shilling", symbol: "USh", decimalPlaces: 0 }, { code: "RWF", name: "Rwandan Franc", symbol: "FRw", decimalPlaces: 0 }, - { code: "BWP", name: "Botswana Pula", symbol: "P", decimalPlaces: 2 }, - { code: "ZAR", name: "South African Rand", symbol: "R", decimalPlaces: 2 }, - { code: "NGN", name: "Nigerian Naira", symbol: "NGN", decimalPlaces: 2 }, + { code: "BIF", name: "Burundian Franc", symbol: "FBu", decimalPlaces: 0 }, + { code: "SSP", name: "South Sudanese Pound", symbol: "£", decimalPlaces: 2 }, { code: "ETB", name: "Ethiopian Birr", symbol: "Br", decimalPlaces: 2 }, + { code: "SOS", name: "Somali Shilling", symbol: "Sh", decimalPlaces: 2 }, + { code: "DJF", name: "Djiboutian Franc", symbol: "Fdj", decimalPlaces: 0 }, + { code: "ERN", name: "Eritrean Nakfa", symbol: "Nfk", decimalPlaces: 2 }, + + // ── Major International ───────────────────────────────────────────── + { code: "USD", name: "US Dollar", symbol: "$", decimalPlaces: 2 }, + { code: "EUR", name: "Euro", symbol: "€", decimalPlaces: 2 }, + { code: "GBP", name: "British Pound", symbol: "£", decimalPlaces: 2 }, + { code: "CHF", name: "Swiss Franc", symbol: "CHF", decimalPlaces: 2 }, + { code: "JPY", name: "Japanese Yen", symbol: "¥", decimalPlaces: 0 }, + { code: "CNY", name: "Chinese Yuan", symbol: "¥", decimalPlaces: 2 }, + { code: "INR", name: "Indian Rupee", symbol: "₹", decimalPlaces: 2 }, + { code: "CAD", name: "Canadian Dollar", symbol: "C$", decimalPlaces: 2 }, + { code: "AUD", name: "Australian Dollar", symbol: "A$", decimalPlaces: 2 }, + + // ── Africa ────────────────────────────────────────────────────────── + { code: "ZAR", name: "South African Rand", symbol: "R", decimalPlaces: 2 }, + { code: "NGN", name: "Nigerian Naira", symbol: "₦", decimalPlaces: 2 }, + { code: "GHS", name: "Ghanaian Cedi", symbol: "GH₵", decimalPlaces: 2 }, + { code: "MWK", name: "Malawian Kwacha", symbol: "MK", decimalPlaces: 2 }, + { code: "ZMW", name: "Zambian Kwacha", symbol: "ZK", decimalPlaces: 2 }, { code: "MZN", name: "Mozambican Metical", symbol: "MT", decimalPlaces: 2 }, { code: "AOA", name: "Angolan Kwanza", symbol: "Kz", decimalPlaces: 2 }, - { code: "GHS", name: "Ghanaian Cedi", symbol: "GH", decimalPlaces: 2 }, + { code: "BWP", name: "Botswana Pula", symbol: "P", decimalPlaces: 2 }, { code: "XAF", name: "CFA Franc BEAC", symbol: "FCFA", decimalPlaces: 0 }, { code: "XOF", name: "CFA Franc BCEAO", symbol: "CFA", decimalPlaces: 0 }, + + // ── Middle East / Asia ────────────────────────────────────────────── + { code: "AED", name: "UAE Dirham", symbol: "د.إ", decimalPlaces: 2 }, + { code: "SAR", name: "Saudi Riyal", symbol: "﷼", decimalPlaces: 2 }, + { code: "KWD", name: "Kuwaiti Dinar", symbol: "د.ك", decimalPlaces: 3 }, + { code: "SGD", name: "Singapore Dollar", symbol: "S$", decimalPlaces: 2 }, + { code: "MYR", name: "Malaysian Ringgit", symbol: "RM", decimalPlaces: 2 }, + { code: "THB", name: "Thai Baht", symbol: "฿", decimalPlaces: 2 }, + { code: "KRW", name: "South Korean Won", symbol: "₩", decimalPlaces: 0 }, + { code: "PKR", name: "Pakistani Rupee", symbol: "₨", decimalPlaces: 2 }, + { code: "BDT", name: "Bangladeshi Taka", symbol: "৳", decimalPlaces: 2 }, + + // ── Americas ──────────────────────────────────────────────────────── + { code: "BRL", name: "Brazilian Real", symbol: "R$", decimalPlaces: 2 }, + { code: "MXN", name: "Mexican Peso", symbol: "Mex$", decimalPlaces: 2 }, ]; const localeMap: Record = { - KES: "en-KE", USD: "en-US", UGX: "en-UG", TZS: "en-TZ", - EUR: "de-DE", GBP: "en-GB", JPY: "ja-JP", KWD: "en-KW", - MWK: "en-MW", ZMW: "en-ZM", RWF: "en-RW", BWP: "en-BW", - ZAR: "en-ZA", NGN: "en-NG", ETB: "en-ET", MZN: "en-MZ", - AOA: "en-AO", GHS: "en-GH", XAF: "en-CM", XOF: "en-SN", + KES: "en-KE", TZS: "en-TZ", UGX: "en-UG", RWF: "en-RW", + BIF: "en-BI", SSP: "en-SS", ETB: "en-ET", SOS: "en-SO", + DJF: "fr-DJ", ERN: "en-ER", + USD: "en-US", EUR: "de-DE", GBP: "en-GB", CHF: "de-CH", + JPY: "ja-JP", CNY: "zh-CN", INR: "en-IN", CAD: "en-CA", + AUD: "en-AU", + ZAR: "en-ZA", NGN: "en-NG", GHS: "en-GH", MWK: "en-MW", + ZMW: "en-ZM", MZN: "en-MZ", AOA: "en-AO", BWP: "en-BW", + XAF: "en-CM", XOF: "en-SN", + AED: "ar-AE", SAR: "ar-SA", KWD: "en-KW", SGD: "en-SG", + MYR: "ms-MY", THB: "th-TH", KRW: "ko-KR", PKR: "en-PK", + BDT: "bn-BD", + BRL: "pt-BR", MXN: "es-MX", }; export function getCurrencyInfo(currency: string): CurrencyInfo { diff --git a/src/lib/timezones.ts b/src/lib/timezones.ts new file mode 100644 index 0000000..c1c9fe5 --- /dev/null +++ b/src/lib/timezones.ts @@ -0,0 +1,83 @@ +/** + * Common timezone definitions grouped by region. + * East African timezones listed first. + */ + +export interface TimezoneInfo { + id: string; + label: string; + region: string; +} + +export const TIMEZONES: readonly TimezoneInfo[] = [ + // ── East Africa ─────────────────────────────────────────────────────── + { id: "Africa/Nairobi", label: "Africa/Nairobi (UTC+3)", region: "East Africa" }, + { id: "Africa/Dar_es_Salaam", label: "Africa/Dar_es_Salaam (UTC+3)", region: "East Africa" }, + { id: "Africa/Kampala", label: "Africa/Kampala (UTC+3)", region: "East Africa" }, + { id: "Africa/Kigali", label: "Africa/Kigali (UTC+2)", region: "East Africa" }, + { id: "Africa/Bujumbura", label: "Africa/Bujumbura (UTC+2)", region: "East Africa" }, + { id: "Africa/Juba", label: "Africa/Juba (UTC+2)", region: "East Africa" }, + { id: "Africa/Addis_Ababa", label: "Africa/Addis_Ababa (UTC+3)", region: "East Africa" }, + { id: "Africa/Mogadishu", label: "Africa/Mogadishu (UTC+3)", region: "East Africa" }, + { id: "Africa/Djibouti", label: "Africa/Djibouti (UTC+3)", region: "East Africa" }, + { id: "Africa/Asmara", label: "Africa/Asmara (UTC+3)", region: "East Africa" }, + + // ── Africa ──────────────────────────────────────────────────────────── + { id: "Africa/Lagos", label: "Africa/Lagos (UTC+1)", region: "Africa" }, + { id: "Africa/Accra", label: "Africa/Accra (UTC+0)", region: "Africa" }, + { id: "Africa/Johannesburg", label: "Africa/Johannesburg (UTC+2)", region: "Africa" }, + { id: "Africa/Cairo", label: "Africa/Cairo (UTC+2)", region: "Africa" }, + { id: "Africa/Casablanca", label: "Africa/Casablanca (UTC+1)", region: "Africa" }, + { id: "Africa/Maputo", label: "Africa/Maputo (UTC+2)", region: "Africa" }, + { id: "Africa/Lusaka", label: "Africa/Lusaka (UTC+2)", region: "Africa" }, + { id: "Africa/Blantyre", label: "Africa/Blantyre (UTC+2)", region: "Africa" }, + { id: "Africa/Harare", label: "Africa/Harare (UTC+2)", region: "Africa" }, + + // ── Europe ──────────────────────────────────────────────────────────── + { id: "Europe/London", label: "Europe/London (UTC+0/+1)", region: "Europe" }, + { id: "Europe/Paris", label: "Europe/Paris (UTC+1/+2)", region: "Europe" }, + { id: "Europe/Berlin", label: "Europe/Berlin (UTC+1/+2)", region: "Europe" }, + { id: "Europe/Madrid", label: "Europe/Madrid (UTC+1/+2)", region: "Europe" }, + { id: "Europe/Rome", label: "Europe/Rome (UTC+1/+2)", region: "Europe" }, + { id: "Europe/Amsterdam", label: "Europe/Amsterdam (UTC+1/+2)", region: "Europe" }, + { id: "Europe/Zurich", label: "Europe/Zurich (UTC+1/+2)", region: "Europe" }, + { id: "Europe/Moscow", label: "Europe/Moscow (UTC+3)", region: "Europe" }, + { id: "Europe/Istanbul", label: "Europe/Istanbul (UTC+3)", region: "Europe" }, + + // ── Americas ────────────────────────────────────────────────────────── + { id: "America/New_York", label: "America/New_York (UTC-5/-4)", region: "Americas" }, + { id: "America/Chicago", label: "America/Chicago (UTC-6/-5)", region: "Americas" }, + { id: "America/Denver", label: "America/Denver (UTC-7/-6)", region: "Americas" }, + { id: "America/Los_Angeles", label: "America/Los_Angeles (UTC-8/-7)", region: "Americas" }, + { id: "America/Toronto", label: "America/Toronto (UTC-5/-4)", region: "Americas" }, + { id: "America/Mexico_City", label: "America/Mexico_City (UTC-6/-5)", region: "Americas" }, + { id: "America/Sao_Paulo", label: "America/Sao_Paulo (UTC-3)", region: "Americas" }, + + // ── Middle East ─────────────────────────────────────────────────────── + { id: "Asia/Dubai", label: "Asia/Dubai (UTC+4)", region: "Middle East" }, + { id: "Asia/Riyadh", label: "Asia/Riyadh (UTC+3)", region: "Middle East" }, + { id: "Asia/Qatar", label: "Asia/Qatar (UTC+3)", region: "Middle East" }, + + // ── Asia / Pacific ──────────────────────────────────────────────────── + { id: "Asia/Kolkata", label: "Asia/Kolkata (UTC+5:30)", region: "Asia" }, + { id: "Asia/Dhaka", label: "Asia/Dhaka (UTC+6)", region: "Asia" }, + { id: "Asia/Singapore", label: "Asia/Singapore (UTC+8)", region: "Asia" }, + { id: "Asia/Kuala_Lumpur", label: "Asia/Kuala_Lumpur (UTC+8)", region: "Asia" }, + { id: "Asia/Bangkok", label: "Asia/Bangkok (UTC+7)", region: "Asia" }, + { id: "Asia/Shanghai", label: "Asia/Shanghai (UTC+8)", region: "Asia" }, + { id: "Asia/Tokyo", label: "Asia/Tokyo (UTC+9)", region: "Asia" }, + { id: "Asia/Seoul", label: "Asia/Seoul (UTC+9)", region: "Asia" }, + { id: "Australia/Sydney", label: "Australia/Sydney (UTC+10/+11)", region: "Asia" }, + { id: "Pacific/Auckland", label: "Pacific/Auckland (UTC+12/+13)", region: "Asia" }, +] as const; + +export function searchTimezones(query: string): readonly TimezoneInfo[] { + if (!query) return TIMEZONES; + const lower = query.toLowerCase(); + return TIMEZONES.filter( + (tz) => + tz.id.toLowerCase().includes(lower) || + tz.label.toLowerCase().includes(lower) || + tz.region.toLowerCase().includes(lower) + ); +} diff --git a/src/pages/BusinessDetails.tsx b/src/pages/BusinessDetails.tsx index 41e24c8..92f07e7 100644 --- a/src/pages/BusinessDetails.tsx +++ b/src/pages/BusinessDetails.tsx @@ -12,6 +12,9 @@ import { buildPrintGeneratedLabel, formatFileSize } from "@/features/business-pr import { isAllowedLogoType, optimizeLogoFile, validateLogoFileSizeBytes } from "@/features/business-profile/logo-utils"; import { toast } from "sonner"; import { Building, Check, ChevronLeft, ChevronRight, FileText, Globe, Landmark, Loader2, Save, Shield, Store, Upload, X } from "lucide-react"; +import { CountryCombobox } from "@/components/ui/country-combobox"; +import { PhoneInput } from "@/components/ui/phone-input"; +import { COUNTRIES, getDefaultCurrencyForCountry } from "@/lib/countries"; const BUSINESS_TYPES = [ "Sole Proprietorship", "Partnership", "Limited Liability Company (LLC)", @@ -415,7 +418,12 @@ export function BusinessDetails() {
    - setForm(p => ({ ...p, phone: e.target.value }))} placeholder="+254 7XX XXX XXX" /> + setForm(p => ({ ...p, phone: val }))} + countryCode={COUNTRIES.find(c => c.name === form.country)?.code ?? ""} + placeholder="712 244 244" + />
    @@ -431,7 +439,22 @@ export function BusinessDetails() {

    Address Information

    Business physical location details

    - setForm(p => ({ ...p, country: e.target.value }))} placeholder="Kenya" /> + c.name === form.country)?.code ?? ""} + onValueChange={(code) => { + const country = COUNTRIES.find(c => c.code === code); + if (country) { + setForm(p => ({ ...p, country: country.name })); + } + }} + onSelect={(country) => { + const currency = getDefaultCurrencyForCountry(country.code); + if (currency) { + // Currency auto-detected: ready for future form.currency field + } + }} + placeholder="Select country..." + />
    diff --git a/src/pages/BusinessOverview.tsx b/src/pages/BusinessOverview.tsx index 49b9829..dd993e6 100644 --- a/src/pages/BusinessOverview.tsx +++ b/src/pages/BusinessOverview.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth"; import { hasPermission, PERMISSIONS } from "@/lib/permissions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Label } from "@/components/ui/label"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -599,7 +600,7 @@ export function BusinessOverview() {
    setLocForm((p) => ({ ...p, address: e.target.value }))} placeholder="Physical address" />
    -
    setLocForm((p) => ({ ...p, phone: e.target.value }))} placeholder="07xx xxx xxx" />
    +
    setLocForm((p) => ({ ...p, phone: value }))} placeholder="07xx xxx xxx" />
    setLocForm((p) => ({ ...p, email: e.target.value }))} />
    setForm(p => ({ ...p, address: e.target.value }))} placeholder="Physical address" />
    -
    setForm(p => ({ ...p, phone: e.target.value }))} placeholder="07xx xxx xxx" />
    +
    setForm(p => ({ ...p, phone: value }))} placeholder="07xx xxx xxx" />
    setForm(p => ({ ...p, email: e.target.value }))} />
    @@ -140,7 +141,7 @@ export function Locations() {
    { e.preventDefault(); updateLoc.mutate({ id: loc.id, ...editForm, defaultMpesaAccountId: editForm.defaultMpesaAccountId ? +editForm.defaultMpesaAccountId : undefined, defaultCashAccountId: editForm.defaultCashAccountId ? +editForm.defaultCashAccountId : undefined }); }} className="space-y-3">
    setEditForm(p => ({ ...p, name: e.target.value }))} required />
    setEditForm(p => ({ ...p, slug: e.target.value }))} required />
    setEditForm(p => ({ ...p, address: e.target.value }))} />
    -
    setEditForm(p => ({ ...p, phone: e.target.value }))} />
    setEditForm(p => ({ ...p, email: e.target.value }))} />
    +
    setEditForm(p => ({ ...p, phone: value }))} />
    setEditForm(p => ({ ...p, email: e.target.value }))} />
    setSignupForm(p => ({ ...p, phone: e.target.value }))} + onChange={value => setSignupForm(p => ({ ...p, phone: value }))} placeholder="+254 7XX XXX XXX" />
    diff --git a/src/pages/Payroll.tsx b/src/pages/Payroll.tsx index c175c04..d89d4d3 100644 --- a/src/pages/Payroll.tsx +++ b/src/pages/Payroll.tsx @@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/useAuth"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; @@ -168,7 +169,7 @@ export function Payroll() { required />
    -
    setEmpForm(p => ({ ...p, fullName: e.target.value }))} required />
    setEmpForm(p => ({ ...p, phone: e.target.value }))} required />
    +
    setEmpForm(p => ({ ...p, fullName: e.target.value }))} required />
    setEmpForm(p => ({ ...p, phone: value }))} />
    setEmpForm(p => ({ ...p, idNumber: e.target.value }))} />
    setEmpForm(p => ({ ...p, kraPin: e.target.value }))} />
    setEmpForm(p => ({ ...p, nssfNumber: e.target.value }))} />
    setEmpForm(p => ({ ...p, nhifNumber: e.target.value }))} />
    {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
    setEmpForm(p => ({ ...p, basicSalary: e.target.value }))} required />
    @@ -325,7 +326,7 @@ export function Payroll() { Edit Employee { e.preventDefault(); const userIdNum = editEmpForm.userId ? Number(editEmpForm.userId) : null; updateEmployee.mutate({ id: emp.id, ...editEmpForm, userId: userIdNum }); }} className="space-y-3"> -
    setEditEmpForm(p => ({ ...p, fullName: e.target.value }))} required />
    setEditEmpForm(p => ({ ...p, phone: e.target.value }))} required />
    +
    setEditEmpForm(p => ({ ...p, fullName: e.target.value }))} required />
    setEditEmpForm(p => ({ ...p, phone: value }))} />
    setEditEmpForm(p => ({ ...p, basicSalary: e.target.value }))} required />
    setEditEmpForm(p => ({ ...p, idNumber: e.target.value }))} />
    setEditEmpForm(p => ({ ...p, bankName: e.target.value }))} />
    setEditEmpForm(p => ({ ...p, bankAccount: e.target.value }))} />
    setEditEmpForm(p => ({ ...p, bankCode: e.target.value }))} />
    setEditEmpForm(p => ({ ...p, kraPin: e.target.value }))} />
    diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index ef18051..5254377 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -8,6 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { UserCircle, Mail, Phone, Key, Lock, Calendar, Clock, Shield, Save, ArrowLeft } from "lucide-react"; import { toast } from "sonner"; @@ -131,11 +132,11 @@ export function Profile() {
    - { setEditForm(p => ({ ...p, phone: e.target.value })); setFormDirty(true); }} + value={currentUser?.phone ?? ""} + onChange={(value) => { setEditForm(p => ({ ...p, phone: value })); setFormDirty(true); }} />
    diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ab01ba5..ca3dd83 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -15,6 +15,7 @@ import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Settings as SettingsIcon, Camera, Briefcase, Shield, Crown, Award, ArrowUpCircle, ArrowDownCircle, Users, MapPin, Gift, Clock, Key, Trash2, Plus, Copy, CheckCircle, Webhook, AlertCircle, Plug, MessageSquare, Eye, RefreshCw, DollarSign, Wallet, Smartphone, Activity, AlertCircle as AlertCircleIcon, CheckCircle2, ChevronRight, CalendarDays } from "lucide-react"; import { toast } from "sonner"; +import { CurrencyCombobox } from "@/components/ui/currency-combobox"; const PLAN_DETAILS: Record = { free: { label: "Free", price: "KES 0/mo", businesses: 1, branches: 1, users: 1, transactions: "100 / month", payroll: "No", support: "Community", color: "text-[#8D8A87]", features: ["1 business", "1 branch", "1 user", "Basic sales & expenses", "M-PESA import"] }, @@ -751,15 +752,21 @@ export function Settings() {
    - + setRateForm(f => ({ ...f, fromCurrency: code }))} + multiCurrency={true} + placeholder="Select currency..." + />
    - + setRateForm(f => ({ ...f, toCurrency: code }))} + multiCurrency={true} + placeholder="Select currency..." + />
    @@ -850,7 +857,26 @@ export function Settings() { {tab === "integrations" && ( <> - {/* API Keys */} + {/* API Documentation */} + + +
    +
    + +
    +
    +

    API Documentation

    +

    Interactive reference for the external REST API

    +
    +
    + + + +
    +
    + API Keys diff --git a/src/pages/Suppliers.tsx b/src/pages/Suppliers.tsx index 0c9607e..0a9fe16 100644 --- a/src/pages/Suppliers.tsx +++ b/src/pages/Suppliers.tsx @@ -7,6 +7,7 @@ import { useAuth } from "@/hooks/useAuth"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Plus, Users, Phone, Mail, CreditCard, TrendingDown, AlertTriangle, FileText, TrendingUp, Search, Trash2, Target, Package, CheckCircle, OctagonX } from "lucide-react"; @@ -221,7 +222,7 @@ export function Suppliers() {
    setForm((p) => ({ ...p, name: e.target.value }))} required />
    -
    setForm((p) => ({ ...p, phone: e.target.value }))} />
    +
    setForm((p) => ({ ...p, phone: value }))} />
    setForm((p) => ({ ...p, email: e.target.value }))} />
    diff --git a/src/pages/Users.tsx b/src/pages/Users.tsx index 82a6ad4..bac2a93 100644 --- a/src/pages/Users.tsx +++ b/src/pages/Users.tsx @@ -8,6 +8,7 @@ import { hasPermission, PERMISSIONS } from "@/lib/permissions"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { PhoneInput } from "@/components/ui/phone-input"; import { Label } from "@/components/ui/label"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; @@ -344,7 +345,7 @@ export function Users() {
    setForm(p => ({ ...p, name: e.target.value }))} placeholder="John Doe" required />
    setForm(p => ({ ...p, email: e.target.value }))} placeholder="john@example.com" />
    -
    setForm(p => ({ ...p, phone: e.target.value }))} placeholder="+254..." />
    +
    setForm(p => ({ ...p, phone: value }))} placeholder="+254..." />
    @@ -450,7 +451,7 @@ export function Users() {
    setEditForm(p => ({ ...p, name: e.target.value }))} required />
    setEditForm(p => ({ ...p, email: e.target.value }))} />
    -
    setEditForm(p => ({ ...p, phone: e.target.value }))} />
    +
    setEditForm(p => ({ ...p, phone: value }))} />
    diff --git a/src/pages/__tests__/Login.test.tsx b/src/pages/__tests__/Login.test.tsx index 52bbe4a..49a952f 100644 --- a/src/pages/__tests__/Login.test.tsx +++ b/src/pages/__tests__/Login.test.tsx @@ -8,6 +8,7 @@ vi.mock("@/providers/trpc", () => ({ trpc: { useUtils: () => ({ invalidate: vi.fn() }), localAuth: { + me: { useQuery: () => ({ data: undefined, isLoading: false }) }, checkAccountAvailability: { useMutation: () => ({ mutateAsync: vi.fn() }) }, lookupAccount: { useMutation: () => ({ mutate: vi.fn(), isPending: false }) }, login: { useMutation: () => ({ mutate: vi.fn(), isPending: false }) }, diff --git a/vite.config.ts b/vite.config.ts index 036cd91..f2fc806 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,7 @@ const releaseName = process.env.npm_package_version // https://vite.dev/config/ export default defineConfig({ plugins: [ - devServer({ entry: "api/boot.ts", exclude: [/^\/(?!(api\/|health)).*$/] }), + devServer({ entry: "api/boot.ts", exclude: [/^\/(?!(api\/|health|docs|openapi\.yaml$)).*$/] }), react(), ...(process.env.SENTRY_AUTH_TOKEN ? [sentryVitePlugin({ diff --git a/vitest.config.ts b/vitest.config.ts index 08091f1..495f00d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -20,14 +20,15 @@ export default defineConfig({ test: { globals: true, environment: "node", - // Integration tests share a single PostgreSQL database. Running them in - // parallel causes lock contention, timeouts, and flake. The single fork - // pool gives us one test worker that runs test files sequentially while - // still parallelising cases inside a file. + // Integration tests share a single PostgreSQL database. Running files in + // parallel causes lock contention, truncated seed data, and missing-table + // races (setup used to DROP/TRUNCATE shared tables per file). Vitest 4 no + // longer honors singleFork — force one worker and disable file parallelism. pool: "forks", - singleFork: true, + fileParallelism: false, + maxWorkers: 1, testTimeout: 30_000, - hookTimeout: 60_000, + hookTimeout: 120_000, include: [ "api/**/*.test.ts", "api/**/*.test.tsx",