feat(financeiro): a comanda ganha as rotas que faltavam

A 0240 trouxe as tabelas e as duas funções que movem dinheiro
(`fn_finalizar_comanda`, `fn_estornar_comanda`), e ninguém as chamava: o módulo
existia inteiro no banco, sem porta. É o invariante 6 do Sistema Vivo, e a
resposta são estas seis rotas.

CLIENT DE SESSÃO, e aqui não é preferência de estilo: `fn_finalizar_comanda`
começa por `auth.uid() is null` e recusa. Chamada com a service key ela levanta
`comanda_forbidden`.

A comissão é resolvida na INCLUSÃO do item e gravada na linha, porque a
finalização não consulta `commission_rules` de propósito: mudar a regra amanhã
não pode mexer no que foi combinado ontem. A precedência é por especificidade,
nunca por valor: pessoa+serviço vence pessoa, que vence serviço, que vence zero.
O teste cobre justamente o caso em que a regra mais específica é a MENOR, que é
onde uma implementação por "maior percentual" passaria despercebida.

Os verbos são três, e a diferença entre eles é o que impede apagar história:
remover item vale só em comanda aberta (não virou nada ainda), cancelar é
mudança de status, e estornar faz contra-lançamento sem tocar no original, que
está pago e é imutável por trigger.

A migration 0243 fecha a corrida do "abrir comanda a partir do agendamento": a
consulta prévia resolve o toque repetido, não duas requisições simultâneas, e
duas comandas para o mesmo atendimento não dão erro nenhum — são faturadas
separadamente e o cliente paga duas vezes.

Também acrescenta as nove tabelas do módulo a `lib/database.types.ts`, que a
0239 e a 0240 não tinham regenerado, e registra as sete ações de auditoria.

A tela do balcão não entra aqui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
423313
2026-09-14 09:30:03 -03:00
co-authored by Claude Opus 5
parent 5130f95c27
commit 8fb6f0eaeb
14 changed files with 1501 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
---
impacto: capacidade_nova
secao: adicionado
titulo: A comanda ganhou as rotas que faltavam
---
As tabelas da comanda e as funções que movem dinheiro já existiam, e nada as
chamava: o módulo estava inteiro no banco, sem porta.
Agora `/api/v1/financeiro/comandas` abre, lista, recebe item, dá desconto,
cancela, finaliza e estorna. A comissão de cada item é resolvida na entrada, com
a precedência combinada (pessoa e serviço vence pessoa, que vence serviço), e
fica congelada na linha: mudar a regra amanhã não mexe no que já foi feito.
A tela do balcão ainda não existe; por enquanto o caminho é a API.
@@ -0,0 +1,105 @@
/**
* ESTORNAR a comanda.
*
* Estorno não é o desfazer de cancelar: cancelar vale para comanda ABERTA, que
* ainda não virou nada; estornar vale para a FINALIZADA, que já virou
* lançamento, comissão e ponto. Por isso são duas operações e não um botão com
* dois sentidos.
*
* E estorno **nunca apaga**. `fn_estornar_comanda` insere o contra-lançamento e
* deixa o original intacto — ele está pago, e o trigger `fn_lancamento_pago_e_
* imutavel` recusaria a alteração de qualquer forma. A comissão vira
* `reversed`, e não some, porque ela existiu e alguém pode já ter sido pago por
* ela.
*
* ⚠️ Exige `manager`, um degrau acima de finalizar. Desfazer dinheiro que já
* entrou é decisão de quem responde pelo caixa, não de quem opera o balcão — e
* quem impõe isso é a própria função, no banco.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { estornarSchema } from "@/lib/financeiro/comanda";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
function traduzirErro(mensagem: string): { code: string; status: number; texto: string } | null {
if (mensagem.includes("estorno_forbidden")) {
return {
code: "forbidden",
status: 403,
texto: "Estornar uma comanda exige perfil de gerente.",
};
}
if (mensagem.includes("comanda_nao_encontrada")) {
return { code: "not_found", status: 404, texto: "Comanda não encontrada." };
}
if (mensagem.includes("comanda_nao_finalizada")) {
return {
code: "conflict",
status: 409,
texto: "Só comanda finalizada pode ser estornada. Comanda aberta se cancela.",
};
}
return null;
}
export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const authz = await requireRole("manager", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const lido = estornarSchema.safeParse(await req.json().catch(() => ({})));
if (!lido.success) {
return fail(
"validation_failed",
// O motivo é obrigatório porque um estorno sem motivo é um buraco no caixa
// que ninguém consegue explicar três meses depois.
lido.error.issues[0]?.message ?? "Informe o motivo do estorno.",
422,
{ requestId },
);
}
const { id } = await ctx.params;
const supabase = await createClient();
const { data, error } = await supabase.rpc("fn_estornar_comanda", {
p_org: authz.org.orgId,
p_sale: id,
p_motivo: lido.data.reason,
});
if (error) {
const traduzido = traduzirErro(error.message);
if (traduzido) {
return fail(traduzido.code as never, traduzido.texto, traduzido.status as never, { requestId });
}
return fail("internal_error", error.message, 500, { requestId });
}
const desfecho = (data ?? {}) as { sale_id?: string; ja_estornada?: boolean };
if (!desfecho.ja_estornada) {
await audit({
action: "comanda.estornada",
resourceType: "sale",
resourceId: id,
requestId,
metadata: { reason: lido.data.reason },
});
}
return ok(desfecho, { requestId });
}
@@ -0,0 +1,115 @@
/**
* FINALIZAR a comanda — as seis coisas numa transação só.
*
* A rota não faz nenhuma delas: quem faz é `fn_finalizar_comanda`, e isso é o
* desenho, não preguiça. Marcar a venda, gerar comissão por item, lançar a
* entrada na conta da forma de pagamento, dar o ponto de fidelidade e concluir o
* agendamento precisam acontecer juntos ou não acontecer — e "juntos" em
* TypeScript, com seis chamadas ao PostgREST, é seis oportunidades de parar no
* meio com metade do dinheiro registrado.
*
* A função também trava a linha (`for update`), o que a torna idempotente de
* fato: dois toques no botão devolvem o mesmo desfecho, não dois lançamentos.
*
* ⚠️ Ela exige `auth.uid()` e papel `agent`. Chamar com o client admin levanta
* `comanda_forbidden` — o client de sessão aqui não é preferência de estilo.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { finalizarSchema } from "@/lib/financeiro/comanda";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
/**
* O erro do banco vira resposta com nome.
*
* Cada um destes é uma recusa DELIBERADA da função, escrita no corpo dela. Sem
* esta tradução, todos chegariam como 500 "internal_error" — e `forma_sem_conta`
* viraria "erro no servidor" quando o que falta é alguém escolher a conta de
* destino numa tela de configuração.
*/
function traduzirErro(mensagem: string): { code: string; status: number; texto: string } | null {
if (mensagem.includes("comanda_forbidden")) {
return { code: "forbidden", status: 403, texto: "Sem permissão para finalizar comandas." };
}
if (mensagem.includes("comanda_nao_encontrada")) {
return { code: "not_found", status: 404, texto: "Comanda não encontrada." };
}
if (mensagem.includes("comanda_cancelada")) {
return { code: "conflict", status: 409, texto: "Comanda cancelada não pode ser finalizada." };
}
if (mensagem.includes("forma_sem_conta")) {
return {
code: "validation_failed",
status: 422,
texto:
"Esta forma de pagamento ainda não tem conta de destino. Defina em Configurações › Financeiro.",
};
}
if (mensagem.includes("forma_de_pagamento_invalida")) {
return { code: "validation_failed", status: 422, texto: "Forma de pagamento inválida." };
}
return null;
}
export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const authz = await requireRole("agent", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const lido = finalizarSchema.safeParse(await req.json().catch(() => ({})));
if (!lido.success) {
return fail("validation_failed", lido.error.issues[0]?.message ?? "corpo inválido", 422, {
requestId,
});
}
const { id } = await ctx.params;
const supabase = await createClient();
const { data, error } = await supabase.rpc("fn_finalizar_comanda", {
p_org: authz.org.orgId,
p_sale: id,
p_payment_method: lido.data.payment_method_id,
p_loyalty_points: lido.data.loyalty_points,
});
if (error) {
const traduzido = traduzirErro(error.message);
if (traduzido) {
return fail(traduzido.code as never, traduzido.texto, traduzido.status as never, { requestId });
}
return fail("internal_error", error.message, 500, { requestId });
}
const desfecho = (data ?? {}) as { sale_id?: string; ja_finalizada?: boolean };
// Auditar a chamada REPETIDA seria contar duas vezes o mesmo faturamento para
// quem for ler o audit depois. A função diz qual das duas aconteceu.
if (!desfecho.ja_finalizada) {
await audit({
action: "comanda.finalizada",
resourceType: "sale",
resourceId: id,
requestId,
metadata: {
payment_method_id: lido.data.payment_method_id,
loyalty_points: lido.data.loyalty_points,
},
});
}
return ok(desfecho, { requestId });
}
@@ -0,0 +1,77 @@
/**
* Remover um item da comanda.
*
* ⚠️ ESTE É O ÚNICO DELETE DO MÓDULO, e ele é legítimo: um item de comanda
* ABERTA ainda não virou nada. Não há lançamento, não há comissão, não há ponto
* de fidelidade — a finalização é que cria os três. Tirar um item digitado
* errado antes de fechar é correção, não reescrita do passado.
*
* Depois de finalizada, o caminho é o ESTORNO. O guard abaixo é o que separa os
* dois casos, e sem ele o mesmo verbo apagaria história.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string; itemId: string }> };
export async function DELETE(_req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const authz = await requireRole("agent", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const { id, itemId } = await ctx.params;
const supabase = await createClient();
const { data: comanda } = await supabase
.from("sales")
.select("id, status, number")
.eq("id", id)
.maybeSingle();
if (!comanda) return fail("not_found", "Comanda não encontrada.", 404, { requestId });
if (comanda.status !== "open") {
return fail(
"conflict",
comanda.status === "finalized"
? "Comanda já finalizada. Para desfazer, use o estorno."
: "Comanda cancelada não aceita alteração.",
409,
{ requestId },
);
}
// `sale_id` no filtro além do `id`: sem ele, um itemId de OUTRA comanda da
// mesma organização seria apagado por esta rota, e a RLS não veria problema
// nenhum — as duas linhas pertencem ao mesmo tenant.
const { data, error } = await supabase
.from("sale_items")
.delete()
.eq("id", itemId)
.eq("sale_id", id)
.select("id")
.maybeSingle();
if (error) return fail("internal_error", error.message, 500, { requestId });
if (!data) return fail("not_found", "Item não encontrado nesta comanda.", 404, { requestId });
await audit({
action: "comanda.item_removido",
resourceType: "sale",
resourceId: id,
requestId,
metadata: { number: comanda.number, item_id: itemId },
});
return ok({ id: itemId, removed: true }, { requestId });
}
@@ -0,0 +1,102 @@
/**
* Os itens da comanda.
*
* ⚠️ A COMISSÃO É RESOLVIDA AQUI, NA INCLUSÃO, e gravada na linha.
* `fn_finalizar_comanda` não consulta `commission_rules` de propósito: mudar a
* regra amanhã não pode mexer no que foi combinado ontem. Se este cálculo
* deixasse de acontecer, o item entraria com 0% e ninguém veria — a comanda
* fecha, o dinheiro entra, e só falta a comissão de quem atendeu.
*
* ⚠️ SÓ COMANDA ABERTA RECEBE ITEM. Depois de finalizada, acrescentar item
* deixaria o total da venda, o lançamento do financeiro e a comissão já gerada
* discordando entre si, sem nada reclamar.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { itemSchema, percentualDaComissao, totalDoItem } from "@/lib/financeiro/comanda";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const authz = await requireRole("agent", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const lido = itemSchema.safeParse(await req.json().catch(() => ({})));
if (!lido.success) {
return fail("validation_failed", lido.error.issues[0]?.message ?? "corpo inválido", 422, {
requestId,
});
}
const { id } = await ctx.params;
const supabase = await createClient();
const org = authz.org.orgId;
const { data: comanda } = await supabase
.from("sales")
.select("id, status, number")
.eq("id", id)
.maybeSingle();
if (!comanda) return fail("not_found", "Comanda não encontrada.", 404, { requestId });
if (comanda.status !== "open") {
return fail("conflict", "Só comanda aberta recebe item.", 409, { requestId });
}
const { data: regras } = await supabase
.from("commission_rules")
.select("attendant_user_id, event_type_id, percent")
.eq("organization_id", org);
const percent = percentualDaComissao(regras ?? [], {
attendantUserId: lido.data.attendant_user_id ?? null,
eventTypeId: lido.data.event_type_id ?? null,
});
const total = totalDoItem({
quantidade: lido.data.quantity,
precoUnitarioCents: lido.data.unit_price_cents,
descontoCents: lido.data.discount_cents,
});
const { data, error } = await supabase
.from("sale_items")
.insert({
organization_id: org,
sale_id: id,
event_type_id: lido.data.event_type_id ?? null,
description: lido.data.description,
attendant_user_id: lido.data.attendant_user_id ?? null,
quantity: lido.data.quantity,
unit_price_cents: lido.data.unit_price_cents,
discount_cents: lido.data.discount_cents,
total_cents: total,
commission_percent: percent,
})
.select("id, description, quantity, unit_price_cents, discount_cents, total_cents, commission_percent")
.single();
if (error) return fail("internal_error", error.message, 500, { requestId });
await audit({
action: "comanda.item_incluido",
resourceType: "sale",
resourceId: id,
requestId,
metadata: { number: comanda.number, item_id: data.id, total_cents: total, commission_percent: percent },
});
return ok(data, { requestId });
}
@@ -0,0 +1,119 @@
/**
* Uma comanda: ler, dar desconto, cancelar.
*
* ⚠️ CANCELAR É STATUS, NUNCA DELETE. Uma comanda cancelada continua contando o
* que aconteceu — quem abriu, quando, e o que chegou a ser lançado nela. É o
* invariante 1 do módulo, e o schema o sustenta: `sale_items` cascateia por
* `sale_id`, o que só é seguro porque a comanda não é apagada.
*
* ⚠️ COMANDA FINALIZADA NÃO ACEITA DESCONTO. Depois de finalizada ela já virou
* lançamento no financeiro e, se houver, comissão e ponto de fidelidade. Mudar
* o desconto ali deixaria os quatro números discordando em silêncio. O caminho
* de desfazer é o ESTORNO, que faz contra-lançamento.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { alterarComandaSchema } from "@/lib/financeiro/comanda";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("viewer", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const { id } = await ctx.params;
const supabase = await createClient();
const { data, error } = await supabase
.from("sales")
// Literal único — ver a nota em `comandas/route.ts`.
.select(
"id, number, status, contact_id, attendant_user_id, appointment_id, discount_cents, total_cents, currency, payment_method_id, notes, finalized_at, cancelled_at, cancel_reason, reversed_at, reverse_reason, created_at, sale_items(id, description, quantity, unit_price_cents, discount_cents, total_cents, commission_percent, attendant_user_id, event_type_id, created_at)",
)
.eq("id", id)
.maybeSingle();
if (error) return fail("internal_error", error.message, 500, { requestId });
if (!data) return fail("not_found", "Comanda não encontrada.", 404, { requestId });
const itens = (data.sale_items ?? []) as Array<{ total_cents: number }>;
const soma = itens.reduce((acc, i) => acc + Number(i.total_cents), 0);
return ok(
{
...data,
items_total_cents: soma,
total_cents:
data.status === "open"
? Math.max(soma - Number(data.discount_cents ?? 0), 0)
: data.total_cents,
},
{ requestId },
);
}
export async function PATCH(req: NextRequest, ctx: Ctx): Promise<Response> {
const requestId = randomUUID();
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const authz = await requireRole("agent", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const lido = alterarComandaSchema.safeParse(await req.json().catch(() => ({})));
if (!lido.success) {
return fail("validation_failed", lido.error.issues[0]?.message ?? "corpo inválido", 422, {
requestId,
});
}
const { id } = await ctx.params;
const supabase = await createClient();
const { data: atual } = await supabase
.from("sales")
.select("id, status, number")
.eq("id", id)
.maybeSingle();
if (!atual) return fail("not_found", "Comanda não encontrada.", 404, { requestId });
if (atual.status !== "open") {
return fail(
"conflict",
atual.status === "finalized"
? "Comanda já finalizada. Para desfazer, use o estorno."
: "Comanda cancelada não aceita alteração.",
409,
{ requestId },
);
}
const mudanca: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (lido.data.discount_cents !== undefined) mudanca.discount_cents = lido.data.discount_cents;
if (lido.data.notes !== undefined) mudanca.notes = lido.data.notes;
if (lido.data.cancel) {
mudanca.status = "cancelled";
mudanca.cancelled_at = new Date().toISOString();
}
const { error } = await supabase.from("sales").update(mudanca).eq("id", id);
if (error) return fail("internal_error", error.message, 500, { requestId });
await audit({
action: lido.data.cancel ? "comanda.cancelada" : "comanda.alterada",
resourceType: "sale",
resourceId: id,
requestId,
metadata: { number: atual.number, ...lido.data },
});
return ok({ id, status: lido.data.cancel ? "cancelled" : "open" }, { requestId });
}
+145
View File
@@ -0,0 +1,145 @@
/**
* A COMANDA — abrir e listar.
*
* A migration 0240 trouxe as tabelas e as duas funções que movem dinheiro
* (`fn_finalizar_comanda`, `fn_estornar_comanda`), e ninguém as chamava: o
* módulo inteiro existia sem porta. Estas rotas são a porta.
*
* ⚠️ CLIENT DE SESSÃO, nunca o admin — e aqui não é só doutrina, é requisito.
* `fn_finalizar_comanda` começa por `auth.uid() is null` e recusa; chamada com a
* service key ela levanta `comanda_forbidden`. A RLS diz quem lê e quem escreve,
* e `requireRole` é só a borda de autenticação.
*/
import { randomUUID } from "node:crypto";
import type { NextRequest } from "next/server";
import { ok, fail } from "@/lib/api/wrappers";
import { audit } from "@/lib/audit";
import { requireRole } from "@/lib/auth/require-role";
import { abrirComandaSchema } from "@/lib/financeiro/comanda";
import { requireSupportWrite } from "@/lib/impersonate/support";
import { createClient } from "@/lib/supabase/server";
export const dynamic = "force-dynamic";
const LIMITE_PADRAO = 50;
const LIMITE_MAXIMO = 200;
export async function GET(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const authz = await requireRole("viewer", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const url = new URL(req.url);
const status = url.searchParams.get("status");
const limite = Math.min(Number(url.searchParams.get("limit")) || LIMITE_PADRAO, LIMITE_MAXIMO);
const supabase = await createClient();
let q = supabase
.from("sales")
// Literal ÚNICO, e não concatenação: o supabase-js infere o tipo do
// resultado a partir do TEXTO do select, e um `+` no meio o reduz a `string`
// — o embed `sale_items(...)` deixa de existir para o TypeScript.
.select(
"id, number, status, contact_id, attendant_user_id, appointment_id, discount_cents, total_cents, currency, payment_method_id, notes, finalized_at, cancelled_at, reversed_at, created_at, sale_items(id, description, quantity, unit_price_cents, discount_cents, total_cents, commission_percent, attendant_user_id, event_type_id)",
)
.order("number", { ascending: false })
.limit(limite);
if (status === "open" || status === "finalized" || status === "cancelled") {
q = q.eq("status", status);
}
const { data, error } = await q;
if (error) return fail("internal_error", error.message, 500, { requestId });
// O total de uma comanda ABERTA é derivado dos itens, sempre. `total_cents` só
// é gravado na finalização, e ler a coluna antes disso mostraria zero numa
// comanda com itens — o tipo de número errado que o operador acredita.
const comandas = (data ?? []).map((c) => {
const itens = (c.sale_items ?? []) as Array<{ total_cents: number }>;
const soma = itens.reduce((acc, i) => acc + Number(i.total_cents), 0);
return {
...c,
total_cents:
c.status === "open" ? Math.max(soma - Number(c.discount_cents ?? 0), 0) : c.total_cents,
};
});
return ok(comandas, { requestId });
}
export async function POST(req: NextRequest): Promise<Response> {
const requestId = randomUUID();
const supportDenied = await requireSupportWrite();
if (supportDenied) return supportDenied;
const authz = await requireRole("agent", { requestId, resource: "financeiro" });
if (!authz.ok) return authz.response;
const lido = abrirComandaSchema.safeParse(await req.json().catch(() => ({})));
if (!lido.success) {
return fail("validation_failed", lido.error.issues[0]?.message ?? "corpo inválido", 422, {
requestId,
});
}
const supabase = await createClient();
const org = authz.org.orgId;
// IDEMPOTÊNCIA POR AGENDAMENTO, antes de qualquer escrita.
//
// O índice único da 0243 é a garantia de verdade (duas requisições simultâneas
// não passam pelas duas consultas). Este atalho existe para o caso comum, o
// toque repetido, devolver a comanda que já existe em vez de um 409 que a tela
// teria de traduzir.
if (lido.data.appointment_id) {
const { data: existente } = await supabase
.from("sales")
.select("id, number, status")
.eq("organization_id", org)
.eq("appointment_id", lido.data.appointment_id)
.neq("status", "cancelled")
.maybeSingle();
if (existente) return ok({ ...existente, ja_existia: true }, { requestId });
}
const { data: numero, error: erroNumero } = await supabase.rpc("fn_proximo_numero_de_comanda", {
p_org: org,
});
if (erroNumero) return fail("internal_error", erroNumero.message, 500, { requestId });
const { data, error } = await supabase
.from("sales")
.insert({
organization_id: org,
number: numero as number,
contact_id: lido.data.contact_id ?? null,
appointment_id: lido.data.appointment_id ?? null,
attendant_user_id: authz.user.id,
created_by_user_id: authz.user.id,
notes: lido.data.notes ?? null,
})
.select("id, number, status")
.single();
if (error) {
// 23505 = a corrida que o índice único pegou: o número foi tomado entre a
// chamada da sequência e o insert, ou o agendamento já tinha comanda.
if (error.code === "23505") {
return fail("conflict", "Esta comanda já foi aberta.", 409, { requestId });
}
return fail("internal_error", error.message, 500, { requestId });
}
await audit({
action: "comanda.aberta",
resourceType: "sale",
resourceId: data.id,
requestId,
metadata: { number: data.number, appointment_id: lido.data.appointment_id ?? null },
});
return ok(data, { requestId });
}
+7
View File
@@ -443,6 +443,13 @@ export const AUDIT_ACTIONS = [
"financeiro.catalogo_criado",
"financeiro.catalogo_alterado",
"financeiro.catalogo_inativado",
"comanda.aberta",
"comanda.alterada",
"comanda.cancelada",
"comanda.item_incluido",
"comanda.item_removido",
"comanda.finalizada",
"comanda.estornada",
// A rodada de renovação — e ela só audita quando FEZ algo, como manda a regra
// do cron desta base. Uma linha por rodada com efeito, carregando a contagem:
// é o que permite responder "quantas agendas precisaram reconectar esta
+516
View File
@@ -124,6 +124,522 @@ export type Database = {
};
Relationships: [];
}
financial_accounts: {
Row: {
created_at: string
currency: string
id: string
is_active: boolean
kind: string
name: string
opening_balance_cents: number
organization_id: string
updated_at: string
}
Insert: {
created_at?: string
currency?: string
id?: string
is_active?: boolean
kind?: string
name: string
opening_balance_cents?: number
organization_id: string
updated_at?: string
}
Update: {
created_at?: string
currency?: string
id?: string
is_active?: boolean
kind?: string
name?: string
opening_balance_cents?: number
organization_id?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "financial_accounts_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
payment_methods: {
Row: {
account_id: string | null
created_at: string
id: string
is_active: boolean
name: string
organization_id: string
updated_at: string
}
Insert: {
account_id?: string | null
created_at?: string
id?: string
is_active?: boolean
name: string
organization_id: string
updated_at?: string
}
Update: {
account_id?: string | null
created_at?: string
id?: string
is_active?: boolean
name?: string
organization_id?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "payment_methods_account_id_fkey"
columns: ["account_id"]
referencedRelation: "financial_accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "payment_methods_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
account_plans: {
Row: {
created_at: string
direction: string
id: string
is_active: boolean
name: string
organization_id: string
updated_at: string
}
Insert: {
created_at?: string
direction: string
id?: string
is_active?: boolean
name: string
organization_id: string
updated_at?: string
}
Update: {
created_at?: string
direction?: string
id?: string
is_active?: boolean
name?: string
organization_id?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "account_plans_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
sales: {
Row: {
appointment_id: string | null
attendant_user_id: string | null
cancel_reason: string | null
cancelled_at: string | null
contact_id: string | null
created_at: string
created_by_user_id: string | null
currency: string
discount_cents: number
finalized_at: string | null
id: string
notes: string | null
number: number
organization_id: string
payment_method_id: string | null
reverse_reason: string | null
reversed_at: string | null
status: string
total_cents: number
updated_at: string
}
Insert: {
appointment_id?: string | null
attendant_user_id?: string | null
cancel_reason?: string | null
cancelled_at?: string | null
contact_id?: string | null
created_at?: string
created_by_user_id?: string | null
currency?: string
discount_cents?: number
finalized_at?: string | null
id?: string
notes?: string | null
number: number
organization_id: string
payment_method_id?: string | null
reverse_reason?: string | null
reversed_at?: string | null
status?: string
total_cents?: number
updated_at?: string
}
Update: {
appointment_id?: string | null
attendant_user_id?: string | null
cancel_reason?: string | null
cancelled_at?: string | null
contact_id?: string | null
created_at?: string
created_by_user_id?: string | null
currency?: string
discount_cents?: number
finalized_at?: string | null
id?: string
notes?: string | null
number?: number
organization_id?: string
payment_method_id?: string | null
reverse_reason?: string | null
reversed_at?: string | null
status?: string
total_cents?: number
updated_at?: string
}
Relationships: [
{
foreignKeyName: "sales_appointment_id_fkey"
columns: ["appointment_id"]
referencedRelation: "calendar_appointments"
referencedColumns: ["id"]
},
{
foreignKeyName: "sales_contact_id_fkey"
columns: ["contact_id"]
referencedRelation: "contacts"
referencedColumns: ["id"]
},
{
foreignKeyName: "sales_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
{
foreignKeyName: "sales_payment_method_id_fkey"
columns: ["payment_method_id"]
referencedRelation: "payment_methods"
referencedColumns: ["id"]
},
]
}
sale_items: {
Row: {
attendant_user_id: string | null
commission_percent: number
created_at: string
description: string
discount_cents: number
event_type_id: string | null
id: string
organization_id: string
quantity: number
sale_id: string
total_cents: number
unit_price_cents: number
}
Insert: {
attendant_user_id?: string | null
commission_percent?: number
created_at?: string
description: string
discount_cents?: number
event_type_id?: string | null
id?: string
organization_id: string
quantity?: number
sale_id: string
total_cents: number
unit_price_cents: number
}
Update: {
attendant_user_id?: string | null
commission_percent?: number
created_at?: string
description?: string
discount_cents?: number
event_type_id?: string | null
id?: string
organization_id?: string
quantity?: number
sale_id?: string
total_cents?: number
unit_price_cents?: number
}
Relationships: [
{
foreignKeyName: "sale_items_event_type_id_fkey"
columns: ["event_type_id"]
referencedRelation: "calendar_event_types"
referencedColumns: ["id"]
},
{
foreignKeyName: "sale_items_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
{
foreignKeyName: "sale_items_sale_id_fkey"
columns: ["sale_id"]
referencedRelation: "sales"
referencedColumns: ["id"]
},
]
}
commission_rules: {
Row: {
attendant_user_id: string | null
created_at: string
event_type_id: string | null
id: string
organization_id: string
percent: number
}
Insert: {
attendant_user_id?: string | null
created_at?: string
event_type_id?: string | null
id?: string
organization_id: string
percent: number
}
Update: {
attendant_user_id?: string | null
created_at?: string
event_type_id?: string | null
id?: string
organization_id?: string
percent?: number
}
Relationships: [
{
foreignKeyName: "commission_rules_event_type_id_fkey"
columns: ["event_type_id"]
referencedRelation: "calendar_event_types"
referencedColumns: ["id"]
},
{
foreignKeyName: "commission_rules_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
]
}
commissions: {
Row: {
amount_cents: number
attendant_user_id: string
created_at: string
id: string
organization_id: string
paid_at: string | null
percent: number
reversed_at: string | null
sale_item_id: string
status: string
}
Insert: {
amount_cents: number
attendant_user_id: string
created_at?: string
id?: string
organization_id: string
paid_at?: string | null
percent: number
reversed_at?: string | null
sale_item_id: string
status?: string
}
Update: {
amount_cents?: number
attendant_user_id?: string
created_at?: string
id?: string
organization_id?: string
paid_at?: string | null
percent?: number
reversed_at?: string | null
sale_item_id?: string
status?: string
}
Relationships: [
{
foreignKeyName: "commissions_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
{
foreignKeyName: "commissions_sale_item_id_fkey"
columns: ["sale_item_id"]
referencedRelation: "sale_items"
referencedColumns: ["id"]
},
]
}
financial_entries: {
Row: {
account_id: string
account_plan_id: string | null
amount_cents: number
created_at: string
created_by_user_id: string | null
currency: string
description: string | null
direction: string
entry_date: string
id: string
organization_id: string
origin: string
paid_at: string | null
reverses_entry_id: string | null
sale_id: string | null
status: string
updated_at: string
}
Insert: {
account_id: string
account_plan_id?: string | null
amount_cents: number
created_at?: string
created_by_user_id?: string | null
currency?: string
description?: string | null
direction: string
entry_date?: string
id?: string
organization_id: string
origin?: string
paid_at?: string | null
reverses_entry_id?: string | null
sale_id?: string | null
status?: string
updated_at?: string
}
Update: {
account_id?: string
account_plan_id?: string | null
amount_cents?: number
created_at?: string
created_by_user_id?: string | null
currency?: string
description?: string | null
direction?: string
entry_date?: string
id?: string
organization_id?: string
origin?: string
paid_at?: string | null
reverses_entry_id?: string | null
sale_id?: string | null
status?: string
updated_at?: string
}
Relationships: [
{
foreignKeyName: "financial_entries_account_id_fkey"
columns: ["account_id"]
referencedRelation: "financial_accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "financial_entries_account_plan_id_fkey"
columns: ["account_plan_id"]
referencedRelation: "account_plans"
referencedColumns: ["id"]
},
{
foreignKeyName: "financial_entries_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
{
foreignKeyName: "financial_entries_sale_id_fkey"
columns: ["sale_id"]
referencedRelation: "sales"
referencedColumns: ["id"]
},
]
}
loyalty_ledger: {
Row: {
contact_id: string
created_at: string
created_by_user_id: string | null
id: string
idempotency_key: string | null
organization_id: string
points: number
reason: string
sale_id: string | null
sale_item_id: string | null
}
Insert: {
contact_id: string
created_at?: string
created_by_user_id?: string | null
id?: string
idempotency_key?: string | null
organization_id: string
points: number
reason: string
sale_id?: string | null
sale_item_id?: string | null
}
Update: {
contact_id?: string
created_at?: string
created_by_user_id?: string | null
id?: string
idempotency_key?: string | null
organization_id?: string
points?: number
reason?: string
sale_id?: string | null
sale_item_id?: string | null
}
Relationships: [
{
foreignKeyName: "loyalty_ledger_contact_id_fkey"
columns: ["contact_id"]
referencedRelation: "contacts"
referencedColumns: ["id"]
},
{
foreignKeyName: "loyalty_ledger_organization_id_fkey"
columns: ["organization_id"]
referencedRelation: "organizations"
referencedColumns: ["id"]
},
{
foreignKeyName: "loyalty_ledger_sale_id_fkey"
columns: ["sale_id"]
referencedRelation: "sales"
referencedColumns: ["id"]
},
]
}
channel_routing_policies: {
Row: {
channel_session_id: string
+108
View File
@@ -0,0 +1,108 @@
/**
* A comissão é a conta que a rota faz ANTES do insert, e que a finalização não
* refaz. Um erro aqui não aparece em teste de tela: a comanda fecha, o dinheiro
* entra, o cliente vai embora satisfeito, e o que falta é a parte de quem
* atendeu — descoberta no fim do mês, por uma pessoa conferindo à mão.
*/
import { describe, expect, it } from "vitest";
import { percentualDaComissao, totalDoItem, type RegraDeComissao } from "./comanda";
const ANA = "11111111-1111-4111-8111-111111111111";
const BIA = "22222222-2222-4222-8222-222222222222";
const MANICURE = "33333333-3333-4333-8333-333333333333";
const PEDICURE = "44444444-4444-4444-8444-444444444444";
const regra = (over: Partial<RegraDeComissao>): RegraDeComissao => ({
attendant_user_id: null,
event_type_id: null,
percent: 0,
...over,
});
describe("percentualDaComissao", () => {
it("sem regra nenhuma é zero, e zero é resposta legítima", () => {
expect(percentualDaComissao([], { attendantUserId: ANA, eventTypeId: MANICURE })).toBe(0);
});
it("regra por SERVIÇO vale para quem atender", () => {
const regras = [regra({ event_type_id: MANICURE, percent: 30 })];
expect(percentualDaComissao(regras, { attendantUserId: ANA, eventTypeId: MANICURE })).toBe(30);
expect(percentualDaComissao(regras, { attendantUserId: BIA, eventTypeId: MANICURE })).toBe(30);
});
it("regra por PESSOA vale para o que ela fizer", () => {
const regras = [regra({ attendant_user_id: ANA, percent: 40 })];
expect(percentualDaComissao(regras, { attendantUserId: ANA, eventTypeId: PEDICURE })).toBe(40);
expect(percentualDaComissao(regras, { attendantUserId: BIA, eventTypeId: PEDICURE })).toBe(0);
});
it("PESSOA vence SERVIÇO — a regra sobre quem atende é mais específica", () => {
const regras = [
regra({ event_type_id: MANICURE, percent: 30 }),
regra({ attendant_user_id: ANA, percent: 40 }),
];
expect(percentualDaComissao(regras, { attendantUserId: ANA, eventTypeId: MANICURE })).toBe(40);
});
it("PESSOA + SERVIÇO vence as duas, mesmo sendo o MENOR percentual", () => {
// O caso que prova que a precedência é por especificidade, e não por valor:
// se ganhasse o maior, a combinação exata que alguém escreveu para este par
// seria ignorada sempre que fosse mais baixa.
const regras = [
regra({ event_type_id: MANICURE, percent: 30 }),
regra({ attendant_user_id: ANA, percent: 40 }),
regra({ attendant_user_id: ANA, event_type_id: MANICURE, percent: 10 }),
];
expect(percentualDaComissao(regras, { attendantUserId: ANA, eventTypeId: MANICURE })).toBe(10);
});
it("a regra exata de OUTRO par não contamina este", () => {
const regras = [
regra({ attendant_user_id: BIA, event_type_id: MANICURE, percent: 90 }),
regra({ event_type_id: MANICURE, percent: 30 }),
];
expect(percentualDaComissao(regras, { attendantUserId: ANA, eventTypeId: MANICURE })).toBe(30);
});
it("item sem profissional cai na regra do serviço, nunca na de uma pessoa", () => {
const regras = [
regra({ attendant_user_id: ANA, percent: 40 }),
regra({ event_type_id: MANICURE, percent: 30 }),
];
expect(percentualDaComissao(regras, { attendantUserId: null, eventTypeId: MANICURE })).toBe(30);
});
it("item sem serviço cai na regra da pessoa", () => {
const regras = [regra({ attendant_user_id: ANA, percent: 40 })];
expect(percentualDaComissao(regras, { attendantUserId: ANA, eventTypeId: null })).toBe(40);
});
it("empate no MESMO nível resolve pelo maior — a favor de quem trabalhou", () => {
const regras = [
regra({ attendant_user_id: ANA, percent: 20 }),
regra({ attendant_user_id: ANA, percent: 35 }),
];
expect(percentualDaComissao(regras, { attendantUserId: ANA, eventTypeId: MANICURE })).toBe(35);
});
});
describe("totalDoItem", () => {
it("quantidade vezes preço, menos o desconto do item", () => {
expect(totalDoItem({ quantidade: 2, precoUnitarioCents: 5000, descontoCents: 1000 })).toBe(9000);
});
it("sem desconto é quantidade vezes preço", () => {
expect(totalDoItem({ quantidade: 3, precoUnitarioCents: 2500, descontoCents: 0 })).toBe(7500);
});
it("desconto maior que o item vira zero, nunca crédito", () => {
// Um total negativo entraria na soma da comanda abatendo os outros itens —
// um desconto que se espalha sem ninguém ter pedido.
expect(totalDoItem({ quantidade: 1, precoUnitarioCents: 3000, descontoCents: 5000 })).toBe(0);
});
it("item de cortesia é zero, e é uma configuração legítima", () => {
expect(totalDoItem({ quantidade: 1, precoUnitarioCents: 0, descontoCents: 0 })).toBe(0);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* As regras da comanda que NÃO moram no banco.
*
* O schema (migration 0240) garante os invariantes: nada é apagado, saldo é
* derivado, lançamento pago é imutável, a comissão fica congelada na linha do
* item. O que sobra para cá são as duas contas que precisam acontecer ANTES do
* insert, e que por isso não podem ser um CHECK: quanto vale o item, e qual
* percentual de comissão a organização combinou para aquele par de pessoa e
* serviço.
*
* As duas são puras. Comissão calculada errado é dinheiro que sai errado do
* bolso de alguém que trabalhou, e isso não se descobre por teste de tela.
*/
import { z } from "zod";
/** Uma linha de `commission_rules`, como a rota a lê. */
export interface RegraDeComissao {
attendant_user_id: string | null;
event_type_id: string | null;
percent: number;
}
/**
* O percentual que vale para este item, pela precedência combinada.
*
* **pessoa + serviço** vence **pessoa**, que vence **serviço**, que vence zero.
* A ordem não é estética: a regra mais específica é a que alguém escreveu
* pensando exatamente naquele caso, e deixá-la perder para uma regra geral
* escrita antes faria a configuração parecer ignorada.
*
* Zero quando nada casa, e zero é resposta legítima: nem todo item gera
* comissão, e inventar um padrão aqui criaria dívida com quem atendeu.
*
* ⚠️ Empate dentro do mesmo nível de especificidade resolve pelo MAIOR
* percentual. Duas regras igualmente específicas são uma configuração
* ambígua — o schema não a proíbe —, e nesse caso errar a favor de quem
* trabalhou é a escolha que não precisa ser explicada depois.
*/
export function percentualDaComissao(
regras: readonly RegraDeComissao[],
alvo: { attendantUserId: string | null; eventTypeId: string | null },
): number {
const { attendantUserId, eventTypeId } = alvo;
const maior = (candidatas: readonly RegraDeComissao[]): number | null =>
candidatas.length === 0 ? null : Math.max(...candidatas.map((r) => Number(r.percent)));
if (attendantUserId && eventTypeId) {
const exatas = maior(
regras.filter(
(r) => r.attendant_user_id === attendantUserId && r.event_type_id === eventTypeId,
),
);
if (exatas !== null) return exatas;
}
if (attendantUserId) {
const porPessoa = maior(
regras.filter((r) => r.attendant_user_id === attendantUserId && r.event_type_id === null),
);
if (porPessoa !== null) return porPessoa;
}
if (eventTypeId) {
const porServico = maior(
regras.filter((r) => r.event_type_id === eventTypeId && r.attendant_user_id === null),
);
if (porServico !== null) return porServico;
}
return 0;
}
/**
* Quanto vale a linha.
*
* O desconto do ITEM entra aqui; o desconto da COMANDA não, e a separação é a
* mesma que o schema faz na comissão: um abatimento dado no caixa não pode
* reduzir o que foi combinado com quem atendeu.
*
* Piso em zero porque um desconto maior que o item viraria crédito silencioso
* na soma da comanda. Recusar a entrada seria pior: quem digitou um desconto
* grande demais quer um item de graça, não um erro.
*/
export function totalDoItem(input: {
quantidade: number;
precoUnitarioCents: number;
descontoCents: number;
}): number {
return Math.max(input.quantidade * input.precoUnitarioCents - input.descontoCents, 0);
}
export const itemSchema = z.object({
event_type_id: z.string().uuid().nullish(),
description: z.string().min(1).max(200),
attendant_user_id: z.string().uuid().nullish(),
quantity: z.number().int().min(1).max(999).default(1),
unit_price_cents: z.number().int().min(0).max(100_000_000),
discount_cents: z.number().int().min(0).max(100_000_000).default(0),
});
export const abrirComandaSchema = z.object({
contact_id: z.string().uuid().nullish(),
/**
* A comanda que nasce de um agendamento.
*
* É o caminho que fecha o laço da agenda: faturar conclui o compromisso, e é
* `fn_finalizar_comanda` quem faz isso. Idempotente por `appointment_id` —
* dois toques no botão não abrem duas comandas para o mesmo atendimento.
*/
appointment_id: z.string().uuid().nullish(),
notes: z.string().max(2000).nullish(),
});
export const alterarComandaSchema = z.object({
discount_cents: z.number().int().min(0).max(100_000_000).optional(),
notes: z.string().max(2000).nullish().optional(),
/** Cancelar é mudança de STATUS, nunca delete: comanda é história. */
cancel: z.literal(true).optional(),
});
export const finalizarSchema = z.object({
payment_method_id: z.string().uuid(),
loyalty_points: z.number().int().min(0).max(10_000).default(0),
});
export const estornarSchema = z.object({
reason: z.string().min(3).max(500),
});
+25
View File
@@ -24633,6 +24633,31 @@ comment on function public.fn_finalizar_comanda(uuid, uuid, uuid, integer) is
'As seis coisas numa transação: venda, comissão por item, entrada na conta da forma de pagamento, ponto de fidelidade e conclusão do agendamento. Idempotente sob FOR UPDATE.';
-- ---- uma comanda por agendamento (migration 0243) ----
-- A rota consulta antes de abrir, e isso resolve o toque repetido, não a
-- corrida: duas requisições simultâneas passam pelas duas consultas antes de
-- qualquer insert. Duas comandas abertas para o mesmo atendimento não dão erro
-- nenhum — são faturadas separadamente, e o cliente paga duas vezes.
--
-- Parcial nas duas pontas: comanda avulsa é a maioria e não se exclui entre si;
-- comanda cancelada deixa de valer, senão cancelar por engano trancaria o
-- agendamento para sempre.
update public.sales s
set appointment_id = null
where s.appointment_id is not null
and s.status <> 'cancelled'
and exists (
select 1 from public.sales anterior
where anterior.appointment_id = s.appointment_id
and anterior.organization_id = s.organization_id
and anterior.status <> 'cancelled'
and (anterior.created_at, anterior.id) < (s.created_at, s.id)
);
create unique index if not exists sales_agendamento_unico_idx
on public.sales (organization_id, appointment_id)
where appointment_id is not null and status <> 'cancelled';
-- ---- VARREDURA anon: função nova nasce exposta em quem ATUALIZA (migration 0116) ----
--
-- ⚠️ ESTE BLOCO É, DE PROPÓSITO, O ÚLTIMO DO ARQUIVO. Apêndice novo entra ANTES
@@ -0,0 +1,37 @@
-- Uma comanda por agendamento, garantido pelo banco.
--
-- A rota que abre comanda a partir de um agendamento consulta antes se já
-- existe, e essa consulta resolve o caso comum (o toque repetido) devolvendo a
-- comanda que já está lá. O que ela NÃO resolve é a corrida: duas requisições
-- simultâneas passam pelas duas consultas antes de qualquer insert, e nascem
-- duas comandas para o mesmo atendimento.
--
-- Duas comandas abertas para o mesmo agendamento não dão erro nenhum. Elas são
-- faturadas separadamente, e o cliente paga o atendimento duas vezes.
--
-- PARCIAL, e as duas condições importam:
-- - `appointment_id is not null` porque comanda avulsa é a maioria, e elas
-- não se excluem entre si;
-- - `status <> 'cancelled'` porque uma comanda cancelada deixa de valer. Sem
-- isso, cancelar por engano trancaria o agendamento para sempre — o erro
-- não teria conserto pela tela.
-- A ordem da doutrina: corrigir os dados ANTES de criar a constraint. Um clone
-- que já tenha rodado a rota sem o índice pode ter o par duplicado, e aí o
-- `update.sh` quebraria no meio. Fica a MAIS ANTIGA de cada agendamento, que é
-- a que tem chance de ter itens lançados.
update public.sales s
set appointment_id = null
where s.appointment_id is not null
and s.status <> 'cancelled'
and exists (
select 1 from public.sales anterior
where anterior.appointment_id = s.appointment_id
and anterior.organization_id = s.organization_id
and anterior.status <> 'cancelled'
and (anterior.created_at, anterior.id) < (s.created_at, s.id)
);
create unique index if not exists sales_agendamento_unico_idx
on public.sales (organization_id, appointment_id)
where appointment_id is not null and status <> 'cancelled';
+1
View File
@@ -296,3 +296,4 @@ To re-apply on a fresh Supabase project, replay the migrations in version order
| `20260911120000` | `0236_opt_in_de_chamada_de_voz` | A chamada de voz nasce DESLIGADA por organização (`org_voice_calls`), com quem aceitou o risco e quando. Ausência de linha é "desligado" — aplicar não liga nada para ninguém. Leitura org-flat, escrita de admin no banco. Baseline INSTALL/UPDATE idempotente. |
| `20260914100000` | `0239_catalogo_financeiro` | Primeira camada do módulo de comanda/financeiro: `financial_accounts` (onde o dinheiro fica), `payment_methods` (como o cliente paga — e cada forma APONTA para a conta em que aquele dinheiro cai) e `account_plans` (classificação, com `direction` in/out). A ordem é obrigatória: a forma de pagamento decide em qual conta a entrada é lançada quando a comanda é finalizada, então sem esta camada a comanda não tem onde depositar. **Nenhum saldo é gravado** — `opening_balance_cents` é o ponto de partida declarado, e o saldo corrente é sempre derivado por soma (saldo gravado e lançamentos divergem no primeiro estorno, sem dar sinal). Dinheiro em `_cents` + `currency`. `payment_methods.account_id` é `on delete restrict` de propósito: cascata deixaria formas apontando para o nada e lançamentos futuros sem destino, em silêncio. RLS tenant-aware com escrita de **manager+** (quem atende não define plano de contas). Nada disso existia no destino. |
| `20260914110000` | `0240_comanda_e_financeiro` | A comanda e o que ela move: `sales`, `sale_items`, `commission_rules`, `commissions`, `financial_entries`, `loyalty_ledger` — mais `fn_finalizar_comanda` e `fn_estornar_comanda`. A finalização faz **seis coisas numa transação** (venda, comissão por item, entrada na conta que a forma de pagamento determina, ponto de fidelidade, conclusão do agendamento), sob `FOR UPDATE` — sem o lock, duas finalizações simultâneas geravam lançamento em dobro. **Invariantes no schema, não na prosa:** nada é apagado (cancela/inativa); nenhum saldo é coluna (nem de conta, nem de fidelidade — é sempre soma); estorno é contra-lançamento com `reverses_entry_id`, nunca exclusão; comissão é resolvida na INCLUSÃO do item e congelada na linha, e a finalização não recalcula; numeração por organização não reinicia; e um trigger recusa UPDATE em lançamento já pago (valor/conta/direção/data). O CHECK `sales_finalizada_tem_forma` põe no schema a regra de que finalizar exige forma de pagamento. A conclusão do agendamento tem a guarda `status not in ('cancelled','no_show')` que o sistema de origem não tinha em todos os caminhos. |
| `20260914140000` | `0243_comanda_por_agendamento_e_unica` | Índice único parcial `sales (organization_id, appointment_id) where appointment_id is not null and status <> 'cancelled'`. A rota consulta antes de abrir, o que resolve o toque repetido mas não a corrida — e duas comandas para o mesmo atendimento não dão erro nenhum: são faturadas separadamente e o cliente paga duas vezes. Parcial nas duas pontas (avulsa é a maioria; cancelada deixa de valer, senão cancelar por engano trancaria o agendamento para sempre). Dedup antes do índice, mantendo a comanda mais antiga. |