Falar com o estúdioContato
Falar com o estúdio
06/7

Varejo de instrumentos musicais

Estoque nas duas lojas, venda e fechamento de caixa

Faz Play

PDV que divide uma venda entre 5 formas de pagamento e fecha o caixa conferido por cédula.

Cliente
F Salomão Instrumentos Musicais
Categoria
PDV / Retail
Período
mar 2025 – jul 2026
Estado
Em produção (emissão fiscal em homologação)

O problema

Loja de instrumentos musicais com duas unidades, uma delas dentro de shopping. Vender é a parte fácil: o dia vai embora em conferir caixa, declarar faturamento pro shopping, dar nota, controlar o mesmo estoque em dois endereços, fechar comissão e mandar XML pro contador. PDV de prateleira não atende duas contas de adquirente, maquininhas de operadoras diferentes e o relatório de repasse do shopping ao mesmo tempo. E trocar de sistema no meio do movimento não é opção.

O que foi construído

Um único app Flutter que roda como PWA no balcão e como app Android no salão, sobre Firebase (Auth, Firestore, Storage, Crashlytics); iOS, macOS e Windows são alvos configurados, não distribuídos. A venda aceita pagamento dividido entre cinco trilhas heterogêneas na mesma comanda, e a nota sai da tela de detalhe da venda por um backend Node próprio contra a Focus NFe. No repositório, o CNPJ do cliente ainda está roteado para a base de homologação. O caixa fecha com conferência por cédula, valor esperado calculado (abertura + vendas em dinheiro − sangrias) e foto do fechamento no Storage.

5
no app Android (4 no PWA)
Formas de pagamento na mesma venda
3
NFC-e, NFC-e com CPF e NF-e
Tipos de nota fiscal na tela da venda
2
uma com repasse ao shopping
Lojas com estoque e caixa separados
12
denominações, de R$ 200 a R$ 0,05
Cédulas e moedas contadas no caixa

Decisões de engenharia

01

Pagamento dividido entre 5 trilhas na mesma venda

Uma comanda pode ser paga em partes por dinheiro, PIX, duas maquininhas Mercado Pago Point com device IDs distintos e uma maquininha PagBank por Bluetooth. Cada parcela tem aprovação, status e retry independentes: se a terceira trilha falhar, as duas já aprovadas não se perdem. Por trás existem duas contas Mercado Pago com tokens e segredos de webhook separados, e o webhook descobre a qual conta a notificação pertence pelo segredo que validou a assinatura.

lib/principal.dart:352-486 (_processarPagamentos); lib/dialogo_pagamento_screen.dart:284-318 (5 itens do dropdown); pdv-jaguar-backend/controllers/mercadoPagoController.js:10-35 (duas contas), :506 (conta resolvida pelo segredo que validou o HMAC)

02

Confirmação de pagamento sem depender do webhook

O handler valida HMAC-SHA256 do x-signature contra os segredos das duas contas MP, com timingSafeEqual, antes de aceitar a notificação. Em paralelo, a tela escuta o documento da venda em tempo real e roda polling ativo a cada 3s, com timeout de 120s no PIX e 180s na maquininha. Webhook perdido ou atrasado não trava o caixa.

pdv-jaguar-backend/controllers/mercadoPagoController.js:464-570 (HMAC em :482-486); lib/pagamento_pix_screen.dart:61-111 (polling 3s, timeout 120s); lib/pagamento_maquininha_screen.dart:51,94-96 (timeout 180s)

03

Ponte Kotlin para o SDK PlugPag da PagBank

Uma camada de Kotlin resolve o que o SDK impõe: instância única de PlugPag por processo com double-checked locking, chamadas bloqueantes em thread própria, permissões de Bluetooth do Android 12+ negociadas em runtime e eventos da maquininha ("insira o cartão", "senha", "processando") streamados pro Dart por EventChannel. No web e no iOS a opção nem chega a aparecer na tela.

android/app/src/main/kotlin/com/example/loja_vendas_com_firebase/PlugPagBridge.kt:38-53,96-98,204 (403 linhas); lib/services/plugpag_service.dart:117-123; android/app/build.gradle:103

04

Venda e baixa de estoque no mesmo WriteBatch

Cada item carrega o local de estoque, e o WriteBatch que fecha a venda decrementa quantidadeTotal e o campo da loja (quantidadeSjp ou quantidadeFazenda) por FieldValue.increment: venda e baixa acontecem juntas ou nenhuma acontece. Excluir a venda estorna dentro de runTransaction antes de deletar. O caixa exige conferência por cédula em 12 denominações tanto na abertura quanto no fechamento, e o fechamento é recusado se o físico divergir do esperado em mais de R$ 1,00.

lib/principal.dart:617-657; lib/relatorio_bloc.dart:180-222; lib/calculadora_caixa.dart:20-33 (12 denominações); lib/caixa_Screen.dart:342-398 (valor esperado), :637 e :735 (conferência obrigatória), :909-919 (tolerância de R$ 1,00 no fechamento)

Stack

App (web e Android em produção)
Flutter / Dart SDK ^3.6.1, Material 3
Estado e UI
flutter_bloc 9.1.1, rxdart 0.27.7, fl_chart 0.68.0
Dados, auth e observabilidade
Cloud Firestore 5.6.5, Firebase Auth 5.5.1, Storage 12.4.4, Crashlytics 4.3.10
Pagamentos
Mercado Pago SDK Node ^2.0.7 (Point, PIX, Checkout Pro) + PagBank PlugPag 4.15.1
Ponte nativa Android
Kotlin, MethodChannel + EventChannel ('pdvrapido/plugpag')
Backend fiscal e de pagamentos
Node + Express 4.19 no Render → Focus NFe (NF-e 55 / NFC-e 65), nodemailer, adm-zip
Documentos e hospedagem
package pdf + printing (cupom térmico 80 mm), Firebase Hosting com CanvasKit servido local
FlutterFirebaseFirestoreMercado Pago PointPIXPagBank PlugPagNFC-e / NF-ePDV

Telas

Estoque separado por loja, SJP e Fazenda, no mesmo produto (valores do cliente omitidos)
Estoque separado por loja, SJP e Fazenda, no mesmo produto (valores do cliente omitidos)
A venda no balcão, com desconto em % ou em reais
A venda no balcão, com desconto em % ou em reais
Abertura, sangria e fechamento de caixa por loja (valores omitidos)
Abertura, sangria e fechamento de caixa por loja (valores omitidos)
Cadastro de produto com quantidade em cada uma das duas lojas
Cadastro de produto com quantidade em cada uma das duas lojas
Ordem de serviço, com foto do instrumento
Ordem de serviço, com foto do instrumento

O papel da CG Mixart

Único desenvolvedor do projeto: arquitetura e implementação do app Flutter (6 plataformas alvo, 5 configuradas no Firebase), do backend Node/Express no Render, das integrações com Mercado Pago (Point, PIX, Checkout Pro), com a PagBank via ponte nativa Kotlin e com a Focus NFe, além da modelagem do Firestore, geração de PDF e cupom térmico, e do deploy no Firebase Hosting.

Ressalvas e notas técnicas, verificadas no códigoabrir

Cliente e escopo: a operação tem duas unidades, "Centro Musical SJP" (dentro de shopping) e "Faz Play Fazenda". O app trata as duas com estoque e caixa separados. O host de produção e os dois backends no Render respondem 200; o emitente configurado é CNPJ real com IE e responsável técnico exigidos pela SEFAZ-PR (lib/services/nfe_service.dart:18-42). O projeto começou em março de 2025 e a última alteração é de julho de 2026. CORRIGIDO nesta revisão (roteamento entre as duas contas de adquirente): a versão anterior da ficha afirmava que "o roteamento entre as duas contas é decidido pelo prefixo do device ID". Isso não é recurso, é bug. Os dois device IDs reais estão em lib/principal.dart:84 (azul, da SJP) e :88 (amarela), e ambos começam com "NEWLAND"; o terceiro, da Fazenda, é placeholder (:85-86). Tanto o app (lib/pagamento_maquininha_screen.dart:288) quanto o backend (controllers/mercadoPagoController.js:105) fazem `deviceId.startsWith("NEWLAND") ? "amarela" : "sjp"`. Ou seja, a maquininha azul da SJP também cai nas credenciais da conta amarela. O que de fato funciona é a resolução no webhook, por qual dos dois segredos validou o HMAC (mercadoPagoController.js:506). O prefixo precisa virar uma tabela device ID → conta. Ainda em pagamentos: `_deviceIdFazenda` é o literal 'ID_DA_MAQUININHA_FAZENDA_SE_HOUVER_OUTRA' (lib/principal.dart:85-86). A trilha Point da unidade Fazenda não está configurada; as 5 formas de pagamento existem de verdade no balcão da SJP. Emissão fiscal: a métrica conta as opções que existem na tela da venda, não notas autorizadas em produção. O `.env.example` do backend documenta `FOCUS_NFE_BASE_URL_HOMO` como "Homologação (fsalomão durante testes)" e explicita que "o backend escolhe homo se o CNPJ do payload for 54887255000147", que é exatamente o CNPJ do emitente configurado no app. Os dois serviços trazem `BASES_POR_CNPJ = { "54887255000147": FOCUS_NFE_BASE_URL_HOMO }` com o comentário que marca a F Salomão como "homologação durante testes" (services/focusNfeService.js:29-33, services/focusNfceService.js:32-36), a base default cai em homologacao.focusnfe.com.br (focusNfeService.js:26) e o mock é `NFE_MOCK !== "false"` (focusNfeService.js:20, focusNfceService.js:23), ou seja, ligado se a env faltar. O `.env.example` traz `NFE_MOCK=false`, mas quem manda é o Render. Em contrapartida, nfe_service.dart:18-19 traz comentário afirmando que os dados do emitente foram "conferidos contra NF-e modelo 55 já autorizada em produção (ref FSL-..., 14/05/2026)". É indício, não prova, e `ambienteHomologacao = false` (:15) é só rótulo de UI. Anunciar emissão fiscal como recurso de produção exige o env do Render, não o repositório; por isso ficou fora da tagline. CORRIGIDO nesta revisão: desktop saiu da solução. Existem seis diretórios de plataforma, mas build/ só tem artefato android e web; macOS reusa o appId de iOS e Windows reusa o appId de web no firebase.json; e firebase_options.dart lança UnsupportedError para Linux. Desktop é alvo configurado, não app distribuído. CORRIGIDO nesta revisão: contradição interna sobre pdf/printing. A ficha anterior dizia que esses pacotes "não constam da stack publicada" enquanto os listava na camada "Documentos e hospedagem". Estão listados de propósito: o cupom térmico 80 mm é real e usado (lib/relatorio.dart:571, lib/relatorio_shopping.dart:300, lib/venda_do_dia_screen.dart:408). O que está errado é a declaração: pdf, printing e path_provider estão sob `dev_dependencies` (pubspec.yaml:64, 67-69), o que faz o build de release funcionar por sorte, não por projeto. Mover para `dependencies`. Já `provider` é importado em lib/main.dart:15 e lib/configuracoes.dart:2 sem estar declarado em lugar nenhum do pubspec (entra transitivo). Por isso não entra na stack. Regras de segurança do Firestore: não existem versionadas. `lib/firestore.rules` está no repo com 0 byte e o `firebase.json` não declara seção `firestore`: nada de regras é deployado por este projeto, e a proteção do banco vive só no console, fora de controle de versão e de review. É o item que eu resolveria primeiro. App Check não está ativado. Não há `firebase_app_check` no pubspec.yaml nem no lock; o único arquivo que tocava no assunto (lib/bacaptela_caixa.dart) está inteiro comentado, órfão e com chave reCAPTCHA hardcoded. Combinado com o item acima, o acesso ao Firestore hoje se apoia só em Auth. Guarda de estado final no webhook: existe apenas na cópia em Cloud Functions, que está fora de uso. O `functions/index.js` passa os bindings mpTokenSJP, mpSecretSJP, mpTokenAmarela e mpSecretAmarela ao setGlobalOptions (:40) sem nunca declará-los (só MERCADO_PAGO_ACCESS_TOKEN e MERCADO_PAGO_WEBHOOK_SECRET são criados, :22-23), então o módulo estoura ReferenceError na carga. O webhook que atende produção é o do Render e faz `saleDocRef.update()` direto (mercadoPagoController.js:562-563), sem transaction e sem guarda: um webhook "rejected" atrasado pode sobrescrever uma venda já concluída. É a próxima correção da fila. A duplicação Functions/Render é dívida assumida, e existem dois hosts Render distintos para o mesmo backend, ambos no ar: meu-backend-mp (lib/mercado_pago_service.dart:22) e pdv-jaguar-backend (lib/services/nfe_service.dart:11). Os dois precisam ser unificados. O serviço de análise por IA aponta pra um túnel de desenvolvimento (https://heavy-places-bet.loca.lt em lib/services/ai_analysis_service.dart:7): o chat que devolve gráficos em fl_chart está em validação, não em produção. O README ainda é o boilerplate do Flutter. Módulos verificados que não couberam nos destaques: ordem de serviço e orçamento, comissão por meta com vales e bonificações, chat interno com FCM, importação de produtos por XML de NF-e do fornecedor e por Excel, scanner de código de barras, relatório de repasse pro shopping em PDF, cupom térmico 80 mm, perfis admin/gerente/vendedor. O front web recebeu tuning pra iPhone: inicializações em paralelo antes do primeiro frame, cache do Firestore com teto de 40 MB (main.dart:46), CanvasKit local em vez de CDN (web/index.html:48, firebase.json:60) e overlay de loading removido no evento flutter-first-frame (web/index.html:51).