feat(web): telas de Estoque e Faturamento no EDEN Core

Continuação direta, sem parar para autorização (a pedido do usuário).
2 subagentes eden-frontend em paralelo.

- Estoque: armazéns (CRUD), ativos com ações contextuais por status
  (só oferece Reservar/Instalar/Devolver quando válido para o status
  atual), entrada de ativo, detalhe com histórico de movimentos, ledger
  só-leitura, reservas genéricas. Achado real documentado no código:
  install/return gravam o movimento com reference_type='contract', não
  'serialized_asset' — filtrar o ledger só pelo id do ativo nunca traz
  esse histórico; contornado buscando também pelos movimentos do
  contrato atual do ativo, registrado como pendência de backend.
- Faturamento: assinaturas (valor mensal pré-preenchido do contrato,
  editável com aviso), fechamentos (aviso de idempotência ao regerar),
  detalhe do fechamento com tabela de faturas + consolidação com
  confirmação em duas etapas (irreversível), lista de faturas.

Reconciliação manual de App.tsx/AuthenticatedLayout.tsx entre os 2
agentes, confirmando typecheck limpo a cada mesclagem. Um dos agentes
restaurou a base do zero (sem git merge) — comparação byte a byte
(diff -rq) contra main confirmou zero desvio antes de aceitar qualquer
arquivo como genuinamente novo.

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; testado ao vivo através do proxy
same-origin (porta 3001); trilha de auditoria conferida no Postgres.
Os próprios agentes já haviam confirmado ao vivo contra a API real:
invariante de serial duplicado (409), ciclo completo de ativo, e o
fluxo idempotente de billing (gerar→regenerar→consolidar→409).

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 23:32:48 -03:00
parent 19e67dbe63
commit 7021994d8e
23 changed files with 3078 additions and 0 deletions

View File

@@ -0,0 +1,163 @@
/**
* Client for `apps/api` `/v1/billing/*` (see apps/api/src/billing/billing.controller.ts,
* billing.service.ts, dto/*, and packages/database/src/repositories/billing.ts
* for the source of truth — shapes below are mirrored, not invented).
*
* Billing is distinct from Finance/AR-AP (títulos, boletos, cobrança — not
* built yet) and from Fiscal (documento fiscal) — see ADR-0008. This module
* only calculates "o que é devido" and produces an internal invoice: a
* `billing_subscription` is born from an active contract; a `billing_run`
* closes a period (year + month + billing day) generating one invoice per
* matching active subscription; while `draft`, re-running with the SAME
* (referenceYear, referenceMonth, billingDay) reuses the run and regenerates
* its invoices (idempotent, never duplicates); once `consolidated` a run and
* its invoices are immutable.
*
* Like Contracts, the top-level response is snake_case straight off the
* table. Money fields (`monthly_value`, `amount`) come back as NUMERIC-typed
* strings from Postgres — always format via `formatCurrencyBRL`, never
* coerce to `number` for storage.
*/
import { apiRequest } from "./http.js";
export type BillingSubscriptionStatus = "active" | "suspended" | "cancelled";
export interface BillingSubscription {
id: string;
created_at: string;
updated_at: string;
contract_id: string;
reseller_id: string | null;
monthly_value: string;
billing_day: number;
status: BillingSubscriptionStatus;
started_at: string;
cancelled_at: string | null;
}
export type BillingRunStatus = "draft" | "consolidated" | "failed";
/** Response shape for `GET /billing/runs` (list) — no invoices array. */
export interface BillingRun {
id: string;
reference_year: number;
reference_month: number;
billing_day: number;
status: BillingRunStatus;
consolidated_at: string | null;
triggered_by: string | null;
created_at: string;
}
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: string;
}
/** Response shape for `POST /billing/runs`, `GET /billing/runs/:id`, `POST /billing/runs/:id/consolidate`. */
export interface BillingRunWithInvoices extends BillingRun {
invoices: Invoice[];
/** Sum of every invoice's `amount` in this run — already a formatted-ready decimal string. */
totalAmount: string;
}
/** Only 'draft' allows further action (consolidate or regenerate) — 'consolidated'/'failed' are terminal. */
export const BILLING_RUN_TERMINAL_STATUSES: BillingRunStatus[] = ["consolidated", "failed"];
export interface CreateBillingSubscriptionInput {
contractId: string;
resellerId?: string;
/** NOT recalculated from the contract server-side in this leva — always sent explicitly by the caller. */
monthlyValue: number;
billingDay: number;
}
export interface CreateBillingRunInput {
referenceYear: number;
referenceMonth: number;
billingDay: number;
}
export interface ListInvoicesFilter {
resellerId?: string;
status?: InvoiceStatus;
referenceYear?: number;
referenceMonth?: number;
}
export function listBillingSubscriptions(accessToken: string): Promise<BillingSubscription[]> {
return apiRequest<BillingSubscription[]>("/billing/subscriptions", { method: "GET" }, { accessToken });
}
export function createBillingSubscription(
input: CreateBillingSubscriptionInput,
accessToken: string,
): Promise<BillingSubscription> {
return apiRequest<BillingSubscription>(
"/billing/subscriptions",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function cancelBillingSubscription(id: string, accessToken: string): Promise<BillingSubscription> {
return apiRequest<BillingSubscription>(
`/billing/subscriptions/${id}/cancel`,
{ method: "PATCH" },
{ accessToken },
);
}
export function listBillingRuns(accessToken: string): Promise<BillingRun[]> {
return apiRequest<BillingRun[]>("/billing/runs", { method: "GET" }, { accessToken });
}
/**
* Creates a new draft run, or — while a draft already exists for the same
* (referenceYear, referenceMonth, billingDay) — reuses it and regenerates
* its invoices in place. 409s if a run for that exact period is already
* `consolidated`.
*/
export function createBillingRun(
input: CreateBillingRunInput,
accessToken: string,
): Promise<BillingRunWithInvoices> {
return apiRequest<BillingRunWithInvoices>(
"/billing/runs",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function getBillingRun(id: string, accessToken: string): Promise<BillingRunWithInvoices> {
return apiRequest<BillingRunWithInvoices>(`/billing/runs/${id}`, { method: "GET" }, { accessToken });
}
/** Irreversible — flips the run and every invoice it owns to `consolidated`. 409s if already consolidated. */
export function consolidateBillingRun(id: string, accessToken: string): Promise<BillingRunWithInvoices> {
return apiRequest<BillingRunWithInvoices>(
`/billing/runs/${id}/consolidate`,
{ method: "POST" },
{ accessToken },
);
}
export function listInvoices(filter: ListInvoicesFilter, accessToken: string): Promise<Invoice[]> {
const params = new URLSearchParams();
if (filter.resellerId) params.set("resellerId", filter.resellerId);
if (filter.status) params.set("status", filter.status);
if (filter.referenceYear != null) params.set("referenceYear", String(filter.referenceYear));
if (filter.referenceMonth != null) params.set("referenceMonth", String(filter.referenceMonth));
const qs = params.toString();
return apiRequest<Invoice[]>(`/billing/invoices${qs ? `?${qs}` : ""}`, { method: "GET" }, { accessToken });
}

View File

@@ -0,0 +1,253 @@
/**
* Client for `apps/api` `/v1/inventory/*` (see
* apps/api/src/inventory/inventory.controller.ts, inventory.service.ts and
* packages/database/src/repositories/inventory.ts for the source of truth
* — shapes below are mirrored, not invented).
*
* Covers the full ADR-0007 lifecycle: serialized assets
* (`in_stock → reserved → installed/comodato → in_stock` on return, or
* `written_off`), the append-only stock-movement ledger (read-only here),
* and generic (product+quantity) reservations. Warehouses are their own
* simpler CRUD — see `./warehouses.ts`.
*/
import { apiRequest } from "./http.js";
// ---------------------------------------------------------------------
// serialized_assets
// ---------------------------------------------------------------------
export type SerializedAssetStatus = "in_stock" | "reserved" | "installed" | "comodato" | "returned" | "written_off";
/** Response shape — snake_case, exactly as returned by the `serialized_assets` table. */
export interface SerializedAsset {
id: string;
created_at: string;
updated_at: string;
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: string | null;
}
/** Request body — camelCase, matches `CreateAssetDto` exactly. At least one of `serial`/`patrimonyTag` is required (validated server-side with a 400, and mirrored client-side for a clearer inline message). */
export interface CreateAssetInput {
productId: string;
warehouseId: string;
serial?: string;
patrimonyTag?: string;
macAddress?: string;
warrantyExpiresAt?: string;
notes?: string;
}
/** Matches `ReserveAssetDto` (body of `POST /assets/:id/reserve`) — only valid from `status === "in_stock"`. */
export interface ReserveAssetInput {
referenceType?: string;
referenceId?: string;
notes?: string;
}
/** Matches `InstallAssetDto` (body of `POST /assets/:id/install`) — only valid from `status` `in_stock` or `reserved`. */
export interface InstallAssetInput {
contractId: string;
assignmentType: "installed" | "comodato";
notes?: string;
}
/** Matches `ReturnAssetDto` (body of `POST /assets/:id/return`) — only valid from `status` `installed` or `comodato`. */
export interface ReturnAssetInput {
warehouseId: string;
notes?: string;
}
export interface ListAssetsFilter {
status?: SerializedAssetStatus;
warehouseId?: string;
productId?: string;
}
function toQueryString<T extends object>(params: T): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string" && value) search.set(key, value);
}
const query = search.toString();
return query ? `?${query}` : "";
}
export function listAssets(accessToken: string, filter: ListAssetsFilter = {}): Promise<SerializedAsset[]> {
return apiRequest<SerializedAsset[]>(
`/inventory/assets${toQueryString(filter)}`,
{ method: "GET" },
{ accessToken },
);
}
export function getAsset(id: string, accessToken: string): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(`/inventory/assets/${id}`, { method: "GET" }, { accessToken });
}
export function createAsset(input: CreateAssetInput, accessToken: string): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
"/inventory/assets",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function reserveAsset(
id: string,
input: ReserveAssetInput,
accessToken: string,
): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
`/inventory/assets/${id}/reserve`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function installAsset(
id: string,
input: InstallAssetInput,
accessToken: string,
): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
`/inventory/assets/${id}/install`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function returnAsset(
id: string,
input: ReturnAssetInput,
accessToken: string,
): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
`/inventory/assets/${id}/return`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
// ---------------------------------------------------------------------
// stock_movements (append-only ledger — read only)
// ---------------------------------------------------------------------
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";
/** Response shape — snake_case, exactly as returned by the `stock_movements` table. `quantity` is a NUMERIC-typed string — never coerce to `number` for anything other than display. */
export interface StockMovement {
id: string;
created_at: string;
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 ListMovementsFilter {
warehouseId?: string;
productId?: string;
referenceType?: string;
referenceId?: string;
}
export function listMovements(accessToken: string, filter: ListMovementsFilter = {}): Promise<StockMovement[]> {
return apiRequest<StockMovement[]>(
`/inventory/movements${toQueryString(filter)}`,
{ method: "GET" },
{ accessToken },
);
}
// ---------------------------------------------------------------------
// stock_reservations (generic product+quantity — no serial picked yet)
// ---------------------------------------------------------------------
export type StockReservationStatus = "active" | "released" | "fulfilled";
/** Response shape — snake_case, exactly as returned by the `stock_reservations` table. */
export interface StockReservation {
id: string;
created_at: string;
updated_at: string;
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;
}
/** Request body — camelCase, matches `CreateReservationDto` exactly. */
export interface CreateReservationInput {
warehouseId: string;
productId: string;
quantity: number;
referenceType?: string;
referenceId?: string;
notes?: string;
}
export interface ListReservationsFilter {
status?: StockReservationStatus;
warehouseId?: string;
productId?: string;
}
export function listReservations(
accessToken: string,
filter: ListReservationsFilter = {},
): Promise<StockReservation[]> {
return apiRequest<StockReservation[]>(
`/inventory/reservations${toQueryString(filter)}`,
{ method: "GET" },
{ accessToken },
);
}
export function createReservation(
input: CreateReservationInput,
accessToken: string,
): Promise<StockReservation> {
return apiRequest<StockReservation>(
"/inventory/reservations",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function releaseReservation(id: string, accessToken: string): Promise<StockReservation> {
return apiRequest<StockReservation>(
`/inventory/reservations/${id}/release`,
{ method: "POST" },
{ accessToken },
);
}

View File

@@ -0,0 +1,62 @@
/**
* Client for `apps/api` `/v1/warehouses` (see
* apps/api/src/inventory/warehouses.controller.ts, warehouses.service.ts
* and packages/database/src/repositories/inventory.ts for the source of
* truth — shapes below are mirrored, not invented). Kept in its own file
* (rather than folded into `./inventory.ts`) because warehouses are a
* simple standalone CRUD, while `inventory.ts` covers the richer
* assets/movements/reservations lifecycle.
*/
import { apiRequest } from "./http.js";
/** Response shape — snake_case, exactly as returned by the `warehouses` table. */
export interface Warehouse {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
company_id: string;
name: string;
code: string;
is_active: boolean;
}
/** Request body — camelCase, matches `CreateWarehouseDto` exactly. */
export interface CreateWarehouseInput {
companyId: string;
name: string;
code: string;
isActive?: boolean;
}
/** Matches `UpdateWarehouseDto`: everything optional. */
export type UpdateWarehouseInput = Partial<Omit<CreateWarehouseInput, "companyId">>;
/** `includeInactive` defaults to `true` here so the admin list shows inactive warehouses too (with a status badge), unlike the bare API default. */
export function listWarehouses(accessToken: string, includeInactive = true): Promise<Warehouse[]> {
return apiRequest<Warehouse[]>(
`/warehouses?includeInactive=${includeInactive ? "true" : "false"}`,
{ method: "GET" },
{ accessToken },
);
}
export function getWarehouse(id: string, accessToken: string): Promise<Warehouse> {
return apiRequest<Warehouse>(`/warehouses/${id}`, { method: "GET" }, { accessToken });
}
export function createWarehouse(input: CreateWarehouseInput, accessToken: string): Promise<Warehouse> {
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateWarehouse(
id: string,
input: UpdateWarehouseInput,
accessToken: string,
): Promise<Warehouse> {
return apiRequest<Warehouse>(
`/warehouses/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}