Integration — Bubble × Backend
Página do handshake real Bubble × backend FastAPI × Omie. O backend é um reconciler autônomo: puxa Orders modificados via Bubble Data API com cursor (CDC pull), decide o que fazer, executa a sequência Omie, projeta o estado de volta no Omie Sync State. Zero workflows Bubble novos.
Divisão de responsabilidade — Bubble vs Backend
Seção intitulada “Divisão de responsabilidade — Bubble vs Backend”Quem faz o quê. Tudo que não está nesta tabela é decisão isolada de um lado.
| Responsabilidade | Bubble | Backend |
|---|---|---|
| Criar/editar Order (no-code) | Sim | — |
Receber webhook Pagar.me charge.paid (HMAC + idempotência) | Sim (legado) | — |
Mutar Order.status_pedido após pagamento | Sim (workflow legado) | — |
| Reconciler cron 1h (CDC pull com cursor) | — | Sim |
| Shadow store local dos Orders + cursor | — | Sim |
| Detecta state delta + roteia para transição | — | Sim |
Regra dia 25 (utils/dia25.compute_billing_date) | — | Sim |
billing_service.decide_billing | — | Sim |
Chama Omie (IncluirCliente/IncluirProjeto/IncluirOS/FaturarOS/EmitirNFSe) | — | Sim |
Projeta para Bubble Omie Sync State (PATCH Data API) | — | Sim |
Botão backoffice “Forçar sync agora” → /sync-omie | Sim (UI) | Sim (handler) |
Lê + exibe Omie Sync State (Repeating Groups, badges) | Sim | — |
Sequence — reconciler tick (caminho automático)
Seção intitulada “Sequence — reconciler tick (caminho automático)”A cada hora, o backend desperta, varre Orders modificados desde o último cursor, decide o que fazer com cada um, executa a sequência Omie e projeta o estado de volta.
sequenceDiagram
autonumber
participant Cron as Cron (1h)
participant API as Backend reconciler
participant PG as Postgres backend<br/>(bubble_sync_cursor + order_shadow)
participant B as Bubble Data API
participant O as Omie
Cron->>API: tick
API->>PG: SELECT last_modified_date FROM bubble_sync_cursor
PG-->>API: 2026-06-09T10:00:00Z
API->>B: GET /api/1.1/obj/order<br/>:search constraint<br/>modified_date > 2026-06-09T10:00:00Z
B-->>API: N orders modificados
loop por order
API->>PG: upsert order_shadow + diff vs anterior
API->>API: decide_billing(order, shadow_delta)
alt status_pedido virou confirmado
API->>API: regra dia 25 + trigger
API->>O: FaturarOS + EmitirNFSe (ou agenda)
O-->>API: nfse_id + numero_nfse
API->>B: PATCH Omie Sync State<br/>status_faturamento=nf_emitida
else nenhuma transição relevante
API->>API: noop · só atualiza shadow
end
end
API->>PG: UPDATE bubble_sync_cursor<br/>SET last_modified_date = MAX(order.modified_date)
Latência típica confirmação → NF emitida: até 60min (vale do cursor próximo + tempo de processamento). Aceitável dado throughput baixo (~10 pedidos/dia) e fluxo fiscal tolerar essa janela.
Sequence — manual force re-sync (caminho operador)
Seção intitulada “Sequence — manual force re-sync (caminho operador)”Operador no backoffice clica “Forçar sync agora” no pedido. Bubble dispara POST /v1/orders/{id}/sync-omie. O mesmo loop do reconciler roda para 1 order, ignorando o cursor.
sequenceDiagram
autonumber
participant U as Operador (Bubble UI)
participant API as Backend
participant B as Bubble Data API
participant O as Omie
U->>API: POST /v1/orders/{id}/sync-omie<br/>Authorization Bearer + Idempotency-Key
API->>B: GET order/{id} (refresh shadow)
B-->>API: order completo
API->>API: decide_billing(order)
API->>O: sequência apropriada (FaturarOS, etc)
API->>B: PATCH Omie Sync State
API-->>U: 200 · projeção ok
Endpoint manual = mesmo handler do reconciler, parametrizado pra 1 bubble_order_id. Idempotência por header (key fixa <order_id>:manual-sync ou UUID por clique — operador escolhe).
Opções consideradas + descartadas
Seção intitulada “Opções consideradas + descartadas”| Opção | Mecanismo | Por que NÃO |
|---|---|---|
| descartado Webhook push Bubble→backend (Opção C antiga, ADR-9 v1) | Workflow Bubble dispara POST após status_pedido mudar | Depende de workflow Bubble (frágil — falha silenciosa, sem retry nativo). Acopla backend a config no-code. |
| descartado Cron polling 60s | Backend varre Bubble a cada 60s | Gasta Bubble Data API à toa (1440 Searches/dia) pra throughput baixo. Plano Pro 4 req/s é folgado, mas custo desnecessário. |
| descartado Backend recebe webhook Pagar.me direto | Migrar Pagar.me Console pro endpoint backend | Webhook Bubble Pagar.me já funcionando (HMAC + idempotência). Migrar = retrabalho fora do MVP-11 + downtime de coordenação. Adiado pra V2. |
| adotado CDC pull com cursor 1h | Backend puxa Orders com modified_date > cursor a cada 1h | Zero workflow Bubble novo. 24 calls/dia. Tolerância de latência aceita. Cursor garante avanço monotônico. |
Por que tick = 1 hora
Seção intitulada “Por que tick = 1 hora”Throughput de pedidos é baixo (~10/dia em produção). O fluxo fiscal aceita latência horária (regra dia 25 já tolera múltiplos dias de espera). A escolha conservadora protege o orçamento da Bubble Data API e o circuit breaker do Omie.
| Métrica | Valor |
|---|---|
| Calls Bubble Data API por dia | 24 (1 Search por tick) |
Latência típica charge.paid → NF emitida | até 60min (varia conforme posição no ciclo) |
| Latência pior caso (Omie em ban 31min) | até ~90min |
| Esforço backend (vs Opção C antiga) | sem endpoint novo · cron */60 * + cursor + shadow |
| Resiliência | reconciler é dono do ritmo · retoma de onde parou (cursor) |
Manual override existe pra casos urgentes — operador clica “Forçar sync” e o reconciler roda imediatamente pra aquele 1 order.
Cursor persistence — schema backend
Seção intitulada “Cursor persistence — schema backend”Cursor monotônico vive em uma tabela simples no Postgres do backend.
-- backend/alembic/versions/<rev>_bubble_sync_cursor.pyCREATE TABLE bubble_sync_cursor ( id SMALLINT PRIMARY KEY DEFAULT 1, -- singleton last_modified_date TIMESTAMPTZ NOT NULL, last_tick_at TIMESTAMPTZ NOT NULL, orders_processed BIGINT NOT NULL DEFAULT 0, CHECK (id = 1));
-- Shadow store: snapshot local do que veio do Bubble, pra detectar deltaCREATE TABLE order_shadow ( bubble_order_id TEXT PRIMARY KEY, modified_date TIMESTAMPTZ NOT NULL, status_pedido TEXT NOT NULL, payload_json JSONB NOT NULL, last_seen_at TIMESTAMPTZ NOT NULL);Regras:
- Cursor avança apenas se o tick terminou processamento sem erro fatal. Se 5xx no meio, cursor fica e próximo tick reprocessa o range.
- Cursor inicial (cold start) =
now() - 24hpor padrão, configurável viaBUBBLE_SYNC_CURSOR_BOOTSTRAP. order_shadowé trim-friendly — pode ser recortado por retenção (ex: 30 dias) sem perder correção (cursor sozinho basta pra detectar futuras mudanças).- Diff
shadow → freshé o que dispara a decisão. Se nada mudou em campos relevantes (status_pedido,valor_final_venda,payment_confirmed_at), tick é noop.
Reconciler — pseudocódigo
Seção intitulada “Reconciler — pseudocódigo”Loop único, idempotente, reentrante. Executa idêntico no tick automático e no force-sync manual.
# backend/src/blu_omie/jobs/reconciler.py (Slice F — não escrito ainda)# Cron ARQ — *cdc_tick*. Reentrante: chamar 2× no mesmo range é noop.@cron("0 * * * *") # toda hora cheiaasync def cdc_tick(): async with db.begin() as tx: cursor = await tx.fetch_one( "SELECT last_modified_date FROM bubble_sync_cursor WHERE id = 1" ) since = cursor.last_modified_date
orders = await bubble.search( type="order", constraints=[ {"key": "modified_date", "constraint_type": "greater than", "value": since.isoformat()}, ], sort_field="modified_date", descending="no", cursor_pagination=True, )
processed = 0 last_seen = since for order in orders: try: await _reconcile_one(order) processed += 1 last_seen = max(last_seen, order["modified_date"]) except Exception: log.exception("reconciler.order_failed", bubble_order_id=order["_id"]) # NÃO avança cursor neste order — próximo tick reprocessa o range. return
async with db.begin() as tx: await tx.execute( "UPDATE bubble_sync_cursor SET last_modified_date = :ts, " "last_tick_at = NOW(), orders_processed = orders_processed + :n " "WHERE id = 1", {"ts": last_seen, "n": processed}, )
async def _reconcile_one(order: dict) -> None: """Core idempotente — também chamado por POST /sync-omie manual.""" bubble_order_id = order["_id"] correlation_id = f"cdc:{bubble_order_id}:{order['modified_date']}"
shadow = await shadow_store.upsert(bubble_order_id, order) delta = shadow.delta # {} se nada relevante mudou
if not delta: return # noop · só refresca shadow
customer = await bubble.fetch_customer(order["bubble_customer_id"]) profile = CustomerBillingProfile( requires_manual_confirmation=customer["flag_requires_confirm"], cutoff_policy=( BillingCutoffPolicy(active=True, cutoff_day=25, rollover_day=1) if customer["pessoa_juridica"] and customer["regra_dia25_ativa"] else None ), )
# status_pedido virou confirmado pela primeira vez nesta janela? if delta.get("status_pedido") == "confirmado": decision = decide_billing( BillingRequest( trigger=BillingTrigger.immediate, event_date=order["event_date"], today=date.today(), ), profile=profile, ) await _execute_billing_decision( bubble_order_id, decision, correlation_id ) return
# status_pedido virou cancelado? if delta.get("status_pedido") == "cancelado": await _execute_cancel_flow(bubble_order_id, correlation_id) return
# Outras transições (valor mudou, payment_confirmed_at chegou tarde, etc) # caem em handlers específicos que o reconciler ramifica. await _execute_generic_resync(bubble_order_id, delta, correlation_id)POST /v1/orders/{id}/sync-omie (manual) chama _reconcile_one(order) direto, sem mexer no cursor:
@router.post("/v1/orders/{bubble_order_id}/sync-omie")async def manual_force_sync(bubble_order_id: str, body: SyncOmieBody): cached = await idempotency.lookup(...) if cached: return cached order = await bubble.fetch_order(bubble_order_id) await _reconcile_one(order) return {"status": "ok", "trigger": "manual", "actor": body.actor}Endpoints consolidados — o que sobra Bubble-facing
Seção intitulada “Endpoints consolidados — o que sobra Bubble-facing”| Endpoint | Status | Substituto |
|---|---|---|
POST /v1/orders/{id}/sync-omie | ativo | manual force re-sync (botão backoffice) |
POST /v1/orders/{id}/payment-confirmed | REMOVIDO | reconciler detecta status_pedido → confirmado no shadow delta |
POST /v1/orders/{id}/retry | REMOVIDO | reconciler retenta em erro_faturamento automaticamente (com backoff) ou operador clica /sync-omie |
POST /v1/orders/{id}/cancel-nf | REMOVIDO | reconciler detecta status_pedido → cancelado no shadow delta |
POST /v1/orders/{id}/mark-nf-manual | REMOVIDO | Bubble grava campo no Order → reconciler reflete em Omie Sync State |
POST /v1/orders/{id}/override-day25 | REMOVIDO | operador edita Order.billing_scheduled_date no Bubble → reconciler propaga |
Todas as ações que antes eram endpoints separados viram edições no Order Bubble que o reconciler observa via modified_date. Backend = bus de execução fiscal; Bubble = bus de intenção comercial.
Cliente PJ — caminho ANTES do dia 25
Seção intitulada “Cliente PJ — caminho ANTES do dia 25”Hoje = 2026-05-10 (≤ 25 → fatura imediato). Pagar.me confirma 10:05, próximo tick às 11:00 detecta status_pedido → confirmado, decide scheduled, executa Omie imediato.
sequenceDiagram
autonumber
participant P as Pagar.me
participant B as Bubble
participant Cron as Cron 1h
participant API as Reconciler
participant O as Omie
P->>B: webhook charge.paid (10:05)
B->>B: workflow legado · status_pedido = confirmado
Note over B: order.modified_date = 10:05
Cron->>API: tick (11:00)
API->>B: search modified_date > 10:00
B-->>API: [order_X]
API->>API: delta detected · status_pedido=confirmado
API->>API: decide_billing → scheduled (today=10 ≤ 25)
API->>O: FaturarOS + EmitirNFSe IMEDIATO
O-->>API: nfse_id + numero_nfse
API->>B: PATCH Omie Sync State · status_faturamento=nf_emitida
Cliente PJ POS dia 25 = mesma sequência, mas decide_billing → deferred_by_cutoff → reconciler agenda run_billing_job ARQ pra scheduled_date = 1º dia útil mês seguinte.
Como o handler manual cobre os endpoints antigos
Seção intitulada “Como o handler manual cobre os endpoints antigos”Tudo que antes era endpoint dedicado vira: edita campo no Order Bubble + (opcional) força tick imediato via /sync-omie.
| Ação operador | Antes (endpoints) | Agora (CDC) |
|---|---|---|
| Retentar NF após erro | POST /retry | Operador clica “Forçar sync” → /sync-omie (manual). Reconciler também retenta sozinho no próximo tick se omie_ban_active=no. |
| Cancelar NF | POST /cancel-nf | Operador muda Order.status_pedido = cancelado no Bubble. Reconciler detecta no próximo tick → executa CancelarNFSe. Pra urgência: clica “Forçar sync”. |
| Marcar NF manual | POST /mark-nf-manual | Operador preenche Order.omie_nfse_numero_manual + Order.faturamento_manual = yes no Bubble. Reconciler reflete em Omie Sync State no próximo tick. |
| Override dia 25 | POST /override-day25 | Operador edita Order.billing_scheduled_date_override no Bubble + motivo. Reconciler reagenda run_billing_job pra nova data. |
Vantagem: menos endpoints, menos workflows Bubble. Operador escreve intenção no Order; backend reconcilia.
Onde a regra dia 25 vive
Seção intitulada “Onde a regra dia 25 vive”| Arquivo | Responsabilidade |
|---|---|
backend/src/blu_omie/utils/dia25.py | Pure logic. BillingCutoffPolicy(active, cutoff_day=25, rollover_day=1) + compute_billing_date(desired, today, policy) → ScheduledDate(date, deferred, reason). Sem I/O. |
backend/src/blu_omie/services/billing_service.py | Compõe dia25 + perfil cliente. decide_billing(request, profile) → BillingDecision(status, scheduled_date, reason). |
backend/src/blu_omie/jobs/reconciler.py planned | Slice F — cron cdc_tick + _reconcile_one. Não escrito ainda. |
backend/src/blu_omie/services/invoice_service.py | emit_invoice chama FaturarOS + EmitirNFSe via omie_call_logged. Já existe. |
Pontos pro Luiz
Seção intitulada “Pontos pro Luiz”- Regra dia 25 não está no Bubble — fica em
backend/src/blu_omie/utils/dia25.pycomo pure logic testada. - Decisão fiscal acontece dentro do reconciler (cron ou manual), nunca no Bubble.
- Cliente PF nunca bate na regra; cliente PJ só bate se
regra_dia25_ativa=Trueno cadastro. - Quando posterga: backend grava
billing_scheduled_date+status_faturamento=bloqueado_dia25noOmie Sync State. ARQ acorda na data, chama Omie. - Override (operador edita data): hoje basta mudar o campo no Order Bubble (
billing_scheduled_date_override+ motivo). Reconciler propaga. Auditoria emaction_logdo backend. - Latência horária do reconciler é aceitável pro fluxo (10 pedidos/dia, dia 25 já tolera espera). Botão “Forçar sync” cobre urgências.
Próximos passos
Seção intitulada “Próximos passos”- Modelo de dados completo da projeção CQRS no Bubble: Omie Sync State.
- Setup Bubble passo a passo (Data Type + Option Sets, sem workflows): Bubble Dev.
- ADR formal da arquitetura CDC: Decisions · ADR-9.