diff --git a/packages/apps/bling-erp/README.md b/packages/apps/bling-erp/README.md new file mode 100644 index 000000000..8211b07ca --- /dev/null +++ b/packages/apps/bling-erp/README.md @@ -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://-.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=` 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. diff --git a/packages/apps/bling-erp/package.json b/packages/apps/bling-erp/package.json new file mode 100644 index 000000000..3504b45ba --- /dev/null +++ b/packages/apps/bling-erp/package.json @@ -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 ", + "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" + } +} diff --git a/packages/apps/bling-erp/scripts/bling-smoke.mjs b/packages/apps/bling-erp/scripts/bling-smoke.mjs new file mode 100644 index 000000000..b7770aeee --- /dev/null +++ b/packages/apps/bling-erp/scripts/bling-smoke.mjs @@ -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); diff --git a/packages/apps/bling-erp/scripts/tests.sh b/packages/apps/bling-erp/scripts/tests.sh new file mode 100644 index 000000000..da2365c65 --- /dev/null +++ b/packages/apps/bling-erp/scripts/tests.sh @@ -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/ diff --git a/packages/apps/bling-erp/src/bling-auth-callback.ts b/packages/apps/bling-erp/src/bling-auth-callback.ts new file mode 100644 index 000000000..f61bdaea4 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-auth-callback.ts @@ -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}/`); +}; diff --git a/packages/apps/bling-erp/src/bling-auth/check-enable-api.ts b/packages/apps/bling-erp/src/bling-auth/check-enable-api.ts new file mode 100644 index 000000000..6b355dc96 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-auth/check-enable-api.ts @@ -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; + 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; diff --git a/packages/apps/bling-erp/src/bling-auth/client.ts b/packages/apps/bling-erp/src/bling-auth/client.ts new file mode 100644 index 000000000..db4ff6c33 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-auth/client.ts @@ -0,0 +1,122 @@ +import type { AxiosInstance, AxiosRequestConfig } from 'axios'; +import createAccess from './create-access'; + +// https://developer.bling.com.br/limites#filtros +const TIME_FORCE_REFRESH = 18000 * 1000; + +const delay = (timeout: number) => new Promise((resolve) => { + setTimeout(() => resolve(true), timeout); +}); + +class Bling { + clientId: string; + + clientSecret: string; + + private _bling: AxiosInstance | null; + + private lastRequest: Date | null; + + constructor(clientId: string, clientSecret: string) { + if (!clientId || !clientSecret) { + const err: any = new Error('Missing Bling clientId or clientSecret'); + err.isConfigError = true; + throw err; + } + this.clientId = clientId; + this.clientSecret = clientSecret; + this._bling = null; + this.lastRequest = null; + } + + // Bling allows up to 3 requests/s, keeping 1 request/s to be safe + private async checkTime() { + const now = new Date(); + if (!this.lastRequest) { + this.lastRequest = now; + return true; + } + const timeout = now.getTime() - this.lastRequest.getTime(); + if (timeout >= 1000) { + this.lastRequest = new Date(); + return true; + } + await delay(1000 - timeout); + this.lastRequest = new Date(); + return true; + } + + private async axios() { + if (!this._bling) { + this._bling = await createAccess(this.clientId, this.clientSecret); + } + return this._bling; + } + + private async request( + method: 'get' | 'post' | 'patch' | 'put' | 'delete', + url: string, + data?: any, + opts?: AxiosRequestConfig, + ): Promise { + await this.checkTime(); + const bling = await this.axios(); + const send = (instance: AxiosInstance) => { + switch (method) { + case 'get': + return instance.get(url, opts); + case 'delete': + return instance.delete(url, opts); + default: + return instance[method](url, data, opts); + } + }; + try { + return await send(bling); + } catch (err: any) { + if (err.response?.data?.error?.type === 'TOO_MANY_REQUESTS') { + const isDailyRateLimitError = Boolean( + err.response.data.error?.description?.includes('diário'), + ); + if (!isDailyRateLimitError) { + await delay(1000); + return send(bling); + } + this._bling = await createAccess( + this.clientId, + this.clientSecret, + TIME_FORCE_REFRESH, + isDailyRateLimitError, + ); + return send(this._bling); + } + throw err; + } + } + + get(url: string, opts?: AxiosRequestConfig) { + return this.request('get', url, undefined, opts); + } + + post(url: string, data?: any, opts?: AxiosRequestConfig) { + return this.request('post', url, data, opts); + } + + patch(url: string, data?: any, opts?: AxiosRequestConfig) { + return this.request('patch', url, data, opts); + } + + put(url: string, data?: any, opts?: AxiosRequestConfig) { + return this.request('put', url, data, opts); + } + + delete(url: string, opts?: AxiosRequestConfig) { + return this.request('delete', url, undefined, opts); + } +} + +export const createBlingClient = (appData: Record) => { + return new Bling(appData.client_id, appData.client_secret); +}; + +export default Bling; diff --git a/packages/apps/bling-erp/src/bling-auth/create-access.ts b/packages/apps/bling-erp/src/bling-auth/create-access.ts new file mode 100644 index 000000000..eb1b200e2 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-auth/create-access.ts @@ -0,0 +1,107 @@ +import { Timestamp } from 'firebase-admin/firestore'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import createAxios from './create-axios'; +import blingAuth from './create-auth'; +import getTokensDocRef from './tokens-doc'; + +/* +Returns an Axios instance authenticated with a valid Bling access token, +refreshing it with the stored `refresh_token` when near expiration. +*/ +const createAccess = async ( + clientId: string, + clientSecret: string, + tokenExpirationGap = 9000, + isRateLimit = false, +) => { + const docRef = getTokensDocRef(); + const docSnapshot = await docRef.get(); + if (!docSnapshot.exists) { + const err: any = new Error('No Bling token document'); + err.code = 'NO_BLING_TOKEN'; + throw err; + } + const { + access_token: docAccessToken, + refresh_token: refreshToken, + expiredAt, + isBloqued, + updatedAt, + isRateLimit: isRateLimitDoc, + } = docSnapshot.data() as Record; + + const now = Timestamp.now(); + const timeLimitBloqued = Timestamp.fromMillis( + (updatedAt?.toMillis() || now.toMillis()) + (12 * 60 * 60 * 1000), + ); + + if (isBloqued) { + throw new Error('Bling refreshToken is invalid need to update'); + } + + if (isRateLimit) { + // Flag daily rate limit + await docRef.set({ + isRateLimit: true, + updatedAt: now, + countErr: 0, + }, { merge: true }).catch(logger.error); + throw new Error('Bling daily rate limit reached, please try again later'); + } + if (isRateLimitDoc) { + if (now.toMillis() < timeLimitBloqued.toMillis()) { + throw new Error('Bling daily rate limit reached, please try again later'); + } + // Disable daily rate limit + await docRef.set({ + isRateLimit: false, + updatedAt: now, + countErr: 0, + }, { merge: true }).catch(logger.error); + } + + let accessToken: string | undefined; + if (expiredAt && now.toMillis() + tokenExpirationGap < expiredAt.toMillis()) { + accessToken = docAccessToken; + } else { + try { + const data = await blingAuth(clientId, clientSecret, null, refreshToken); + await docRef.set({ + ...data, + updatedAt: now, + expiredAt: Timestamp.fromMillis(now.toMillis() + ((data.expires_in - 300) * 1000)), + countErr: 0, + }, { merge: true }); + accessToken = data.access_token; + } catch (err: any) { + const isInvalidGrant = err.response?.data?.error?.type === 'invalid_grant'; + logger.warn(`Cant refresh Bling OAuth token ${JSON.stringify({ + url: err.config?.url, + response: err.response?.data, + status: err.response?.status, + })}`); + if (isInvalidGrant) { + await docRef.set({ + isBloqued: true, + updatedAt: now, + }, { merge: true }).catch(logger.error); + } else { + const doc = await docRef.get(); + const countErr = (doc.data()?.countErr || 0) + 1; + if (countErr > 3) { + await docRef.set({ + isBloqued: true, + updatedAt: now, + }, { merge: true }).catch(logger.error); + } else { + await docRef.set({ countErr }, { merge: true }).catch(logger.error); + } + } + throw err; + } + } + + return createAxios(accessToken); +}; + +export default createAccess; diff --git a/packages/apps/bling-erp/src/bling-auth/create-auth.ts b/packages/apps/bling-erp/src/bling-auth/create-auth.ts new file mode 100644 index 000000000..db299fe7d --- /dev/null +++ b/packages/apps/bling-erp/src/bling-auth/create-auth.ts @@ -0,0 +1,53 @@ +import { URLSearchParams } from 'url'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import createAxios from './create-axios'; + +export type BlingOAuthTokens = { + access_token: string, + refresh_token: string, + expires_in: number, + token_type?: string, + scope?: string, +}; + +/* +Authorization flow: +https://developer.bling.com.br/aplicativos#fluxo-de-autoriza%C3%A7%C3%A3o +*/ +const createAuth = ( + clientId: string, + clientSecret: string, + code?: string | null, + refreshToken?: string | null, +) => new Promise((resolve, reject) => { + const axios = createAxios(undefined, clientId, clientSecret); + const request = (retryCount = 0) => { + logger.info(`>> Create Bling auth with ${refreshToken ? 'refresh_token' : 'code'}`); + const grantType: Record = { + grant_type: refreshToken ? 'refresh_token' : 'authorization_code', + }; + if (refreshToken) { + grantType.refresh_token = refreshToken; + } else if (code) { + grantType.code = code; + } + const params = new URLSearchParams(grantType); + axios.post('/oauth/token', params.toString()) + .then(({ data }) => resolve(data)) + .catch((err: any) => { + logger.warn(`Failed Bling OAuth: ${JSON.stringify({ + status: err.response?.status, + response: err.response?.data, + })}`); + if (retryCount < 2 && err.response?.status === 429) { + const delay = retryCount === 0 ? 15000 : 30000; + setTimeout(() => request(retryCount + 1), delay); + } else { + reject(err); + } + }); + }; + request(0); +}); + +export default createAuth; diff --git a/packages/apps/bling-erp/src/bling-auth/create-axios.ts b/packages/apps/bling-erp/src/bling-auth/create-axios.ts new file mode 100644 index 000000000..9e0dd23e5 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-auth/create-axios.ts @@ -0,0 +1,30 @@ +import axios from 'axios'; + +// https://developer.bling.com.br/referencia +export const BLING_API_BASE_URL = 'https://api.bling.com.br/Api/v3'; + +export default ( + accessToken?: string, + clientId?: string, + clientSecret?: string, +) => { + let headers: Record = {}; + if (accessToken) { + headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }; + } else if (clientId && clientSecret) { + headers = { + 'Accept': '1.0', + 'enable-jwt': '1', + 'Authorization': 'Basic ' + + Buffer.from(`${clientId}:${clientSecret}`, 'utf8').toString('base64'), + }; + } + return axios.create({ + baseURL: BLING_API_BASE_URL, + headers, + timeout: accessToken ? 10000 : 30000, + }); +}; diff --git a/packages/apps/bling-erp/src/bling-auth/tokens-doc.ts b/packages/apps/bling-erp/src/bling-auth/tokens-doc.ts new file mode 100644 index 000000000..55cfa6ed3 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-auth/tokens-doc.ts @@ -0,0 +1,24 @@ +import type { DocumentReference } from 'firebase-admin/firestore'; +import { getFirestore, Timestamp } from 'firebase-admin/firestore'; +import getEnv from '@cloudcommerce/firebase/lib/env'; + +export const BLING_TOKENS_COLLECTION = 'blingTokens'; + +export type BlingTokensDoc = { + access_token?: string, + refresh_token?: string, + expiredAt?: Timestamp, + createdAt?: Timestamp, + updatedAt?: Timestamp, + isBloqued?: boolean, + isRateLimit?: boolean, + countErr?: number, +}; + +export const getTokensDocRef = () => { + const { storeId } = getEnv(); + return getFirestore() + .doc(`${BLING_TOKENS_COLLECTION}/${storeId}`) as DocumentReference; +}; + +export default getTokensDocRef; diff --git a/packages/apps/bling-erp/src/bling-callback.ts b/packages/apps/bling-erp/src/bling-callback.ts new file mode 100644 index 000000000..938e1bdd1 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-callback.ts @@ -0,0 +1,126 @@ +import type { Request, Response } from 'firebase-functions/v1'; +import type { Applications } from '@cloudcommerce/types'; +import api from '@cloudcommerce/api'; +import config, { logger } from '@cloudcommerce/firebase/lib/config'; +import checkEnableApi from './bling-auth/check-enable-api'; +import importProduct from './integration/import-product-from-bling'; +import importOrder from './integration/import-order-from-bling'; +import afterQueue from './integration/after-bling-queue'; + +let hasWarnedNoToken = false; + +const getRetorno = (body: any) => { + if (typeof body !== 'object' || !body) return null; + if (body.retorno) return body.retorno; + if (typeof body.data === 'string') { + try { + return JSON.parse(body.data).retorno; + } catch { + return null; + } + } + return null; +}; + +export default async (req: Request, res: Response) => { + const retorno = getRetorno(req.body); + if (!retorno) { + logger.info(`Unexpected Bling callback: ${JSON.stringify(req.body)}`); + res.status(200).send('Ignoring invalid request body'); + return; + } + + const { apps: { blingErp: { appId } } } = config.get(); + const applicationId = req.query._id; + const appEndpoint = applicationId && typeof applicationId === 'string' + ? `applications/${applicationId}` as `applications/${Applications['_id']}` + : `applications/app_id:${appId}` as const; + const application = (await api.get(appEndpoint)).data; + const appData = { + ...application.data, + ...application.hidden_data, + }; + + const callbackToken = process.env.BLINGERP_CALLBACK_TOKEN || appData.callback_token; + if (callbackToken) { + if (req.query.token !== callbackToken) { + res.sendStatus(401); + return; + } + } else if (!hasWarnedNoToken) { + hasWarnedNoToken = true; + logger.warn('Bling callback accepted without token validation,' + + ' set `BLINGERP_CALLBACK_TOKEN` and append `?token=` to the callback URL on Bling'); + } + if (!(await checkEnableApi())) { + logger.warn('> Error in request to Bling API'); + res.sendStatus(403); + return; + } + + const runQueueEntry = async ( + queueEntry: Record, + handler: any, + canCreateNew = false, + ) => { + try { + const payload = await handler( + {}, + queueEntry, + appData, + canCreateNew, + Boolean(queueEntry.isHiddenQueue), + ); + return await afterQueue(queueEntry, appData, application, payload); + } catch (err: any) { + return afterQueue(queueEntry, appData, application, err); + } + }; + + const { pedidos, estoques } = retorno; + if (Array.isArray(pedidos)) { + for (let i = 0; i < pedidos.length; i++) { + const { numero } = pedidos[i].pedido || pedidos[i]; + if (numero) { + logger.info(`> Bling callback order ${numero}`); + // eslint-disable-next-line no-await-in-loop + await runQueueEntry({ + action: 'importation', + queue: 'order_numbers', + nextId: String(numero), + isNotQueued: true, + isHiddenQueue: true, + }, importOrder); + } + } + } + + if (Array.isArray(estoques) && appData.import_quantity !== false) { + for (let i = 0; i < estoques.length; i++) { + const { id, codigo } = estoques[i].estoque || estoques[i]; + /* + Variations created on Bling without a SKU have an empty `codigo`, and are + imported with the Bling ID as SKU, so it's also the queue reference. + */ + const sku = codigo || (id && String(id)); + if (sku) { + logger.info(`> Bling callback stock ${sku} (${id})`); + /* + `import_product` is what enables creating the product on the store when + it's not found by SKU, so it must drive `canCreateNew` here. + */ + // eslint-disable-next-line no-await-in-loop + await runQueueEntry({ + action: 'importation', + queue: 'skus', + nextId: `${sku};:`, + _blingId: id, + isNotQueued: true, + isHiddenQueue: true, + }, importProduct, Boolean(appData.import_product)); + } + } + } + + res.sendStatus(200); +}; diff --git a/packages/apps/bling-erp/src/bling-erp.ts b/packages/apps/bling-erp/src/bling-erp.ts new file mode 100644 index 000000000..672efe234 --- /dev/null +++ b/packages/apps/bling-erp/src/bling-erp.ts @@ -0,0 +1,48 @@ +/* eslint-disable import/prefer-default-export */ + +import '@cloudcommerce/firebase/lib/init'; +import * as functions from 'firebase-functions/v1'; +import config, { createExecContext } from '@cloudcommerce/firebase/lib/config'; +import { createAppEventsFunction } from '@cloudcommerce/firebase/lib/helpers/pubsub'; +import handleApiEvent from './event-to-bling'; +import handleBlingCallback from './bling-callback'; +import handleBlingAuthCallback from './bling-auth-callback'; +import refreshBlingToken from './refresh-bling-token'; + +const { httpsFunctionOptions } = config.get(); +const { region } = httpsFunctionOptions; + +export const blingerp = { + onStoreEvent: createAppEventsFunction( + 'blingErp', + handleApiEvent, + { memory: '512MB' }, + ), + + callback: functions + .region(region) + .runWith({ + ...httpsFunctionOptions, + memory: '512MB', + timeoutSeconds: 120, + }) + .https.onRequest((req, res) => { + return createExecContext(() => handleBlingCallback(req, res)); + }), + + authCallback: functions + .region(region) + .runWith({ + ...httpsFunctionOptions, + timeoutSeconds: 60, + }) + .https.onRequest((req, res) => { + return createExecContext(() => handleBlingAuthCallback(req, res)); + }), + + cronRefreshToken: functions + .region(region) + .runWith({ timeoutSeconds: 120, memory: '256MB' }) + .pubsub.schedule(process.env.CRONTAB_BLINGERP_REFRESH_TOKEN || '36,51 * * * *') + .onRun(() => refreshBlingToken()), +}; diff --git a/packages/apps/bling-erp/src/event-to-bling.ts b/packages/apps/bling-erp/src/event-to-bling.ts new file mode 100644 index 000000000..9de635b44 --- /dev/null +++ b/packages/apps/bling-erp/src/event-to-bling.ts @@ -0,0 +1,145 @@ +import type { ApiEventHandler } from '@cloudcommerce/firebase/lib/helpers/pubsub'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import checkEnableApi from './bling-auth/check-enable-api'; +import exportProduct from './integration/export-product-to-bling'; +import exportOrder from './integration/export-order-to-bling'; +import importProduct from './integration/import-product-from-bling'; +import importOrder from './integration/import-order-from-bling'; +import afterQueue from './integration/after-bling-queue'; + +// Async integration handlers +const integrationHandlers = { + exportation: { + product_ids: exportProduct, + order_ids: exportOrder, + }, + importation: { + skus: importProduct, + order_numbers: importOrder, + }, +}; + +const handleApiEvent: ApiEventHandler = async ({ + evName, + apiEvent, + apiDoc, + app, +}) => { + const resourceId = apiEvent.resource_id; + logger.info(`>> ${resourceId} - Action: ${apiEvent.action}`); + const key = `${evName}_${resourceId}`; + if ( + evName === 'applications-dataSet' + && !apiEvent.modified_fields.includes('data') + ) { + logger.info(`>> ${key} - Skipped application event without \`data\` changes`); + return null; + } + const appData = { ...app.data, ...app.hidden_data }; + if ( + Array.isArray(appData.ignore_events) + && appData.ignore_events.includes(evName) + ) { + logger.info(`>> ${key} - Ignored event`); + return null; + } + if (!appData.client_id || !appData.client_secret) { + logger.warn('Missing Bling client_id/client_secret'); + return null; + } + + let integrationConfig: Record | undefined; + /* + `canCreateNew` is a tri-state, `undefined` means the resource can be created + on Bling only when it was not exported before. + */ + let canCreateNew: boolean | undefined = false; + let isQueued = false; + if (evName === 'applications-dataSet') { + integrationConfig = appData; + canCreateNew = true; + isQueued = true; + } else if (evName === 'orders-anyStatusSet') { + canCreateNew = appData.new_orders ? undefined : false; + integrationConfig = { + _exportation: { + order_ids: [resourceId], + }, + }; + } else { + if (evName === 'products-new') { + if (!appData.new_products) { + return null; + } + canCreateNew = true; + } else if (evName === 'products-priceSet') { + if (!appData.export_price) { + return null; + } + } else if (!appData.export_quantity) { + return null; + } + integrationConfig = { + _exportation: { + product_ids: [resourceId], + }, + }; + } + + if (!integrationConfig) { + return null; + } + if (!(await checkEnableApi())) { + logger.warn('Bling API is not enabled, check the app authorization'); + return null; + } + + const actions = Object.keys(integrationHandlers); + actions.forEach((action) => { + for (let i = 1; i <= 3; i++) { + actions.push(`${('_'.repeat(i))}${action}`); + } + }); + for (let i = 0; i < actions.length; i++) { + const action = actions[i]; + const actionQueues = integrationConfig[action]; + if (typeof actionQueues === 'object' && actionQueues) { + // eslint-disable-next-line guard-for-in, no-restricted-syntax + for (const queue in actionQueues) { + const ids = actionQueues[queue]; + if (Array.isArray(ids) && ids.length) { + const isHiddenQueue = action.charAt(0) === '_'; + const handlerName = action.replace(/^_+/, ''); + const handler = integrationHandlers[handlerName][queue.toLowerCase()]; + const nextId = ids[0]; + if (typeof nextId === 'string' && nextId.length && handler) { + logger.info(`> Starting #${action}/${queue}/${nextId}`, { canCreateNew }); + const queueEntry = { + action, + queue, + nextId, + key, + app, + isNotQueued: !isQueued, + }; + return handler( + apiDoc, + queueEntry, + appData, + canCreateNew, + isHiddenQueue, + ).then((payload: any) => { + return afterQueue(queueEntry, appData, app, payload); + }).catch((err: any) => { + return afterQueue(queueEntry, appData, app, err); + }); + } + } + } + } + } + // Nothing to do + return null; +}; + +export default handleApiEvent; diff --git a/packages/apps/bling-erp/src/index.ts b/packages/apps/bling-erp/src/index.ts new file mode 100644 index 000000000..37d5baef9 --- /dev/null +++ b/packages/apps/bling-erp/src/index.ts @@ -0,0 +1 @@ +export * from './bling-erp'; diff --git a/packages/apps/bling-erp/src/integration/after-bling-queue.ts b/packages/apps/bling-erp/src/integration/after-bling-queue.ts new file mode 100644 index 000000000..200782063 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/after-bling-queue.ts @@ -0,0 +1,112 @@ +import type { AppOrId } from '@cloudcommerce/firebase/lib/helpers/update-app-data'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import updateAppData from '@cloudcommerce/firebase/lib/helpers/update-app-data'; + +export default async ( + queueEntry: Record, + appData: Record, + application: AppOrId, + payload: any, +) => { + const isError = payload instanceof Error; + const isImportation = !!queueEntry.action?.endsWith('importation'); + const isQueued = !queueEntry.isNotQueued; + const logs = appData.logs || []; + const logEntry: Record = { + resource: /order/i.test(queueEntry.queue) ? 'orders' : 'products', + [(isImportation ? 'bling_id' : 'resource_id')]: queueEntry.nextId, + success: !isError, + timestamp: new Date().toISOString(), + }; + + let notes: string | undefined; + if (payload) { + if (!isError) { + // payload = response + const { data, status, config } = payload; + if (data && data._id) { + logEntry.resource_id = data._id; + } + notes = `Status ${status}`; + if (config) { + notes += ` [${config.url}]`; + } + } else { + const { config, response } = payload as any; + if (response) { + const { data, status } = response; + if (isQueued && (!status || status === 429 || status >= 500)) { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(payload); + }, 2000); + }); + } + notes = `Error: Status ${status} `; + try { + notes += `\n${JSON.stringify(data)} `; + } catch { + // + } + if (config) { + const { url, method, data: reqData } = config; + try { + notes += `\n\n-- Request -- \n${method} ${url} `; + notes += `\n${JSON.stringify(reqData)} `; + } catch { + // + } + } + } else if ((payload as any).isConfigError === true) { + notes = payload.message; + } else { + notes = payload.stack; + } + } + } + if (notes) { + logEntry.notes = notes.substring(0, 5000); + } + + /* + Failures are always logged on app data, so the merchant sees them on the admin + panel even for automatic (not manually queued) exportations. Successes are kept + out of importation logs to avoid flooding it with stock updates. + */ + if (isError || (isQueued && !isImportation)) { + logs.unshift(logEntry); + await updateAppData(application, { + logs: logs.slice(0, 200), + }, { + isHiddenData: true, + canSendPubSub: false, + }); + } + if (isError) { + logger.warn(`Log for ${logEntry.resource} failure`, { logEntry }); + } + const { action, queue, nextId } = queueEntry; + if (!action) { + return null; + } + const queueList: string[] | undefined = appData[action]?.[queue]; + if (Array.isArray(queueList)) { + const idIndex = queueList.indexOf(nextId); + if (idIndex > -1) { + queueList.splice(idIndex, 1); + const data = { + [action]: { + ...appData[action], + [queue]: queueList, + }, + }; + try { + logger.info(JSON.stringify(data)); + } catch { + logger.info(`Update app queue after ${nextId} (stringify failed)`); + } + return updateAppData(application, data); + } + } + return null; +}; diff --git a/packages/apps/bling-erp/src/integration/export-order-to-bling.ts b/packages/apps/bling-erp/src/integration/export-order-to-bling.ts new file mode 100644 index 000000000..60186c6f6 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/export-order-to-bling.ts @@ -0,0 +1,239 @@ +import type { Orders } from '@cloudcommerce/types'; +import { URLSearchParams } from 'url'; +import ecomUtils from '@ecomplus/utils'; +import api from '@cloudcommerce/api'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import { createBlingClient } from '../bling-auth/client'; +import parseOrder from './parsers/order-to-bling'; +import parseStatusToBling from './parsers/status-to-bling'; +import getCustomerBling from './helpers/get-customer-bling'; +import getProductsBling from './helpers/get-products-bling'; +import { getPaymentBling } from './helpers/payment-method'; +import getStatusBling from './helpers/get-status-bling'; + +/* +`canCreateNew` is a tri-state: +- `true`: always create the order on Bling when not found; +- `undefined`: create only when the order was never exported (no `bling:id` metafield); +- `false`: never create. +*/ +const exportOrderToBling = async ( + apiDoc: Record, + queueEntry: Record, + appData: Record, + canCreateNew?: boolean, +) => { + const orderId = queueEntry.nextId; + let order: Orders; + if (orderId === apiDoc._id) { + order = apiDoc as Orders; + } else { + try { + order = (await api.get(`orders/${orderId}`)).data; + } catch (err: any) { + const status = err.statusCode || err.response?.status; + if (status >= 400 && status < 500) { + const error: any = new Error(`O pedido ${orderId} não existe (:${status})`); + error.isConfigError = true; + return error; + } + throw err; + } + } + if (!order.financial_status) { + logger.info(`${orderId} skipped with no financial status`); + return null; + } + + const blingStore = appData.bling_store; + const transaction = order.transactions?.[0]; + const metafields = (order.metafields || []) as Array>; + const metafieldId = metafields.find(({ field }) => field === 'bling:id'); + let blingOrderNumber = metafields.find(({ field }) => field === 'bling:numero')?.value; + let blingOrderId: string | number | undefined; + let hasCreatedBlingOrder = false; + if (metafieldId) { + if (metafieldId.value === 'skip') { + logger.info(`${orderId} skipped by metafield`); + return null; + } + blingOrderId = metafieldId.value; + hasCreatedBlingOrder = Boolean(blingOrderId); + } + + const urlParams: Record = { + numero: String(appData.random_order_number === true ? blingOrderNumber : order.number), + }; + if (blingStore) { + urlParams.idLoja = String(blingStore); + } + const params = new URLSearchParams(urlParams); + const bling = createBlingClient(appData); + const searchEndpoint = `/pedidos/vendas?${params.toString()}`; + + const paymentTypeId = transaction + ? await getPaymentBling(bling, transaction, appData.parse_payment, order.payment_method_label) + : null; + const allStatusBling = await getStatusBling(bling); + const blingStatuses = parseStatusToBling(order, appData); + + let blingSavedOrder: Record | undefined; + const findBlingOrder = async () => { + const endpoint = hasCreatedBlingOrder + ? `/pedidos/vendas/${blingOrderId}` + : searchEndpoint; + try { + return (await bling.get(endpoint)).data.data; + } catch (err: any) { + if (err.response?.status !== 404) { + throw err; + } + if (hasCreatedBlingOrder) { + hasCreatedBlingOrder = false; + blingOrderId = undefined; + try { + return (await bling.get(searchEndpoint)).data.data; + } catch (retryErr: any) { + if (retryErr.response?.status !== 404) { + throw retryErr; + } + } + } + logger.warn(`Order not found on Bling ${endpoint}`); + return undefined; + } + }; + + const data = await findBlingOrder(); + const hasFoundByNumber = Boolean(Array.isArray(data) && data.length); + if (Array.isArray(data)) { + blingSavedOrder = data.find((pedido) => { + if (String(order.number) === pedido.numeroLoja) { + return !blingStore || String(blingStore) === String(pedido.loja?.id); + } + return false; + }); + if (!blingSavedOrder && blingOrderNumber) { + blingSavedOrder = data.find((pedido) => String(pedido.numero) === String(blingOrderNumber)); + } + } else if (data) { + blingSavedOrder = data; + } + + if (blingSavedOrder) { + blingOrderId = blingSavedOrder.id; + } else if (canCreateNew === false || (!canCreateNew && hasCreatedBlingOrder)) { + logger.info(`${orderId} skipped without creating new Bling order`); + } else { + if (appData.approved_orders_only && blingStatuses) { + const isNotApproved = blingStatuses.some((blingStatus) => { + return blingStatus === 'pendente' || blingStatus === 'cancelado'; + }); + if (isNotApproved) { + logger.info(`${orderId} skipped with status "${blingStatuses[0]}"`); + return null; + } + } + if (!blingOrderNumber) { + blingOrderNumber = (hasFoundByNumber || appData.random_order_number === true) + ? String(Math.floor(Math.random() * (99999999 - 10000000)) + 10000000) + : String(order.number); + } + + const customerIdBling = await getCustomerBling(bling, appData, order); + if (!customerIdBling) { + throw new Error('Bling Customer not found'); + } + const itemsBling = await getProductsBling(bling, order); + const blingOrder = parseOrder( + order, + blingOrderNumber, + blingStore, + appData, + customerIdBling, + paymentTypeId, + itemsBling, + blingSavedOrder, + ); + const endpoint = `/pedidos/vendas${blingOrderId ? `/${blingOrderId}` : ''}`; + const method = blingOrderId ? 'put' : 'post'; + logger.info(`[${method}]: ${endpoint} for ${order._id}`, { blingOrder, blingStatuses }); + try { + const { data: { data: savedData } } = await bling[method](endpoint, blingOrder); + logger.info(`Bling order ${method === 'put' ? 'upd' : 'cre'}ated successfully`); + if (savedData?.id) { + blingSavedOrder = blingOrder; + blingOrderId = savedData.id; + if (metafieldId) { + metafieldId.value = String(blingOrderId); + } else { + metafields.push({ + _id: ecomUtils.randomObjectId(), + namespace: 'bling', + field: 'bling:id', + value: String(blingOrderId), + }); + } + await api.patch(`orders/${order._id}`, { metafields } as any).catch(logger.error); + } + } catch (err: any) { + if (err.response) { + logger.warn(`Failed exporting order ${order._id}`, { + blingOrder, + response: err.response.data, + }); + } + throw err; + } + } + + if (blingOrderId && blingStatuses) { + /* + Bling accounts with the default "situações" don't have an equivalent for + every store status (`ready_for_shipping`, for one). The order is already + exported at this point, so a missing match only skips the status update — + the merchant can create the "situação" on Bling or map it on `parse_status`. + */ + if (!allStatusBling) { + logger.warn(`${orderId} status not updated: no "situações" listed from Bling`); + return null; + } + let newStatusBling: Record | undefined; + for (let i = 0; i < blingStatuses.length && !newStatusBling; i++) { + const blingStatus = blingStatuses[i]; + newStatusBling = allStatusBling.find(({ nome }) => nome?.toLowerCase() === blingStatus); + } + if (!newStatusBling?.id) { + logger.warn(`${orderId} status not updated: no Bling "situação" matches`, { + blingStatuses, + blingSituacoes: allStatusBling.map(({ nome }) => nome), + }); + return null; + } + let { situacao } = blingSavedOrder || {}; + if (!situacao?.id) { + situacao = (await bling.get(`/pedidos/vendas/${blingOrderId}`)).data.data?.situacao; + } + logger.info(`Maybe updating ${orderId} to ${newStatusBling.nome} (${newStatusBling.id})`, { + blingStatuses, + situacao, + }); + if (String(situacao?.id) !== String(newStatusBling.id)) { + return bling.patch(`/pedidos/vendas/${blingOrderId}/situacoes/${newStatusBling.id}`) + .then((response) => { + logger.info('Bling order status updated successfully'); + return response; + }) + .catch((err: any) => { + if (err.response) { + logger.warn(`Failed updating Bling order status: ${JSON.stringify(err.response.data)}`); + } + logger.error(err); + return null; + }); + } + } + return null; +}; + +export default exportOrderToBling; diff --git a/packages/apps/bling-erp/src/integration/export-product-to-bling.ts b/packages/apps/bling-erp/src/integration/export-product-to-bling.ts new file mode 100644 index 000000000..9f23b4ba2 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/export-product-to-bling.ts @@ -0,0 +1,224 @@ +import type { Products } from '@cloudcommerce/types'; +import { URLSearchParams } from 'url'; +import ecomUtils from '@ecomplus/utils'; +import api from '@cloudcommerce/api'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import { createBlingClient } from '../bling-auth/client'; +import parseProduct from './parsers/product-to-bling'; + +const getBlingStockId = (bling: any, blingProductId: string | number) => { + const params = new URLSearchParams({ 'idsProdutos[]': String(blingProductId) }); + return bling.get(`/estoques/saldos?${params.toString()}`) + .then(({ data }) => data?.data?.[0]?.depositos?.[0]?.id) + .catch((err: any) => { + logger.warn(`Failed listing Bling stock balances: ${err.message}`); + return undefined; + }); +}; + +const exportProductToBling = async ( + apiDoc: Record, + queueEntry: Record, + appData: Record, + canCreateNew?: boolean, +) => { + const productId = queueEntry.nextId; + logger.info(`>> Export product to Bling ${productId}`); + const blingStore = appData.bling_store; + const blingDeposit = appData.bling_deposit; + let product: Products; + if (productId === apiDoc._id) { + product = apiDoc as Products; + } else { + try { + product = (await api.get(`products/${productId}`)).data; + } catch (err: any) { + const status = err.statusCode || err.response?.status; + if (status >= 400 && status < 500) { + const error: any = new Error(`O produto ${productId} não existe (:${status})`); + error.isConfigError = true; + return error; + } + throw err; + } + } + + const metafields = (product.metafields || []) as Array>; + const metafieldCodigo = metafields.find(({ field }) => field === 'bling:codigo'); + const metafieldId = metafields.find(({ field }) => field === 'bling:id'); + const blingProductCode = metafieldCodigo?.value || product.sku; + let blingProductId: string | number | undefined = metafieldId?.value; + + const bling = createBlingClient(appData); + const urlParams: Record = { codigo: String(blingProductCode) }; + if (blingStore) { + urlParams.idLoja = String(blingStore); + } + const params = new URLSearchParams(urlParams); + const searchEndpoint = `/produtos?${params.toString()}`; + + const findBlingProducts = async () => { + const endpoint = blingProductId ? `/produtos/${blingProductId}` : searchEndpoint; + try { + return (await bling.get(endpoint)).data.data; + } catch (err: any) { + if (err.response?.status !== 404) { + throw err; + } + if (blingProductId) { + return (await bling.get(searchEndpoint)).data.data; + } + return null; + } + }; + + const blingProducts = await findBlingProducts(); + let originalBlingProduct: Record | undefined; + if (Array.isArray(blingProducts) && blingProducts.length) { + originalBlingProduct = blingProducts.find(({ codigo }) => product.sku === String(codigo)); + if (!blingProductId && originalBlingProduct) { + blingProductId = originalBlingProduct.id; + } + if (!canCreateNew && !originalBlingProduct) { + logger.info(`${productId} not found on Bling and cannot create new`); + return null; + } + } else if (blingProducts && !Array.isArray(blingProducts)) { + originalBlingProduct = blingProducts; + blingProductId = blingProducts.id; + } + + let response: any = null; + let bodyBlingProduct: Record | undefined; + if (canCreateNew || appData.export_quantity || !blingStore) { + /* + Listing endpoints return a summarized product, without `variacoes`. The full + document is required to send each variation with its Bling ID, otherwise the + update is rejected as if the variations were being created again. + */ + if (blingProductId && !originalBlingProduct?.variacoes) { + originalBlingProduct = await bling.get(`/produtos/${blingProductId}`) + .then(({ data }) => data.data) + .catch(() => originalBlingProduct); + } + bodyBlingProduct = parseProduct(product, originalBlingProduct, appData); + const endpoint = `/produtos${originalBlingProduct ? `/${blingProductId}` : ''}`; + logger.info(`[${originalBlingProduct ? 'put' : 'post'}]: ${endpoint}`, { bodyBlingProduct }); + response = originalBlingProduct + ? await bling.put(endpoint, bodyBlingProduct) + : await bling.post(endpoint, bodyBlingProduct); + } + + const responseData = response?.data?.data; + if (responseData?.id) { + blingProductId = String(responseData.id); + } + if (blingProductId) { + if (metafieldId) { + metafieldId.value = String(blingProductId); + } else { + metafields.push({ + _id: ecomUtils.randomObjectId(), + namespace: 'bling', + field: 'bling:id', + value: String(blingProductId), + }); + } + } + if (blingProductCode && !metafieldCodigo) { + metafields.push({ + _id: ecomUtils.randomObjectId(), + namespace: 'bling', + field: 'bling:codigo', + value: String(blingProductCode), + }); + } + if (metafields.length) { + await api.patch(`products/${product._id}`, { metafields } as any).catch(logger.error); + } + + if (!blingProductId) { + return response; + } + + /* + Bling ignores the price sent for each variation when saving the parent product, + applying the parent price to all of them, so variations priced differently on + the store must be updated one by one. + */ + if (bodyBlingProduct?.variacoes?.length) { + const divergentVariations = bodyBlingProduct.variacoes.filter(({ preco }) => { + return preco && preco !== bodyBlingProduct!.preco; + }); + if (divergentVariations.length) { + const savedVariations = await bling.get(`/produtos/${blingProductId}`) + .then(({ data }) => data.data?.variacoes as Array> | undefined) + .catch((err: any) => { + logger.warn(`Failed listing Bling variations: ${err.message}`); + return undefined; + }); + for (let i = 0; i < divergentVariations.length; i++) { + const { codigo, preco } = divergentVariations[i]; + const savedVariation = savedVariations?.find((variacao) => variacao.codigo === codigo); + if (savedVariation?.id && savedVariation.preco !== preco) { + logger.info(`Fixing variation ${codigo} price to ${preco}`); + // eslint-disable-next-line no-await-in-loop + await bling.put(`/produtos/${savedVariation.id}`, { ...savedVariation, preco }) + .catch((err: any) => { + const errData = err.response?.data; + logger.warn(`Failed updating variation ${codigo} price`, { + response: errData, + }); + }); + } + } + } + } + + const estoqueId = blingDeposit || await getBlingStockId(bling, blingProductId); + if (!estoqueId) { + return response; + } + + const isVariations = Boolean(product.variations && product.variations.length); + const isUpdateStock = appData.export_quantity === true || !originalBlingProduct; + const stockRequests: Array> = []; + if (!isVariations) { + const productQuantity = product.quantity || 0; + if (isUpdateStock && originalBlingProduct?.estoque?.saldoVirtualTotal !== productQuantity) { + stockRequests.push(bling.post('/estoques', { + produto: { id: Number(blingProductId) }, + deposito: { id: Number(estoqueId) }, + operacao: 'B', + quantidade: productQuantity, + observacoes: `Update in ${new Date().toISOString()}`, + }).catch(logger.error)); + } + } else if (bodyBlingProduct?.variacoes) { + const newVariations: Array> = responseData?.variations?.saved + || responseData?.variacoes + || []; + product.variations?.forEach((variation) => { + const variationFind = bodyBlingProduct!.variacoes.find(({ nome }) => nome === variation.name); + if (!variationFind) return; + const newVariation = newVariations.find(({ nomeVariacao, nome }) => { + return (nomeVariacao || nome) === variationFind.variacao?.nome; + }); + const isUpdateStockVariation = appData.export_quantity === true || Boolean(newVariation); + const variationBlingId = (newVariation || variationFind).id; + if (!isUpdateStockVariation || !variationBlingId) return; + stockRequests.push(bling.post('/estoques', { + produto: { id: Number(variationBlingId) }, + deposito: { id: Number(estoqueId) }, + operacao: 'B', + quantidade: variation.quantity || 0, + observacoes: `Update in ${new Date().toISOString()}`, + }).catch(logger.error)); + }); + } + await Promise.all(stockRequests); + + return response; +}; + +export default exportProductToBling; diff --git a/packages/apps/bling-erp/src/integration/helpers/get-customer-bling.ts b/packages/apps/bling-erp/src/integration/helpers/get-customer-bling.ts new file mode 100644 index 000000000..bcf6b22e1 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/helpers/get-customer-bling.ts @@ -0,0 +1,107 @@ +import type { Orders } from '@cloudcommerce/types'; +import type Bling from '../../bling-auth/client'; +import { URLSearchParams } from 'url'; +import ecomUtils from '@ecomplus/utils'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import parseAddress from '../parsers/address-to-bling'; + +export default async ( + bling: Bling, + appData: Record, + order: Orders, +) => { + const contatTypeClientId = appData.other_config?._contatTypeClientId + || appData.outher_config?._contatTypeClientId; + const buyer = order.buyers?.[0]; + + let contato: Record | undefined; + if (buyer?.doc_number) { + const params = new URLSearchParams({ + numeroDocumento: buyer.doc_number, + criterio: '1', // Todos + }); + contato = await bling.get(`/contatos?${params.toString()}`) + .then(({ data }) => data?.data?.[0]) + .catch((err: any) => { + logger.warn(`Failed listing Bling contacts: ${err.message}`); + return undefined; + }); + if (contato && contato.situacao === 'A') { + return contato.id; + } + } + + const shippingLine = order.shipping_lines?.[0]; + const transaction = order.transactions?.[0]; + const shippingAddress = shippingLine && shippingLine.to; + const billingAddress = transaction && transaction.billing_address; + let body: Record; + + if (buyer) { + const blingCustomer: Record = { + nome: (buyer.corporate_name || ecomUtils.fullName(buyer)).substring(0, 30) + || `Comprador de #${order.number}`, + tipo: buyer.registry_type === 'j' ? 'J' : 'F', + }; + if (buyer.doc_number && buyer.doc_number.length <= 18) { + blingCustomer.numeroDocumento = buyer.doc_number; + } + if (!appData.disable_buyer_inscription) { + if ( + buyer.inscription_number + && buyer.inscription_number.length <= 18 + && buyer.inscription_type !== 'Municipal' + ) { + blingCustomer.ie = buyer.inscription_number; + } + } + if (buyer.main_email && buyer.main_email.length <= 60) { + blingCustomer.email = buyer.main_email; + blingCustomer.emailNotaFiscal = buyer.main_email; + } + if (buyer.phones) { + ['celular', 'tel'].forEach((blingCustomerField, i) => { + const phoneNumber = buyer.phones?.[i]?.number; + if (phoneNumber && phoneNumber.length >= 9 && phoneNumber.length <= 11) { + blingCustomer[blingCustomerField] = phoneNumber.length === 9 + ? `11${phoneNumber}` + : phoneNumber; + } + }); + } + let cobranca: Record | undefined; + let geral: Record | undefined; + if (billingAddress) { + cobranca = {}; + parseAddress(billingAddress, cobranca); + } + if (shippingAddress) { + geral = {}; + parseAddress(shippingAddress, geral); + } + blingCustomer.endereco = { cobranca, geral }; + body = blingCustomer; + } else { + body = { + nome: `Comprador de #${order.number}`, + }; + } + + if (contatTypeClientId) { + body.tiposContato = { id: contatTypeClientId }; + } + body.situacao = 'A'; + + const method = contato ? 'put' : 'post'; + const endpoint = `/contatos${contato ? `/${contato.id}` : ''}`; + return bling[method](endpoint, body) + .then(({ data }) => (contato ? contato.id : data?.data?.id)) + .catch((err: any) => { + if (err.response) { + logger.warn(`Failed saving Bling contact: ${JSON.stringify(err.response.data)}`); + } else { + logger.error(err); + } + return undefined; + }); +}; diff --git a/packages/apps/bling-erp/src/integration/helpers/get-products-bling.ts b/packages/apps/bling-erp/src/integration/helpers/get-products-bling.ts new file mode 100644 index 000000000..da90dfd60 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/helpers/get-products-bling.ts @@ -0,0 +1,25 @@ +import type { Orders } from '@cloudcommerce/types'; +import type Bling from '../../bling-auth/client'; +import { logger } from '@cloudcommerce/firebase/lib/config'; + +export default async (bling: Bling, order: Orders): Promise>> => { + let url = ''; + order.items?.forEach((item) => { + if (!item.sku) return; + url += url ? `&codigo[]=${item.sku}` : `/produtos?codigo[]=${item.sku}`; + }); + if (!url) return []; + try { + const { data } = await bling.get(url); + if (data.data && data.data.length) { + return data.data; + } + } catch (err: any) { + if (err.response) { + logger.warn(`Failed listing Bling products: ${JSON.stringify(err.response.data)}`); + } else { + logger.error(err); + } + } + return []; +}; diff --git a/packages/apps/bling-erp/src/integration/helpers/get-status-bling.ts b/packages/apps/bling-erp/src/integration/helpers/get-status-bling.ts new file mode 100644 index 000000000..07edfcebb --- /dev/null +++ b/packages/apps/bling-erp/src/integration/helpers/get-status-bling.ts @@ -0,0 +1,33 @@ +import type Bling from '../../bling-auth/client'; +import { getFirestore, Timestamp } from 'firebase-admin/firestore'; +import getEnv from '@cloudcommerce/firebase/lib/env'; + +const firestoreColl = 'blingStatuses'; + +/* +Lists the "situações" of the Bling sales module, cached for 1h on Firestore. +*/ +const getStatusBling = async (bling: Bling): Promise> | null> => { + const { storeId } = getEnv(); + const docRef = getFirestore().doc(`${firestoreColl}/${storeId}`); + const docSnapshot = await docRef.get(); + const now = Timestamp.now(); + if (docSnapshot.exists) { + const { situacoes, updatedAt } = docSnapshot.data() as Record; + if (updatedAt && now.toMillis() - updatedAt.toMillis() < 1000 * 60 * 60) { + return situacoes; + } + } + const { data: { data: modules } } = await bling.get('/situacoes/modulos'); + const salesModule = modules?.find(({ nome }: Record) => { + return nome?.toLowerCase() === 'vendas'; + }); + if (!salesModule) { + return null; + } + const { data: { data: situacoes } } = await bling.get(`/situacoes/modulos/${salesModule.id}`); + await docRef.set({ situacoes, updatedAt: now }); + return situacoes; +}; + +export default getStatusBling; diff --git a/packages/apps/bling-erp/src/integration/helpers/payment-method.ts b/packages/apps/bling-erp/src/integration/helpers/payment-method.ts new file mode 100644 index 000000000..6ed02dac0 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/helpers/payment-method.ts @@ -0,0 +1,47 @@ +import type { Orders } from '@cloudcommerce/types'; +import type Bling from '../../bling-auth/client'; + +// https://developer.bling.com.br/referencia#/Formas%20de%20Pagamento +export const parsePaymentType = { + credit_card: 3, // Cartão de Crédito + banking_billet: 15, // Boleto Bancário + online_debit: 16, + account_deposit: 20, // Pagamento Instantâneo (PIX) – Estático + debit_card: 4, // Cartão de Débito + balance_on_intermediary: 18, + loyalty_points: 19, // Programa de Fidelidade, Cashback, Crédito Virtual + other: 99, // Outros +}; + +export const getPaymentBling = async ( + bling: Bling, + transaction: Exclude[0] | undefined, + appDataParsePayment: Array> | undefined, + paymentLabel?: string, +) => { + const paymentMethod = transaction?.payment_method || ({} as Record); + const namePaymentMethod = (paymentLabel || paymentMethod.name)?.toLowerCase(); + let parsePayment: Record | undefined; + if (appDataParsePayment && appDataParsePayment.length) { + parsePayment = appDataParsePayment.find(({ ecom_payment: ecomPayment }) => { + return ecomPayment?.toLowerCase() === namePaymentMethod; + }); + } + if (parsePayment) { + return parsePayment.bling_payment; + } + const query = paymentMethod.code + ? `?tiposPagamentos[]=${parsePaymentType[paymentMethod.code]}` + : ''; + const formaPagamento = await bling.get(`/formas-pagamentos${query}`) + .then(({ data }) => { + if (!data.data?.length) { + return bling.get('/formas-pagamentos') + .then(({ data: fallback }) => fallback.data?.[0]); + } + return data.data[0]; + }); + return formaPagamento?.id; +}; + +export default getPaymentBling; diff --git a/packages/apps/bling-erp/src/integration/helpers/try-image-upload.ts b/packages/apps/bling-erp/src/integration/helpers/try-image-upload.ts new file mode 100644 index 000000000..b66c38882 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/helpers/try-image-upload.ts @@ -0,0 +1,110 @@ +import axios from 'axios'; +import ecomUtils from '@ecomplus/utils'; +import api from '@cloudcommerce/api'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import getEnv from '@cloudcommerce/firebase/lib/env'; +import { imageSize } from 'image-size'; + +const tryImageSize = (data: any) => { + try { + return imageSize(Buffer.from(data)); + } catch { + return null; + } +}; + +let ecomAccessToken: string | undefined; + +const tryImageUpload = async ( + originImgUrl: string, + productName: string, +) => { + const { + storeId, + apiAuth: { + authenticationId, + apiKey, + }, + } = getEnv(); + if (!ecomAccessToken) { + const { data } = await api.post('authenticate', { + _id: authenticationId, + api_key: apiKey, + }); + ecomAccessToken = data.access_token; + } + try { + const { data } = await axios.get(originImgUrl, { + responseType: 'arraybuffer', + timeout: 20000, + }); + const dimensions = tryImageSize(data); + const formData = new FormData(); + let filename = originImgUrl.replace(/.*\/([^/]+)$/, '$1').replace(/\?.*$/, ''); + if (!/\.[a-z]+$/i.test(filename)) { + filename += '.jpg'; + } + formData.append('file', new Blob([data]), filename); + const { + data: { picture }, + status, + } = await axios.post('https://ecomplus.app/api/storage/upload.json', formData, { + headers: { + 'X-Store-ID': storeId, + 'X-My-ID': authenticationId, + 'X-Access-Token': ecomAccessToken, + }, + timeout: 60000, + }); + if (picture) { + const w = dimensions?.width; + const h = dimensions?.height; + if (w && h && picture.zoom) { + picture.zoom.size = `${w}x${h}`; + } + Object.keys(picture).forEach((imgSize) => { + if (!picture[imgSize]) return; + if (!picture[imgSize].url) { + delete picture[imgSize]; + return; + } + const maxPx = picture[imgSize].size; + if (w && h && maxPx > 0) { + if (maxPx >= Math.max(w, h)) { + picture[imgSize].size = `${w}x${h}`; + } else { + picture[imgSize].size = w > h + ? `${maxPx}x${Math.round((h * maxPx) / w)}` + : `${Math.round((w * maxPx) / h)}x${maxPx}`; + } + } else if (picture[imgSize].size !== undefined) { + delete picture[imgSize].size; + } + picture[imgSize].alt = `${productName} (${imgSize})`; + }); + if (Object.keys(picture).length) { + return { + _id: ecomUtils.randomObjectId(), + normal: picture.zoom, + ...picture, + }; + } + } + const err: any = new Error('Unexpected Storage API response'); + err.response = { data, status }; + throw err; + } catch (err: any) { + logger.warn(`Failed uploading ${originImgUrl}`, { + message: err.message, + }); + return { + _id: ecomUtils.randomObjectId(), + normal: { + url: originImgUrl, + alt: productName, + }, + }; + } +}; + +export default tryImageUpload; diff --git a/packages/apps/bling-erp/src/integration/import-category-from-bling.ts b/packages/apps/bling-erp/src/integration/import-category-from-bling.ts new file mode 100644 index 000000000..af263bc12 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/import-category-from-bling.ts @@ -0,0 +1,78 @@ +import type Bling from '../bling-auth/client'; +import api from '@cloudcommerce/api'; +import ecomUtils from '@ecomplus/utils'; +import { logger } from '@cloudcommerce/firebase/lib/config'; + +const removeAccents = (str: string) => str.trim() + .replace(/[áàãâÁÀÃÂ]/gi, 'a') + .replace(/[éêÉÊ]/gi, 'e') + .replace(/[óõôÓÕÔ]/gi, 'o') + .replace(/[íÍ]/gi, 'i') + .replace(/[úÚ]/gi, 'u') + .replace(/[çÇ]/gi, 'c') + .replace(/[-.]/gi, ''); + +const findCategory = async (query: string) => { + const endpoint = `categories?${query}&limit=1` as `categories?${string}`; + const { data: { result } } = await api.get(endpoint); + return result[0] || null; +}; + +const importCategory = async ( + bling: Bling, + blingCategoryId: string | number | undefined, +): Promise | null> => { + if (!blingCategoryId) { + return null; + } + const existing = await findCategory('metafields.namespace=bling' + + '&metafields.field=bling:categoria-id' + + `&metafields.value=${blingCategoryId}`); + if (existing) { + return existing; + } + + const { data: { data: blingCategory } } = await bling + .get(`/categorias/produtos/${blingCategoryId}`); + if (!blingCategory?.descricao) { + return null; + } + + const sameName = await findCategory(`name=${encodeURIComponent(blingCategory.descricao)}`); + if (sameName) { + return sameName; + } + + const body: Record = { + name: blingCategory.descricao, + slug: removeAccents(blingCategory.descricao.toLowerCase()) + .replace(/[^a-z0-9-_./]/gi, '-'), + metafields: [{ + _id: ecomUtils.randomObjectId(), + namespace: 'bling', + field: 'bling:categoria-id', + value: `${blingCategoryId}`, + }], + }; + + const parentId = blingCategory.categoriaPai?.id; + if (parentId) { + const parentCategory = await importCategory(bling, parentId) + .catch((err: any) => { + logger.warn(`[CATEGORY_IMPORT] erro ao importar categoria pai ${parentId}: ${err.message}`); + return null; + }); + if (parentCategory) { + body.parent = { + _id: parentCategory._id, + name: parentCategory.name, + slug: parentCategory.slug, + }; + } + } + + const { data } = await api.post('categories', body as any); + return { _id: data._id, ...body }; +}; + +export default importCategory; diff --git a/packages/apps/bling-erp/src/integration/import-order-from-bling.ts b/packages/apps/bling-erp/src/integration/import-order-from-bling.ts new file mode 100644 index 000000000..fc39715e6 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/import-order-from-bling.ts @@ -0,0 +1,85 @@ +import type { Orders } from '@cloudcommerce/types'; +import api from '@cloudcommerce/api'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import { createBlingClient } from '../bling-auth/client'; +import parseOrder from './parsers/order-from-bling'; +import parseStatusFromBling from './parsers/status-from-bling'; + +const getLastStatus = (records: Array> | undefined) => { + let statusRecord: Record | undefined; + records?.forEach((record) => { + if ( + record + && (!statusRecord || !record.date_time || record.date_time >= statusRecord.date_time) + ) { + statusRecord = record; + } + }); + return statusRecord?.status; +}; + +const importOrderFromBling = async ( + _apiDoc: Record, + queueEntry: Record, + appData: Record, +) => { + const blingOrderNumber = queueEntry.nextId; + const bling = createBlingClient(appData); + + const { data: { data: foundOrders } } = await bling + .get(`/pedidos/vendas?limite=1&numero=${blingOrderNumber}`); + const blingOrderId = Array.isArray(foundOrders) && foundOrders.length && foundOrders[0].id; + if (!blingOrderId) { + const err: any = new Error(`Pedido ${blingOrderNumber} não encontrado no Bling`); + err.isConfigError = true; + return err; + } + const { data: { data: blingOrder } } = await bling.get(`/pedidos/vendas/${blingOrderId}`); + logger.info(`Found Bling order ${blingOrder.numero}`); + + const situacao = blingOrder.situacao?.id + ? await bling.get(`/situacoes/${blingOrder.situacao.id}`) + .then(({ data }) => data.data?.nome?.toLowerCase()) + : null; + + const number = blingOrder.numeroLoja?.length ? blingOrder.numeroLoja : blingOrder.numero; + const endpoint = 'orders' + + '?fields=_id,payments_history,fulfillments,shipping_lines' + + `&number=${number}` + + '&limit=1' as `orders?${string}`; + const { data: { result } } = await api.get(endpoint); + if (!result.length) { + logger.info(`Order ${number} not found on store`); + return null; + } + const order = result[0] as Orders; + + const partialOrder = await parseOrder(blingOrder, order.shipping_lines, bling); + const promises: Array> = []; + if (partialOrder && Object.keys(partialOrder).length) { + promises.push(api.patch(`orders/${order._id}`, partialOrder)); + } + + const { financialStatus, fulfillmentStatus } = parseStatusFromBling(situacao, appData); + const statusBody = { + date_time: new Date().toISOString(), + flags: ['from-bling'], + }; + ([ + [financialStatus, 'payments_history'], + [fulfillmentStatus, 'fulfillments'], + ] as Array<[string | undefined, 'payments_history' | 'fulfillments']>) + .forEach(([newStatus, subresource]) => { + if (newStatus && getLastStatus(order[subresource]) !== newStatus) { + promises.push(api.post(`orders/${order._id}/${subresource}`, { + ...statusBody, + status: newStatus, + } as any)); + } + }); + + const [firstResult] = await Promise.all(promises); + return firstResult || null; +}; + +export default importOrderFromBling; diff --git a/packages/apps/bling-erp/src/integration/import-product-from-bling.ts b/packages/apps/bling-erp/src/integration/import-product-from-bling.ts new file mode 100644 index 000000000..0936bd7f3 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/import-product-from-bling.ts @@ -0,0 +1,325 @@ +import type { Products } from '@cloudcommerce/types'; +import type Bling from '../bling-auth/client'; +import api from '@cloudcommerce/api'; +import ecomUtils from '@ecomplus/utils'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import { createBlingClient } from '../bling-auth/client'; +import parseProduct from './parsers/product-from-bling'; +import importCategory from './import-category-from-bling'; + +const findProductBySku = async (sku: string) => { + try { + return (await api.get(`products/skus:${sku}`)).data; + } catch (err: any) { + if (err.statusCode === 404 || err.response?.status === 404) { + return null; + } + throw err; + } +}; + +const parseStockFromDeposits = ( + blingItem: Record, + blingDeposit: string | number | undefined, + hasStockReserve: boolean, +) => { + const depositFind = blingItem.depositos.find(({ id }: Record) => { + return String(id) === String(blingDeposit); + }); + const deposits = depositFind ? [depositFind] : blingItem.depositos; + let quantity = 0; + deposits.forEach((deposit: Record) => { + if (hasStockReserve) { + const saldoVirtual = Number(deposit.saldoVirtual); + quantity += !Number.isNaN(saldoVirtual) ? saldoVirtual : 0; + } else { + const saldo = typeof deposit.saldo === 'number' + ? Number(deposit.saldo) + : Number(deposit.saldoFisico); + quantity += !Number.isNaN(saldo) ? saldo : 0; + } + }); + return quantity; +}; + +const createUpdateProduct = async ( + appData: Record, + sku: string, + product: Products | null, + variationId: string | undefined, + blingProduct: Record, + isStockOnly: boolean, +) => { + const blingDeposit = appData.bling_deposit; + let blingItems = [blingProduct]; + if (Array.isArray(blingProduct.variacoes)) { + blingItems = blingItems.concat(blingProduct.variacoes); + } + blingItems.forEach((blingItem) => { + if ( + typeof blingItem.estoqueAtual !== 'number' + && typeof blingItem.estoque?.saldoVirtualTotal === 'number' + ) { + blingItem.estoqueAtual = Math.max(0, blingItem.estoque.saldoVirtualTotal); + } + if ( + Array.isArray(blingItem.depositos) + && (blingDeposit || typeof blingItem.estoqueAtual !== 'number') + ) { + blingItem.estoqueAtual = parseStockFromDeposits( + blingItem, + blingDeposit, + Boolean(appData.has_stock_reserve), + ); + delete blingItem.depositos; + } + }); + + const blingProductFind = !variationId + ? blingProduct + // Variations without SKU on Bling are referenced by the Bling ID + : blingItems.find((item) => item.codigo === sku || String(item.id) === sku); + let quantity = Number(blingProductFind?.estoqueAtual); + logger.info(`[STOCK] sku=${sku} quantity=${quantity}`, { + hasStockReserve: Boolean(appData.has_stock_reserve), + blingDeposit, + }); + + if (product && (isStockOnly === true || !appData.update_product || variationId)) { + if (Number.isNaN(quantity)) { + return null; + } + if (quantity < 0) { + quantity = 0; + } + let endpoint = `products/${product._id}`; + if (variationId) { + endpoint += `/variations/${variationId}`; + } + endpoint += '/quantity'; + logger.info(endpoint, { quantity, sku }); + // @ts-ignore + return api.put(endpoint, quantity); + } + + if (!product && blingProduct.codigoPai) { + logger.info(`Skipping ${sku} - is a variation without parent product on store`); + return null; + } + + const isNew = !product; + const bodyProduct = await parseProduct( + blingProduct, + product?.variations, + isNew, + appData, + ); + if (!Number.isNaN(quantity)) { + bodyProduct.quantity = quantity >= 0 ? quantity : 0; + } + if (!product?.metafields?.length) { + bodyProduct.metafields = [{ + _id: ecomUtils.randomObjectId(), + namespace: 'bling', + field: 'bling:id', + value: `${blingProduct.id}`, + }] as any; + } + if (product) { + logger.info(`PATCH products/${product._id}`, { bodyProduct }); + return api.patch(`products/${product._id}`, bodyProduct); + } + logger.info('POST products', { bodyProduct }); + return api.post('products', bodyProduct); +}; + +const getBlingProduct = async ( + bling: Bling, + sku: string, + blingProductId: string | number | undefined, + queueEntry: Record, + appData: Record, +) => { + const endpoint = blingProductId ? `/produtos/${blingProductId}` : `/produtos?codigo=${sku}`; + const { data } = await bling.get(endpoint); + const responseData = data?.data; + let foundProduct = !blingProductId && Array.isArray(responseData) + ? responseData[0] + : responseData; + if (!foundProduct && queueEntry._blingId) { + /* + Variations without SKU on Bling are imported with the Bling ID as SKU, + so there's nothing to find by `codigo` and the ID must be used. + */ + foundProduct = await bling.get(`/produtos/${queueEntry._blingId}`) + .then(({ data: { data: byId } }) => byId) + .catch(() => undefined); + } + if (!foundProduct) { + const err: any = new Error(`SKU ${sku} não encontrado no Bling`); + err.isConfigError = true; + throw err; + } + + /* + Bling may return the variation itself when searching by SKU, + in that case the parent product must be loaded to import the whole set. + */ + let blingProductData = foundProduct; + if (!blingProductId) { + const { data: { data: fullProduct } } = await bling + .get(`/produtos/${foundProduct.id || queueEntry._blingId}`); + const parentId = fullProduct?.variacao?.produtoPai?.id; + blingProductData = parentId + ? (await bling.get(`/produtos/${parentId}`)).data.data + : fullProduct; + } + if (!blingProductData) { + const err: any = new Error(`SKU ${sku} não encontrado no Bling`); + err.isConfigError = true; + throw err; + } + + const idsProdutos: Array = [blingProductData.id]; + blingProductData.variacoes?.forEach(({ id }: Record) => { + idsProdutos.push(id); + }); + const stockParams = idsProdutos.reduce((acc, id) => `${acc}idsProdutos[]=${id}&`, ''); + const blingProductStock = await bling.get(`/estoques/saldos?${stockParams}`) + .then((response) => response.data?.data) + .catch((err: any) => { + logger.warn(`Failed listing Bling stock: ${err.message}`); + return []; + }); + if (blingProductStock?.length) { + const stockProduct = blingProductStock.find(({ produto }) => { + return produto.id === blingProductData.id; + }); + if (stockProduct) { + blingProductData.depositos = stockProduct.depositos; + } + blingProductData.variacoes?.forEach((variation: Record) => { + const stockVariation = blingProductStock.find(({ produto }) => produto.id === variation.id); + if (stockVariation) { + variation.depositos = stockVariation.depositos; + } + }); + } + + const blingStore = appData.bling_store; + if (blingStore && blingProductData.id) { + try { + const { data: { data: produtosLoja } } = await bling + .get(`/produtos/lojas?idProduto=${blingProductData.id}&idLoja=${blingStore}`); + if (Array.isArray(produtosLoja) && produtosLoja.length) { + const { + preco: precoLoja, + precoPromocional: precoPromocionalLoja, + } = produtosLoja[0]; + if (precoLoja) { + logger.info(`[PRICE_MULTILOJA] sku=${sku} preco=${precoLoja}`); + blingProductData.preco = precoLoja; + if (precoPromocionalLoja) { + blingProductData.precoPromocional = precoPromocionalLoja; + } + } + } + } catch (err: any) { + logger.warn(`[PRICE_MULTILOJA] erro ao buscar preço da loja: ${err.message}`); + } + } + + if (blingProductData.categoria?.id) { + try { + const category = await importCategory(bling, blingProductData.categoria.id); + if (category) { + blingProductData.ecomCategories = [{ + _id: category._id, + name: category.name, + slug: category.slug, + }]; + } + } catch (err: any) { + const categoryId = blingProductData.categoria.id; + logger.warn(`[CATEGORY_IMPORT] erro ao importar categoria ${categoryId}: ${err.message}`); + } + } + + return blingProductData; +}; + +const importProductFromBling = async ( + _apiDoc: Record, + queueEntry: Record, + appData: Record, + canCreateNew?: boolean, + isHiddenQueue = false, +) => { + const [sku, queueProductId] = String(queueEntry.nextId).split(';:'); + let product: Products | null = null; + if (queueProductId) { + try { + product = (await api.get(`products/${queueProductId as Products['_id']}`)).data; + } catch (err: any) { + if (err.statusCode !== 404 && err.response?.status !== 404) { + throw err; + } + logger.info(`${queueProductId} not found on store`); + } + } else if (sku.length) { + product = await findProductBySku(sku); + } + + let variationId: string | undefined; + const hasVariations = Boolean(product?.variations?.length); + if (product && hasVariations) { + const variation = product.variations?.find(({ sku: variationSku }) => sku === variationSku); + if (variation) { + variationId = variation._id; + } else if (isHiddenQueue) { + if (product.sku !== sku) { + product = null; + } + } else if (!appData.update_product) { + const err: any = new Error(`${sku} corresponde a um produto com variações,` + + ' especifique o SKU da variação para importar.'); + err.isConfigError = true; + return err; + } + } else if (!product && !sku.length) { + const err: any = new Error('Produto sem SKU, especifique-o para importar.'); + err.isConfigError = true; + return err; + } + + const canImportNew = appData.import_product && canCreateNew !== false; + if (!product && (isHiddenQueue || queueProductId) && !canImportNew) { + logger.info(`Skipping ${sku} / ${queueProductId} => isHiddenQueue: ${isHiddenQueue}`); + return null; + } + + const bling = createBlingClient(appData); + const metafields = (product?.metafields || []) as Array>; + const blingProductId = metafields.find(({ field }) => field === 'bling:id')?.value; + const blingProduct = await getBlingProduct(bling, sku, blingProductId, queueEntry, appData); + + // Fallback: if variation SKU search failed, try finding by parent product SKU + if (!product && blingProduct.codigo && blingProduct.codigo !== sku) { + const parentSku = String(blingProduct.codigo); + logger.info(`Variation SKU "${sku}" not found, trying parent SKU "${parentSku}"`); + product = await findProductBySku(parentSku).catch(() => null); + if (product?.variations?.length) { + const variation = product.variations.find(({ sku: variationSku }) => variationSku === sku); + if (variation) { + variationId = variation._id; + } + } + } + + const isStockOnly = Boolean( + product && !(appData.update_product || appData.update_product_auto), + ); + return createUpdateProduct(appData, sku, product, variationId, blingProduct, isStockOnly); +}; + +export default importProductFromBling; diff --git a/packages/apps/bling-erp/src/integration/parsers/address-to-bling.ts b/packages/apps/bling-erp/src/integration/parsers/address-to-bling.ts new file mode 100644 index 000000000..7eae7bdca --- /dev/null +++ b/packages/apps/bling-erp/src/integration/parsers/address-to-bling.ts @@ -0,0 +1,27 @@ +export default ( + address: Record | undefined, + blingAddress: Record, + blingCityField = 'municipio', +) => { + if (!address) return; + ([ + ['name', 'nome', 120], + ['street', 'endereco', 50], + ['number', 'numero', 10], + ['complement', 'complemento', 50], + ['borough', 'bairro', 30], + ['zip', 'cep', 10], + ['city', blingCityField, 30], + ['province_code', 'uf', 30], + ] as Array<[string, string, number]>).forEach(([addressField, blingAddressField, maxLength]) => { + if (address[addressField] && !blingAddress[blingAddressField]) { + blingAddress[blingAddressField] = String(address[addressField]) + .trim() + .substring(0, maxLength); + } + }); + if (blingAddress.cep && /[0-9]{7,8}/.test(blingAddress.cep)) { + blingAddress.cep = blingAddress.cep.padStart(8, '0') + .replace(/^([\d]{2})([\d]{3})([\d]{3})$/, '$1.$2-$3'); + } +}; diff --git a/packages/apps/bling-erp/src/integration/parsers/order-from-bling.ts b/packages/apps/bling-erp/src/integration/parsers/order-from-bling.ts new file mode 100644 index 000000000..4792e8f81 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/parsers/order-from-bling.ts @@ -0,0 +1,125 @@ +import type { Orders } from '@cloudcommerce/types'; +import type Bling from '../../bling-auth/client'; + +type ShippingLines = Exclude; + +export default async ( + blingOrder: Record, + shippingLines: ShippingLines | undefined, + bling: Bling, +): Promise> => { + const partialOrder: Record = {}; + if (blingOrder.observacaointerna) { + partialOrder.staff_notes = blingOrder.observacaointerna; + } + if (shippingLines && shippingLines.length) { + const isGeneratedFallback = (existing: Record | undefined) => { + if (!existing) { + return true; + } + return existing.code === 'Sem codigo | Consultar no link' + || (existing.link && existing.link.startsWith('https://www.melhorrastreio.com.br/rastreio/')); + }; + + const addTrackingCode = (shippingLine: ShippingLines[0], volume: Record) => { + if (!volume || (!volume.codigoRastreamento && !volume.urlRastreamento)) { + return; + } + const existing = shippingLine.tracking_codes?.[0]; + if (existing && !isGeneratedFallback(existing)) { + return; + } + const tracking = volume.codigoRastreamento + ? { + code: String(volume.codigoRastreamento), + link: volume.urlRastreamento + || `https://www.melhorrastreio.com.br/rastreio/${volume.codigoRastreamento}`, + } + : { + code: 'Sem codigo | Consultar no link', + link: volume.urlRastreamento, + }; + if (existing && existing.code === tracking.code && existing.link === tracking.link) { + return; + } + shippingLine.tracking_codes = [tracking]; + partialOrder.shipping_lines = shippingLines; + }; + + const checkTrackingCodes = ({ codigosRastreamento, transporte }: Record) => { + if (transporte && transporte.volumes) { + const { volumes } = transporte; + for (let i = 0; i < volumes.length && i < shippingLines.length; i++) { + const volume = volumes[i].volume || volumes[i]; + addTrackingCode(shippingLines[i], volume); + } + } + if (codigosRastreamento) { + addTrackingCode(shippingLines[0], codigosRastreamento[0] || codigosRastreamento); + } + }; + checkTrackingCodes(blingOrder); + + const { nota } = blingOrder; + if (nota && nota.numero) { + const shippingLine = shippingLines[0]; + if (!shippingLine.invoices) { + shippingLine.invoices = []; + } + let invoiceIndex = shippingLine.invoices.findIndex(({ number }) => { + return number === String(nota.numero); + }); + if (invoiceIndex === -1) { + const invoice: Record = { + number: String(nota.numero), + }; + if (nota.serie) { + invoice.serial_number = String(nota.serie); + } + if (nota.chaveAcesso) { + invoice.access_key = String(nota.chaveAcesso); + } + if (nota.dataEmissao) { + const date = new Date(nota.dataEmissao); + if (date.getTime() > 0) { + invoice.issued_at = date.toISOString(); + } + } + shippingLine.invoices.push(invoice as any); + invoiceIndex = shippingLine.invoices.length - 1; + partialOrder.shipping_lines = shippingLines; + } else if (invoiceIndex && nota.chaveAcesso) { + shippingLine.invoices[invoiceIndex].access_key = String(nota.chaveAcesso); + } + + if (nota.serie) { + const data = await bling.get(`/notafiscal/${nota.numero}/${nota.serie}`) + .then((response) => response.data) + .catch(() => null); + let blingInvoice: Record | undefined; + if (Array.isArray(data?.notasfiscais)) { + blingInvoice = data.notasfiscais.find((fiscal: Record) => { + return !nota.chaveAcesso + || String(fiscal.notafiscal.chaveAcesso) === String(nota.chaveAcesso); + }); + if (blingInvoice) { + blingInvoice = blingInvoice.notafiscal; + } + } + if (blingInvoice) { + checkTrackingCodes(blingInvoice); + ([ + ['linkDanfe', 'link'], + ['chaveAcesso', 'access_key'], + ] as Array<[string, string]>).forEach(([blingField, field]) => { + if (blingInvoice![blingField] && !shippingLine.invoices![invoiceIndex][field]) { + shippingLine.invoices![invoiceIndex][field] = String(blingInvoice![blingField]); + partialOrder.shipping_lines = shippingLines; + } + }); + } + } + } + } + return partialOrder; +}; diff --git a/packages/apps/bling-erp/src/integration/parsers/order-to-bling.ts b/packages/apps/bling-erp/src/integration/parsers/order-to-bling.ts new file mode 100644 index 000000000..d029b7837 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/parsers/order-to-bling.ts @@ -0,0 +1,232 @@ +import type { Orders } from '@cloudcommerce/types'; +import ecomUtils from '@ecomplus/utils'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import parseAddress from './address-to-bling'; + +const holidays = [ + '2026-01-01', '2026-02-16', '2026-02-17', '2026-04-03', '2026-04-21', + '2026-05-01', '2026-06-04', '2026-07-09', '2026-09-07', '2026-10-12', + '2026-11-02', '2026-11-20', '2026-12-24', '2026-12-25', '2026-12-31', + '2027-01-01', '2027-02-08', '2027-02-09', '2027-04-02', '2027-04-21', + '2027-05-01', '2027-05-27', '2027-07-09', '2027-09-07', '2027-10-12', + '2027-11-02', '2027-11-20', '2027-12-24', '2027-12-25', '2027-12-31', +]; + +const toDateStr = (d: Date) => `${d.getUTCFullYear()}-` + + `${String(d.getUTCMonth() + 1).padStart(2, '0')}-` + + `${String(d.getUTCDate()).padStart(2, '0')}`; + +const isNonWorkingDay = (dateStr: string) => { + const day = new Date(`${dateStr}T12:00:00Z`).getUTCDay(); + return day === 0 || day === 6 || holidays.includes(dateStr); +}; + +const addDaysToDate = (startDateStr: string, days: number, workingDays?: boolean) => { + const d = new Date(`${startDateStr}T12:00:00Z`); + let remaining = days; + while (remaining > 0) { + d.setUTCDate(d.getUTCDate() + 1); + if (!workingDays || !isNonWorkingDay(toDateStr(d))) remaining -= 1; + } + return toDateStr(d); +}; + +export default ( + order: Orders, + blingOrderNumber: string | undefined, + blingStore: string | number | undefined, + appData: Record, + customerIdBling: number | string, + paymentTypeId: number | string | null, + itemsBling: Array>, + originalBlingOrder?: Record, +) => { + try { + const { amount } = order; + const blingOrder: Record = { + numeroLoja: String(order.number), + data: (order.opened_at || order.created_at).substring(0, 10), + numeroPedidoCompra: order.number ? String(order.number) : undefined, + contato: { id: customerIdBling }, + }; + blingOrder.dataSaida = blingOrder.data; + if (order.number && !appData.disable_order_number) { + blingOrder.numero = appData.random_order_number === true ? blingOrderNumber : order.number; + } + if (blingStore) { + blingOrder.loja = { id: Number(blingStore) }; + } + + if (appData.bling_order_data) { + Object.keys(appData.bling_order_data).forEach((field) => { + let value = appData.bling_order_data[field]; + switch (value) { + case undefined: + case '': + case null: + break; + default: + if (typeof value === 'string') { + value = value.trim(); + if (value) { + blingOrder[field] = value; + } + } else { + blingOrder[field] = value; + } + } + }); + } + + const shippingLine = order.shipping_lines?.[0]; + const transaction = order.transactions?.[0]; + const shippingAddress = shippingLine && shippingLine.to; + + let subtotal = 0; + if (order.items && order.items.length) { + blingOrder.itens = []; + order.items.forEach((item) => { + if (item.quantity) { + const itemRef = String(item.sku || item._id || '').substring(0, 40); + const valor = Math.round(ecomUtils.price(item) * 100) / 100; + const itemToBling: Record = { + codigo: itemRef, + descricao: item.name ? item.name.substring(0, 120) : itemRef, + unidade: 'Un', + quantidade: item.quantity, + valor, + }; + subtotal += (valor * item.quantity); + const productBlingId = itemsBling.find(({ codigo }) => codigo === item.sku)?.id; + if (productBlingId) { + itemToBling.produto = { id: productBlingId }; + } + blingOrder.itens.push(itemToBling); + } + }); + } + + if (shippingLine) { + const { posting_deadline: postingDeadline, delivery_time: deliveryTime } = shippingLine; + if (postingDeadline?.days || deliveryTime?.days) { + let estimatedDate = (order.opened_at || order.created_at).substring(0, 10); + if (postingDeadline?.days) { + estimatedDate = addDaysToDate( + estimatedDate, + postingDeadline.days, + postingDeadline.working_days, + ); + } + if (deliveryTime?.days) { + estimatedDate = addDaysToDate( + estimatedDate, + deliveryTime.days, + deliveryTime.working_days, + ); + } + blingOrder.dataPrevista = estimatedDate; + } + + blingOrder.transporte = {}; + let shippingService: Record | undefined; + const blingShipping = appData.parse_shipping; + if (shippingLine.app && blingShipping && blingShipping.length) { + shippingService = blingShipping.find(({ ecom_shipping: ecomShipping }) => { + return ecomShipping + && ecomShipping.toLowerCase() === shippingLine.app?.label?.toLowerCase(); + }); + } + if (!originalBlingOrder || !originalBlingOrder.transporte?.volumes?.length) { + let shippingLabel = shippingService?.bling_shipping; + if (!shippingLabel) { + shippingLabel = shippingLine.app?.service_code || order.shipping_method_label; + } + if (shippingLabel) { + blingOrder.transporte.volumes = [{ servico: shippingLabel }]; + } + } + + if (shippingLine.package?.weight) { + const { unit, value } = shippingLine.package.weight; + let pesoBruto = value; + if (unit === 'g') { + pesoBruto = value / 1000; + } else if (unit === 'mg') { + pesoBruto = value / 1000000; + } + blingOrder.transporte.pesoBruto = pesoBruto; + } + + if (shippingAddress) { + blingOrder.transporte.etiqueta = {}; + parseAddress(shippingAddress, blingOrder.transporte.etiqueta); + } + + if (typeof amount.freight === 'number') { + blingOrder.transporte.frete = Math.round(amount.freight * 100) / 100; + } + } + + if (amount.discount) { + blingOrder.desconto = { + valor: Math.round(amount.discount * 100) / 100, + unidade: 'REAL', + }; + } + if (amount.balance) { + if (!blingOrder.desconto) { + blingOrder.desconto = { + valor: 0, + unidade: 'REAL', + }; + } + blingOrder.desconto.valor += Math.round(amount.balance * 100) / 100; + } + + if (transaction) { + let blingPaymentLabel = ''; + if (order.payment_method_label) { + blingPaymentLabel = order.payment_method_label; + } else if (transaction.payment_method.name) { + blingPaymentLabel = transaction.payment_method.name.substring(0, 140); + } + const total = subtotal + + (blingOrder.transporte?.frete || 0) + - (blingOrder.desconto?.valor || 0); + blingOrder.parcelas = []; + if (transaction.installments) { + const { number } = transaction.installments; + const vlr = Math.round((total * 100) / number) / 100; + const date = new Date(blingOrder.data).getTime(); + for (let i = 0; i < number; i++) { + const addDaysMs = i ? (i * 30 * 24 * 60 * 60 * 1000) : 0; + const deadLine = new Date(date + addDaysMs); + blingOrder.parcelas.push({ + dataVencimento: deadLine.toISOString().substring(0, 10), + valor: i < number - 1 + ? vlr + : Math.round((total - (vlr * i)) * 100) / 100, + observacoes: `${blingPaymentLabel} (${(i + 1)}/${number})`, + formaPagamento: { id: paymentTypeId }, + }); + } + } else { + blingOrder.parcelas.push({ + dataVencimento: blingOrder.data, + valor: Math.round(total * 100) / 100, + observacoes: `${blingPaymentLabel} (1/1)`, + formaPagamento: { id: paymentTypeId }, + }); + } + } + + if (order.notes) { + blingOrder.observacoes = order.notes; + } + + return blingOrder; + } catch (err) { + logger.error(err); + throw err; + } +}; diff --git a/packages/apps/bling-erp/src/integration/parsers/product-from-bling.ts b/packages/apps/bling-erp/src/integration/parsers/product-from-bling.ts new file mode 100644 index 000000000..5499a7d55 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/parsers/product-from-bling.ts @@ -0,0 +1,296 @@ +import type { Products, ProductSet } from '@cloudcommerce/types'; +import ecomUtils from '@ecomplus/utils'; +import { logger } from '@cloudcommerce/firebase/lib/config'; +import tryImageUpload from '../helpers/try-image-upload'; + +const removeAccents = (str: string) => str.replace(/[áàãâÁÀÃÂ]/g, 'a') + .replace(/[éêÉÊ]/g, 'e') + .replace(/[óõôÓÕÔ]/g, 'o') + .replace(/[íÍ]/g, 'i') + .replace(/[úÚ]/g, 'u') + .replace(/[çÇ]/g, 'c'); + +const hexaColors = (color: string) => { + switch (removeAccents(color.toLowerCase())) { + case 'azulclaro': return '#add8e6'; + case 'branco': + case 'branca': return '#ffffff'; + case 'cinza': return '#808080'; + case 'vermelho': + case 'vermelha': return '#ff0000'; + case 'amarelo': + case 'amarela': return '#ffff00'; + case 'verde': return '#008000'; + case 'preto': + case 'preta': return '#000000'; + case 'azul': return '#0000ff'; + case 'petroleo': return '#006666'; + case 'verde limao': return '#32cd32'; + case 'rosa': + case 'pink': return '#ffc0cb'; + case 'roxo': return '#800080'; + case 'laranja': + case 'laranjao': return '#ffa500'; + case 'muffin': return '#d6a78a'; + case 'off': + case 'offwhite': + case 'off-white': + case 'off white': return '#fffafa'; + case 'marrom': return '#a52a2a'; + case 'areia': return '#f0e68c'; + case 'vinho': + case 'vinha': return '#800000'; + case 'ciano': return '#00ffff'; + case 'prata': return '#c0c0c0'; + case 'grafite': return '#808080'; + case 'magento': return '#ff00ff'; + case 'dourado': return '#ffd700'; + case 'turquesa': return '#40e0d0'; + case 'chocolatebranco': return '#d2691e'; + case 'verde oliva': return '#6b8e23'; + case 'caqui': return '#f0e68c'; + case 'pessego': return '#ffe5b4'; + case 'indigo': return '#4b0082'; + default: return '#ffffff'; + } +}; + +const validateGtin = (gtin: any) => { + return typeof gtin === 'string' && /^([0-9]{8}|[0-9]{12,14})$/.test(gtin); +}; + +/* +Reverse of the grid titles sent on exportation, so the store keeps its +first-class grids (`colors`, `size`…) instead of generic ones named after the +Bling label. Unknown titles still fall back to a slug of the label itself. +*/ +const gridIdsByTitle = { + cor: 'colors', + cores: 'colors', + tamanho: 'size', + idade: 'age_group', + genero: 'gender', +}; + +const parseGridId = (gridName: string) => { + const title = removeAccents(gridName.trim().toLowerCase()); + return gridIdsByTitle[title] + || title + .replace(/\s+/g, '_') + .replace(/[^a-z0-9_]/g, '') + .substring(0, 30) + .padStart(2, 'i'); +}; + +const parseDimensions = (dimensoes: Record | undefined) => { + const dimensions: Record = {}; + ([ + ['largura', 'width'], + ['altura', 'height'], + ['profundidade', 'length'], + ] as Array<[string, string]>).forEach(([lado, side]) => { + const value = parseFloat(dimensoes?.[lado]); + if (value > 0) { + dimensions[side] = { unit: 'cm', value }; + } + }); + return Object.keys(dimensions).length ? dimensions : null; +}; + +const getBaseUrl = (link: string) => { + try { + const url = new URL(link); + return `${url.protocol}//${url.hostname}${url.pathname}`; + } catch { + return link; + } +}; + +export default async ( + blingProduct: Record, + variations: Products['variations'] | undefined, + isNew: boolean, + appData: Record, +): Promise => { + const sku = blingProduct.codigo || String(blingProduct.id); + const name = (blingProduct.nome || sku).trim(); + const product: ProductSet = { + available: blingProduct.situacao === 'A', + sku, + name, + quantity: 0, + price: Number(blingProduct.preco), + }; + + if (!appData.non_update_description) { + if (blingProduct.descricaoComplementar) { + product.body_html = String(blingProduct.descricaoComplementar); + } else if (blingProduct.descricaoCurta) { + product.body_html = String(blingProduct.descricaoCurta); + } + } + + if (blingProduct.preco && blingProduct.precoPromocional) { + product.price = Number(blingProduct.precoPromocional); + product.base_price = Number(blingProduct.preco); + } + + if (Array.isArray(blingProduct.ecomCategories) && blingProduct.ecomCategories.length) { + product.categories = blingProduct.ecomCategories; + } + if (blingProduct.itensPorCaixa) { + product.min_quantity = Number(blingProduct.itensPorCaixa); + } + if (blingProduct.tributacao?.ncm) { + product.mpn = [String(blingProduct.tributacao.ncm)]; + } + if (validateGtin(blingProduct.gtin)) { + product.gtin = [blingProduct.gtin]; + if (validateGtin(blingProduct.gtinEmbalagem)) { + product.gtin.push(blingProduct.gtinEmbalagem); + } + } + + if (isNew) { + product.slug = removeAccents(name.toLowerCase()) + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-_./]/g, ''); + if (!/[a-z0-9]/.test(product.slug.charAt(0))) { + product.slug = `p-${product.slug}`; + } + } + + if (blingProduct.midia?.video?.url) { + product.videos = [{ url: blingProduct.midia.video.url }]; + } + + const weight = parseFloat(blingProduct.pesoBruto || blingProduct.pesoLiq); + if (weight > 0) { + product.weight = { unit: 'kg', value: weight }; + } + const dimensions = parseDimensions(blingProduct.dimensoes); + if (dimensions) { + product.dimensions = dimensions; + } + + // Picture indexes to be replaced by the uploaded picture IDs + const pendingPictureIds: Array<{ variation: Record, pictureIndex: number }> = []; + + if (Array.isArray(blingProduct.variacoes) && blingProduct.variacoes.length) { + product.variations = (variations || []) as Exclude; + if (!blingProduct.midia) blingProduct.midia = {}; + if (!blingProduct.midia.imagens) blingProduct.midia.imagens = {}; + if (!Array.isArray(blingProduct.midia.imagens.externas)) { + blingProduct.midia.imagens.externas = []; + } + const { externas } = blingProduct.midia.imagens; + const externalLinks = new Set(externas.map(({ link }) => getBaseUrl(link))); + + blingProduct.variacoes.forEach((variacao: Record) => { + if (!variacao?.nome || !variacao.variacao?.nome) return; + const gridsAndValues = String(variacao.variacao.nome).split(';'); + if (!gridsAndValues.length) return; + const specifications: Record>> = {}; + const specTexts: string[] = []; + gridsAndValues.forEach((gridAndValue) => { + const [gridName, text] = gridAndValue.trim().split(':'); + if (!gridName || !text) return; + const gridId = parseGridId(gridName); + const spec: Record = { text }; + specTexts.push(text); + if (gridId === 'colors') { + spec.value = hexaColors(text); + } else { + spec.value = removeAccents(text.toLowerCase()).substring(0, 100); + } + if (!specifications[gridId]) { + specifications[gridId] = [spec]; + } else { + specifications[gridId].push(spec); + } + }); + if (!specTexts.length) return; + + const { + midia, + codigo, + preco, + gtin, + dimensoes, + pesoBruto, + pesoLiq, + tributacao, + id, + } = variacao; + let pictureId = 0; + if (Array.isArray(midia?.imagens?.externas) && midia.imagens.externas.length) { + midia.imagens.externas.forEach(({ link }: Record) => { + const baseUrl = getBaseUrl(link); + if (!externalLinks.has(baseUrl)) { + externas.push({ link }); + externalLinks.add(baseUrl); + } + }); + pictureId = externas.length - 1; + } + let variation = variations + ?.find(({ sku: variationSku }) => variationSku === codigo) as Record; + if (!variation) { + variation = { _id: ecomUtils.randomObjectId() }; + product.variations!.push(variation as any); + } + variation.name = `${name} / ${specTexts.join(' / ')}`.substring(0, 100); + variation.sku = codigo || String(id); + variation.specifications = specifications; + variation.quantity = variacao.estoqueAtual >= 0 ? variacao.estoqueAtual : 0; + if (pictureId > 0) { + pendingPictureIds.push({ variation, pictureIndex: pictureId }); + } + const price = parseFloat(preco); + if (price && preco !== blingProduct.preco) { + variation.price = price; + } + if (validateGtin(gtin)) { + variation.gtin = gtin; + } + const variationDimensions = parseDimensions(dimensoes); + if (variationDimensions) { + variation.dimensions = variationDimensions; + } + const variationWeight = parseFloat(pesoBruto || pesoLiq); + if (variationWeight > 0) { + variation.weight = { unit: 'kg', value: variationWeight }; + } + if (tributacao?.ncm) { + variation.mpn = String(tributacao.ncm); + } + }); + } + + if (isNew && blingProduct.midia?.imagens) { + const { externas, internas } = blingProduct.midia.imagens; + const links: string[] = [ + ...(Array.isArray(externas) ? externas : []), + ...(Array.isArray(internas) ? internas : []), + ] + .map(({ link }) => link) + .filter((link) => typeof link === 'string' && link.startsWith('http')); + if (links.length) { + const pictures: Array> = []; + for (let i = 0; i < links.length; i++) { + // eslint-disable-next-line no-await-in-loop + pictures.push(await tryImageUpload(links[i], name)); + } + product.pictures = pictures as any; + pendingPictureIds.forEach(({ variation, pictureIndex }) => { + const picture = pictures[pictureIndex]; + if (picture?._id) { + variation.picture_id = picture._id; + } + }); + logger.info(`Uploaded ${pictures.length} pictures for ${sku}`); + } + } + + return product; +}; diff --git a/packages/apps/bling-erp/src/integration/parsers/product-to-bling.ts b/packages/apps/bling-erp/src/integration/parsers/product-to-bling.ts new file mode 100644 index 000000000..ef8609dc4 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/parsers/product-to-bling.ts @@ -0,0 +1,238 @@ +import type { Products } from '@cloudcommerce/types'; +import ecomUtils from '@ecomplus/utils'; + +const parseDimensions = (dimensions: Record | undefined) => { + const blingDimensoes: Record = {}; + if (dimensions) { + Object.keys(dimensions).forEach((side) => { + const { value } = dimensions[side] || {}; + if (value) { + let field: string; + if (side === 'width') { + field = 'largura'; + } else if (side === 'height') { + field = 'altura'; + } else { + field = 'profundidade'; + } + blingDimensoes[field] = value; + } + }); + } + if (Object.keys(blingDimensoes).length) { + blingDimensoes.unidadeMedida = 1; + return blingDimensoes; + } + return null; +}; + +const parseWeight = (weight: Record | undefined) => { + if (!weight?.value) return null; + let pesoBruto = weight.value; + switch (weight.unit) { + case 'mg': + pesoBruto /= 1000000; + break; + case 'g': + pesoBruto /= 1000; + break; + default: + } + return pesoBruto; +}; + +export default ( + product: Products, + originalBlingProduct: Record | undefined, + appData: Record, +) => { + const isVariations = Boolean(product.variations && product.variations.length); + let unidade = 'UN'; + if (originalBlingProduct?.unidade) { + unidade = originalBlingProduct.unidade; + } else if ( + product.measurement + && product.measurement.unit !== 'oz' + && product.measurement.unit !== 'ct' + ) { + unidade = product.measurement.unit.substring(0, 6).toUpperCase(); + } + + const blingProduct: Record = { + nome: product.name || '', + codigo: product.sku || product._id, + tipo: 'P', + situacao: product.available && product.visible ? 'A' : 'I', + formato: isVariations ? 'V' : 'S', + preco: ecomUtils.price(product), + descricaoCurta: product.short_description, + descricaoComplementar: product.body_html, + unidade, + }; + + if (originalBlingProduct?.id) { + blingProduct.id = originalBlingProduct.id; + } + + if (product.condition) { + blingProduct.condicao = 0; + if (product.condition === 'new') { + blingProduct.condicao = 1; + } else if (product.condition === 'used') { + blingProduct.condicao = 2; + } + } + if (product.min_quantity) { + blingProduct.itensPorCaixa = product.min_quantity; + } + if (product.mpn && product.mpn.length) { + blingProduct.tributacao = { + ncm: product.mpn[0], + }; + } + if (product.gtin && product.gtin.length) { + [blingProduct.gtin] = product.gtin; + if (product.gtin[1]) { + blingProduct.gtinEmbalagem = product.gtin[1]; + } + } + + const pesoBruto = parseWeight(product.weight); + if (pesoBruto) { + blingProduct.pesoBruto = pesoBruto; + blingProduct.pesoLiquido = pesoBruto; + } + const dimensoes = parseDimensions(product.dimensions); + if (dimensoes) { + blingProduct.dimensoes = dimensoes; + } + + blingProduct.midia = {}; + if (product.brands && product.brands.length) { + blingProduct.marca = product.brands[0].name; + } + if (product.videos?.length && product.videos[0].url) { + blingProduct.midia.video = { + url: product.videos[0].url, + }; + } + if (product.pictures && product.pictures.length) { + blingProduct.midia.imagens = { + imagensURL: [], + }; + product.pictures.forEach(({ zoom, big, normal }) => { + const img = (zoom || big || normal); + if (img) { + blingProduct.midia.imagens.imagensURL.push({ link: img.url }); + } + }); + } + + // Stock + if (!isVariations) { + if ( + typeof product.quantity === 'number' + && (!originalBlingProduct || appData.export_quantity) + ) { + blingProduct.estoque = { maximo: product.quantity }; + } else if (typeof originalBlingProduct?.estoque?.maximo === 'number') { + blingProduct.estoque = { maximo: originalBlingProduct.estoque.maximo }; + } + } + + if (isVariations && product.variations) { + blingProduct.variacoes = []; + product.variations.forEach((variation, i) => { + const codigo = variation.sku || `${product.sku}-${(i + 1)}`; + const blingVariationOriginal = originalBlingProduct?.variacoes?.find( + ({ codigo: codigoFind }) => codigoFind === codigo, + ); + const blingVariation: Record = { + nome: variation.name, + tipo: 'P', + situacao: product.available && product.visible ? 'A' : 'I', + formato: 'S', + preco: ecomUtils.price({ ...product, ...variation }), + codigo: blingVariationOriginal?.codigo || codigo, + }; + if (blingVariationOriginal?.id) { + blingVariation.id = blingVariationOriginal.id; + } + + // Stock variation + if ( + typeof variation.quantity === 'number' + && (!blingVariationOriginal || appData.export_quantity) + ) { + blingVariation.estoque = { maximo: variation.quantity }; + } else if (typeof blingVariationOriginal?.estoque?.maximo === 'number') { + blingVariation.estoque = { maximo: blingVariationOriginal.estoque.maximo }; + } + + if (variation.mpn && variation.mpn.length) { + blingVariation.tributacao = { + ncm: variation.mpn[0], + }; + } + if (variation.gtin && variation.gtin.length) { + [blingVariation.gtin] = variation.gtin; + if (variation.gtin[1]) { + blingVariation.gtinEmbalagem = variation.gtin[1]; + } + } + const variationWeight = parseWeight(variation.weight); + if (variationWeight) { + blingVariation.pesoBruto = variationWeight; + } + const variationDimensoes = parseDimensions(variation.dimensions); + if (variationDimensoes) { + blingVariation.dimensoes = variationDimensoes; + } + + const variacao: Record = { + nome: '', + ordem: i, + produtoPai: { cloneInfo: Boolean(!variation.dimensions) }, + }; + const { specifications } = variation; + if (specifications) { + Object.keys(specifications).forEach((gridId) => { + const gridOptions = specifications[gridId]; + if (gridOptions && gridOptions.length) { + gridOptions.forEach(({ text }, optionIndex) => { + let gridTitle: string; + switch (gridId) { + case 'colors': + gridTitle = 'Cor'; + break; + case 'size': + gridTitle = 'Tamanho'; + break; + case 'age_group': + gridTitle = 'Idade'; + break; + case 'gender': + gridTitle = 'Gênero'; + break; + default: + gridTitle = gridId.charAt(0).toUpperCase() + gridId.slice(1).replace('_', ' '); + } + if (variacao.nome) { + variacao.nome += ';'; + variacao.ordem = optionIndex + 1; + if (optionIndex > 0) { + gridTitle += optionIndex === 1 ? ' secundária' : ` ${(optionIndex + 1)}`; + } + } + variacao.nome += `${gridTitle}:${text.replace(/[:;]/g, '')}`; + }); + } + }); + } + blingVariation.variacao = variacao; + blingProduct.variacoes.push(blingVariation); + }); + } + + return blingProduct; +}; diff --git a/packages/apps/bling-erp/src/integration/parsers/status-from-bling.ts b/packages/apps/bling-erp/src/integration/parsers/status-from-bling.ts new file mode 100644 index 000000000..5d54bf5bf --- /dev/null +++ b/packages/apps/bling-erp/src/integration/parsers/status-from-bling.ts @@ -0,0 +1,73 @@ +import type { Orders } from '@cloudcommerce/types'; + +type FinancialStatus = Exclude['current']; +type FulfillmentStatus = Exclude['current']; + +export default (blingSituacao: string | undefined, appData: Record) => { + const situacao = `${blingSituacao}`.toLowerCase(); + let financialStatus: FinancialStatus | undefined; + let fulfillmentStatus: FulfillmentStatus | undefined; + const mappedStatus = appData.parse_status?.find((status: Record) => { + return status?.status_bling?.toLowerCase() === situacao; + }); + switch (mappedStatus?.status_ecom?.toLowerCase() || situacao) { + case 'pendente': + financialStatus = 'pending'; + break; + case 'em análise': + financialStatus = 'under_analysis'; + break; + case 'autorizado': + financialStatus = 'authorized'; + break; + case 'não autorizado': + financialStatus = 'unauthorized'; + break; + case 'venda agenciada': + case 'aprovado': + case 'pago': + financialStatus = 'paid'; + break; + case 'em andamento': + case 'em separação': + case 'em separacao': + fulfillmentStatus = 'in_separation'; + break; + case 'em produção': + case 'em producao': + fulfillmentStatus = 'in_production'; + break; + case 'faturado': + case 'atendido': + case 'nf emitida': + fulfillmentStatus = 'invoice_issued'; + break; + case 'pronto para envio': + fulfillmentStatus = 'ready_for_shipping'; + break; + case 'enviado': + case 'despachado': + fulfillmentStatus = 'shipped'; + break; + case 'entregue': + fulfillmentStatus = 'delivered'; + break; + case 'cancelado': + financialStatus = 'voided'; + break; + case 'aguardando troca': + fulfillmentStatus = 'received_for_exchange'; + break; + case 'devolvido': + financialStatus = 'refunded'; + break; + case 'retorno e troca': + fulfillmentStatus = 'returned_for_exchange'; + break; + case 'disputa': + financialStatus = 'in_dispute'; + break; + default: + } + return { financialStatus, fulfillmentStatus }; +}; diff --git a/packages/apps/bling-erp/src/integration/parsers/status-to-bling.ts b/packages/apps/bling-erp/src/integration/parsers/status-to-bling.ts new file mode 100644 index 000000000..29a3873d8 --- /dev/null +++ b/packages/apps/bling-erp/src/integration/parsers/status-to-bling.ts @@ -0,0 +1,82 @@ +import type { Orders } from '@cloudcommerce/types'; + +const parseStatusTitle = { + pending: 'Pendente', + under_analysis: 'Em análise', + authorized: 'Autorizado', + unauthorized: 'Não autorizado', + partially_paid: 'Parte pago', + paid: 'Pago', + in_dispute: 'Disputa', + partially_refunded: 'Parte devolvido', + refunded: 'Devolvido', + voided: 'Cancelado', + in_production: 'Em produção', + in_separation: 'Em separação', + ready_for_shipping: 'Pronto para envio', + invoice_issued: 'NF emitida', + shipped: 'Enviado', + partially_shipped: 'Parte enviado', + partially_delivered: 'Parte entregue', + delivered: 'Entregue', + returned_for_exchange: 'Retorno e troca', + received_for_exchange: 'Aguardando troca', +}; + +const findStatusConfig = (statusApi: string, appData: Record) => { + if (!appData.parse_status?.length) return null; + const statusApp = appData.parse_status.find((status: Record) => { + return status.status_ecom === parseStatusTitle[statusApi]; + }); + return statusApp?.status_bling + ? [String(statusApp.status_bling).toLowerCase()] + : null; +}; + +export default (order: Orders, appData: Record): string[] => { + let financialStatus = order.financial_status?.current; + if (!financialStatus) { + const paymentsHistory = order.payments_history; + if (paymentsHistory && paymentsHistory.length) { + financialStatus = paymentsHistory[paymentsHistory.length - 1].status; + } + } + switch (financialStatus) { + case 'pending': + case 'under_analysis': + case 'unknown': + case 'authorized': + case 'partially_paid': + return findStatusConfig(financialStatus, appData) || ['pendente', 'em aberto']; + case 'voided': + case 'refunded': + case 'in_dispute': + case 'unauthorized': + case 'partially_refunded': + return findStatusConfig(financialStatus, appData) || ['cancelado']; + default: + } + const fulfillmentStatus = order.fulfillment_status?.current; + switch (fulfillmentStatus) { + case 'in_production': + return findStatusConfig(fulfillmentStatus, appData) + || ['em produção', 'em producao', 'em andamento']; + case 'in_separation': + return findStatusConfig(fulfillmentStatus, appData) + || ['em separação', 'em separacao', 'em andamento']; + case 'invoice_issued': + return findStatusConfig(fulfillmentStatus, appData) || ['faturado', 'atendido']; + case 'ready_for_shipping': + return findStatusConfig(fulfillmentStatus, appData) || ['pronto para envio', 'pronto envio']; + case 'shipped': + case 'partially_shipped': + return findStatusConfig(fulfillmentStatus, appData) || ['enviado', 'atendido']; + case 'delivered': + return findStatusConfig(fulfillmentStatus, appData) || ['entregue', 'atendido']; + default: + } + if (financialStatus === 'paid') { + return findStatusConfig(financialStatus, appData) || ['aprovado', 'em aberto']; + } + return ['em aberto', 'pendente']; +}; diff --git a/packages/apps/bling-erp/src/refresh-bling-token.ts b/packages/apps/bling-erp/src/refresh-bling-token.ts new file mode 100644 index 000000000..cfd36f555 --- /dev/null +++ b/packages/apps/bling-erp/src/refresh-bling-token.ts @@ -0,0 +1,28 @@ +import * as logger from 'firebase-functions/logger'; +import getAppData from '@cloudcommerce/firebase/lib/helpers/get-app-data'; +import createAccess from './bling-auth/create-access'; + +/* +Keeps the Bling access token fresh, refreshing it more than 1h before expiration, +so the stored `refresh_token` never gets too old. +*/ +export const refreshBlingToken = async () => { + const appData = await getAppData('blingErp', ['hidden_data', 'data']); + const { client_id: clientId, client_secret: clientSecret } = appData; + if (!clientId || !clientSecret) { + logger.warn('Missing Bling client_id/client_secret'); + return; + } + try { + await createAccess(clientId, clientSecret, (1000 * 60 * 60) + (1000 * 60 * 10)); + logger.info('Checked Bling token'); + } catch (err: any) { + if (err.code === 'NO_BLING_TOKEN') { + logger.info('Bling app is not authorized yet'); + return; + } + logger.warn(err); + } +}; + +export default refreshBlingToken; diff --git a/packages/apps/bling-erp/tests/parsers-from-bling.test.mjs b/packages/apps/bling-erp/tests/parsers-from-bling.test.mjs new file mode 100644 index 000000000..9d3aa77d0 --- /dev/null +++ b/packages/apps/bling-erp/tests/parsers-from-bling.test.mjs @@ -0,0 +1,219 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import parseStatusFromBling from '../lib/integration/parsers/status-from-bling.js'; +import parseOrderFromBling from '../lib/integration/parsers/order-from-bling.js'; +import parseProductFromBling from '../lib/integration/parsers/product-from-bling.js'; +import { blingProduct, blingOrderWithInvoice, order } from './payloads.mjs'; + +const appData = {}; + +describe('Parse Bling status to store', async () => { + test('Financial statuses', () => { + assert.deepStrictEqual(parseStatusFromBling('Aprovado', appData), { + financialStatus: 'paid', + fulfillmentStatus: undefined, + }); + assert.deepStrictEqual(parseStatusFromBling('Cancelado', appData), { + financialStatus: 'voided', + fulfillmentStatus: undefined, + }); + assert.deepStrictEqual(parseStatusFromBling('Devolvido', appData), { + financialStatus: 'refunded', + fulfillmentStatus: undefined, + }); + }); + + test('Fulfillment statuses', () => { + assert.deepStrictEqual(parseStatusFromBling('Em separação', appData), { + financialStatus: undefined, + fulfillmentStatus: 'in_separation', + }); + assert.deepStrictEqual(parseStatusFromBling('Atendido', appData), { + financialStatus: undefined, + fulfillmentStatus: 'invoice_issued', + }); + }); + + test('Unknown status maps to nothing', () => { + assert.deepStrictEqual(parseStatusFromBling('Situação inexistente', appData), { + financialStatus: undefined, + fulfillmentStatus: undefined, + }); + }); + + test('Custom `parse_status` mapping wins', () => { + const parsed = parseStatusFromBling('Aguardando Coleta', { + parse_status: [{ + status_ecom: 'Pronto para envio', + status_bling: 'Aguardando Coleta', + }], + }); + assert.strictEqual(parsed.fulfillmentStatus, 'ready_for_shipping'); + }); +}); + +describe('Parse Bling order to store', async () => { + const blingStub = { + get: async () => ({ data: {} }), + }; + + test('Tracking code, invoice and staff notes', async () => { + const shippingLines = JSON.parse(JSON.stringify(order.shipping_lines)); + const partialOrder = await parseOrderFromBling( + blingOrderWithInvoice, + shippingLines, + blingStub, + ); + assert.strictEqual(partialOrder.staff_notes, 'Separado pela equipe A'); + const [shippingLine] = partialOrder.shipping_lines; + assert.deepStrictEqual(shippingLine.tracking_codes, [{ + code: 'AA123456789BR', + link: 'https://rastreio.test/AA123456789BR', + }]); + assert.strictEqual(shippingLine.invoices.length, 1); + assert.strictEqual(shippingLine.invoices[0].number, '00123'); + assert.strictEqual(shippingLine.invoices[0].serial_number, '1'); + assert.strictEqual( + shippingLine.invoices[0].access_key, + '35260712345678000199550010000001231000001234', + ); + }); + + test('Keep manually set tracking code', async () => { + const shippingLines = JSON.parse(JSON.stringify(order.shipping_lines)); + shippingLines[0].tracking_codes = [{ + code: 'MANUAL123', + link: 'https://rastreio.test/MANUAL123', + }]; + const partialOrder = await parseOrderFromBling( + blingOrderWithInvoice, + shippingLines, + blingStub, + ); + assert.strictEqual(shippingLines[0].tracking_codes[0].code, 'MANUAL123'); + assert.strictEqual(partialOrder.staff_notes, 'Separado pela equipe A'); + }); + + test('Order without shipping lines', async () => { + const partialOrder = await parseOrderFromBling(blingOrderWithInvoice, [], blingStub); + assert.deepStrictEqual(Object.keys(partialOrder), ['staff_notes']); + }); +}); + +describe('Parse Bling product to store', async () => { + test('Base fields with promotional price', async () => { + const parsed = await parseProductFromBling( + JSON.parse(JSON.stringify(blingProduct)), + undefined, + true, + appData, + ); + assert.strictEqual(parsed.sku, 'CAM-BASICA'); + assert.strictEqual(parsed.name, 'Camiseta Básica'); + assert.strictEqual(parsed.available, true); + assert.strictEqual(parsed.price, 79.9); + assert.strictEqual(parsed.base_price, 89.9); + assert.strictEqual(parsed.slug, 'camiseta-basica'); + assert.strictEqual(parsed.body_html, '

Camiseta de algodão

'); + assert.deepStrictEqual(parsed.mpn, ['61091000']); + assert.deepStrictEqual(parsed.gtin, ['07891234567895']); + assert.deepStrictEqual(parsed.weight, { unit: 'kg', value: 0.3 }); + assert.deepStrictEqual(parsed.dimensions.width, { unit: 'cm', value: 30 }); + }); + + test('Variations with specifications and quantities', async () => { + const parsed = await parseProductFromBling( + JSON.parse(JSON.stringify(blingProduct)), + undefined, + true, + appData, + ); + assert.strictEqual(parsed.variations.length, 2); + const [first, second] = parsed.variations; + assert.strictEqual(first.sku, 'CAM-BASICA-P'); + assert.strictEqual(first.name, 'Camiseta Básica / P / Azul'); + assert.strictEqual(first.quantity, 5); + // Known Bling labels map back to the store first-class grids + assert.deepStrictEqual(first.specifications.size, [{ text: 'P', value: 'p' }]); + assert.deepStrictEqual(first.specifications.colors, [{ text: 'Azul', value: '#0000ff' }]); + assert.strictEqual(first.gtin, '07891234567895'); + assert.strictEqual(first.mpn, '61091000'); + assert.strictEqual(second.price, 94.9); + }); + + test('Grid labels map to store grids, unknown ones keep a slug', async () => { + const blingProductWithGrids = JSON.parse(JSON.stringify(blingProduct)); + blingProductWithGrids.variacoes = [{ + id: 1, + nome: 'Variação', + codigo: 'SKU-1', + preco: 89.9, + estoqueAtual: 1, + variacao: { nome: 'Idade:Adulto;Gênero:Feminino;Sabor:Morango' }, + }]; + const parsed = await parseProductFromBling(blingProductWithGrids, undefined, true, appData); + const [variation] = parsed.variations; + assert.deepStrictEqual(Object.keys(variation.specifications), [ + 'age_group', + 'gender', + 'sabor', + ]); + assert.deepStrictEqual(variation.specifications.age_group, [ + { text: 'Adulto', value: 'adulto' }, + ]); + }); + + test('Variation without SKU on Bling falls back to its ID', async () => { + const blingProductNoSku = JSON.parse(JSON.stringify(blingProduct)); + blingProductNoSku.variacoes = [{ + id: 16686983749, + nome: 'Saia jeans tamanho:p', + codigo: '', + preco: 100, + estoqueAtual: 4, + variacao: { nome: 'tamanho:p' }, + }]; + const parsed = await parseProductFromBling(blingProductNoSku, undefined, true, appData); + const [variation] = parsed.variations; + assert.strictEqual(variation.sku, '16686983749'); + assert.strictEqual(variation.quantity, 4); + assert.deepStrictEqual(variation.specifications.size, [{ text: 'p', value: 'p' }]); + }); + + test('Keep existing variation IDs', async () => { + const variations = [{ + _id: '9e2b3c4d5f6a7b8c9d0e1f2a', + sku: 'CAM-BASICA-P', + name: 'Nome antigo', + quantity: 0, + }]; + const parsed = await parseProductFromBling( + JSON.parse(JSON.stringify(blingProduct)), + variations, + false, + appData, + ); + const kept = parsed.variations.find(({ sku }) => sku === 'CAM-BASICA-P'); + assert.strictEqual(kept._id, '9e2b3c4d5f6a7b8c9d0e1f2a'); + assert.strictEqual(kept.quantity, 5); + assert.strictEqual(parsed.slug, undefined); + }); + + test('Description is skipped with `non_update_description`', async () => { + const parsed = await parseProductFromBling( + JSON.parse(JSON.stringify(blingProduct)), + undefined, + false, + { non_update_description: true }, + ); + assert.strictEqual(parsed.body_html, undefined); + }); + + test('Invalid GTIN is ignored', async () => { + const parsed = await parseProductFromBling({ + ...JSON.parse(JSON.stringify(blingProduct)), + gtin: '123', + }, undefined, false, appData); + assert.strictEqual(parsed.gtin, undefined); + }); +}); diff --git a/packages/apps/bling-erp/tests/parsers-to-bling.test.mjs b/packages/apps/bling-erp/tests/parsers-to-bling.test.mjs new file mode 100644 index 000000000..fede09502 --- /dev/null +++ b/packages/apps/bling-erp/tests/parsers-to-bling.test.mjs @@ -0,0 +1,216 @@ +import assert from 'node:assert'; +import test, { describe } from 'node:test'; +import parseOrder from '../lib/integration/parsers/order-to-bling.js'; +import parseStatusToBling from '../lib/integration/parsers/status-to-bling.js'; +import parseProduct from '../lib/integration/parsers/product-to-bling.js'; +import parseAddress from '../lib/integration/parsers/address-to-bling.js'; +import { order, product } from './payloads.mjs'; + +const appData = {}; +const itemsBling = [{ id: 555, codigo: 'CAM-P-AZUL' }]; + +describe('Parse address to Bling', async () => { + test('Format CEP and map fields', () => { + const blingAddress = {}; + parseAddress(order.shipping_lines[0].to, blingAddress); + assert.strictEqual(blingAddress.endereco, 'Rua das Flores'); + assert.strictEqual(blingAddress.numero, '100'); + assert.strictEqual(blingAddress.municipio, 'São Paulo'); + assert.strictEqual(blingAddress.uf, 'SP'); + assert.strictEqual(blingAddress.cep, '01.001-000'); + }); + + test('Keep already set fields', () => { + const blingAddress = { endereco: 'Rua Original' }; + parseAddress(order.shipping_lines[0].to, blingAddress); + assert.strictEqual(blingAddress.endereco, 'Rua Original'); + }); +}); + +describe('Parse order status to Bling', async () => { + test('Paid and ready for shipping', () => { + assert.deepStrictEqual( + parseStatusToBling(order, appData), + ['pronto para envio', 'pronto envio'], + ); + }); + + test('Pending payment takes precedence over fulfillment', () => { + const statuses = parseStatusToBling({ + ...order, + financial_status: { current: 'pending' }, + }, appData); + assert.deepStrictEqual(statuses, ['pendente', 'em aberto']); + }); + + test('Voided order is cancelled', () => { + const statuses = parseStatusToBling({ + ...order, + financial_status: { current: 'voided' }, + }, appData); + assert.deepStrictEqual(statuses, ['cancelado']); + }); + + test('Custom `parse_status` mapping wins', () => { + const statuses = parseStatusToBling(order, { + parse_status: [{ + status_ecom: 'Pronto para envio', + status_bling: 'Aguardando Coleta', + }], + }); + assert.deepStrictEqual(statuses, ['aguardando coleta']); + }); + + test('Fallback to payments history when no financial status', () => { + const statuses = parseStatusToBling({ + ...order, + financial_status: undefined, + fulfillment_status: undefined, + payments_history: [{ status: 'paid' }], + }, appData); + assert.deepStrictEqual(statuses, ['aprovado', 'em aberto']); + }); +}); + +describe('Parse order to Bling', async () => { + const blingOrder = parseOrder(order, '1042', undefined, appData, 42, 3, itemsBling); + + test('Base fields', () => { + assert.strictEqual(blingOrder.numeroLoja, '1042'); + assert.strictEqual(blingOrder.numero, 1042); + assert.strictEqual(blingOrder.data, '2026-07-30'); + assert.deepStrictEqual(blingOrder.contato, { id: 42 }); + assert.strictEqual(blingOrder.observacoes, 'Entregar no período da tarde'); + }); + + test('Items with matched Bling product ID', () => { + assert.strictEqual(blingOrder.itens.length, 1); + const [item] = blingOrder.itens; + assert.strictEqual(item.codigo, 'CAM-P-AZUL'); + assert.strictEqual(item.quantidade, 2); + assert.strictEqual(item.valor, 110); + assert.deepStrictEqual(item.produto, { id: 555 }); + }); + + test('Freight, weight and shipping service', () => { + assert.strictEqual(blingOrder.transporte.frete, 39.9); + assert.strictEqual(blingOrder.transporte.pesoBruto, 0.8); + assert.deepStrictEqual(blingOrder.transporte.volumes, [{ servico: 'PAC' }]); + assert.strictEqual(blingOrder.transporte.etiqueta.cep, '01.001-000'); + }); + + test('Estimated date skips weekends and holidays', () => { + // 2026-07-30 (Thu) + 2 posting + 3 delivery working days => 2026-08-06 (Thu) + assert.strictEqual(blingOrder.dataPrevista, '2026-08-06'); + }); + + test('Discount', () => { + assert.deepStrictEqual(blingOrder.desconto, { valor: 10, unidade: 'REAL' }); + }); + + test('Installments split the total', () => { + assert.strictEqual(blingOrder.parcelas.length, 2); + const total = blingOrder.parcelas.reduce((acc, { valor }) => acc + valor, 0); + // 220 (items) + 39.9 (freight) - 10 (discount) + assert.strictEqual(Math.round(total * 100) / 100, 249.9); + assert.deepStrictEqual(blingOrder.parcelas[0].formaPagamento, { id: 3 }); + assert.match(blingOrder.parcelas[0].observacoes, /\(1\/2\)$/); + }); + + test('Random order number when configured', () => { + const randomized = parseOrder(order, '87654321', undefined, { + random_order_number: true, + }, 42, 3, itemsBling); + assert.strictEqual(randomized.numero, '87654321'); + assert.strictEqual(randomized.numeroLoja, '1042'); + }); + + test('Disable order number when configured', () => { + const noNumber = parseOrder(order, '1042', undefined, { + disable_order_number: true, + }, 42, 3, itemsBling); + assert.strictEqual(noNumber.numero, undefined); + }); + + test('Bling store and predefined order data', () => { + const withStore = parseOrder(order, '1042', 205000, { + bling_order_data: { vendedor: { id: 77 }, outrasDespesas: 5 }, + }, 42, 3, itemsBling); + assert.deepStrictEqual(withStore.loja, { id: 205000 }); + assert.deepStrictEqual(withStore.vendedor, { id: 77 }); + assert.strictEqual(withStore.outrasDespesas, 5); + }); + + test('Custom shipping mapping by app label', () => { + const mapped = parseOrder(order, '1042', undefined, { + parse_shipping: [{ ecom_shipping: 'correios', bling_shipping: 'SEDEX' }], + }, 42, 3, itemsBling); + assert.deepStrictEqual(mapped.transporte.volumes, [{ servico: 'SEDEX' }]); + }); +}); + +describe('Parse product to Bling', async () => { + const blingProduct = parseProduct(product, undefined, appData); + + test('Base fields', () => { + assert.strictEqual(blingProduct.nome, 'Camiseta Básica'); + assert.strictEqual(blingProduct.codigo, 'CAM-BASICA'); + assert.strictEqual(blingProduct.formato, 'V'); + assert.strictEqual(blingProduct.situacao, 'A'); + assert.strictEqual(blingProduct.preco, 89.9); + assert.strictEqual(blingProduct.unidade, 'UN'); + assert.strictEqual(blingProduct.condicao, 1); + assert.strictEqual(blingProduct.marca, 'Marca Teste'); + }); + + test('Weight converted to kg and dimensions', () => { + assert.strictEqual(blingProduct.pesoBruto, 0.3); + assert.strictEqual(blingProduct.pesoLiquido, 0.3); + assert.deepStrictEqual(blingProduct.dimensoes, { + largura: 30, + altura: 2, + profundidade: 40, + unidadeMedida: 1, + }); + }); + + test('Taxation, GTIN and pictures', () => { + assert.deepStrictEqual(blingProduct.tributacao, { ncm: '61091000' }); + assert.strictEqual(blingProduct.gtin, '07891234567895'); + assert.deepStrictEqual(blingProduct.midia.imagens.imagensURL, [ + { link: 'https://cdn.test/camiseta-zoom.jpg' }, + ]); + }); + + test('Variations with grid names', () => { + assert.strictEqual(blingProduct.variacoes.length, 2); + const [first, second] = blingProduct.variacoes; + assert.strictEqual(first.codigo, 'CAM-BASICA-P'); + assert.strictEqual(first.variacao.nome, 'Tamanho:P;Cor:Azul'); + assert.strictEqual(second.preco, 94.9); + assert.strictEqual(second.variacao.nome, 'Tamanho:M;Cor:Azul'); + }); + + test('Stock is only sent for new products or when exporting quantity', () => { + assert.deepStrictEqual(blingProduct.variacoes[0].estoque, { maximo: 5 }); + const kept = parseProduct(product, { + id: 1, + estoque: { maximo: 99 }, + variacoes: [{ codigo: 'CAM-BASICA-P', estoque: { maximo: 42 } }], + }, appData); + assert.deepStrictEqual(kept.variacoes[0].estoque, { maximo: 42 }); + const exported = parseProduct(product, { + id: 1, + estoque: { maximo: 99 }, + variacoes: [{ codigo: 'CAM-BASICA-P', estoque: { maximo: 42 } }], + }, { export_quantity: true }); + assert.deepStrictEqual(exported.variacoes[0].estoque, { maximo: 5 }); + }); + + test('Simple product keeps `S` format', () => { + const simple = parseProduct({ ...product, variations: undefined }, undefined, appData); + assert.strictEqual(simple.formato, 'S'); + assert.deepStrictEqual(simple.estoque, { maximo: 12 }); + assert.strictEqual(simple.variacoes, undefined); + }); +}); diff --git a/packages/apps/bling-erp/tests/payloads.mjs b/packages/apps/bling-erp/tests/payloads.mjs new file mode 100644 index 000000000..20db38093 --- /dev/null +++ b/packages/apps/bling-erp/tests/payloads.mjs @@ -0,0 +1,159 @@ +export const order = { + _id: '1e2b3c4d5f6a7b8c9d0e1f2a', + number: 1042, + opened_at: '2026-07-30T12:00:00.000Z', + created_at: '2026-07-30T12:00:00.000Z', + payment_method_label: 'Cartão de crédito - Braspag', + shipping_method_label: 'PAC', + notes: 'Entregar no período da tarde', + financial_status: { current: 'paid' }, + fulfillment_status: { current: 'ready_for_shipping' }, + amount: { + total: 259.9, + subtotal: 220, + freight: 39.9, + discount: 10, + }, + buyers: [{ + _id: '2e2b3c4d5f6a7b8c9d0e1f2a', + main_email: 'comprador@teste.com', + name: { given_name: 'Maria', family_name: 'Souza' }, + display_name: 'Maria Souza', + doc_number: '12345678909', + registry_type: 'p', + phones: [{ number: '11987654321' }], + }], + items: [{ + _id: '3e2b3c4d5f6a7b8c9d0e1f2a', + product_id: '4e2b3c4d5f6a7b8c9d0e1f2a', + sku: 'CAM-P-AZUL', + name: 'Camiseta P Azul', + quantity: 2, + price: 110, + }], + shipping_lines: [{ + _id: '5e2b3c4d5f6a7b8c9d0e1f2a', + posting_deadline: { days: 2, working_days: true }, + delivery_time: { days: 3, working_days: true }, + package: { weight: { unit: 'g', value: 800 } }, + app: { label: 'Correios', service_code: 'PAC' }, + to: { + name: 'Maria Souza', + street: 'Rua das Flores', + number: 100, + borough: 'Centro', + city: 'São Paulo', + province_code: 'SP', + zip: '01001000', + }, + }], + transactions: [{ + _id: '6e2b3c4d5f6a7b8c9d0e1f2a', + amount: 259.9, + payment_method: { code: 'credit_card', name: 'Cartão de crédito' }, + installments: { number: 2, value: 129.95 }, + }], +}; + +export const product = { + _id: '4e2b3c4d5f6a7b8c9d0e1f2a', + sku: 'CAM-BASICA', + name: 'Camiseta Básica', + available: true, + visible: true, + price: 89.9, + quantity: 12, + body_html: '

Camiseta de algodão

', + short_description: 'Camiseta de algodão', + condition: 'new', + mpn: ['61091000'], + gtin: ['07891234567895'], + weight: { unit: 'g', value: 300 }, + dimensions: { + width: { unit: 'cm', value: 30 }, + height: { unit: 'cm', value: 2 }, + length: { unit: 'cm', value: 40 }, + }, + brands: [{ _id: '7e2b3c4d5f6a7b8c9d0e1f2a', name: 'Marca Teste' }], + pictures: [{ + _id: '8e2b3c4d5f6a7b8c9d0e1f2a', + zoom: { url: 'https://cdn.test/camiseta-zoom.jpg' }, + normal: { url: 'https://cdn.test/camiseta.jpg' }, + }], + variations: [{ + _id: '9e2b3c4d5f6a7b8c9d0e1f2a', + sku: 'CAM-BASICA-P', + name: 'Camiseta Básica / P / Azul', + quantity: 5, + specifications: { + size: [{ text: 'P', value: 'p' }], + colors: [{ text: 'Azul', value: '#0000ff' }], + }, + }, { + _id: '0e2b3c4d5f6a7b8c9d0e1f2b', + sku: 'CAM-BASICA-M', + name: 'Camiseta Básica / M / Azul', + quantity: 7, + price: 94.9, + specifications: { + size: [{ text: 'M', value: 'm' }], + colors: [{ text: 'Azul', value: '#0000ff' }], + }, + }], +}; + +export const blingProduct = { + id: 16063274538, + nome: 'Camiseta Básica', + codigo: 'CAM-BASICA', + preco: 89.9, + precoPromocional: 79.9, + situacao: 'A', + formato: 'V', + descricaoCurta: 'Camiseta de algodão', + descricaoComplementar: '

Camiseta de algodão

', + itensPorCaixa: 2, + pesoBruto: 0.3, + gtin: '07891234567895', + tributacao: { ncm: '61091000' }, + dimensoes: { largura: 30, altura: 2, profundidade: 40 }, + estoque: { saldoVirtualTotal: 12 }, + variacoes: [{ + id: 16063274539, + nome: 'Camiseta Básica P Azul', + codigo: 'CAM-BASICA-P', + preco: 89.9, + estoqueAtual: 5, + pesoBruto: 0.3, + gtin: '07891234567895', + tributacao: { ncm: '61091000' }, + variacao: { nome: 'Tamanho:P;Cor:Azul' }, + }, { + id: 16063274540, + nome: 'Camiseta Básica M Azul', + codigo: 'CAM-BASICA-M', + preco: 94.9, + estoqueAtual: 7, + variacao: { nome: 'Tamanho:M;Cor:Azul' }, + }], +}; + +export const blingOrderWithInvoice = { + id: 987654321, + numero: 1042, + numeroLoja: '1042', + observacaointerna: 'Separado pela equipe A', + situacao: { id: 9, valor: 9 }, + nota: { + numero: '00123', + serie: '1', + chaveAcesso: '35260712345678000199550010000001231000001234', + dataEmissao: '2026-07-31 09:12:00', + }, + transporte: { + volumes: [{ + codigoRastreamento: 'AA123456789BR', + urlRastreamento: 'https://rastreio.test/AA123456789BR', + }], + }, +}; diff --git a/packages/apps/bling-erp/tests/settings.json b/packages/apps/bling-erp/tests/settings.json new file mode 100644 index 000000000..c418c5bad --- /dev/null +++ b/packages/apps/bling-erp/tests/settings.json @@ -0,0 +1,6 @@ +{ + "name": "Bling ERP test store", + "lang": "pt_br", + "currency": "BRL", + "currency_symbol": "R$" +} diff --git a/packages/apps/bling-erp/tsconfig.json b/packages/apps/bling-erp/tsconfig.json new file mode 100644 index 000000000..618c6c3e9 --- /dev/null +++ b/packages/apps/bling-erp/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../../tsconfig.json" +} diff --git a/packages/events/package.json b/packages/events/package.json index ab98e1aab..272e37e54 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -32,6 +32,7 @@ "@cloudcommerce/app-affiliate-program": "workspace:*", "@cloudcommerce/app-appmax": "workspace:*", "@cloudcommerce/app-asaas": "workspace:*", + "@cloudcommerce/app-bling-erp": "workspace:*", "@cloudcommerce/app-braspag": "workspace:*", "@cloudcommerce/app-datafrete": "workspace:*", "@cloudcommerce/app-emails": "workspace:*", diff --git a/packages/events/src/firebase.ts b/packages/events/src/firebase.ts index 744a28f56..f5256464b 100644 --- a/packages/events/src/firebase.ts +++ b/packages/events/src/firebase.ts @@ -6,6 +6,8 @@ export * from '@cloudcommerce/app-emails'; export * from '@cloudcommerce/app-tiny-erp'; +export * from '@cloudcommerce/app-bling-erp'; + export * from '@cloudcommerce/app-evendas'; export * from '@cloudcommerce/app-pagarme-v5/events'; diff --git a/packages/firebase/src/config.ts b/packages/firebase/src/config.ts index 87bfad460..1ee37932f 100644 --- a/packages/firebase/src/config.ts +++ b/packages/firebase/src/config.ts @@ -145,6 +145,16 @@ export const configApps = { 'applications-dataSet', ] as ApiEventName[], }, + blingErp: { + appId: 102418, + events: [ + 'orders-anyStatusSet', + 'products-new', + 'products-priceSet', + 'products-quantitySet', + 'applications-dataSet', + ] as ApiEventName[], + }, evendas: { appId: 109851, events: [