Bllt (se lee "billete") es una aplicación de escritorio gratuita y open source para negocios pequeños en Venezuela, como bodegas, abastos y tiendas de barrio. Lleva el inventario, las ventas y los clientes en dólares, y guarda la tasa del BCV con la que se hizo cada venta. Todo vive en la PC del negocio y funciona sin internet. La nube es opcional y sirve para usar varias computadoras con los mismos datos y para ver el resumen del día desde el teléfono.
Nació para resolver la necesidad de un amigo que estaba por abrir su negocio. La historia, con las decisiones que tomé y lo que haría distinto, está en cómo nació Bllt. Aquí me enfoco en qué hace, cómo está construido y por qué puede servirle a otros negocios.
El problema que resuelve
En Venezuela muchos negocios fijan sus precios en dólares y cobran en bolívares a la tasa oficial del día. Eso complica algo que debería ser simple, como saber cuánto se ganó. Si la tasa cambia todos los días, una hoja de cálculo con precios en bolívares deja de reflejar la realidad muy rápido, y recalcular el histórico con la tasa de hoy produce números que nunca ocurrieron.
A eso se suma la conexión. Los cortes de luz y las caídas de internet son parte del día a día, y un negocio no puede dejar de vender porque un servicio en la nube no responde.
Las opciones disponibles suelen irse a uno de dos extremos. Por un lado están los sistemas administrativos completos, con facturación fiscal, impresoras y módulos que un negocio pequeño no necesita, y por el otro una libreta y una calculadora. Bllt se queda en el medio, con lo justo para controlar inventario y ventas, sin configuraciones complicadas.
Qué hace
La tasa del día, confirmada por una persona
La primera vez que se abre Bllt cada día pide confirmar la tasa. La app sugiere la tasa del BCV consultando una API pública (DolarApi (se abre en una pestaña nueva)), pero la tasa que se usa es la que confirma un usuario, y siempre se puede escribir a mano. Sin tasa confirmada no se registran ventas, así cada venta queda asociada a una tasa real.
No existe una API oficial del BCV (se abre en una pestaña nueva) y las fuentes disponibles leen la página del banco por debajo. Por eso la tasa automática es siempre una sugerencia y la manual siempre está disponible.
Ventas rápidas, con o sin cliente
La pantalla de venta está pensada para el mostrador. Se escribe el código o el nombre, se presiona Enter y el producto entra a la venta. El cliente es opcional. El total se muestra en dólares y en bolívares, y al registrar la venta se descuenta el inventario.
Cada venta genera un comprobante en PDF tamaño carta con el logo, el nombre y el RIF del negocio, los datos del cliente, la tasa del día y cada producto con su monto en dólares y en bolívares. Es un comprobante interno, no una factura fiscal.
Ganancias que no cambian con el tiempo
Esta fue una de las reglas más importantes del diseño. Cada línea de venta guarda el costo y el precio del producto en ese momento, y cada venta guarda su propia tasa. La ganancia de una línea es la cantidad por la diferencia entre precio y costo, y su equivalente en bolívares se calcula con la tasa de esa venta, no con la de hoy. Si mañana cambia el costo de un producto o sube la tasa, las ganancias de ayer siguen siendo las de ayer.
El inicio muestra la tasa del día, la ganancia de hoy y del mes, las ventas del mes y la lista de ventas del día.
Usuarios, respaldos y exportación
- Usuarios con roles: la primera vez que se abre se registra al dueño como administrador. Los empleados pueden vender, y el administrador además gestiona usuarios, respaldos y exportaciones.
- Respaldos automáticos: una copia al día en
Documentos/Bllt, sin que nadie tenga que acordarse. - Exportación de ventas a Excel o CSV.
Nube opcional y gratuita
Bllt funciona completo sin nube. Pero hay dos necesidades que una base de datos local no resuelve, que son ver cómo va el día desde el teléfono y usar más de una computadora en el mismo negocio. Para eso existe la nube opcional, que corre en Cloudflare Workers (se abre en una pestaña nueva) con D1 (se abre en una pestaña nueva) dentro del plan gratuito.
Cada negocio despliega su propio Worker, en su propia cuenta de Cloudflare, con el botón "Deploy to Cloudflare" del repositorio. No hay un servidor central, así que los datos del negocio quedan en su cuenta y, dentro de los límites del plan gratuito, no cuesta nada.
El Worker cumple tres funciones:
- Sincronizar varias PCs. Cada PC sigue siendo independiente y vende sin internet. Sus cambios se guardan en un outbox, en la misma transacción que la escritura, y se suben en lotes cuando hay conexión; después baja lo que hicieron las demás. Una PC nueva se une desde su primera pantalla y carga todos los datos sin repetir la configuración.
- Servir la PWA (se abre en una pestaña nueva) del teléfono. Una app web instalable y mínima a propósito, que solo permite iniciar sesión, ver el resumen del día y sugerir la tasa.
- Consultar la tasa cada 6 horas con un Cron Trigger (se abre en una pestaña nueva), para sugerirla a las PCs.
Algunos detalles de la sincronización:
- En productos, clientes, usuarios y tasas gana el último cambio.
- El inventario no viaja como un número, sino como movimientos de stock. Así, dos PCs que venden el mismo producto al mismo tiempo no se pisan.
- Cada PC tiene su token y su propia serie de factura (
A-000123,B-000045), así los números nunca chocan. - Una venta anulada nunca vuelve a quedar como completada.
- D1 limita las consultas por invocación en el plan gratuito, así que el Worker acepta la parte del lote que cabe y la PC reenvía el resto.
El botón de despliegue usa una rama cloud que se publica solo desde versiones estables, igual que el instalador. Así ningún negocio corre código que no haya pasado por un release.
Cómo está construido
Es un monorepo con pnpm (se abre en una pestaña nueva), todo en TypeScript (se abre en una pestaña nueva):
apps/desktop: Electron (se abre en una pestaña nueva), Svelte (se abre en una pestaña nueva) y SQLite (se abre en una pestaña nueva) (con better-sqlite3 (se abre en una pestaña nueva)). Es la fuente de verdad.apps/worker: Cloudflare Worker con Hono (se abre en una pestaña nueva), D1 y un cron.apps/web: la PWA del teléfono en Svelte y Vite (se abre en una pestaña nueva), servida por el mismo Worker.apps/site: landing y documentación con SvelteKit (se abre en una pestaña nueva) estático, en bllt.juanl.dev (se abre en una pestaña nueva).packages/shared: esquema de Drizzle ORM (se abre en una pestaña nueva), validaciones con Zod (se abre en una pestaña nueva), manejo de dinero y hora, contrato del sync y cálculo de ganancias.packages/ui: componentes de shadcn-svelte (se abre en una pestaña nueva) y piezas de marca, con Tailwind CSS (se abre en una pestaña nueva).
Usar un solo lenguaje en todo el proyecto fue una decisión práctica, porque el escritorio y la nube comparten el mismo esquema, las mismas validaciones y el mismo cálculo de ganancias. El escritorio y el teléfono muestran exactamente el mismo número porque salen de la misma función.
Una estructura de carpetas por dominio
Cada app se organiza igual, con las carpetas core/, libs/, utils/ y modules/<dominio>/. Dentro de cada módulo hay tres tipos de archivo, siempre con el mismo nombre y la misma responsabilidad. Así se ve el proceso principal del escritorio:
apps/desktop/src/main/
├── core/ # bootstrap, db, ipc, session, config, errors
├── libs/
├── utils/
└── modules/
├── sales/
│ ├── sale.repository.ts # queries: the only place that touches Drizzle
│ ├── sale.service.ts # business rules
│ ├── sale.ipc.ts # adapter: Zod + session + role
│ └── receipt.service.ts
├── products/
├── rates/
├── stock/
├── sync/
└── ...
El Worker usa exactamente la misma forma. La única diferencia es el adaptador, que en lugar de *.ipc.ts es *.routes.ts con Hono.
apps/worker/src/modules/sync/
├── sync.repository.ts
├── sync.service.ts
└── sync.routes.ts
Las ventajas que le encuentro:
- Todo lo de un dominio está junto. Si algo falla en las ventas, todo lo necesario está en
modules/sales/, no repartido entre carpetas de controllers, services y models. Es la misma idea que expliqué en organizar por dominio con vertical slices. - Capas con reglas claras. Solo los repositories tocan la base de datos, los adaptadores solo validan y delegan, y un módulo usa a otro a través de su service, nunca de su repository. Las reglas de negocio no dependen de si la llamada vino de la UI o de una petición HTTP.
- El escritorio y el Worker se leen igual. Pasar de uno a otro no exige cambiar de modelo mental.
- Es predecible para un agente. Un agente de código sabe dónde poner cada cosa sin adivinar. El repositorio documenta estas reglas en
AGENTS.md, y en la práctica eso se nota en cambios que respetan la estructura desde el primer intento.
Con esa separación, un adaptador queda muy delgado. Este es el archivo IPC completo de ventas:
// apps/desktop/src/main/modules/sales/sale.ipc.ts
export function registerSaleIpc(): void {
handle('sales:create', { input: saleInput }, (input, user) => saleService.create(user, input))
handle('sales:list', { input: salesQuery }, (query) => saleService.list(query))
handle('sales:page', { input: salesPageQuery }, (query) => saleService.page(query))
handle('sales:get', { input: uuid }, (id) => saleService.get(id))
handle('sales:void', { input: uuid }, (id, user) => saleService.void(user, id))
handle('sales:previewPdf', { input: uuid }, (id) => receiptService.preview(id))
registerReceiptPreview()
}
handle() valida la entrada con Zod, exige sesión y rol, y siempre devuelve un resultado en lugar de lanzar errores a la interfaz. El renderer de Electron nunca ve la base de datos, solo funciones concretas expuestas desde el preload.
Registrar una venta
El service de ventas reúne en una sola transacción las reglas más importantes del proyecto. Sin tasa no hay venta, cada línea copia el precio y el costo del momento, la venta guarda su propia tasa, el inventario se mueve como movimientos de stock y el cambio entra al outbox del sync.
// apps/desktop/src/main/modules/sales/sale.service.ts (simplified)
create(user: SessionUser, input: SaleInput): SaleDto {
// No confirmed rate for today, no sale
const rate = rateService.requireToday()
const id = transaction(() => {
// ...load products and check stock
items.push({
id: newId(),
saleId,
productId,
qty,
priceCents: product.priceCents, // price at this moment
costCents: product.costCents // cost at this moment
})
const series = deviceService.series() // one series per PC: A, B...
const sale: SaleRow = {
id: saleId,
series,
number: saleRepository.nextNumber(series),
rate: rate.bsPerUsd, // the sale keeps its own rate
status: SaleStatus.COMPLETED
// ...
}
saleRepository.insert(sale, items)
// Stock travels as movements, not as a number
for (const item of items) {
stockService.record({ productId: item.productId, delta: -item.qty, reason: StockReason.SALE })
}
snapshot(sale) // queues the sale in the sync outbox, same transaction
return saleId
})
return this.get(id)
}
Dinero sin decimales flotantes
Los montos en dólares se guardan en centavos y la tasa como un entero escalado a cuatro decimales. La conversión a bolívares y el cálculo de ganancias viven en @bllt/shared, y es el único lugar donde existen:
// packages/shared/src/money.ts
export const RATE_SCALE = 10_000 // 36.1234 Bs/USD -> 361234
export function usdCentsToBsCents(usdCents: number, rate: number): number {
const product = usdCents * rate
return Math.sign(product) * Math.round(Math.abs(product) / RATE_SCALE)
}
// packages/shared/src/profit.ts
for (const line of lines) {
if (line.status !== SaleStatus.COMPLETED) continue
const profit = line.qty * (line.priceCents - line.costCents)
summary.profitUsdCents += profit
summary.profitBsCents += usdCentsToBsCents(profit, line.rate) // the line's own sale rate
}
Como el escritorio y el Worker importan la misma función, no hay forma de que el resumen del teléfono y el de la PC muestren números distintos.
Otras decisiones
- Siempre en hora de Venezuela. El "día de negocio" se calcula en UTC-4, nunca con la zona horaria de la PC.
- Un mismo esquema para SQLite y D1. Las migraciones de ambos lados salen del mismo esquema de Drizzle.
- Nada se borra. Los usuarios se desactivan y las ventas se anulan devolviendo el stock.
- Pruebas. Pruebas unitarias para dinero, hora, ganancias y contraseñas; una prueba de humo con Playwright (se abre en una pestaña nueva) sobre la app construida (primer arranque, tasa, productos, venta, dashboard y respaldo); y una prueba con dos PCs contra un Worker local.
- Distribución. Al crear un tag, GitHub Actions (se abre en una pestaña nueva) compila el instalador de Windows con electron-builder (se abre en una pestaña nueva) y lo publica en Releases.
Qué no hace
Bllt no emite facturas fiscales ni se integra con el SENIAT, no maneja cobros ni pasarelas de pago, no se conecta a impresoras fiscales ni a equipos POS, y no es multi-tienda. Desde el inicio la decisión fue resolver bien lo esencial en lugar de intentar cubrirlo todo.
Por qué usarlo
- Es gratis y open source, con licencia Apache 2.0 (se abre en una pestaña nueva).
- Funciona sin internet. Si se cae la conexión, el negocio sigue vendiendo.
- Está pensado para cómo se vende en Venezuela: precios en dólares, cobro en bolívares y la tasa de cada día.
- Los datos son del negocio: viven en su PC y, si activa la nube, en su propia cuenta de Cloudflare.
- Sin suscripciones ni servidores de terceros.
- Fácil de empezar: instalador para Windows 10 o superior y guías paso a paso.
Estado actual
Bllt está en uso en el negocio para el que nació, en dos computadoras. Funciona en Windows 10 o superior y cada versión se publica en GitHub Releases (se abre en una pestaña nueva).
El código está en GitHub (se abre en una pestaña nueva) y la documentación en bllt.juanl.dev (se abre en una pestaña nueva). El nombre y el logo de Bllt no están cubiertos por la licencia, así que si quieres publicar un fork revisa antes el archivo TRADEMARKS.md del repositorio.
