Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions packages/apps/bling-erp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# `@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/{storeId}`.

## 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` (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_CLIENT_ID=... BLING_CLIENT_SECRET=... BLING_REFRESH_TOKEN=... \
node scripts/bling-smoke.mjs [SKU] [NUMERO_PEDIDO]
```

## Coleções no Firestore

- `blingTokens/{storeId}`: tokens OAuth, flags de bloqueio e de limite diário;
- `blingStatuses/{storeId}`: 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"
}
}
163 changes: 163 additions & 0 deletions packages/apps/bling-erp/scripts/bling-smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/* 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_CLIENT_ID=... BLING_CLIENT_SECRET=... BLING_REFRESH_TOKEN=... \
node scripts/bling-smoke.mjs [SKU] [NUMERO_PEDIDO]

Get `BLING_REFRESH_TOKEN` from the Firestore doc `blingTokens/{storeId}` of an
already authorized store, or from the authorization flow response.
*/
import { URLSearchParams } from 'node:url';

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

if (!clientId || !clientSecret || !refreshToken) {
console.error('Set BLING_CLIENT_ID, BLING_CLIENT_SECRET and 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 () => {
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}`);
}
if (pedido?.nota?.numero && pedido.nota.serie) {
/*
Legacy path kept from the v1 app, only used to enrich invoice link/tracking;
a failure here is expected on API v3 and safely ignored at runtime.
*/
await check(
'Nota fiscal (path legado)',
`/notafiscal/${pedido.nota.numero}/${pedido.nota.serie}`,
{ optional: true },
);
}
}
}

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/
71 changes: 71 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,71 @@
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 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().set({
...data,
expiredAt: Timestamp.fromMillis(now.toMillis() + ((data.expires_in - 3600) * 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}/`);
};
30 changes: 30 additions & 0 deletions packages/apps/bling-erp/src/bling-auth/check-enable-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Timestamp } from 'firebase-admin/firestore';
import { logger } from '@cloudcommerce/firebase/lib/config';
import getTokensDocRef from './tokens-doc';

/*
Checks whether Bling API can be reached with the stored tokens,
skipping requests while blocked by an invalid refresh token
or by the daily rate limit (kept for 24h).
*/
const checkEnableApi = async () => {
const docSnapshot = await getTokensDocRef().get();
if (!docSnapshot.exists) {
return false;
}
const { isBloqued, updatedAt, isRateLimit } = docSnapshot.data() as Record<string, any>;
const now = Timestamp.now();
const timeLimitBloqued = Timestamp.fromMillis(
(updatedAt?.toMillis() || 0) + (24 * 60 * 60 * 1000),
);
if (isBloqued) {
logger.warn('Bling refreshToken is invalid need to update');
return false;
}
if (isRateLimit && now.toMillis() < timeLimitBloqued.toMillis()) {
return false;
}
return true;
};

export default checkEnableApi;
Loading
Loading