diff --git a/HANDOFF.md b/HANDOFF.md
index aa25244..0c39f1f 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -1,38 +1,36 @@
-# HANDOFF — 2026-05-18 noite (C1-C4 OK, UX convênio refinado, C5 ainda não rodou save)
+# HANDOFF — 2026-05-19 (C1-C6 ✅, próximo C7 — pacote upfront)
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
> testes manuais dos 13 cenários do doc viva
-> `src/docs/agenda-compromisso-financeiro-cenarios.html`. C1-C4 ✅, **C5
-> ainda não rodou save** — a tarde do dia 18 foi consumida refinando UX
-> de convênio (3 bugs/melhorias) e preparando o C5. Próximo passo:
-> **executar de fato o save do C5** (Sándor + Unimed Nacional + R$ 95).
+> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C6 ✅**.
+> Próximo passo: **Cenário 7** (Donald Winnicott · pacote UPFRONT · 4 ×
+> R$ 200 = cobrança única de R$ 800).
-> **🟡 WORKING TREE BEM PESADO** — refactor de payment, indicadores
-> visuais (barra verde + popover + Resumo do dialog), inline quick-create
-> de procedimento, fix de rota convênios, botão "+ Novo convênio",
-> hint contextual. Migrations da Fase 5 já rodadas em 14/05. **Considerar
-> commitar antes de mais trabalho** — diff tá grande.
+> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 da manhã. Migration
+> nova (20260519000001) já rodada no DB local. Pronto pra próximo bloco.
---
-## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 5 (Convênio)
-
-Receita do doc HTML. Resumo:
+## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 7 (Pacote UPFRONT)
| Campo | Valor |
|---|---|
-| Paciente | **Sándor** |
-| Convênio | **Unimed Nacional** (criar via `InsurancePlanQuickCreateDialog` se não existir) |
-| Valor | **R$ 95** |
+| Paciente | **Donald Winnicott** |
+| Frequência | **Semanal · 4 ocorrências** |
+| Serviço | qualquer (ex: Sessão particular R$ 200) |
+| Cobrança ao salvar | **Gerar cobrança** + estilo **"Pacote único (upfront)"** |
+| Total esperado | **R$ 800** (4 × 200) |
**Esperado:**
-- Record com `insurance_plan_id` preenchido + pill "convênio" visível
-- Na agenda: badge $ amber (record pendente até fechamento mensal do convênio)
-- Popover: linha amber "A receber R$ 95,00 (cobrança pendente)"
+- 1 row em `recurrence_rules` + **1 row em `agenda_eventos`** (1ª materializada)
+- 1 row em `billing_contracts` (type=package, charging_style=upfront, total_sessions=4, package_price=800)
+- **1 row em `financial_records`** com amount=800, status=pending (cobrança ÚNICA do pacote, vencimento na 1ª sessão)
+- Agenda: 1ª com badge $ (R$ 800 a cobrar) + 3 virtuais limpas (cobertas pelo pacote)
-Após o 5 passar: 6-9 (recorrentes) → 10-13 (status change + edit cobrada). Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
+Após C7: C8 (saldo) → C9 (per_session) → C10-C13 (status change + edit cobrada).
+Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
---
@@ -150,7 +148,9 @@ User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
| 2 | Avulsa sem cobrança | ✅ |
| 3 | Avulsa cobrar ao salvar | ✅ |
| 4 | Avulsa "já recebi" no salvar | ✅ |
-| **5** | **Avulsa convênio (Sándor + Unimed)** | 🔴 **PRÓXIMO** |
+| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
+| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
+| **7** | **Pacote UPFRONT (Donald Winnicott 4 × R$ 200)** | 🔴 **PRÓXIMO** |
| 6 | Recorrente sem pacote (Anna Freud 4 sem) | ⏳ |
| 7 | Pacote upfront (Donald Winnicott 4× R$ 200) | ⏳ |
| 8 | Pacote saldo (Carl Jung 4× R$ 40) | ⏳ |
diff --git a/Obsidian/Brain/log.md b/Obsidian/Brain/log.md
index 68e1edf..eae2218 100644
--- a/Obsidian/Brain/log.md
+++ b/Obsidian/Brain/log.md
@@ -14,6 +14,74 @@ Chronological, append-only record of everything that's happened in this wiki.
---
+## [2026-05-19 14:00] session | C5+C6 OK + atalho gerar fatura + RPC idempotencia fix
+Touched: project_rpc_idempotency_cancelled (nova)
+Detalhes: sessao longa cobrindo C5 e C6 ate green checkmark, com varios
+fixes intermediarios:
+
+CENARIO 5 (Sandor + Unimed convenio):
+- 2 bugs no save: amount lia 'price' (null em convenio) e payment_method
+ caia em 'asaas' por fallback. Fix: detecta convenio via insurance_plan_id,
+ amount=insurance_value, payment_method='convenio'. markPaidNow ignorado
+ pra convenio (sempre nasce pending).
+- 2 bugs na visualizacao: popover so mostrava "Cobranca pendente" sem
+ valor (lia ev.price) e /financeiro nao mostrava pill convenio.
+ Fix: normalize expoe insurance_value; popover fallback price ?? insurance_value;
+ /financeiro ganhou branch pra payment_method='convenio' (pill violeta).
+
+CENARIO 6 (Maria Magali, recorrente sem pacote):
+- chargeMode='none' nao materializava 1a (todas viravam virtuais, sem badge).
+ Fix: adicionado bloco no fluxo de criacao recorrente que materializa 1a
+ quando chargeMode='none' && paciente_id. Package/per_session ja
+ materializam nos seus helpers; package saldo intencionalmente NAO.
+- Primeira tentativa de fix usou paciente_id (Portuguese) mas
+ agendaRepository dropa esse campo (legado). Corrigido pra patient_id
+ (English DB column). Fallback `normalized.patient_id ?? normalized.paciente_id`.
+
+ATALHO "Gerar fatura" no popover:
+- Pill amber "Gerar fatura" ao lado de "A cobrar R$ X" no popover.
+- Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick.
+- Fecha popover apos sucesso pra impedir click duplicado.
+- Tooltip simplificado: "Gerar fatura agora".
+
+BUG RPC IDEMPOTENCIA (gravado em memoria):
+- create_financial_record_for_session checava idempotencia por
+ agenda_evento_id + deleted_at IS NULL, MAS sem filtrar status='cancelled'.
+ User cancelava cobranca sem querer → todo regenerar retornava cancelado
+ e nada inseria. Toast verde mentindo.
+- Fix: migration 20260519000001 adiciona AND status != 'cancelled'.
+- Memoria salva em project_rpc_idempotency_cancelled.md (padrao a aplicar
+ em toda RPC futura com idempotencia por chave natural).
+
+INFO PACOTE NO POPOVER:
+- Header do popover agora mostra "Sessao · Pacote · N sessoes" ou similar
+ (computed seriesLabel le do _raw). Visual: violeta sem caps lock.
+
+TITULO DIALOG "Sessao do Pacote · Sessao":
+- Quando commitment name eh "Sessao" (default), drop pra evitar duplicacao.
+ Outros nomes (Avaliacao etc) permanecem com forma completa.
+
+CSS:
+- .aed-row-50 perdeu margin-bottom (user request).
+- .field-card.mb-4 ganhou margin-top: 1rem (scoped via composer wrappers).
+
+OUTROS BUGS RESOLVIDOS NO CAMINHO:
+- AgendaEventoFinanceiroPanel.fetchRecord agora filtra cancelled.
+- bulk-load do useMelissaAgenda agora filtra cancelled.
+- recurrence_exceptions cancel agora usa upsert (idempotente, nao quebra
+ com unique constraint quando user cancela 2x ou tem exception zumbi).
+- "Excluir serie inteira" botao novo no popover (hard delete: rule +
+ materializadas + financial_records pendentes; bloqueia se paid).
+- cancel_session exception agora SOME da agenda (era visivel com status
+ cancelado, doc dizia "some da agenda" mas codigo mantinha).
+- ConfiguracoesConveniosPage ganhou botao "+ Novo convenio" (faltava).
+- goToConveniosConfig removida (dead code pos quick-create inline).
+- Quick-create inline de procedimento (InsurancePlanServiceQuickCreateDialog).
+- Hint contextual abaixo do card Sessao/Honorarios (convenio = "N guia
+ opcional"; gratuito = "sem cobranca").
+
+PROXIMO: Cenario 7 (Donald Winnicott · pacote UPFRONT · 4 × R$ 200).
+
## [2026-05-18 23:30] session | UX de convenio refinado (3 fixes) + hint contextual
Touched: none (sem nova wiki page; tudo em codigo + HANDOFF)
Detalhes: tarde inteira consumida em refinar UX de convenio antes do
diff --git a/database-novo/migrations/20260519000001_create_financial_record_ignore_cancelled.sql b/database-novo/migrations/20260519000001_create_financial_record_ignore_cancelled.sql
new file mode 100644
index 0000000..853dc98
--- /dev/null
+++ b/database-novo/migrations/20260519000001_create_financial_record_ignore_cancelled.sql
@@ -0,0 +1,86 @@
+-- ============================================================================
+-- create_financial_record_for_session: idempotência ignora cancelled
+-- ----------------------------------------------------------------------------
+-- Bug: a função recusava criar um novo financial_record quando já existia
+-- um record cancelled pro mesmo agenda_evento_id, porque a checagem de
+-- idempotência só filtrava `deleted_at IS NULL` (e cancel preserva
+-- deleted_at = NULL pra manter auditoria).
+--
+-- Consequência: user cancelava cobrança sem querer e ficava preso — todo
+-- "Gerar cobrança" subsequente retornava o registro cancelado sem inserir
+-- nova linha (frontend recebia data, achava sucesso, mas DB ficava como
+-- estava).
+--
+-- Fix: adiciona `AND status != 'cancelled'` na checagem. Cancelled passa a
+-- ser tratado como "sem cobrança ativa" pra idempotência. Audit history
+-- continua preservado (rows cancelled permanecem na tabela).
+--
+-- Idempotente: CREATE OR REPLACE substitui a função existente.
+-- ============================================================================
+
+BEGIN;
+
+CREATE OR REPLACE FUNCTION public.create_financial_record_for_session(
+ p_tenant_id uuid,
+ p_owner_id uuid,
+ p_patient_id uuid,
+ p_agenda_evento_id uuid,
+ p_amount numeric,
+ p_due_date date
+) RETURNS SETOF public.financial_records
+ LANGUAGE plpgsql SECURITY DEFINER
+ SET search_path TO 'public'
+ AS $$
+DECLARE
+ v_existing public.financial_records%ROWTYPE;
+ v_new public.financial_records%ROWTYPE;
+BEGIN
+ -- Idempotência: retorna o registro existente se já foi criado.
+ -- Ignora cancelled (treat as "no active record") pra permitir regenerar
+ -- cobrança após cancelamento.
+ SELECT * INTO v_existing
+ FROM public.financial_records
+ WHERE agenda_evento_id = p_agenda_evento_id
+ AND deleted_at IS NULL
+ AND status != 'cancelled'
+ LIMIT 1;
+
+ IF FOUND THEN
+ RETURN NEXT v_existing;
+ RETURN;
+ END IF;
+
+ -- Cria o novo registro
+ INSERT INTO public.financial_records (
+ tenant_id,
+ owner_id,
+ patient_id,
+ agenda_evento_id,
+ amount,
+ discount_amount,
+ final_amount,
+ status,
+ due_date
+ ) VALUES (
+ p_tenant_id,
+ p_owner_id,
+ p_patient_id,
+ p_agenda_evento_id,
+ p_amount,
+ 0,
+ p_amount,
+ 'pending',
+ p_due_date
+ )
+ RETURNING * INTO v_new;
+
+ -- Marca o evento da agenda como billed = true
+ UPDATE public.agenda_eventos
+ SET billed = TRUE
+ WHERE id = p_agenda_evento_id;
+
+ RETURN NEXT v_new;
+END;
+$$;
+
+COMMIT;
diff --git a/src/components/agenda/AgendaEventoFinanceiroPanel.vue b/src/components/agenda/AgendaEventoFinanceiroPanel.vue
index cadc00a..bb23822 100644
--- a/src/components/agenda/AgendaEventoFinanceiroPanel.vue
+++ b/src/components/agenda/AgendaEventoFinanceiroPanel.vue
@@ -106,10 +106,15 @@ async function fetchRecord() {
fetching.value = true;
try {
+ // Ignora records cancelados — permite que o user gere nova cobrança
+ // após cancelar (caso comum: cancelou sem querer ou quer recobrar).
+ // Sem esse filtro, o scenario ficava em 'com-cobranca' mostrando
+ // o cancelado, e o botão "Gerar cobrança" sumia.
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id)
+ .neq('status', 'cancelled')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
diff --git a/src/features/agenda/components/AgendaEventDialog.vue b/src/features/agenda/components/AgendaEventDialog.vue
index 0ee8fba..ace059b 100644
--- a/src/features/agenda/components/AgendaEventDialog.vue
+++ b/src/features/agenda/components/AgendaEventDialog.vue
@@ -924,7 +924,14 @@ const headerMainLabel = computed(() => {
const name = selectedCommitment.value?.name || '';
if (!name) return headerTitle.value;
if (isEdit.value) {
- if (hasSerie.value) return `Sessão do Pacote · ${name}`;
+ if (hasSerie.value) {
+ // Evita "Sessão do Pacote · Sessão" (nome duplicado) — quando
+ // o commitment chama 'Sessão' (default), mostra só "Sessão do
+ // Pacote". Outros nomes (ex: Avaliação) entram normais.
+ const lower = name.toLowerCase();
+ if (lower === 'sessão' || lower === 'sessao') return 'Sessão do Pacote';
+ return `Sessão do Pacote · ${name}`;
+ }
return `Editar ${name}`;
}
if (isPacote.value) {
@@ -2303,20 +2310,6 @@ onBeforeUnmount(() => {
-
-
-
- Nº da guia é opcional — você pode salvar a sessão e preencher depois, quando o convênio responder.
-