mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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:
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user