Open-source Point of Sale (POS) and inventory management system with Brazilian fiscal module (NF-e/NFC-e). Built with Next.js 16, React 19 and embedded PostgreSQL via PGLite. Turborepo monorepo with the fiscal module as a standalone package. Zero external dependencies to run — bun install && bun run dev and you're set.
- Features
- Architecture
- Tech Stack
- Quick Start
- Scripts
- Project Structure
- Fiscal Module (NF-e / NFC-e)
- API
- Testing
- Docker Deploy
- Database
- Contributing
- License
- Dashboard with interactive charts (revenue, expenses, cash flow, profit margin)
- Product Management with categories and stock control
- Customer Management with active/inactive status
- Order Management with items, totals and status tracking
- Point of Sale (POS) for quick sales processing
- Cashier with income and expense transaction logging
- Authentication with email/password via Better Auth
- API Documentation auto-generated interactive docs via Scalar at
/api/docs
- Electronic Invoicing — NF-e (model 55, B2B) and NFC-e (model 65, consumer)
- Tax Calculations — ICMS (15 CST + 10 CSOSN), PIS, COFINS, IPI, II, ISSQN
- SEFAZ Integration — authorize, cancel, void, query with mTLS client certificate
- Digital Signature — XML signing with A1 e-CNPJ certificate (PFX/PKCS#12)
- QR Code — NFC-e QR code generation (v2.00/v3.00, online + offline)
- Contingency — SVC-AN, SVC-RS (NF-e) and EPEC (NFC-e) offline modes
- IBS/CBS Reform Events — 14 event types for the Brazilian tax reform (PL_010)
- Settings UI — company info, address, certificate, CSC, default tax codes
- CEP Auto-fill — address completion via ViaCEP + BrasilAPI
flowchart LR
Browser["Browser React 19"]
Proxy["proxy.ts (session check)"]
tRPC["tRPC v11 (superjson)"]
Auth["Better Auth (session cookie)"]
Drizzle["Drizzle ORM"]
PGLite["PGLite (PostgreSQL WASM)"]
Scalar["Scalar /api/docs"]
Fiscal["Fiscal Module (NF-e / NFC-e)"]
SEFAZ["SEFAZ (tax authority)"]
Browser -->|HTTP request| Proxy
Proxy -->|authenticated| tRPC
Proxy -->|/api/auth/*| Auth
tRPC -->|protectedProcedure| Drizzle
tRPC -->|fiscal routes| Fiscal
Drizzle -->|SQL| PGLite
tRPC -.->|OpenAPI spec| Scalar
Auth -->|session| PGLite
Fiscal -->|build XML + sign| SEFAZ
Fiscal -->|persist| Drizzle
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| UI | React 19, Tailwind CSS 4, Radix UI, Recharts |
| Database | PGLite (PostgreSQL via WASM) |
| ORM | Drizzle ORM |
| API | tRPC v11 (end-to-end type safety) |
| Auth | Better Auth |
| API Docs | Scalar (OpenAPI 3.0) |
| XML Signing | xml-crypto |
| XML Parsing | fast-xml-parser |
| Runtime | Bun |
| i18n | next-intl (en + pt-BR) |
| Monorepo | Turborepo, Biome |
| Fiscal Module | @finopenpos/fiscal (standalone package) |
git clone https://github.com/JoaoHenriqueBarbosa/FinOpenPOS.git
cd FinOpenPOS
cp apps/web/.env.example apps/web/.envEdit apps/web/.env with a secure secret:
BETTER_AUTH_SECRET=generate-with-openssl-rand-base64-32
BETTER_AUTH_URL=http://localhost:3001
bun install
bun run devOpen http://localhost:3001 and use the Fill demo credentials button to sign in with the test account (test@example.com / test1234).
The first
bun run devautomatically creates the database atapps/web/data/pglite, pushes the schema via Drizzle and runs the seed with demo data (20 customers, 32 products, 40 orders, 25 transactions) + ~5570 IBGE cities.
| Command | Description |
|---|---|
bun run dev |
Start all apps via Turborepo |
bun run dev:web |
Start only the web app |
bun run check |
Lint and format with Biome |
cd apps/web && bun test |
Run tRPC router tests |
cd packages/fiscal && bun test |
Run fiscal module tests (754 tests) |
cd apps/web && bun run prepare-prod |
Migrate from PGLite to real PostgreSQL |
FinOpenPOS/
├── apps/
│ └── web/ # Next.js 16 web application
│ ├── src/
│ │ ├── app/ # Pages (admin, login, signup, API routes)
│ │ ├── components/ # UI components (shadcn + custom)
│ │ ├── lib/
│ │ │ ├── db/ # Drizzle schema + PGLite singleton
│ │ │ ├── invoice-service.ts # Invoice lifecycle orchestrator
│ │ │ ├── invoice-repository.ts # Invoice persistence (Drizzle)
│ │ │ ├── fiscal-settings-repository.ts
│ │ │ └── trpc/ # tRPC routers (business + fiscal)
│ │ ├── messages/ # i18n (en.ts, pt-BR.ts)
│ │ └── proxy.ts # Next.js 16 middleware
│ ├── scripts/ # DB ensure, ER gen, prepare-prod
│ └── data/ # PGLite database (gitignored)
├── packages/
│ └── fiscal/ # @finopenpos/fiscal — standalone fiscal library
│ └── src/
│ ├── __tests__/ # 754 tests (ported from PHP sped-nfe)
│ ├── value-objects/ # AccessKey, TaxId
│ ├── tax-icms.ts # ICMS tax engine (25 variants)
│ ├── tax-pis-cofins-ipi.ts # PIS/COFINS/IPI/II
│ ├── xml-builder.ts # NF-e XML generation
│ ├── certificate.ts # PFX extraction + XML signing
│ ├── sefaz-*.ts # SEFAZ communication layer
│ └── ... # 30+ modules (see docs/)
├── turbo.json # Turborepo task config
├── biome.json # Linter/formatter config
├── Dockerfile # Dev (PGLite) Docker image
├── Dockerfile.production # Production (PostgreSQL) Docker image
└── docs/ # Detailed fiscal documentation (12 files)
The fiscal module lives in packages/fiscal/ as @finopenpos/fiscal — a standalone package with zero database dependencies. It can be used independently in any TypeScript/JavaScript project.
The fiscal module implements complete Brazilian electronic invoicing following the SEFAZ MOC 4.00 specification, ported from the PHP sped-nfe library to TypeScript with DDD architecture.
flowchart TD
Start([Order placed]) --> LoadSettings[Load fiscal settings + certificate]
LoadSettings --> BuildXML[Build NF-e/NFC-e XML from order items]
BuildXML --> CalcTax[Calculate taxes ICMS + PIS + COFINS + IPI]
CalcTax --> GenKey[Generate access key 44-digit mod-11]
GenKey --> Sign[Sign XML with A1 e-CNPJ certificate]
Sign --> SendSEFAZ{Send to SEFAZ}
SendSEFAZ -->|cStat 100| Authorized[Authorized ✓]
SendSEFAZ -->|cStat 110| Denied[Denied ✗]
SendSEFAZ -->|timeout| Contingency{Model?}
Contingency -->|NFC-e 65| Offline[Save offline status=contingency]
Contingency -->|NF-e 55| Error[Throw error]
Authorized --> AttachProto[Attach protocol nfeProc XML]
AttachProto --> SaveDB[(Save to DB invoice + items)]
Offline --> SaveDB
Denied --> SaveDB
SaveDB --> IncrNumber[Increment next number]
Authorized -.->|later| Cancel[Cancel invoice]
Cancel --> EventXML[Build cancellation event XML]
EventXML --> SignEvent[Sign + send to SEFAZ]
Offline -.->|connection back| Sync[Sync pending invoices]
flowchart LR
subgraph Domain["Domain Layer (pure logic)"]
ICMS["tax-icms.ts 15 CST + 10 CSOSN"]
PIS["tax-pis-cofins-ipi.ts PIS / COFINS / IPI / II"]
TE["tax-element.ts TaxElement interface"]
end
subgraph Infra["Infrastructure Layer"]
XB["xml-builder.ts Full NF-e XML"]
XU["xml-utils.ts tag() + escapeXml()"]
FU["format-utils.ts cents → '10.50'"]
end
ICMS -->|returns TaxElement| TE
PIS -->|returns TaxElement| TE
TE -->|serializeTaxElement| XB
XB --> XU
ICMS --> FU
PIS --> FU
Tax modules never import XML code — they return TaxElement structures that the builder serializes. This keeps domain logic pure and testable.
sequenceDiagram
participant App as Invoice Service
participant Builder as Request Builder
participant Cert as Certificate
participant Transport as SEFAZ Transport
participant SEFAZ as SEFAZ Web Service
App->>Builder: buildAuthorizationRequestXml(signedNFe)
App->>Cert: extractCertFromPfx(pfx, password)
Cert-->>App: PEM cert + key
App->>Transport: sefazRequest(url, xml, cert, key)
Transport->>Transport: Build SOAP 1.2 envelope
Transport->>Transport: Write PEM to temp files
Transport->>SEFAZ: curl --cert cert.pem --key key.pem (mTLS)
SEFAZ-->>Transport: SOAP response
Transport->>Transport: Extract content from SOAP body
Transport-->>App: { httpStatus, body, content }
App->>App: parseAuthorizationResponse(content)
App->>App: attachProtocol(request, response)
Why curl? Bun's
node:httpsAgent does not support PFX for mTLS. The workaround extracts PEM from PFX via openssl and uses curl for the HTTPS request.
The docs/ folder contains 12 in-depth documents:
| Document | Topic |
|---|---|
| 00-architecture.md | Layers, dependency graph, numeric conventions |
| 01-tax-engine.md | ICMS/PIS/COFINS/IPI, TaxElement pattern |
| 02-xml-generation.md | xml-builder, complement, NF-e XML structure |
| 03-sefaz-communication.md | Transport, URLs, request builders, reform events |
| 04-certificate-signing.md | PFX extraction, XML digital signature |
| 05-value-objects.md | AccessKey (mod-11), TaxId (CPF/CNPJ) |
| 06-invoice-workflow.md | Invoice service lifecycle, repositories |
| 07-contingency.md | SVC-AN/SVC-RS, EPEC, offline modes |
| 08-qrcode.md | NFC-e QR code v2.00/v3.00 |
| 09-txt-conversion.md | SPED TXT legacy format conversion |
| 10-database-schema.md | Fiscal tables, multi-tenancy |
| 11-utilities.md | GTIN, CEP lookup, state codes |
All API procedures require authentication via Better Auth session cookie. The API uses tRPC for end-to-end type safety — frontend components consume procedures directly with full TypeScript inference.
Visit /api/docs for the full interactive API reference powered by Scalar, auto-generated from the tRPC router definitions.
The raw OpenAPI 3.0 spec is available at /api/openapi.json.
| Router | Procedures | Description |
|---|---|---|
products |
list, create, update, delete |
Product CRUD with stock and categories |
customers |
list, create, update, delete |
Customer CRUD with status |
orders |
list, create, update, delete |
Order management with items and transactions |
transactions |
list, create, update, delete |
Income/expense transaction logging |
paymentMethods |
list, create, update, delete |
Payment method management |
dashboard |
stats |
Aggregated revenue, expenses, profit, cash flow, margins |
fiscal |
list, getById, issue, cancel, void, sync |
Invoice management |
fiscalSettings |
get, upsert, testConnection, getCertificateInfo |
Fiscal configuration |
cities |
listByState |
IBGE city lookup for fiscal address |
840 tests across 2 test suites (754 fiscal + 86 tRPC), all passing with 0 failures.
# tRPC router tests (from apps/web)
cd apps/web && bun test
# Fiscal module tests (from packages/fiscal)
cd packages/fiscal && bun test
# Coverage report
cd apps/web && bun run test:coverageNote: Run fiscal and tRPC tests separately — Bun can segfault on large parallel runs.
flowchart TB
subgraph FiscalTests["Fiscal Tests (754)"]
TaxTests["Tax engine ICMS / PIS / COFINS / IPI"]
XMLTests["XML builder + complement"]
PortedTests["Ported from PHP sped-nfe test suite"]
QRTests["QR code + certificate"]
end
subgraph tRPCTests["tRPC Tests (86)"]
PGLite["PGLite (in-memory)"]
Mock["mock.module (@/lib/db)"]
Caller["createCallerFactory"]
end
Schema["schema.ts"] -->|DDL| PGLite
Mock -->|injects| PGLite
Caller -->|calls router| Mock
subgraph Verifications
CRUD["CRUD → list() confirms state"]
Isolation["cross-user → invisible"]
Zod["Zod reject → unchanged"]
end
Caller --> Verifications
The project includes a multi-stage Alpine-based Dockerfile and Docker Compose with a persistent volume.
docker compose up -d # Build and start
docker compose logs -f # View logs
docker compose down # Stop
docker compose down -v # Stop and delete database dataThe compose.yaml expects BETTER_AUTH_SECRET and BETTER_AUTH_URL environment variables. For local dev, configure apps/web/.env. For Docker, create a root .env file or pass them via -e:
BETTER_AUTH_SECRET=your-secret-key-at-least-32-chars
BETTER_AUTH_URL=https://your-domain.comThe project works with Coolify and similar platforms that detect compose.yaml. Set the environment variables in the platform UI. The default internal port is 3111 (configurable via PORT env).
erDiagram
products {
serial id PK
varchar name
text description
integer price
integer in_stock
varchar user_uid
varchar category
varchar ncm
varchar cfop
varchar icms_cst
varchar pis_cst
varchar cofins_cst
varchar unit_of_measure
timestamp created_at
}
customers {
serial id PK
varchar name
varchar email UK
varchar phone
varchar user_uid
varchar status
timestamp created_at
}
payment_methods {
serial id PK
varchar name UK
timestamp created_at
}
orders {
serial id PK
integer customer_id FK
integer total_amount
varchar user_uid
varchar status
timestamp created_at
}
order_items {
serial id PK
integer order_id FK
integer product_id FK
integer quantity
integer price
timestamp created_at
}
transactions {
serial id PK
text description
integer order_id FK
integer payment_method_id FK
integer amount
varchar user_uid
varchar type
varchar category
varchar status
timestamp created_at
}
customers |o--o{ orders : "has"
orders |o--o{ order_items : "contains"
products |o--o{ order_items : "references"
orders |o--o{ transactions : "generates"
payment_methods |o--o{ transactions : "uses"
All monetary values are stored as integer cents (e.g., $49.99 = 4999). This avoids floating-point precision issues. All tables with user_uid enforce multi-tenancy.
PGLite runs full PostgreSQL via WASM, directly in the Node.js process. Data is stored at apps/web/data/pglite (filesystem). No external PostgreSQL server required.
Pros: zero config, no dependencies, ideal for dev and small projects.
Limitations: single-process (no external concurrent connections), lower performance than native PostgreSQL under heavy load, no replication.
When the project grows and needs a real database, migration is straightforward because Drizzle ORM abstracts the data access layer — the schema is identical.
Run the built-in script that handles all steps automatically:
cd apps/web && bun run prepare-prodThen set DATABASE_URL in your apps/web/.env file and run:
cd apps/web && bun run db:push
cd apps/web && bun run devIf you prefer to do it step by step:
bun add pg
bun remove @electric-sql/pgliteimport { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
export const db = drizzle(process.env.DATABASE_URL!, { schema });import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./src/lib/db/schema.ts",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});DATABASE_URL=postgresql://user:password@host:5432/finopenpos
cd apps/web && bun run db:push
bun run dev- Delete
scripts/ensure-db.ts(only exists for PGLite recovery) - Remove
db:ensurefromdevandbuildscripts inpackage.json - Remove
serverExternalPackagesfromnext.config.mjs - In Docker, replace the PGLite volume with a PostgreSQL connection via
DATABASE_URL
The Drizzle schema (
apps/web/src/lib/db/schema.ts) doesn't change. All queries, relations and tRPC procedures keep working without modification.
Contributions are welcome! Open an issue or submit a Pull Request.
MIT License — see LICENSE.