Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b494a6d
feat(apps): Integração com Bling ERP na plataforma
vitorrgg Aug 4, 2026
4fb2666
fix(apps): Registrar falhas de exportação automática no log do app Bling
vitorrgg Aug 5, 2026
c51443a
fix(bling-erp): Cancelar pedido devolvido e exigir token no callback
vitorrgg Aug 10, 2026
8db7b72
fix(bling-erp): Não desativar integração por corrida no refresh do token
vitorrgg Aug 10, 2026
7738fee
fix(bling-erp): Não regredir status de pedido importado do Bling
vitorrgg Aug 10, 2026
2dcaa6a
fix(bling-erp): Comparar estoque pelo saldo físico do depósito no Bling
vitorrgg Aug 10, 2026
d264598
ci: Falhar quando o pnpm-lock.yaml estiver desatualizado
vitorrgg Aug 10, 2026
eec45a1
fix(bling-erp): Voltar callback Bling a exigir token só quando config…
vitorrgg Aug 10, 2026
303a028
fix(bling-erp): Unificar base de estoque de import e export do Bling
vitorrgg Aug 10, 2026
9ba6861
fix(bling-erp): Proteger releitura do token Bling contra falha do Fir…
vitorrgg Aug 10, 2026
3bd3c33
fix(bling-erp): Impedir que escritas do próprio app voltem como evento
vitorrgg Aug 13, 2026
a086efd
fix(bling-erp): Corrigir estoque que só descia em loja com pedidos re…
vitorrgg Aug 13, 2026
83ee9e2
fix(bling-erp): Blindar callback contra documento de aplicação arbitr…
vitorrgg Aug 13, 2026
8572f4c
fix(bling-erp): Não descartar itens da fila quando o limite diário do…
vitorrgg Aug 13, 2026
975b03d
fix(bling-erp): Respeitar o limite de requisições do Bling em chamada…
vitorrgg Aug 13, 2026
f4deea2
fix(bling-erp): Smoke test sem matar a integração da loja
vitorrgg Aug 13, 2026
72f8f83
ci: Retirar validação de lockfile desta PR
vitorrgg Aug 13, 2026
c8caabd
fix(bling-erp): Importar o rastreio completo enviado pelo callback do…
vitorrgg Aug 14, 2026
b023212
fix(bling-erp): Invalidar tokens ao trocar as credenciais do Bling
vitorrgg Aug 14, 2026
9bae005
fix(bling-erp): Manter upload de imagens funcionando em instância quente
vitorrgg Aug 14, 2026
eab62a6
perf(bling-erp): Poupar a cota diária do Bling em caminhos que não us…
vitorrgg Aug 14, 2026
c7df2e7
chore(bling-erp): Ajustes menores apontados no review
vitorrgg Aug 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ inputs:
description: 'Analytics: Awin API key'
tinyerp-token:
description: 'App: Tiny ERP API token'
blingerp-callback-token:
description: 'App: Random token (https://randomkeygen.com/) to validate Bling ERP callbacks'
galaxpay-partner-id:
description: 'App: Optional Galax Pay partner ID'
galaxpay-partner-hash:
Expand Down Expand Up @@ -305,6 +307,7 @@ runs:
AWIN_ADVERTISER_ID: ${{ inputs.awin-advertiser-id }}
AWIN_API_KEY: ${{ inputs.awin-api-key }}
TINYERP_TOKEN: ${{ inputs.tinyerp-token }}
BLINGERP_CALLBACK_TOKEN: ${{ inputs.blingerp-callback-token }}
GALAXPAY_PARTNER_ID: ${{ inputs.galaxpay-partner-id }}
GALAXPAY_PARTNER_HASH: ${{ inputs.galaxpay-partner-hash }}
DATAFRETE_OPERATOR_TOKEN: ${{ inputs.datafrete-operator-token }}
Expand Down Expand Up @@ -363,6 +366,7 @@ runs:
AWIN_ADVERTISER_ID=$AWIN_ADVERTISER_ID
AWIN_API_KEY=$AWIN_API_KEY
TINYERP_TOKEN=$TINYERP_TOKEN
BLINGERP_CALLBACK_TOKEN=$BLINGERP_CALLBACK_TOKEN
GALAXPAY_PARTNER_ID=$GALAXPAY_PARTNER_ID
GALAXPAY_PARTNER_HASH=$GALAXPAY_PARTNER_HASH
DATAFRETE_OPERATOR_TOKEN=$DATAFRETE_OPERATOR_TOKEN
Expand Down
1 change: 1 addition & 0 deletions packages/apps/bling-erp/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Please refer to GitHub [repository releases](https://github.com/ecomplus/cloud-commerce/releases) or monorepo unified [CHANGELOG.md](https://github.com/ecomplus/cloud-commerce/blob/main/CHANGELOG.md).
74 changes: 74 additions & 0 deletions packages/apps/bling-erp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# `@cloudcommerce/app-bling-erp`

Integração com o [Bling ERP](https://www.bling.com.br/) usando a
[API v3](https://developer.bling.com.br/referencia), portada do app
`app-bling-erp-v2` para o monorepo Cloud Commerce.

## Funções

| Função | Descrição |
|---|---|
| `blingerp-onStoreEvent` | Trata eventos da loja (pedidos, produtos e fila manual em `applications-dataSet`) exportando/importando do Bling |
| `blingerp-callback` | Recebe os callbacks de estoque e pedidos configurados no Bling |
| `blingerp-authCallback` | Recebe o `code` do fluxo OAuth do Bling e salva os tokens |
| `blingerp-cronRefreshToken` | Renova o `access_token` antes de expirar (`CRONTAB_BLINGERP_REFRESH_TOKEN`) |

## Autorização

1. Configure `client_id` e `client_secret` (do aplicativo criado no
[Bling Developer](https://developer.bling.com.br/aplicativos)) nas configurações do app;
2. Cadastre a URL de redirecionamento do aplicativo no Bling apontando para a função
`blingerp-authCallback`:
`https://<region>-<project>.cloudfunctions.net/blingerp-authCallback`;
3. Autorize o aplicativo pelo Bling — os tokens ficam salvos no Firestore em
`blingTokens/{clientId}`.

## Callbacks do Bling

Cadastre no Bling (Preferências > Integrações > Callbacks) a URL da função
`blingerp-callback`. Recomendado: defina a variável de ambiente
`BLINGERP_CALLBACK_TOKEN` (input `blingerp-callback-token` na GitHub Action de
deploy, ou o campo `callback_token` nas configurações do app) e
inclua `?token=<valor>` na URL. Sem isso o app aceita qualquer requisição com corpo
válido (e registra um aviso no log) — o conteúdo do callback não é confiado, todos
os dados são relidos da API do Bling, mas o token evita processamento indevido.

## Produtos com variações

Preencha o **código (SKU) de cada variação no Bling**. Variações criadas sem código
são importadas usando o ID do Bling como SKU na loja — funciona, inclusive para
sincronizar estoque, mas gera SKUs numéricos. Se o código for preenchido depois, a
variação passa a ser tratada como uma nova (o casamento é por SKU).

O Bling ignora o preço enviado em cada variação ao salvar o produto pai, aplicando o
preço do pai a todas; o app corrige isso com um `PUT /produtos/{idVariacao}` apenas
para as variações com preço diferente do produto principal.

## Testes

```bash
pnpm --filter @cloudcommerce/app-bling-erp build
pnpm --filter @cloudcommerce/app-bling-erp test
```

Os testes em `tests/` cobrem os parsers (pedido/produto/status/endereço em ambas as
direções) e rodam offline — sem credenciais do Bling nem da Store API.

Para validar credenciais e endpoints contra a API real (somente leitura, nada é
criado ou alterado):

```bash
BLING_ACCESS_TOKEN=... node scripts/bling-smoke.mjs [SKU] [NUMERO_PEDIDO]
```

Use o `access_token` do doc `blingTokens/{clientId}` de uma loja já autorizada
(válido por ~6h). O script também aceita `BLING_CLIENT_ID` + `BLING_CLIENT_SECRET`
+ `BLING_REFRESH_TOKEN`, **mas o Bling rotaciona o refresh token a cada uso**: se
ele veio de uma integração ativa, grave o novo refresh token impresso pelo script
de volta no doc do Firestore, senão o próximo refresh da integração recebe
`invalid_grant` e ela é bloqueada até reautorizar.

## Coleções no Firestore

- `blingTokens/{clientId}`: tokens OAuth, flags de bloqueio e de limite diário;
- `blingStatuses/{clientId}`: cache (1h) das situações do módulo de vendas.
41 changes: 41 additions & 0 deletions packages/apps/bling-erp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@cloudcommerce/app-bling-erp",
"type": "module",
"version": "2.61.2",
"description": "e-com.plus Cloud Commerce app for Bling ERP",
"main": "lib/bling-erp.js",
"files": [
"/lib",
"/lib-mjs",
"/types",
"/*.{js,mjs,ts}"
],
"repository": {
"type": "git",
"url": "git+https://github.com/ecomplus/cloud-commerce.git",
"directory": "packages/apps/bling-erp"
},
"author": "E-Com Club Softwares para E-commerce <ti@e-com.club>",
"license": "MIT",
"bugs": {
"url": "https://github.com/ecomplus/cloud-commerce/issues"
},
"homepage": "https://github.com/ecomplus/cloud-commerce/tree/main/packages/apps/bling-erp#readme",
"scripts": {
"build": "bash ../../../scripts/build-lib.sh",
"test": "bash scripts/tests.sh"
},
"dependencies": {
"@cloudcommerce/api": "workspace:*",
"@cloudcommerce/firebase": "workspace:*",
"@ecomplus/utils": "1.5.0-rc.6",
"axios": "^1.18.0",
"firebase-admin": "^13.10.0",
"firebase-functions": "^7.2.5",
"image-size": "^2.0.2"
},
"devDependencies": {
"@cloudcommerce/types": "workspace:*",
"@firebase/app-types": "^0.9.5"
}
}
166 changes: 166 additions & 0 deletions packages/apps/bling-erp/scripts/bling-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/* eslint-disable no-console */
/*
Read-only smoke test against the real Bling API (v3), to validate credentials,
scopes and every endpoint used by the app. Nothing is created or updated.

Usage:
BLING_ACCESS_TOKEN=... node scripts/bling-smoke.mjs [SKU] [NUMERO_PEDIDO]

Get `BLING_ACCESS_TOKEN` from the `access_token` on the Firestore doc
`blingTokens/{clientId}` of an already authorized store (valid for ~6h).

Running with BLING_CLIENT_ID + BLING_CLIENT_SECRET + BLING_REFRESH_TOKEN is
also supported BUT the refresh token is rotated on use: the store integration
keeps the old one and gets `invalid_grant` on the next refresh, blocking it.
Only use it with a token that is NOT stored by a live integration, or save the
printed new refresh token back to the Firestore doc right after running.
*/
import { URLSearchParams } from 'node:url';

const {
BLING_ACCESS_TOKEN: directAccessToken,
BLING_CLIENT_ID: clientId,
BLING_CLIENT_SECRET: clientSecret,
BLING_REFRESH_TOKEN: refreshToken,
} = process.env;
const [sku, orderNumber] = process.argv.slice(2);

if (!directAccessToken && (!clientId || !clientSecret || !refreshToken)) {
console.error('Set BLING_ACCESS_TOKEN (preferred, no token rotation)'
+ ' or BLING_CLIENT_ID + BLING_CLIENT_SECRET + BLING_REFRESH_TOKEN');
process.exit(1);
}

const BASE_URL = 'https://api.bling.com.br/Api/v3';
let checks = 0;
let failures = 0;

const getAccessToken = async () => {
if (directAccessToken) {
console.log('✓ Using BLING_ACCESS_TOKEN directly (no refresh, nothing is rotated)');
return directAccessToken;
}
console.warn('⚠ Refreshing rotates the refresh token: if it came from a live'
+ ' integration, save the printed new refresh_token back to the Firestore doc'
+ ' `blingTokens/{clientId}`, or the integration will be blocked on invalid_grant');
const res = await fetch(`${BASE_URL}/oauth/token`, {
method: 'POST',
headers: {
'Accept': '1.0',
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
}).toString(),
});
const data = await res.json();
if (!res.ok || !data.access_token) {
throw new Error(`OAuth failed (${res.status}): ${JSON.stringify(data)}`);
}
console.log(`✓ OAuth refresh_token => access_token (expires_in ${data.expires_in}s)`);
console.log(` next refresh_token: ${data.refresh_token}`);
return data.access_token;
};

const accessToken = await getAccessToken();

const check = async (label, endpoint, { optional = false } = {}) => {
checks += 1;
// Bling rate limit: 3 req/s
await new Promise((resolve) => { setTimeout(resolve, 400); });
const res = await fetch(`${BASE_URL}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
});
let body;
try {
body = await res.json();
} catch {
body = null;
}
if (!res.ok) {
const msg = `${res.status} ${JSON.stringify(body?.error || body)}`;
if (optional) {
console.log(`~ ${label}: ${msg}`);
} else {
failures += 1;
console.log(`✗ ${label} [${endpoint}]: ${msg}`);
}
return null;
}
const { data } = body;
const count = Array.isArray(data) ? `${data.length} item(s)` : 'object';
console.log(`✓ ${label} [${endpoint}]: ${count}`);
if (process.env.BLING_SMOKE_DUMP) {
console.log(`${JSON.stringify(data, null, 2)}\n`);
}
return data;
};

const modules = await check('Situações: módulos', '/situacoes/modulos');
const salesModule = Array.isArray(modules)
&& modules.find(({ nome }) => nome?.toLowerCase() === 'vendas');
if (salesModule) {
const situacoes = await check(
'Situações do módulo de vendas',
`/situacoes/modulos/${salesModule.id}`,
);
if (Array.isArray(situacoes)) {
console.log(` ${situacoes.map(({ nome }) => nome).join(', ')}`);
}
} else {
failures += 1;
console.log('✗ Módulo "Vendas" não encontrado em /situacoes/modulos');
}

await check('Tipos de contato', '/contatos/tipos');
await check('Formas de pagamento', '/formas-pagamentos');
await check('Categorias de produtos', '/categorias/produtos');

let sampleSku = sku;
if (!sampleSku) {
const produtos = await check('Primeiro produto (amostra)', '/produtos?limite=1');
sampleSku = Array.isArray(produtos) && produtos[0]?.codigo;
}
if (sampleSku) {
const produtos = await check(
`Produto por código ${sampleSku}`,
`/produtos?codigo=${sampleSku}`,
);
const blingProduct = Array.isArray(produtos) && produtos[0];
if (blingProduct) {
const produto = await check('Produto completo', `/produtos/${blingProduct.id}`);
const idsProdutos = [blingProduct.id]
.concat((produto?.variacoes || []).map(({ id }) => id));
await check(
'Saldos de estoque',
`/estoques/saldos?${idsProdutos.map((id) => `idsProdutos[]=${id}`).join('&')}`,
);
}
}

let sampleOrderNumber = orderNumber;
if (!sampleOrderNumber) {
const pedidos = await check('Último pedido (amostra)', '/pedidos/vendas?limite=1');
sampleOrderNumber = Array.isArray(pedidos) && pedidos[0]?.numero;
}
if (sampleOrderNumber) {
const pedidos = await check(
`Pedido por número ${sampleOrderNumber}`,
`/pedidos/vendas?numero=${sampleOrderNumber}`,
);
const blingOrder = Array.isArray(pedidos) && pedidos[0];
if (blingOrder) {
const pedido = await check('Pedido completo', `/pedidos/vendas/${blingOrder.id}`);
if (pedido?.situacao?.id) {
await check('Situação do pedido', `/situacoes/${pedido.situacao.id}`);
}
}
}

console.log(`\n${checks - failures}/${checks} checks OK`);
process.exit(failures ? 1 : 0);
15 changes: 15 additions & 0 deletions packages/apps/bling-erp/scripts/tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/bash

# Parsers are pure functions, no Bling nor Store API credentials needed,
# just stub env vars for `@cloudcommerce/firebase` config module.
export SETTINGS_FILEPATH="$(pwd)/tests/settings.json"
export ECOM_STORE_ID="${ECOM_STORE_ID:-1011}"
export ECOM_AUTHENTICATION_ID="${ECOM_AUTHENTICATION_ID:-000000000000000000000000}"
export ECOM_API_KEY="${ECOM_API_KEY:-test}"

if [ ! -d lib ]; then
echo -e "Run \`pnpm build\` before testing\n"
exit 1
fi

node --test tests/
72 changes: 72 additions & 0 deletions packages/apps/bling-erp/src/bling-auth-callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Request, Response } from 'firebase-functions/v1';
import { Timestamp } from 'firebase-admin/firestore';
import api from '@cloudcommerce/api';
import config, { logger } from '@cloudcommerce/firebase/lib/config';
import updateAppData from '@cloudcommerce/firebase/lib/helpers/update-app-data';
import blingAuth from './bling-auth/create-auth';
import getTokensDocRef, { EXPIRES_IN_GAP_SEC } from './bling-auth/tokens-doc';
import { createBlingClient } from './bling-auth/client';

/*
Receives the redirect from Bling authorization flow with the `code` to be
exchanged for access/refresh tokens:
https://developer.bling.com.br/aplicativos#fluxo-de-autoriza%C3%A7%C3%A3o
*/
export default async (req: Request, res: Response) => {
const { code, state } = req.query;
if (typeof code !== 'string' || !code) {
res.status(400).send('Missing `code` on Bling authorization callback');
return;
}
logger.info(`>> Bling authorization callback (state: ${state})`);

const { apps: { blingErp: { appId } } } = config.get();
const application = (await api.get(`applications/app_id:${appId}`)).data;
const appData = {
...application.data,
...application.hidden_data,
};
const { client_id: clientId, client_secret: clientSecret } = appData;
if (!clientId || !clientSecret) {
res.status(409).send('Missing Bling `client_id`/`client_secret` on app settings');
return;
}

try {
const data = await blingAuth(clientId, clientSecret, code);
const now = Timestamp.now();
await getTokensDocRef(clientId).set({
...data,
expiredAt: Timestamp
.fromMillis(now.toMillis() + ((data.expires_in - EXPIRES_IN_GAP_SEC) * 1000)),
createdAt: now,
updatedAt: now,
isBloqued: false,
isRateLimit: false,
countErr: 0,
});
} catch (err: any) {
logger.error(err);
res.status(400).send('Failed getting Bling tokens, check the app credentials');
return;
}

try {
const bling = createBlingClient(appData);
const contatosTipos = await bling.get('/contatos/tipos').then(({ data }) => data?.data);
const contatTypeClient = contatosTipos?.find(({ descricao }) => descricao === 'Cliente');
if (contatTypeClient) {
const otherConfig = {
...appData.other_config,
_contatTypeClientId: contatTypeClient.id,
};
await updateAppData(application, { other_config: otherConfig }, {
isHiddenData: true,
});
}
} catch (err: any) {
logger.warn(`Failed setting Bling contact type: ${err.message}`);
}

res.redirect(`https://app.e-com.plus/#/apps/edit/${appId}/`);
};
Loading
Loading