Pular para o conteúdo

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.

ResponsabilidadeBubbleBackend
Criar/editar Order (no-code)Sim
Receber webhook Pagar.me charge.paid (HMAC + idempotência)Sim (legado)
Mutar Order.status_pedido após pagamentoSim (workflow legado)
Reconciler cron 1h (CDC pull com cursor)Sim
Shadow store local dos Orders + cursorSim
Detecta state delta + roteia para transiçãoSim
Regra dia 25 (utils/dia25.compute_billing_date)Sim
billing_service.decide_billingSim
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-omieSim (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çãoMecanismoPor que NÃO
descartado Webhook push Bubble→backend (Opção C antiga, ADR-9 v1)Workflow Bubble dispara POST após status_pedido mudarDepende de workflow Bubble (frágil — falha silenciosa, sem retry nativo). Acopla backend a config no-code.
descartado Cron polling 60sBackend varre Bubble a cada 60sGasta 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 diretoMigrar Pagar.me Console pro endpoint backendWebhook 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 1hBackend puxa Orders com modified_date > cursor a cada 1hZero workflow Bubble novo. 24 calls/dia. Tolerância de latência aceita. Cursor garante avanço monotônico.

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étricaValor
Calls Bubble Data API por dia24 (1 Search por tick)
Latência típica charge.paid → NF emitidaaté 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ênciareconciler é 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 monotônico vive em uma tabela simples no Postgres do backend.

-- backend/alembic/versions/<rev>_bubble_sync_cursor.py
CREATE 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 delta
CREATE 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() - 24h por padrão, configurável via BUBBLE_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.

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 cheia
async 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”
EndpointStatusSubstituto
POST /v1/orders/{id}/sync-omieativomanual force re-sync (botão backoffice)
POST /v1/orders/{id}/payment-confirmedREMOVIDOreconciler detecta status_pedido → confirmado no shadow delta
POST /v1/orders/{id}/retryREMOVIDOreconciler retenta em erro_faturamento automaticamente (com backoff) ou operador clica /sync-omie
POST /v1/orders/{id}/cancel-nfREMOVIDOreconciler detecta status_pedido → cancelado no shadow delta
POST /v1/orders/{id}/mark-nf-manualREMOVIDOBubble grava campo no Order → reconciler reflete em Omie Sync State
POST /v1/orders/{id}/override-day25REMOVIDOoperador 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.

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.

Tudo que antes era endpoint dedicado vira: edita campo no Order Bubble + (opcional) força tick imediato via /sync-omie.

Ação operadorAntes (endpoints)Agora (CDC)
Retentar NF após erroPOST /retryOperador clica “Forçar sync” → /sync-omie (manual). Reconciler também retenta sozinho no próximo tick se omie_ban_active=no.
Cancelar NFPOST /cancel-nfOperador muda Order.status_pedido = cancelado no Bubble. Reconciler detecta no próximo tick → executa CancelarNFSe. Pra urgência: clica “Forçar sync”.
Marcar NF manualPOST /mark-nf-manualOperador preenche Order.omie_nfse_numero_manual + Order.faturamento_manual = yes no Bubble. Reconciler reflete em Omie Sync State no próximo tick.
Override dia 25POST /override-day25Operador 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.

ArquivoResponsabilidade
backend/src/blu_omie/utils/dia25.pyPure 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.pyCompõe dia25 + perfil cliente. decide_billing(request, profile) → BillingDecision(status, scheduled_date, reason).
backend/src/blu_omie/jobs/reconciler.py plannedSlice F — cron cdc_tick + _reconcile_one. Não escrito ainda.
backend/src/blu_omie/services/invoice_service.pyemit_invoice chama FaturarOS + EmitirNFSe via omie_call_logged. Já existe.
  • Regra dia 25 não está no Bubble — fica em backend/src/blu_omie/utils/dia25.py como 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=True no cadastro.
  • Quando posterga: backend grava billing_scheduled_date + status_faturamento=bloqueado_dia25 no Omie 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 em action_log do 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.