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:
163
apps/core-web/src/lib/api/billing.ts
Normal file
163
apps/core-web/src/lib/api/billing.ts
Normal 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 });
|
||||
}
|
||||
253
apps/core-web/src/lib/api/inventory.ts
Normal file
253
apps/core-web/src/lib/api/inventory.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
62
apps/core-web/src/lib/api/warehouses.ts
Normal file
62
apps/core-web/src/lib/api/warehouses.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user