feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System

Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).

## Backend

- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
  login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
  genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
  segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
  asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
  nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
  no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
  em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
  AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
  para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
  5 das ~19 tabelas), product_fiscal_profiles com invariante de único
  profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
  substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
  confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.

## Frontend — primeira leva

- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
  achado que mudou o escopo: o tema já vem em React+Tailwind (não só
  Angular), tokens visuais reais portados (cores, Geist como fonte real,
  divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
  via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".

## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)

1. **Segurança**: o token de desafio MFA era aceito como access token normal
   pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
   `purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
   contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
   o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
   Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
   variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
   worktrees de subagentes (665MB) para dentro do contexto Docker.

Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.

Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:25:31 -03:00
parent 18838a9031
commit eff515b70d
105 changed files with 8875 additions and 21 deletions

View File

@@ -10,6 +10,11 @@ export * as quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js";
export * as clientRegistrationsRepo from "./repositories/client-registrations.js";
export * as clientRegistrationPartnersRepo from "./repositories/client-registration-partners.js";
export * as inventoryRepo from "./repositories/inventory.js";
export * as supportTicketsRepo from "./repositories/support-tickets.js";
export * as hrTimeclockRepo from "./repositories/hr-timeclock.js";
export * as fiscalRepo from "./repositories/fiscal.js";
export * as billingRepo from "./repositories/billing.js";
export {
listLeads,
getLead,
@@ -74,3 +79,87 @@ export type {
CreatePartnerInput,
UpdatePartnerInput,
} from "./repositories/client-registration-partners.js";
export type {
Warehouse,
CreateWarehouseInput,
UpdateWarehouseInput,
StockMovement,
StockMovementType,
ListStockMovementsFilter,
CreateStockMovementInput,
SerializedAsset,
SerializedAssetStatus,
ListSerializedAssetsFilter,
CreateAssetInput,
ReserveAssetInput,
InstallAssetInput,
ReturnAssetInput,
AssetAssignment,
StockReservation,
StockReservationStatus,
ListStockReservationsFilter,
CreateGenericReservationInput,
} from "./repositories/inventory.js";
export type {
TicketStatus,
TicketPriority,
SupportTicket,
TicketComment,
TicketStatusHistoryEntry,
SlaTimer,
TicketWithDetails,
ListTicketsFilter,
CreateTicketInput,
CreateTicketCommentInput,
UpdateTicketStatusExtra,
} from "./repositories/support-tickets.js";
export type {
TimeclockDeviceStatus,
TimeclockDevice,
CreateTimeclockDeviceInput,
UpdateTimeclockDeviceInput,
TimeclockEmployee,
AfdImportBatchStatus,
AfdCrcValidity,
AfdValidationStatus,
AfdImportBatch,
CreateAfdImportBatchInput,
AfdRecord,
AfdRecordInsertInput,
ImportAfdBatchInput,
ImportAfdBatchResult,
ListAfdRecordsFilter,
} from "./repositories/hr-timeclock.js";
export type {
FiscalNcm,
CreateSimpleCatalogInput,
UpdateSimpleCatalogInput,
ListCatalogFilter,
FiscalCfop,
CreateCfopInput,
UpdateCfopInput,
FiscalMunicipality,
CreateMunicipalityInput,
UpdateMunicipalityInput,
FiscalOperationNature,
FiscalBillingComponent,
FiscalDocumentType,
ProductFiscalProfile,
CreateProductFiscalProfileInput,
} from "./repositories/fiscal.js";
export type {
BillingSubscriptionStatus,
BillingSubscription,
BillingRunStatus,
BillingRun,
InvoiceStatus,
Invoice,
InvoiceItemType,
InvoiceItem,
BillingRunWithInvoices,
CreateBillingSubscriptionInput,
ListBillingSubscriptionsFilter,
GenerateBillingRunInput,
ListBillingRunsFilter,
ListInvoicesFilter,
} from "./repositories/billing.js";

View File

@@ -0,0 +1,471 @@
import { query, withTransaction } from "../pool.js";
/**
* Billing bounded context (Master Prompt §6.10, ADR-0008) — recurring
* subscription -> billing_run -> invoice flow. See PENDING_billing.cjs's
* header comment for the full list of tables this MVP builds vs. defers
* (billing_accounts, billing_cycles, charge_components/usage_charges,
* invoice_adjustments, billing_run_logs, proration — all deferred).
*
* Mirrors contracts.ts's conventions: snake_case rows, withTransaction for
* multi-row writes that must commit/rollback together, typed domain
* errors mapped to HTTP status by the service layer (never here).
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BillingSubscriptionStatus = "active" | "suspended" | "cancelled";
export interface BillingSubscription {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
contract_id: string;
reseller_id: string | null;
monthly_value: string;
billing_day: number;
status: BillingSubscriptionStatus;
started_at: Date;
cancelled_at: Date | null;
}
export type BillingRunStatus = "draft" | "consolidated" | "failed";
export interface BillingRun {
id: string;
reference_year: number;
reference_month: number;
billing_day: number;
status: BillingRunStatus;
consolidated_at: Date | null;
triggered_by: string | null;
created_at: Date;
}
export type InvoiceStatus = "draft" | "consolidated" | "cancelled";
export interface Invoice {
id: string;
billing_run_id: string;
billing_subscription_id: string;
reseller_id: string | null;
amount: string;
reference_year: number;
reference_month: number;
status: InvoiceStatus;
due_date: string | null;
created_at: Date;
}
export type InvoiceItemType = "recurring" | "proration" | "implementation" | "adjustment";
export interface InvoiceItem {
id: string;
invoice_id: string;
description: string;
amount: string;
item_type: InvoiceItemType;
created_at: Date;
}
export interface BillingRunWithInvoices extends BillingRun {
invoices: Invoice[];
totalAmount: string;
}
// ---------------------------------------------------------------------------
// Errors — mapped to HTTP status by the service layer, never here
// ---------------------------------------------------------------------------
export class BillingSubscriptionNotFoundError extends Error {
constructor(public readonly subscriptionId: string) {
super(`Billing subscription ${subscriptionId} not found`);
this.name = "BillingSubscriptionNotFoundError";
}
}
export class BillingRunNotFoundError extends Error {
constructor(public readonly billingRunId: string) {
super(`Billing run ${billingRunId} not found`);
this.name = "BillingRunNotFoundError";
}
}
/**
* Thrown when an operation tries to write (regenerate invoices, alter
* items) against a billing_run that is already 'consolidated' — the
* central invariant of this module. The service layer maps this to 409.
*/
export class BillingRunConsolidatedError extends Error {
constructor(public readonly billingRunId: string) {
super(`Billing run ${billingRunId} is already consolidated and cannot be modified`);
this.name = "BillingRunConsolidatedError";
}
}
/**
* Thrown when creating/regenerating a run for a (year, month, billing_day)
* that already has a CONSOLIDATED run. Controlled re-billing (a new run
* deliberately superseding a consolidated one) is explicitly deferred
* (see migration header) — until it exists, this is a hard stop rather
* than a silent double-bill.
*/
export class BillingPeriodAlreadyConsolidatedError extends Error {
constructor(
public readonly referenceYear: number,
public readonly referenceMonth: number,
public readonly billingDay: number,
) {
super(
`Billing run for ${referenceYear}-${referenceMonth}/day ${billingDay} is already consolidated`,
);
this.name = "BillingPeriodAlreadyConsolidatedError";
}
}
// ---------------------------------------------------------------------------
// billing_subscriptions
// ---------------------------------------------------------------------------
export interface CreateBillingSubscriptionInput {
contractId: string;
resellerId: string | null;
monthlyValue: number;
billingDay: number;
createdBy: string | null;
}
export async function createBillingSubscription(
input: CreateBillingSubscriptionInput,
): Promise<BillingSubscription> {
const { rows } = await query<BillingSubscription>(
`INSERT INTO billing_subscriptions
(contract_id, reseller_id, monthly_value, billing_day, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $5)
RETURNING *`,
[input.contractId, input.resellerId, input.monthlyValue, input.billingDay, input.createdBy],
);
const row = rows[0];
if (!row) throw new Error("failed to create billing subscription");
return row;
}
export async function getBillingSubscriptionById(id: string): Promise<BillingSubscription | null> {
const { rows } = await query<BillingSubscription>(
"SELECT * FROM billing_subscriptions WHERE id = $1",
[id],
);
return rows[0] ?? null;
}
export interface ListBillingSubscriptionsFilter {
scope: "all" | "reseller";
/** Required (and enforced by the caller) when scope === 'reseller'. */
resellerId?: string | null;
status?: BillingSubscriptionStatus;
}
export async function listBillingSubscriptions(
filter: ListBillingSubscriptionsFilter,
): Promise<BillingSubscription[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.scope === "reseller") {
// Deliberately never falls back to unfiltered: an actor with
// reseller-only scope and no resellerId sees nothing, not everything.
params.push(filter.resellerId ?? null);
conditions.push(`reseller_id = $${params.length}`);
}
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<BillingSubscription>(
`SELECT * FROM billing_subscriptions ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
/**
* Cancels a subscription — idempotent: cancelling an already-cancelled
* subscription is a no-op that returns the existing row rather than
* erroring, mirroring the "safe to retry" philosophy applied to billing
* runs elsewhere in this module. cancelled_at is set only the first time.
*/
export async function cancelBillingSubscription(
id: string,
actorId: string | null,
): Promise<BillingSubscription> {
const { rows } = await query<BillingSubscription>(
`UPDATE billing_subscriptions SET
status = 'cancelled',
cancelled_at = COALESCE(cancelled_at, now()),
updated_by = $2
WHERE id = $1
RETURNING *`,
[id, actorId],
);
const row = rows[0];
if (!row) throw new BillingSubscriptionNotFoundError(id);
return row;
}
// ---------------------------------------------------------------------------
// billing_runs / invoices / invoice_items
// ---------------------------------------------------------------------------
function dueDateFor(referenceYear: number, referenceMonth: number, billingDay: number): string {
const mm = String(referenceMonth).padStart(2, "0");
const dd = String(billingDay).padStart(2, "0");
return `${referenceYear}-${mm}-${dd}`;
}
export interface GenerateBillingRunInput {
referenceYear: number;
referenceMonth: number;
billingDay: number;
triggeredBy: string | null;
}
/**
* Idempotent billing close. Behavior for (referenceYear, referenceMonth,
* billingDay):
* - if a CONSOLIDATED run already exists for this exact period ->
* throws BillingPeriodAlreadyConsolidatedError (controlled re-billing
* is deferred, see migration header — this is a hard stop, not a
* silent double-bill).
* - if a DRAFT run already exists -> reused in place: its invoices (and
* their items) are deleted and regenerated from the current set of
* active subscriptions, all inside one transaction. Running this
* twice with the same parameters therefore never duplicates invoices
* — the second run replaces the first draft's invoices 1:1.
* - otherwise -> a new 'draft' run is created.
*
* One invoice per active billing_subscription whose billing_day matches,
* each carrying a single 'recurring' invoice_item for the full
* monthly_value (no proration in this MVP — see migration header).
*/
export async function generateOrRegenerateBillingRun(
input: GenerateBillingRunInput,
): Promise<BillingRunWithInvoices> {
return withTransaction(async (client) => {
const { rows: existingRows } = await client.query<BillingRun>(
`SELECT * FROM billing_runs
WHERE reference_year = $1 AND reference_month = $2 AND billing_day = $3
AND status IN ('draft', 'consolidated')
FOR UPDATE`,
[input.referenceYear, input.referenceMonth, input.billingDay],
);
const consolidated = existingRows.find((r) => r.status === "consolidated");
if (consolidated) {
throw new BillingPeriodAlreadyConsolidatedError(
input.referenceYear,
input.referenceMonth,
input.billingDay,
);
}
const existingDraft = existingRows.find((r) => r.status === "draft");
let run: BillingRun;
if (existingDraft) {
// Regeneration: wipe this draft's invoices (items cascade) and
// refresh triggered_by to reflect who ran it last.
await client.query(
`DELETE FROM invoices WHERE billing_run_id = $1`,
[existingDraft.id],
);
const { rows: updatedRows } = await client.query<BillingRun>(
`UPDATE billing_runs SET triggered_by = $2 WHERE id = $1 RETURNING *`,
[existingDraft.id, input.triggeredBy],
);
run = updatedRows[0]!;
} else {
const { rows: createdRows } = await client.query<BillingRun>(
`INSERT INTO billing_runs (reference_year, reference_month, billing_day, triggered_by)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[input.referenceYear, input.referenceMonth, input.billingDay, input.triggeredBy],
);
run = createdRows[0]!;
}
const { rows: subscriptions } = await client.query<BillingSubscription>(
`SELECT * FROM billing_subscriptions WHERE status = 'active' AND billing_day = $1`,
[input.billingDay],
);
const dueDate = dueDateFor(input.referenceYear, input.referenceMonth, input.billingDay);
const invoices: Invoice[] = [];
for (const subscription of subscriptions) {
const { rows: invoiceRows } = await client.query<Invoice>(
`INSERT INTO invoices
(billing_run_id, billing_subscription_id, reseller_id, amount, reference_year, reference_month, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
run.id,
subscription.id,
subscription.reseller_id,
subscription.monthly_value,
input.referenceYear,
input.referenceMonth,
dueDate,
],
);
const invoice = invoiceRows[0]!;
await client.query(
`INSERT INTO invoice_items (invoice_id, description, amount, item_type)
VALUES ($1, $2, $3, 'recurring')`,
[invoice.id, "Mensalidade recorrente", subscription.monthly_value],
);
invoices.push(invoice);
}
return { ...run, invoices, totalAmount: sumAmounts(invoices) };
});
}
export interface ListBillingRunsFilter {
referenceYear?: number;
referenceMonth?: number;
status?: BillingRunStatus;
}
export async function listBillingRuns(filter: ListBillingRunsFilter = {}): Promise<BillingRun[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.referenceYear != null) {
params.push(filter.referenceYear);
conditions.push(`reference_year = $${params.length}`);
}
if (filter.referenceMonth != null) {
params.push(filter.referenceMonth);
conditions.push(`reference_month = $${params.length}`);
}
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<BillingRun>(
`SELECT * FROM billing_runs ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
function sumAmounts(invoices: Invoice[]): string {
const total = invoices.reduce((sum, inv) => sum + Number(inv.amount), 0);
return total.toFixed(2);
}
export async function getBillingRunById(id: string): Promise<BillingRunWithInvoices | null> {
const { rows } = await query<BillingRun>("SELECT * FROM billing_runs WHERE id = $1", [id]);
const run = rows[0];
if (!run) return null;
const { rows: invoices } = await query<Invoice>(
"SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC",
[id],
);
return { ...run, invoices, totalAmount: sumAmounts(invoices) };
}
export async function listInvoiceItems(invoiceId: string): Promise<InvoiceItem[]> {
const { rows } = await query<InvoiceItem>(
"SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY created_at ASC",
[invoiceId],
);
return rows;
}
/**
* Consolidates a billing_run: flips the run and every invoice it owns to
* 'consolidated' and stamps consolidated_at, all in one transaction.
* 409s (via BillingRunConsolidatedError) if already consolidated —
* consolidation is a one-way door in this MVP (no un-consolidate).
*/
export async function consolidateBillingRun(
id: string,
_actorId: string | null,
): Promise<BillingRunWithInvoices> {
return withTransaction(async (client) => {
const { rows } = await client.query<BillingRun>(
"SELECT * FROM billing_runs WHERE id = $1 FOR UPDATE",
[id],
);
const run = rows[0];
if (!run) throw new BillingRunNotFoundError(id);
if (run.status === "consolidated") throw new BillingRunConsolidatedError(id);
const { rows: updatedRows } = await client.query<BillingRun>(
`UPDATE billing_runs SET status = 'consolidated', consolidated_at = now() WHERE id = $1 RETURNING *`,
[id],
);
const updated = updatedRows[0]!;
await client.query(
`UPDATE invoices SET status = 'consolidated' WHERE billing_run_id = $1`,
[id],
);
const { rows: invoices } = await client.query<Invoice>(
"SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC",
[id],
);
return { ...updated, invoices, totalAmount: sumAmounts(invoices) };
});
}
export interface ListInvoicesFilter {
scope: "all" | "reseller";
/** Required (and enforced by the caller) when scope === 'reseller'. */
resellerId?: string | null;
status?: InvoiceStatus;
referenceYear?: number;
referenceMonth?: number;
}
export async function listInvoices(filter: ListInvoicesFilter): Promise<Invoice[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.scope === "reseller") {
params.push(filter.resellerId ?? null);
conditions.push(`reseller_id = $${params.length}`);
}
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
if (filter.referenceYear != null) {
params.push(filter.referenceYear);
conditions.push(`reference_year = $${params.length}`);
}
if (filter.referenceMonth != null) {
params.push(filter.referenceMonth);
conditions.push(`reference_month = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<Invoice>(
`SELECT * FROM invoices ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}

View File

@@ -0,0 +1,484 @@
import { query, withTransaction } from "../pool.js";
/**
* Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5).
* See PENDING_fiscal.cjs migration header for full scope/deferral notes.
* This MVP covers 3 "catalog" tables (fiscal_ncm, fiscal_cfop,
* fiscal_municipality), the read-only fiscal_operation_nature seed, and
* product_fiscal_profiles — the piece that actually links a commercial
* product to its fiscal classification.
*/
// ===========================================================================
// fiscal_ncm — "simple catalog" shape (eden.md §2.2)
// ===========================================================================
export interface FiscalNcm {
id: string;
code: string;
description: string;
valid_from: string | null;
valid_until: string | null;
active: boolean;
source: string | null;
source_version: string | null;
source_updated_at: Date | null;
metadata: Record<string, unknown> | null;
created_at: Date;
updated_at: Date;
}
export interface CreateSimpleCatalogInput {
code: string;
description: string;
validFrom?: string | null;
validUntil?: string | null;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
sourceUpdatedAt?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface UpdateSimpleCatalogInput {
code?: string;
description?: string;
validFrom?: string | null;
validUntil?: string | null;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
sourceUpdatedAt?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface ListCatalogFilter {
q?: string;
active?: boolean;
}
export async function listNcm(filter: ListCatalogFilter = {}): Promise<FiscalNcm[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.q) {
params.push(`%${filter.q}%`);
conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`);
}
if (filter.active !== undefined) {
params.push(filter.active);
conditions.push(`active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<FiscalNcm>(
`SELECT * FROM fiscal_ncm ${where} ORDER BY code ASC NULLS LAST`,
params,
);
return rows;
}
export async function getNcmById(id: string): Promise<FiscalNcm | null> {
const { rows } = await query<FiscalNcm>("SELECT * FROM fiscal_ncm WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createNcm(input: CreateSimpleCatalogInput): Promise<FiscalNcm> {
const { rows } = await query<FiscalNcm>(
`INSERT INTO fiscal_ncm (code, description, valid_from, valid_until, active, source, source_version, source_updated_at, metadata)
VALUES ($1,$2,$3,$4,COALESCE($5,true),$6,$7,$8,$9)
RETURNING *`,
[
input.code,
input.description,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create fiscal_ncm row");
return row;
}
export async function updateNcm(id: string, input: UpdateSimpleCatalogInput): Promise<FiscalNcm | null> {
const { rows } = await query<FiscalNcm>(
`UPDATE fiscal_ncm SET
code = COALESCE($2, code),
description = COALESCE($3, description),
valid_from = COALESCE($4, valid_from),
valid_until = COALESCE($5, valid_until),
active = COALESCE($6, active),
source = COALESCE($7, source),
source_version = COALESCE($8, source_version),
source_updated_at = COALESCE($9, source_updated_at),
metadata = COALESCE($10, metadata)
WHERE id = $1
RETURNING *`,
[
id,
input.code ?? null,
input.description ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
return rows[0] ?? null;
}
// ===========================================================================
// fiscal_cfop — "simple catalog" shape + direction/scope (eden.md §2.3/§4)
// ===========================================================================
export interface FiscalCfop {
id: string;
code: string;
description: string;
direction: string | null;
scope: string | null;
valid_from: string | null;
valid_until: string | null;
active: boolean;
source: string | null;
source_version: string | null;
source_updated_at: Date | null;
metadata: Record<string, unknown> | null;
created_at: Date;
updated_at: Date;
}
export interface CreateCfopInput extends CreateSimpleCatalogInput {
direction?: string | null;
scope?: string | null;
}
export interface UpdateCfopInput extends UpdateSimpleCatalogInput {
direction?: string | null;
scope?: string | null;
}
export async function listCfop(filter: ListCatalogFilter = {}): Promise<FiscalCfop[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.q) {
params.push(`%${filter.q}%`);
conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`);
}
if (filter.active !== undefined) {
params.push(filter.active);
conditions.push(`active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<FiscalCfop>(
`SELECT * FROM fiscal_cfop ${where} ORDER BY code ASC NULLS LAST`,
params,
);
return rows;
}
export async function getCfopById(id: string): Promise<FiscalCfop | null> {
const { rows } = await query<FiscalCfop>("SELECT * FROM fiscal_cfop WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createCfop(input: CreateCfopInput): Promise<FiscalCfop> {
const { rows } = await query<FiscalCfop>(
`INSERT INTO fiscal_cfop (code, description, direction, scope, valid_from, valid_until, active, source, source_version, source_updated_at, metadata)
VALUES ($1,$2,$3,$4,$5,$6,COALESCE($7,true),$8,$9,$10,$11)
RETURNING *`,
[
input.code,
input.description,
input.direction ?? null,
input.scope ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create fiscal_cfop row");
return row;
}
export async function updateCfop(id: string, input: UpdateCfopInput): Promise<FiscalCfop | null> {
const { rows } = await query<FiscalCfop>(
`UPDATE fiscal_cfop SET
code = COALESCE($2, code),
description = COALESCE($3, description),
direction = COALESCE($4, direction),
scope = COALESCE($5, scope),
valid_from = COALESCE($6, valid_from),
valid_until = COALESCE($7, valid_until),
active = COALESCE($8, active),
source = COALESCE($9, source),
source_version = COALESCE($10, source_version),
source_updated_at = COALESCE($11, source_updated_at),
metadata = COALESCE($12, metadata)
WHERE id = $1
RETURNING *`,
[
id,
input.code ?? null,
input.description ?? null,
input.direction ?? null,
input.scope ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
return rows[0] ?? null;
}
// ===========================================================================
// fiscal_municipality — distinct shape, no valid_from/valid_until/metadata
// (eden.md §2.3)
// ===========================================================================
export interface FiscalMunicipality {
id: string;
code: string;
name: string;
state: string;
active: boolean;
source: string | null;
source_version: string | null;
created_at: Date;
updated_at: Date;
}
export interface CreateMunicipalityInput {
code: string;
name: string;
state: string;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
}
export interface UpdateMunicipalityInput {
code?: string;
name?: string;
state?: string;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
}
export async function listMunicipalities(filter: ListCatalogFilter = {}): Promise<FiscalMunicipality[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.q) {
params.push(`%${filter.q}%`);
conditions.push(`(code ILIKE $${params.length} OR name ILIKE $${params.length})`);
}
if (filter.active !== undefined) {
params.push(filter.active);
conditions.push(`active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<FiscalMunicipality>(
`SELECT * FROM fiscal_municipality ${where} ORDER BY code ASC NULLS LAST`,
params,
);
return rows;
}
export async function getMunicipalityById(id: string): Promise<FiscalMunicipality | null> {
const { rows } = await query<FiscalMunicipality>("SELECT * FROM fiscal_municipality WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createMunicipality(input: CreateMunicipalityInput): Promise<FiscalMunicipality> {
const { rows } = await query<FiscalMunicipality>(
`INSERT INTO fiscal_municipality (code, name, state, active, source, source_version)
VALUES ($1,$2,$3,COALESCE($4,true),$5,$6)
RETURNING *`,
[
input.code,
input.name,
input.state,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create fiscal_municipality row");
return row;
}
export async function updateMunicipality(
id: string,
input: UpdateMunicipalityInput,
): Promise<FiscalMunicipality | null> {
const { rows } = await query<FiscalMunicipality>(
`UPDATE fiscal_municipality SET
code = COALESCE($2, code),
name = COALESCE($3, name),
state = COALESCE($4, state),
active = COALESCE($5, active),
source = COALESCE($6, source),
source_version = COALESCE($7, source_version)
WHERE id = $1
RETURNING *`,
[
id,
input.code ?? null,
input.name ?? null,
input.state ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
],
);
return rows[0] ?? null;
}
// ===========================================================================
// fiscal_operation_nature — internal enum-like catalog, read-only via API
// (eden.md §2.3) — seeded by the migration, no create/update in this MVP.
// ===========================================================================
export interface FiscalOperationNature {
id: string;
code: string;
name: string;
description: string | null;
active: boolean;
created_at: Date;
updated_at: Date;
}
export async function listOperationNatures(activeOnly = false): Promise<FiscalOperationNature[]> {
const { rows } = await query<FiscalOperationNature>(
activeOnly
? "SELECT * FROM fiscal_operation_nature WHERE active = true ORDER BY code ASC"
: "SELECT * FROM fiscal_operation_nature ORDER BY code ASC",
);
return rows;
}
// ===========================================================================
// product_fiscal_profiles — links products to fiscal classification
// (eden.md §2.5). Reduced to the 4 catalogs created in this MVP.
// ===========================================================================
export type FiscalBillingComponent = "RECURRING" | "IMPLEMENTATION";
export type FiscalDocumentType = "NFCOM" | "NFSE" | "NFE" | "NONE";
export interface ProductFiscalProfile {
id: string;
product_id: string;
billing_component: FiscalBillingComponent;
document_type: FiscalDocumentType;
ncm_id: string | null;
cfop_id: string | null;
municipality_id: string | null;
operation_nature_id: string | null;
valid_from: string | null;
valid_until: string | null;
active: boolean;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
}
export async function listProductFiscalProfiles(productId: string): Promise<ProductFiscalProfile[]> {
const { rows } = await query<ProductFiscalProfile>(
`SELECT * FROM product_fiscal_profiles
WHERE product_id = $1
ORDER BY billing_component ASC, created_at ASC`,
[productId],
);
return rows;
}
export async function getActiveProductFiscalProfile(
productId: string,
billingComponent: FiscalBillingComponent,
): Promise<ProductFiscalProfile | null> {
const { rows } = await query<ProductFiscalProfile>(
`SELECT * FROM product_fiscal_profiles
WHERE product_id = $1 AND billing_component = $2 AND active = true`,
[productId, billingComponent],
);
return rows[0] ?? null;
}
export interface CreateProductFiscalProfileInput {
productId: string;
billingComponent: FiscalBillingComponent;
documentType?: FiscalDocumentType;
ncmId?: string | null;
cfopId?: string | null;
municipalityId?: string | null;
operationNatureId?: string | null;
validFrom?: string | null;
validUntil?: string | null;
createdBy?: string | null;
}
/**
* Creates a new ACTIVE profile for (productId, billingComponent), and — in
* the same transaction — inactivates whichever profile was previously
* active for that same pair (never a destructive UPDATE/DELETE of the old
* one, per eden.md §2.5's central invariant: at most one active profile per
* product per billing_component, enforced again at the DB level by
* idx_product_fiscal_profiles_active_component). If two concurrent callers
* raced here without the transaction ordering below, the partial unique
* index is the actual backstop — it would reject the second INSERT outright.
*/
export async function createActiveProductFiscalProfile(
input: CreateProductFiscalProfileInput,
): Promise<ProductFiscalProfile> {
return withTransaction(async (client) => {
await client.query(
`UPDATE product_fiscal_profiles
SET active = false, updated_by = $3
WHERE product_id = $1 AND billing_component = $2 AND active = true`,
[input.productId, input.billingComponent, input.createdBy ?? null],
);
const { rows } = await client.query<ProductFiscalProfile>(
`INSERT INTO product_fiscal_profiles (
product_id, billing_component, document_type, ncm_id, cfop_id,
municipality_id, operation_nature_id, valid_from, valid_until,
created_by, updated_by
) VALUES ($1,$2,COALESCE($3,'NONE'),$4,$5,$6,$7,$8,$9,$10,$10)
RETURNING *`,
[
input.productId,
input.billingComponent,
input.documentType ?? null,
input.ncmId ?? null,
input.cfopId ?? null,
input.municipalityId ?? null,
input.operationNatureId ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.createdBy ?? null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create product_fiscal_profiles row");
return row;
});
}

View File

@@ -0,0 +1,468 @@
import { query, withTransaction } from "../pool.js";
import type { PoolClient } from "pg";
/**
* HR/Timeclock (eden.md módulo 6) — foundation leva. See
* packages/database/migrations/PENDING_hr-timeclock.cjs for the full data
* model rationale (employees-vs-users decision, deferred tables, naming
* deviations from eden.md). This module deliberately does NOT expose any
* UPDATE/DELETE for afd_records — see importAfdBatch below and the
* afd-records.controller.ts (read-only) that consumes listAfdRecords.
*/
// ─────────────────────────────────────────────────────────────────────────
// timeclock_devices
// ─────────────────────────────────────────────────────────────────────────
export type TimeclockDeviceStatus = "UNKNOWN" | "ONLINE" | "OFFLINE" | "ERROR";
export interface TimeclockDevice {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
company_id: string | null;
name: string;
description: string | null;
branch: string | null;
host: string;
port: number;
use_https: boolean;
username: string;
password_encrypted: string;
serial_number: string | null;
model: string | null;
firmware_version: string | null;
timezone: string;
status: TimeclockDeviceStatus;
last_seen_at: Date | null;
last_sync_at: Date | null;
last_error: string | null;
last_afd_nsr: string | null; // BIGINT comes back as string from `pg`
active: boolean;
}
export interface CreateTimeclockDeviceInput {
companyId?: string | null;
name: string;
description?: string | null;
branch?: string | null;
host: string;
port?: number;
useHttps?: boolean;
username: string;
/** Already encrypted (v1:iv:tag:ciphertext) by the caller via FieldEncryptionService — this repo never sees plaintext. */
passwordEncrypted: string;
timezone?: string;
createdBy?: string | null;
}
export async function listTimeclockDevices(includeInactive = false): Promise<TimeclockDevice[]> {
const { rows } = await query<TimeclockDevice>(
includeInactive
? "SELECT * FROM timeclock_devices ORDER BY created_at DESC"
: "SELECT * FROM timeclock_devices WHERE active = true ORDER BY created_at DESC",
);
return rows;
}
export async function getTimeclockDeviceById(id: string): Promise<TimeclockDevice | null> {
const { rows } = await query<TimeclockDevice>("SELECT * FROM timeclock_devices WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createTimeclockDevice(input: CreateTimeclockDeviceInput): Promise<TimeclockDevice> {
const { rows } = await query<TimeclockDevice>(
`INSERT INTO timeclock_devices (
company_id, name, description, branch, host, port, use_https, username,
password_encrypted, timezone, created_by
) VALUES ($1,$2,$3,$4,$5,COALESCE($6,443),COALESCE($7,true),$8,$9,COALESCE($10,'America/Sao_Paulo'),$11)
RETURNING *`,
[
input.companyId ?? null,
input.name,
input.description ?? null,
input.branch ?? null,
input.host,
input.port ?? null,
input.useHttps ?? null,
input.username,
input.passwordEncrypted,
input.timezone ?? null,
input.createdBy ?? null,
],
);
const device = rows[0];
if (!device) throw new Error("failed to create timeclock device");
return device;
}
export interface UpdateTimeclockDeviceInput {
name?: string;
description?: string | null;
branch?: string | null;
host?: string;
port?: number;
useHttps?: boolean;
username?: string;
/** Only re-encrypted/set when the caller actually supplied a new password — omit to leave unchanged. */
passwordEncrypted?: string;
active?: boolean;
updatedBy?: string | null;
}
export async function updateTimeclockDevice(
id: string,
input: UpdateTimeclockDeviceInput,
): Promise<TimeclockDevice | null> {
const { rows } = await query<TimeclockDevice>(
`UPDATE timeclock_devices SET
name = COALESCE($2, name),
description = COALESCE($3, description),
branch = COALESCE($4, branch),
host = COALESCE($5, host),
port = COALESCE($6, port),
use_https = COALESCE($7, use_https),
username = COALESCE($8, username),
password_encrypted = COALESCE($9, password_encrypted),
active = COALESCE($10, active),
updated_by = COALESCE($11, updated_by)
WHERE id = $1
RETURNING *`,
[
id,
input.name ?? null,
input.description ?? null,
input.branch ?? null,
input.host ?? null,
input.port ?? null,
input.useHttps ?? null,
input.username ?? null,
input.passwordEncrypted ?? null,
input.active ?? null,
input.updatedBy ?? null,
],
);
return rows[0] ?? null;
}
// ─────────────────────────────────────────────────────────────────────────
// timeclock_employees — no CRUD in this leva (see migration header); only
// the CPF-resolution lookup that AFD import needs is implemented here.
// ─────────────────────────────────────────────────────────────────────────
export interface TimeclockEmployee {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
company_id: string | null;
user_id: string | null;
full_name: string;
cpf: string;
registration: string | null;
active: boolean;
}
/**
* Looks up employees by normalized CPF (11 digits) for AFD import
* resolution — never invents a match. Returns every row found; the
* caller (afd-imports.service.ts) is responsible for the "same CPF
* matched in more than one company and the device has no company" case,
* where it must leave employee_id unresolved rather than guess.
*/
export async function findTimeclockEmployeesByCpfs(cpfs: string[]): Promise<TimeclockEmployee[]> {
if (cpfs.length === 0) return [];
const { rows } = await query<TimeclockEmployee>(
"SELECT * FROM timeclock_employees WHERE cpf = ANY($1)",
[cpfs],
);
return rows;
}
// ─────────────────────────────────────────────────────────────────────────
// afd_import_batches / afd_records
// ─────────────────────────────────────────────────────────────────────────
export type AfdImportBatchStatus = "VALID" | "WARNING" | "INVALID";
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
export interface AfdImportBatch {
id: string;
device_id: string;
company_id: string | null;
source: "manual_upload";
file_hash: string;
file_size: number;
record_count: number;
inserted_count: number;
valid_count: number;
warning_count: number;
invalid_count: number;
unsupported_count: number;
parse_failure_count: number;
status: AfdImportBatchStatus;
first_nsr: string | null;
last_nsr: string | null;
summary: Record<string, unknown>;
imported_by: string | null;
imported_at: Date;
}
export interface CreateAfdImportBatchInput {
deviceId: string;
companyId: string | null;
fileHash: string;
fileSize: number;
recordCount: number;
validCount: number;
warningCount: number;
invalidCount: number;
unsupportedCount: number;
parseFailureCount: number;
status: AfdImportBatchStatus;
firstNsr: number | null;
lastNsr: number | null;
summary: Record<string, unknown>;
importedBy: string | null;
}
export interface AfdRecord {
id: string;
created_at: Date;
afd_import_batch_id: string;
device_id: string;
company_id: string | null;
nsr: string | null; // BIGINT -> string
record_type: string;
event_datetime: Date | null;
event_datetime_raw: string | null;
employee_cpf_raw: string | null;
employee_id: string | null;
raw_line: string;
crc_received: string | null;
crc_calculated: string | null;
crc_valid: AfdCrcValidity;
parsed_data: Record<string, unknown>;
validation_status: AfdValidationStatus;
validation_notes: string | null;
}
export interface AfdRecordInsertInput {
nsr: number | null;
recordType: string;
eventDatetime: Date | null;
eventDatetimeRaw: string | null;
employeeCpfRaw: string | null;
employeeId: string | null;
rawLine: string;
crcReceived: string | null;
crcCalculated: string | null;
crcValid: AfdCrcValidity;
parsedData: Record<string, unknown>;
validationStatus: AfdValidationStatus;
validationNotes: string | null;
}
export class TimeclockDeviceNotFoundError extends Error {
constructor(public readonly deviceId: string) {
super(`Timeclock device ${deviceId} not found`);
this.name = "TimeclockDeviceNotFoundError";
}
}
export async function findAfdImportBatchByFileHash(fileHash: string): Promise<AfdImportBatch | null> {
const { rows } = await query<AfdImportBatch>("SELECT * FROM afd_import_batches WHERE file_hash = $1", [fileHash]);
return rows[0] ?? null;
}
export async function getAfdImportBatchById(id: string): Promise<AfdImportBatch | null> {
const { rows } = await query<AfdImportBatch>("SELECT * FROM afd_import_batches WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface ImportAfdBatchInput {
batch: CreateAfdImportBatchInput;
records: AfdRecordInsertInput[];
}
export interface ImportAfdBatchResult {
batch: AfdImportBatch;
insertedCount: number;
}
/**
* The whole AFD import write path, in one transaction:
*
* 1. `SELECT ... FOR UPDATE` locks the device row — this is what
* guarantees "never two concurrent imports for the same device"
* (eden.md módulo 6 §12's sequential-execution invariant for anything
* device-adjacent): a second concurrent import for the same device_id
* blocks here until the first one commits or rolls back.
* 2. Inserts the afd_import_batches row (needs to exist first — afd_records
* has a NOT NULL FK to it).
* 3. Inserts each afd_records row one at a time (matches this codebase's
* established multi-row-child pattern, see contracts.ts's item/party
* loops) with `ON CONFLICT (device_id, nsr) DO NOTHING` — the
* per-record idempotency layer (eden.md §12): a record whose
* (device_id, nsr) already exists from an earlier, different import is
* silently skipped, never duplicated, and never overwritten (afd_records
* has no UPDATE route at all — see the migration's trigger).
* 4. Updates the batch's `inserted_count` with how many rows actually
* landed (can be less than `records.length` because of step 3's
* ON CONFLICT skips).
*
* Whole-file idempotency (SHA-256 of the received content) is checked by
* the caller (afd-imports.service.ts) BEFORE calling this, via
* findAfdImportBatchByFileHash — this function does not re-check it,
* though the UNIQUE constraint on afd_import_batches.file_hash is a
* database-level backstop against a race between the two.
*/
export async function importAfdBatch(input: ImportAfdBatchInput): Promise<ImportAfdBatchResult> {
return withTransaction(async (client: PoolClient) => {
const { rows: deviceRows } = await client.query<{ id: string }>(
"SELECT id FROM timeclock_devices WHERE id = $1 FOR UPDATE",
[input.batch.deviceId],
);
if (!deviceRows[0]) {
throw new TimeclockDeviceNotFoundError(input.batch.deviceId);
}
const { rows: batchRows } = await client.query<AfdImportBatch>(
`INSERT INTO afd_import_batches (
device_id, company_id, file_hash, file_size, record_count, inserted_count,
valid_count, warning_count, invalid_count, unsupported_count, parse_failure_count,
status, first_nsr, last_nsr, summary, imported_by
) VALUES ($1,$2,$3,$4,$5,0,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING *`,
[
input.batch.deviceId,
input.batch.companyId,
input.batch.fileHash,
input.batch.fileSize,
input.batch.recordCount,
input.batch.validCount,
input.batch.warningCount,
input.batch.invalidCount,
input.batch.unsupportedCount,
input.batch.parseFailureCount,
input.batch.status,
input.batch.firstNsr,
input.batch.lastNsr,
JSON.stringify(input.batch.summary),
input.batch.importedBy,
],
);
const batch = batchRows[0];
if (!batch) throw new Error("failed to create afd_import_batch");
let insertedCount = 0;
for (const record of input.records) {
const { rowCount } = await client.query(
`INSERT INTO afd_records (
afd_import_batch_id, device_id, company_id, nsr, record_type,
event_datetime, event_datetime_raw, employee_cpf_raw, employee_id, raw_line,
crc_received, crc_calculated, crc_valid, parsed_data, validation_status, validation_notes
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (device_id, nsr) DO NOTHING`,
[
batch.id,
input.batch.deviceId,
input.batch.companyId,
record.nsr,
record.recordType,
record.eventDatetime,
record.eventDatetimeRaw,
record.employeeCpfRaw,
record.employeeId,
record.rawLine,
record.crcReceived,
record.crcCalculated,
record.crcValid,
JSON.stringify(record.parsedData),
record.validationStatus,
record.validationNotes,
],
);
insertedCount += rowCount ?? 0;
}
const { rows: updatedBatchRows } = await client.query<AfdImportBatch>(
"UPDATE afd_import_batches SET inserted_count = $2 WHERE id = $1 RETURNING *",
[batch.id, insertedCount],
);
const updatedBatch = updatedBatchRows[0] ?? batch;
return { batch: updatedBatch, insertedCount };
});
}
export interface ListAfdRecordsFilter {
deviceId?: string;
employeeId?: string;
recordType?: string;
/** Inclusive lower bound on event_datetime. */
dateFrom?: Date;
/** Exclusive upper bound on event_datetime (caller passes "day after dateTo" to include the whole end day). */
dateTo?: Date;
limit?: number;
offset?: number;
}
const DEFAULT_PAGE_SIZE = 50;
/**
* Read-only, paginated. There is deliberately no equivalent write
* function for arbitrary fields — afd_records rows are only ever created
* via importAfdBatch's INSERT, never updated.
*/
export async function listAfdRecords(filter: ListAfdRecordsFilter): Promise<{ records: AfdRecord[]; total: number }> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.deviceId) {
params.push(filter.deviceId);
conditions.push(`device_id = $${params.length}`);
}
if (filter.employeeId) {
params.push(filter.employeeId);
conditions.push(`employee_id = $${params.length}`);
}
if (filter.recordType) {
params.push(filter.recordType);
conditions.push(`record_type = $${params.length}`);
}
if (filter.dateFrom) {
params.push(filter.dateFrom);
conditions.push(`event_datetime >= $${params.length}`);
}
if (filter.dateTo) {
params.push(filter.dateTo);
conditions.push(`event_datetime < $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limit = filter.limit ?? DEFAULT_PAGE_SIZE;
const offset = filter.offset ?? 0;
const { rows: countRows } = await query<{ count: string }>(
`SELECT count(*) FROM afd_records ${where}`,
params,
);
const total = Number(countRows[0]?.count ?? 0);
const { rows } = await query<AfdRecord>(
`SELECT * FROM afd_records ${where} ORDER BY event_datetime ASC NULLS LAST, created_at ASC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset],
);
return { records: rows, total };
}
export async function getAfdRecordById(id: string): Promise<AfdRecord | null> {
const { rows } = await query<AfdRecord>("SELECT * FROM afd_records WHERE id = $1", [id]);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,747 @@
import { query, withTransaction } from "../pool.js";
import type { PoolClient } from "pg";
/**
* Inventory/Warehouse/Assets bounded context — Master Prompt §6.8,
* ADR-0007. See the migration's header comment (PENDING_inventory.cjs)
* for the full list of possible entities and which 5 this MVP builds vs.
* explicitly deferred ones.
*
* Mirrors the conventions of companies.ts/products.ts (COALESCE-based
* partial writes, snake_case rows) and contracts.ts (withTransaction for
* multi-row writes that must commit/rollback together, custom error
* classes mapped to 404/409 by the service layer).
*
* NOT registered in the barrel (packages/database/src/index.ts) yet —
* that's deliberate integration work left to the merge step (see
* .claude/agents/eden-inventory.md task boundaries).
*/
// ===========================================================================
// warehouses
// ===========================================================================
export interface Warehouse {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
company_id: string;
name: string;
code: string;
is_active: boolean;
}
export async function listWarehouses(includeInactive = false): Promise<Warehouse[]> {
const { rows } = await query<Warehouse>(
includeInactive
? "SELECT * FROM warehouses ORDER BY created_at ASC"
: "SELECT * FROM warehouses WHERE is_active = true ORDER BY created_at ASC",
);
return rows;
}
export async function getWarehouseById(id: string): Promise<Warehouse | null> {
const { rows } = await query<Warehouse>("SELECT * FROM warehouses WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreateWarehouseInput {
companyId: string;
name: string;
code: string;
isActive?: boolean;
createdBy?: string | null;
}
export async function createWarehouse(input: CreateWarehouseInput): Promise<Warehouse> {
const { rows } = await query<Warehouse>(
`INSERT INTO warehouses (company_id, name, code, is_active, created_by)
VALUES ($1,$2,$3,COALESCE($4,true),$5)
RETURNING *`,
[input.companyId, input.name, input.code, input.isActive ?? null, input.createdBy ?? null],
);
const warehouse = rows[0];
if (!warehouse) throw new Error("failed to create warehouse");
return warehouse;
}
export interface UpdateWarehouseInput {
name?: string;
code?: string;
isActive?: boolean;
}
export async function updateWarehouse(id: string, input: UpdateWarehouseInput): Promise<Warehouse | null> {
const { rows } = await query<Warehouse>(
`UPDATE warehouses SET
name = COALESCE($2, name),
code = COALESCE($3, code),
is_active = COALESCE($4, is_active)
WHERE id = $1
RETURNING *`,
[id, input.name ?? null, input.code ?? null, input.isActive ?? null],
);
return rows[0] ?? null;
}
// ===========================================================================
// stock_movements — append-only ledger
// ===========================================================================
export type StockMovementType =
| "purchase_in"
| "adjustment_in"
| "adjustment_out"
| "transfer_out"
| "transfer_in"
| "reservation"
| "reservation_release"
| "sale_out"
| "install_out"
| "comodato_out"
| "return_in"
| "rma_out"
| "write_off";
export interface StockMovement {
id: string;
created_at: Date;
created_by: string | null;
warehouse_id: string;
product_id: string;
movement_type: StockMovementType;
quantity: string;
reference_type: string | null;
reference_id: string | null;
notes: string | null;
}
export interface ListStockMovementsFilter {
warehouseId?: string;
productId?: string;
referenceType?: string;
referenceId?: string;
}
export async function listStockMovements(filter: ListStockMovementsFilter = {}): Promise<StockMovement[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.warehouseId) {
params.push(filter.warehouseId);
conditions.push(`warehouse_id = $${params.length}`);
}
if (filter.productId) {
params.push(filter.productId);
conditions.push(`product_id = $${params.length}`);
}
if (filter.referenceType) {
params.push(filter.referenceType);
conditions.push(`reference_type = $${params.length}`);
}
if (filter.referenceId) {
params.push(filter.referenceId);
conditions.push(`reference_id = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<StockMovement>(
`SELECT * FROM stock_movements ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export interface CreateStockMovementInput {
warehouseId: string;
productId: string;
movementType: StockMovementType;
quantity: number;
referenceType?: string | null;
referenceId?: string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Appends one movement. No update/edit function exists on purpose — a
* correction is always a new call to this function with the reversing
* type (also enforced at the DB level by trg_stock_movements_no_update/
* no_delete). Accepts an optional `client` so callers that need this
* insert inside a larger transaction (asset entry/install/return) can
* pass the transaction's client instead of grabbing a new pool
* connection — used by future endpoints (adjustment/transfer/write_off)
* that aren't wired up yet in this MVP; the asset lifecycle functions
* below (createAssetWithPurchaseMovement, reserveAsset, installAsset,
* returnAsset) inline their own movement INSERTs instead of calling this,
* since they already hold the transaction's client for the surrounding
* asset row lock.
*/
export async function recordStockMovement(
input: CreateStockMovementInput,
client?: PoolClient,
): Promise<StockMovement> {
const sql = `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`;
const params = [
input.warehouseId,
input.productId,
input.movementType,
input.quantity,
input.referenceType ?? null,
input.referenceId ?? null,
input.notes ?? null,
input.createdBy ?? null,
];
const { rows } = client
? await client.query<StockMovement>(sql, params)
: await query<StockMovement>(sql, params);
const row = rows[0];
if (!row) throw new Error("failed to record stock movement");
return row;
}
// ===========================================================================
// serialized_assets
// ===========================================================================
export type SerializedAssetStatus =
| "in_stock"
| "reserved"
| "installed"
| "comodato"
| "returned"
| "written_off";
export interface SerializedAsset {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
product_id: string;
serial: string | null;
patrimony_tag: string | null;
mac_address: string | null;
warehouse_id: string | null;
status: SerializedAssetStatus;
current_contract_id: string | null;
warranty_expires_at: Date | null;
}
export interface ListSerializedAssetsFilter {
status?: SerializedAssetStatus;
warehouseId?: string;
productId?: string;
}
export async function listSerializedAssets(filter: ListSerializedAssetsFilter = {}): Promise<SerializedAsset[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
if (filter.warehouseId) {
params.push(filter.warehouseId);
conditions.push(`warehouse_id = $${params.length}`);
}
if (filter.productId) {
params.push(filter.productId);
conditions.push(`product_id = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<SerializedAsset>(
`SELECT * FROM serialized_assets ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export async function getSerializedAssetById(id: string): Promise<SerializedAsset | null> {
const { rows } = await query<SerializedAsset>("SELECT * FROM serialized_assets WHERE id = $1", [id]);
return rows[0] ?? null;
}
async function getSerializedAssetByIdForUpdate(client: PoolClient, id: string): Promise<SerializedAsset | null> {
const { rows } = await client.query<SerializedAsset>(
"SELECT * FROM serialized_assets WHERE id = $1 FOR UPDATE",
[id],
);
return rows[0] ?? null;
}
export class AssetNotFoundError extends Error {
constructor(public readonly assetId: string) {
super(`Serialized asset ${assetId} not found`);
this.name = "AssetNotFoundError";
}
}
export class InvalidAssetTransitionError extends Error {
constructor(
public readonly fromStatus: SerializedAssetStatus,
public readonly action: string,
) {
super(`Asset in status '${fromStatus}' cannot be ${action}`);
this.name = "InvalidAssetTransitionError";
}
}
export interface CreateAssetInput {
productId: string;
warehouseId: string;
serial?: string | null;
patrimonyTag?: string | null;
macAddress?: string | null;
warrantyExpiresAt?: Date | string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Entrada de um ativo novo — cria a linha em serialized_assets (status
* in_stock) e o stock_movement purchase_in correspondente numa única
* transação. Se o serial/patrimônio/MAC já estiver "ativo" em outro
* ativo, a constraint de banco (índice único parcial) rejeita o INSERT —
* o erro do Postgres (23505) é deixado subir; a camada de serviço decide
* como traduzir para 409.
*/
export async function createAssetWithPurchaseMovement(input: CreateAssetInput): Promise<{
asset: SerializedAsset;
movement: StockMovement;
}> {
return withTransaction(async (client) => {
const { rows: assetRows } = await client.query<SerializedAsset>(
`INSERT INTO serialized_assets (
product_id, serial, patrimony_tag, mac_address, warehouse_id, status,
warranty_expires_at, created_by, updated_by
) VALUES ($1,$2,$3,$4,$5,'in_stock',$6,$7,$7)
RETURNING *`,
[
input.productId,
input.serial ?? null,
input.patrimonyTag ?? null,
input.macAddress ?? null,
input.warehouseId,
input.warrantyExpiresAt ?? null,
input.createdBy ?? null,
],
);
const asset = assetRows[0];
if (!asset) throw new Error("failed to create serialized asset");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'purchase_in',1,'serialized_asset',$3,$4,$5)
RETURNING *`,
[input.warehouseId, input.productId, asset.id, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record purchase_in movement");
return { asset, movement };
});
}
export interface ReserveAssetInput {
referenceType?: string | null;
referenceId?: string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Reserva um ativo específico já em estoque: cria a stock_reservation
* (quantity=1, reference_type='serialized_asset', reference_id=asset.id)
* e move o ativo para status='reserved'. Só permitido a partir de
* 'in_stock' — throws InvalidAssetTransitionError caso contrário.
*/
export async function reserveAsset(
assetId: string,
input: ReserveAssetInput,
): Promise<{ asset: SerializedAsset; reservation: StockReservation; movement: StockMovement }> {
return withTransaction(async (client) => {
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
if (!asset) throw new AssetNotFoundError(assetId);
if (asset.status !== "in_stock") throw new InvalidAssetTransitionError(asset.status, "reserved");
if (!asset.warehouse_id) throw new Error(`asset ${assetId} has status in_stock but no warehouse_id`);
const { rows: updatedRows } = await client.query<SerializedAsset>(
`UPDATE serialized_assets SET status = 'reserved', updated_by = $2 WHERE id = $1 RETURNING *`,
[assetId, input.createdBy ?? null],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
const { rows: reservationRows } = await client.query<StockReservation>(
`INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by)
VALUES ($1,$2,1,'serialized_asset',$3,'active',$4,$5)
RETURNING *`,
[asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null],
);
const reservation = reservationRows[0];
if (!reservation) throw new Error("failed to create reservation");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'reservation',1,'serialized_asset',$3,$4,$5)
RETURNING *`,
[asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record reservation movement");
return { asset: updated, reservation, movement };
});
}
export interface InstallAssetInput {
contractId: string;
assignmentType: "installed" | "comodato";
notes?: string | null;
createdBy?: string | null;
}
/**
* Vincula um ativo (em 'in_stock' ou 'reserved') a um contrato: cria
* asset_assignments, move o ativo para status='installed'/'comodato'
* (limpando warehouse_id — a localização agora é "no cliente"), grava o
* stock_movement install_out/comodato_out, e — se havia uma reserva
* ATIVA para este ativo específico — marca-a 'fulfilled'. Tudo numa
* transação. Só permitido a partir de in_stock/reserved.
*/
export async function installAsset(
assetId: string,
input: InstallAssetInput,
): Promise<{ asset: SerializedAsset; assignment: AssetAssignment; movement: StockMovement }> {
return withTransaction(async (client) => {
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
if (!asset) throw new AssetNotFoundError(assetId);
if (asset.status !== "in_stock" && asset.status !== "reserved") {
throw new InvalidAssetTransitionError(asset.status, input.assignmentType);
}
if (!asset.warehouse_id) throw new Error(`asset ${assetId} has no warehouse_id before install`);
const originWarehouseId = asset.warehouse_id;
const movementType = input.assignmentType === "comodato" ? "comodato_out" : "install_out";
const { rows: updatedRows } = await client.query<SerializedAsset>(
`UPDATE serialized_assets
SET status = $2, warehouse_id = NULL, current_contract_id = $3, updated_by = $4
WHERE id = $1
RETURNING *`,
[assetId, input.assignmentType, input.contractId, input.createdBy ?? null],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
const { rows: assignmentRows } = await client.query<AssetAssignment>(
`INSERT INTO asset_assignments (asset_id, contract_id, assignment_type, created_by)
VALUES ($1,$2,$3,$4)
RETURNING *`,
[assetId, input.contractId, input.assignmentType, input.createdBy ?? null],
);
const assignment = assignmentRows[0];
if (!assignment) throw new Error("failed to create asset assignment");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,$3,1,'contract',$4,$5,$6)
RETURNING *`,
[originWarehouseId, asset.product_id, movementType, input.contractId, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record install/comodato movement");
// If there was an active asset-specific reservation, close it as fulfilled.
await client.query(
`UPDATE stock_reservations
SET status = 'fulfilled'
WHERE reference_type = 'serialized_asset' AND reference_id = $1 AND status = 'active'`,
[assetId],
);
return { asset: updated, assignment, movement };
});
}
export interface ReturnAssetInput {
/** Warehouse receiving the physical unit back — not necessarily the one it left from (see migration comment on serialized_assets.warehouse_id). */
warehouseId: string;
notes?: string | null;
createdBy?: string | null;
}
export class NoOpenAssignmentError extends Error {
constructor(public readonly assetId: string) {
super(`Asset ${assetId} has no open assignment to close`);
this.name = "NoOpenAssignmentError";
}
}
/**
* Fecha o assignment aberto (unassigned_at = now()), grava o
* stock_movement return_in no warehouse de destino, e volta o ativo para
* status='in_stock' com current_contract_id limpo. Só permitido a partir
* de installed/comodato.
*/
export async function returnAsset(
assetId: string,
input: ReturnAssetInput,
): Promise<{ asset: SerializedAsset; movement: StockMovement }> {
return withTransaction(async (client) => {
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
if (!asset) throw new AssetNotFoundError(assetId);
if (asset.status !== "installed" && asset.status !== "comodato") {
throw new InvalidAssetTransitionError(asset.status, "returned");
}
const { rows: assignmentRows } = await client.query<AssetAssignment>(
`UPDATE asset_assignments SET unassigned_at = now()
WHERE asset_id = $1 AND unassigned_at IS NULL
RETURNING *`,
[assetId],
);
if (!assignmentRows[0]) throw new NoOpenAssignmentError(assetId);
const { rows: updatedRows } = await client.query<SerializedAsset>(
`UPDATE serialized_assets
SET status = 'in_stock', warehouse_id = $2, current_contract_id = NULL, updated_by = $3
WHERE id = $1
RETURNING *`,
[assetId, input.warehouseId, input.createdBy ?? null],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'return_in',1,'contract',$3,$4,$5)
RETURNING *`,
[input.warehouseId, asset.product_id, asset.current_contract_id, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record return_in movement");
return { asset: updated, movement };
});
}
// ===========================================================================
// asset_assignments
// ===========================================================================
export interface AssetAssignment {
id: string;
created_at: Date;
created_by: string | null;
asset_id: string;
contract_id: string;
assignment_type: "installed" | "comodato";
assigned_at: Date;
unassigned_at: Date | null;
}
export async function listAssetAssignments(assetId: string): Promise<AssetAssignment[]> {
const { rows } = await query<AssetAssignment>(
"SELECT * FROM asset_assignments WHERE asset_id = $1 ORDER BY assigned_at DESC",
[assetId],
);
return rows;
}
// ===========================================================================
// stock_reservations
// ===========================================================================
export type StockReservationStatus = "active" | "released" | "fulfilled";
export interface StockReservation {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
warehouse_id: string;
product_id: string;
quantity: string;
reference_type: string | null;
reference_id: string | null;
status: StockReservationStatus;
notes: string | null;
}
export interface ListStockReservationsFilter {
status?: StockReservationStatus;
warehouseId?: string;
productId?: string;
}
export async function listStockReservations(
filter: ListStockReservationsFilter = {},
): Promise<StockReservation[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
if (filter.warehouseId) {
params.push(filter.warehouseId);
conditions.push(`warehouse_id = $${params.length}`);
}
if (filter.productId) {
params.push(filter.productId);
conditions.push(`product_id = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<StockReservation>(
`SELECT * FROM stock_reservations ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export async function getStockReservationById(id: string): Promise<StockReservation | null> {
const { rows } = await query<StockReservation>("SELECT * FROM stock_reservations WHERE id = $1", [id]);
return rows[0] ?? null;
}
export class ReservationNotFoundError extends Error {
constructor(public readonly reservationId: string) {
super(`Stock reservation ${reservationId} not found`);
this.name = "ReservationNotFoundError";
}
}
export class InvalidReservationTransitionError extends Error {
constructor(
public readonly fromStatus: StockReservationStatus,
public readonly action: string,
) {
super(`Reservation in status '${fromStatus}' cannot be ${action}`);
this.name = "InvalidReservationTransitionError";
}
}
export interface CreateGenericReservationInput {
warehouseId: string;
productId: string;
quantity: number;
referenceType?: string | null;
referenceId?: string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Reserva GENÉRICA de quantidade (produto+warehouse), sem serial
* selecionado ainda — o primeiro passo do fluxo descrito no Master
* Prompt §6.8 antes de "posteriormente selecionar unidade serializada
* específica". Distinta de reserveAsset() (que reserva um serial já
* escolhido).
*/
export async function createGenericReservation(
input: CreateGenericReservationInput,
): Promise<{ reservation: StockReservation; movement: StockMovement }> {
return withTransaction(async (client) => {
const { rows: reservationRows } = await client.query<StockReservation>(
`INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by)
VALUES ($1,$2,$3,$4,$5,'active',$6,$7)
RETURNING *`,
[
input.warehouseId,
input.productId,
input.quantity,
input.referenceType ?? null,
input.referenceId ?? null,
input.notes ?? null,
input.createdBy ?? null,
],
);
const reservation = reservationRows[0];
if (!reservation) throw new Error("failed to create reservation");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'reservation',$3,$4,$5,$6,$7)
RETURNING *`,
[
input.warehouseId,
input.productId,
input.quantity,
input.referenceType ?? null,
input.referenceId ?? null,
input.notes ?? null,
input.createdBy ?? null,
],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record reservation movement");
return { reservation, movement };
});
}
/**
* Cancela uma reserva ATIVA (status -> released) e grava o
* reservation_release correspondente. Se a reserva era de um ativo
* específico (reference_type='serialized_asset'), também devolve esse
* ativo para 'in_stock' — desfazendo o que reserveAsset() fez.
*/
export async function releaseReservation(
reservationId: string,
actorId: string | null,
): Promise<{ reservation: StockReservation; movement: StockMovement }> {
return withTransaction(async (client) => {
const { rows: currentRows } = await client.query<StockReservation>(
"SELECT * FROM stock_reservations WHERE id = $1 FOR UPDATE",
[reservationId],
);
const current = currentRows[0];
if (!current) throw new ReservationNotFoundError(reservationId);
if (current.status !== "active") {
throw new InvalidReservationTransitionError(current.status, "released");
}
const { rows: updatedRows } = await client.query<StockReservation>(
`UPDATE stock_reservations SET status = 'released' WHERE id = $1 RETURNING *`,
[reservationId],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`reservation ${reservationId} disappeared mid-transaction`);
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'reservation_release',$3,$4,$5,$6,$7)
RETURNING *`,
[
current.warehouse_id,
current.product_id,
current.quantity,
current.reference_type,
current.reference_id,
null,
actorId,
],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record reservation_release movement");
if (current.reference_type === "serialized_asset" && current.reference_id) {
await client.query(
`UPDATE serialized_assets SET status = 'in_stock', updated_by = $2
WHERE id = $1 AND status = 'reserved'`,
[current.reference_id, actorId],
);
}
return { reservation: updated, movement };
});
}

View File

@@ -0,0 +1,423 @@
import { query, withTransaction } from "../pool.js";
/**
* Support/Service Desk bounded context (Master Prompt §6.13). Greenfield —
* no legacy module to port. See the migration's header comment
* (PENDING_support.cjs) for the full list of tables this MVP builds vs.
* explicitly deferred ones (real S3 attachments, CSAT, dedicated work
* order entity, routing queues, configurable attendance calendar).
*
* Mirrors contracts.ts's conventions closely: withTransaction + SELECT
* ... FOR UPDATE for the status-machine transition, an explicit
* transitions table, a NotFound/InvalidTransition error pair the service
* layer maps to 404/409, and a *WithDetails aggregate for the "get one"
* endpoint.
*
* SLA breach/elapsed-time math (computeElapsedSeconds/isSlaBreached) is
* deliberately NOT implemented in this file — it's pure, DB-independent
* business logic that lives in apps/api/src/support/sla.ts (this package
* must not depend on apps/api). This repository only performs the SQL
* side effect of pausing/resuming the timer (using the DB's own `now()`,
* to avoid app/DB clock skew for that specific bookkeeping) and persists
* whatever met/unmet flags the service layer has already computed.
*/
export type TicketStatus =
| "new"
| "triage"
| "in_progress"
| "waiting_customer"
| "waiting_third_party"
| "resolved"
| "closed"
| "cancelled";
export type TicketPriority = "low" | "medium" | "high" | "critical";
export interface SupportTicket {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
protocol_number: string;
title: string;
description: string;
category: string;
subcategory: string | null;
priority: TicketPriority;
status: TicketStatus;
reseller_id: string | null;
client_registration_id: string | null;
contract_id: string | null;
owner_user_id: string | null;
opened_by_user_id: string;
root_cause: string | null;
resolution_notes: string | null;
first_response_at: Date | null;
resolved_at: Date | null;
closed_at: Date | null;
}
export interface TicketComment {
id: string;
ticket_id: string;
author_user_id: string;
body: string;
is_internal: boolean;
created_at: Date;
}
export interface TicketStatusHistoryEntry {
id: string;
ticket_id: string;
from_status: TicketStatus | null;
to_status: TicketStatus;
reason: string | null;
changed_by: string | null;
changed_at: Date;
}
export interface SlaTimer {
ticket_id: string;
created_at: Date;
updated_at: Date;
first_response_due_at: Date;
resolution_due_at: Date;
first_response_met: boolean | null;
resolution_met: boolean | null;
paused_since: Date | null;
accumulated_pause_seconds: number;
}
export interface TicketWithDetails extends SupportTicket {
comments: TicketComment[];
statusHistory: TicketStatusHistoryEntry[];
slaTimer: SlaTimer | null;
}
/**
* MVP lifecycle (eden-support persona spec): new -> triage -> in_progress
* <-> {waiting_customer, waiting_third_party} -> resolved -> closed, plus
* cancelled reachable from any non-terminal state. closed/cancelled are
* terminal (no outgoing transitions modeled).
*/
export const TICKET_STATUS_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = {
new: ["triage", "cancelled"],
triage: ["in_progress", "cancelled"],
in_progress: ["waiting_customer", "waiting_third_party", "resolved", "cancelled"],
waiting_customer: ["in_progress", "cancelled"],
waiting_third_party: ["in_progress", "cancelled"],
resolved: ["closed", "cancelled"],
closed: [],
cancelled: [],
};
export class TicketNotFoundError extends Error {
constructor(public readonly ticketId: string) {
super(`Support ticket ${ticketId} not found`);
this.name = "TicketNotFoundError";
}
}
export class InvalidTicketTransitionError extends Error {
constructor(
public readonly fromStatus: TicketStatus,
public readonly toStatus: TicketStatus,
) {
super(`Invalid ticket status transition: '${fromStatus}' -> '${toStatus}'`);
this.name = "InvalidTicketTransitionError";
}
}
export interface ListTicketsFilter {
scope: "all" | "reseller";
/** Required (and enforced) when scope === 'reseller'. Ignored when scope === 'all'. */
resellerId?: string | null;
}
export async function listTickets(filter: ListTicketsFilter): Promise<SupportTicket[]> {
if (filter.scope === "reseller") {
// Deliberately never falls back to an unfiltered query — deny-by-default,
// same discipline as contracts.ts::listContracts.
const { rows } = await query<SupportTicket>(
"SELECT * FROM support_tickets WHERE reseller_id = $1 ORDER BY created_at DESC",
[filter.resellerId ?? null],
);
return rows;
}
const { rows } = await query<SupportTicket>("SELECT * FROM support_tickets ORDER BY created_at DESC");
return rows;
}
async function listTicketComments(ticketId: string): Promise<TicketComment[]> {
const { rows } = await query<TicketComment>(
"SELECT * FROM ticket_comments WHERE ticket_id = $1 ORDER BY created_at ASC",
[ticketId],
);
return rows;
}
async function listTicketStatusHistory(ticketId: string): Promise<TicketStatusHistoryEntry[]> {
const { rows } = await query<TicketStatusHistoryEntry>(
"SELECT * FROM ticket_status_history WHERE ticket_id = $1 ORDER BY changed_at ASC",
[ticketId],
);
return rows;
}
async function getSlaTimer(ticketId: string): Promise<SlaTimer | null> {
const { rows } = await query<SlaTimer>("SELECT * FROM sla_timers WHERE ticket_id = $1", [ticketId]);
return rows[0] ?? null;
}
export async function getTicketById(id: string): Promise<TicketWithDetails | null> {
const { rows } = await query<SupportTicket>("SELECT * FROM support_tickets WHERE id = $1", [id]);
const ticket = rows[0];
if (!ticket) return null;
const [comments, statusHistory, slaTimer] = await Promise.all([
listTicketComments(id),
listTicketStatusHistory(id),
getSlaTimer(id),
]);
return { ...ticket, comments, statusHistory, slaTimer };
}
export interface CreateTicketInput {
title: string;
description: string;
category: string;
subcategory?: string | null;
priority: TicketPriority;
resellerId?: string | null;
clientRegistrationId?: string | null;
contractId?: string | null;
ownerUserId?: string | null;
openedByUserId: string;
createdBy?: string | null;
/** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */
firstResponseDueAt: Date;
/** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */
resolutionDueAt: Date;
}
/**
* Creates the ticket plus its 1:1 sla_timers row in a single transaction
* (all-or-nothing) — mirrors contracts.ts::createContract. Status always
* starts 'new'; protocol_number is allocated by the column DEFAULT
* (support_tickets_protocol_number_seq), same pattern as quotes.quote_number.
*/
export async function createTicket(input: CreateTicketInput): Promise<TicketWithDetails> {
return withTransaction(async (client) => {
const { rows } = await client.query<SupportTicket>(
`INSERT INTO support_tickets (
title, description, category, subcategory, priority,
reseller_id, client_registration_id, contract_id,
owner_user_id, opened_by_user_id, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
input.title,
input.description,
input.category,
input.subcategory ?? null,
input.priority,
input.resellerId ?? null,
input.clientRegistrationId ?? null,
input.contractId ?? null,
input.ownerUserId ?? null,
input.openedByUserId,
input.createdBy ?? null,
],
);
const ticket = rows[0];
if (!ticket) throw new Error("failed to create support ticket");
const { rows: timerRows } = await client.query<SlaTimer>(
`INSERT INTO sla_timers (ticket_id, first_response_due_at, resolution_due_at)
VALUES ($1,$2,$3)
RETURNING *`,
[ticket.id, input.firstResponseDueAt, input.resolutionDueAt],
);
const slaTimer = timerRows[0];
if (!slaTimer) throw new Error("failed to create sla timer");
return { ...ticket, comments: [], statusHistory: [], slaTimer };
});
}
export interface CreateTicketCommentInput {
authorUserId: string;
body: string;
isInternal: boolean;
}
export async function addTicketComment(
ticketId: string,
input: CreateTicketCommentInput,
): Promise<TicketComment> {
const { rows } = await query<TicketComment>(
`INSERT INTO ticket_comments (ticket_id, author_user_id, body, is_internal)
VALUES ($1,$2,$3,$4)
RETURNING *`,
[ticketId, input.authorUserId, input.body, input.isInternal],
);
const row = rows[0];
if (!row) throw new Error("failed to add ticket comment");
return row;
}
/**
* Once the first public (non-internal) comment is added on a ticket whose
* first_response_at is still NULL, the service layer computes whether
* that response met the SLA (apps/api/src/support/sla.ts::isSlaBreached)
* and calls this to persist it. Idempotent by construction (WHERE ... IS
* NULL on both tables) — a second call after first_response_at is already
* set is a silent no-op, never overwrites.
*/
export async function recordFirstResponse(
ticketId: string,
met: boolean,
respondedAt: Date,
): Promise<void> {
await query(
`UPDATE support_tickets SET first_response_at = $2 WHERE id = $1 AND first_response_at IS NULL`,
[ticketId, respondedAt],
);
await query(
`UPDATE sla_timers SET first_response_met = $2, updated_at = now()
WHERE ticket_id = $1 AND first_response_met IS NULL`,
[ticketId, met],
);
}
/**
* Called by the service layer right after a transition into 'resolved',
* once it has computed the met/unmet flag from the (already-returned,
* necessarily-unpaused — see updateTicketStatus below) sla timer via
* sla.ts::isSlaBreached.
*/
export async function recordResolutionMet(ticketId: string, met: boolean): Promise<void> {
await query(`UPDATE sla_timers SET resolution_met = $2, updated_at = now() WHERE ticket_id = $1`, [
ticketId,
met,
]);
}
export interface UpdateTicketStatusExtra {
rootCause?: string | null;
resolutionNotes?: string | null;
}
/**
* Validates the transition against TICKET_STATUS_TRANSITIONS and, only if
* valid, atomically: updates support_tickets.status, sets resolved_at/
* closed_at via COALESCE the first time the ticket enters that state
* (never overwritten afterwards, same semantics as contracts.activated_at),
* accepts root_cause/resolution_notes only on the transition into
* 'closed', appends a ticket_status_history row, AND pauses/resumes the
* SLA timer:
* - entering waiting_customer/waiting_third_party -> paused_since = now()
* (COALESCE — a no-op if somehow already paused)
* - waiting_customer/waiting_third_party -> in_progress -> the elapsed
* pause is folded into accumulated_pause_seconds and paused_since is
* cleared
* All using the DB's own now() for this specific bookkeeping (single
* statement, no app/DB clock skew risk). Returns the updated ticket AND
* the post-transition sla timer row so the service layer can compute
* first_response_met/resolution_met with the pure functions in
* apps/api/src/support/sla.ts. Throws TicketNotFoundError /
* InvalidTicketTransitionError instead of silently no-oping — the service
* layer maps these to 404/409.
*/
export async function updateTicketStatus(
id: string,
toStatus: TicketStatus,
reason: string | null,
actorId: string | null,
extra?: UpdateTicketStatusExtra,
): Promise<{ ticket: SupportTicket; slaTimer: SlaTimer }> {
return withTransaction(async (client) => {
const { rows } = await client.query<{ status: TicketStatus }>(
"SELECT status FROM support_tickets WHERE id = $1 FOR UPDATE",
[id],
);
const current = rows[0];
if (!current) {
throw new TicketNotFoundError(id);
}
const fromStatus = current.status;
const allowed = TICKET_STATUS_TRANSITIONS[fromStatus] ?? [];
if (!allowed.includes(toStatus)) {
throw new InvalidTicketTransitionError(fromStatus, toStatus);
}
const enteringWaiting = toStatus === "waiting_customer" || toStatus === "waiting_third_party";
const resumingFromWaiting =
(fromStatus === "waiting_customer" || fromStatus === "waiting_third_party") && toStatus === "in_progress";
const enteringResolved = toStatus === "resolved";
const enteringClosed = toStatus === "closed";
// Lock the timer row too (1:1, always exists once the ticket does) and
// apply the pause/resume side effect. All SET expressions in a single
// UPDATE see the row's OLD values, so referencing paused_since while
// also overwriting it in the same statement is safe standard SQL.
await client.query(
`UPDATE sla_timers SET
accumulated_pause_seconds = CASE
WHEN $3 AND paused_since IS NOT NULL
THEN accumulated_pause_seconds + GREATEST(0, EXTRACT(EPOCH FROM (now() - paused_since))::int)
ELSE accumulated_pause_seconds
END,
paused_since = CASE
WHEN $2 THEN COALESCE(paused_since, now())
WHEN $3 THEN NULL
ELSE paused_since
END,
updated_at = now()
WHERE ticket_id = $1`,
[id, enteringWaiting, resumingFromWaiting],
);
const { rows: updatedRows } = await client.query<SupportTicket>(
`UPDATE support_tickets SET
status = $2,
resolved_at = CASE WHEN $3 THEN COALESCE(resolved_at, now()) ELSE resolved_at END,
closed_at = CASE WHEN $4 THEN COALESCE(closed_at, now()) ELSE closed_at END,
root_cause = CASE WHEN $4 THEN COALESCE($5, root_cause) ELSE root_cause END,
resolution_notes = CASE WHEN $4 THEN COALESCE($6, resolution_notes) ELSE resolution_notes END,
updated_by = $7
WHERE id = $1
RETURNING *`,
[
id,
toStatus,
enteringResolved,
enteringClosed,
extra?.rootCause ?? null,
extra?.resolutionNotes ?? null,
actorId ?? null,
],
);
const ticket = updatedRows[0];
if (!ticket) throw new Error(`support ticket ${id} disappeared mid-transaction`);
await client.query(
`INSERT INTO ticket_status_history (ticket_id, from_status, to_status, reason, changed_by)
VALUES ($1,$2,$3,$4,$5)`,
[id, fromStatus, toStatus, reason ?? null, actorId ?? null],
);
const { rows: timerRows } = await client.query<SlaTimer>(
"SELECT * FROM sla_timers WHERE ticket_id = $1",
[id],
);
const slaTimer = timerRows[0];
if (!slaTimer) throw new Error(`sla timer for ticket ${id} missing`);
return { ticket, slaTimer };
});
}

View File

@@ -142,3 +142,25 @@ export async function assignUserToReseller(
]);
return (rowCount ?? 0) > 0;
}
/**
* Stages a TOTP secret without enabling MFA yet — `mfa_enabled` stays
* false until `confirmMfaEnrollment` verifies a code against it. Login
* only checks `mfa_enabled`, so an unconfirmed enrollment is inert.
*/
export async function setMfaSecret(userId: string, encryptedSecret: string): Promise<void> {
await query("UPDATE users SET mfa_secret_encrypted = $2, mfa_enabled = false WHERE id = $1", [
userId,
encryptedSecret,
]);
}
export async function confirmMfaEnrollment(userId: string): Promise<void> {
await query("UPDATE users SET mfa_enabled = true WHERE id = $1", [userId]);
}
export async function disableMfa(userId: string): Promise<void> {
await query("UPDATE users SET mfa_enabled = false, mfa_secret_encrypted = null WHERE id = $1", [
userId,
]);
}