diff --git a/billing-finance-core/.env.example b/billing-finance-core/.env.example new file mode 100644 index 0000000..ca8a203 --- /dev/null +++ b/billing-finance-core/.env.example @@ -0,0 +1,9 @@ +NODE_ENV=development +PORT=3000 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/billing_finance_core +JWT_SECRET=change-me +JWT_PUBLIC_KEY= +JWT_ISSUER=identity-gateway +PAYMENT_WEBHOOK_SECRET=change-me +STRIPE_API_KEY= +APP_LOG_LEVEL=info diff --git a/billing-finance-core/Dockerfile b/billing-finance-core/Dockerfile new file mode 100644 index 0000000..1ce5958 --- /dev/null +++ b/billing-finance-core/Dockerfile @@ -0,0 +1,18 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm install +COPY tsconfig.json nest-cli.json ./ +COPY prisma ./prisma +COPY src ./src +RUN npm run prisma:generate +RUN npm run build + +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json* ./ +RUN npm install --omit=dev +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma ./prisma +CMD ["node", "dist/main.js"] diff --git a/billing-finance-core/README.md b/billing-finance-core/README.md new file mode 100644 index 0000000..1a05b1f --- /dev/null +++ b/billing-finance-core/README.md @@ -0,0 +1,77 @@ +# billing-finance-core + +Backend financeiro, billing, fiscal e CRM para uma plataforma SaaS multi-tenant. + +## Visão geral +- **Multi-tenant desde o início** com isolamento por `tenantId`. +- **Sem autenticação própria**: confia no `identity-gateway` via JWT interno. +- **Core financeiro**: planos, assinaturas, invoices, pagamentos, conciliação. +- **Fiscal (base)**: estrutura para emissão de NFS-e. +- **CRM**: empresas, contatos e pipeline simples de negócios. + +## Stack +- Node.js + TypeScript +- NestJS +- PostgreSQL + Prisma +- Docker + +## Integração com identity-gateway +- Todas as rotas são protegidas por JWT interno. +- O token deve conter: + - `tenantId` + - `userId` + - `roles` +- O guard valida `issuer` e assina com `JWT_PUBLIC_KEY` ou `JWT_SECRET`. + +## Modelo de dados (resumo) +- **Tenant**: empresa cliente +- **Plan**: preço, ciclo e limites +- **Subscription**: tenant + plano +- **Invoice**: contas a receber +- **Payment**: pagamentos com gateway +- **FiscalDocument**: base para NFS-e +- **CRM**: companies, contacts, deals + +## Fluxo de cobrança +1. Criar plano +2. Criar assinatura +3. Gerar invoice +4. Criar pagamento via gateway +5. Receber webhook e conciliar + +## Endpoints mínimos +``` +POST /tenants +GET /tenants +POST /plans +GET /plans +POST /subscriptions +GET /subscriptions +POST /invoices +GET /invoices +POST /payments/:invoiceId +POST /webhooks/:gateway +GET /crm/companies +POST /crm/deals +GET /health +``` + +## Configuração local +```bash +cp .env.example .env +npm install +npm run prisma:generate +npm run prisma:migrate +npm run start:dev +``` + +## Docker +```bash +docker compose up --build +``` + +## Estrutura +- `src/core`: guard e contexto do tenant +- `src/modules`: domínios de negócio +- `prisma/`: schema e migrations +- `docs/`: documentação técnica diff --git a/billing-finance-core/docker-compose.yml b/billing-finance-core/docker-compose.yml new file mode 100644 index 0000000..0c3be7a --- /dev/null +++ b/billing-finance-core/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3.9' + +services: + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: billing_finance_core + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - '5432:5432' + volumes: + - postgres_data:/var/lib/postgresql/data + + billing-finance-core: + build: . + environment: + NODE_ENV: development + PORT: 3000 + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/billing_finance_core + JWT_SECRET: change-me + JWT_ISSUER: identity-gateway + PAYMENT_WEBHOOK_SECRET: change-me + ports: + - '3000:3000' + depends_on: + - postgres + +volumes: + postgres_data: diff --git a/billing-finance-core/docs/architecture.md b/billing-finance-core/docs/architecture.md new file mode 100644 index 0000000..f17af20 --- /dev/null +++ b/billing-finance-core/docs/architecture.md @@ -0,0 +1,18 @@ +# Arquitetura do billing-finance-core + +## Visão geral +O serviço `billing-finance-core` é responsável pelo core financeiro, billing, fiscal e CRM da plataforma SaaS multi-tenant. Ele confia no `identity-gateway` para autenticação e recebe o `tenantId` via JWT interno. + +## Principais componentes +- **Core**: Guard de autenticação JWT e contexto de tenant. +- **Módulos de domínio**: tenants, planos, assinaturas, invoices, payments, fiscal e CRM. +- **Gateways de pagamento**: padrão Strategy para Pix, boleto e cartão. +- **Persistência**: PostgreSQL com Prisma e migrations. + +## Multi-tenant +- Todas as rotas usam `tenantId` extraído do JWT interno. +- Consultas sempre filtram por `tenantId`. + +## Integrações +- **Identity Gateway**: JWT interno contendo `tenantId`, `userId`, `roles`. +- **Gateways de pagamento**: integração via webhooks e reconciliação idempotente. diff --git a/billing-finance-core/docs/billing-flow.md b/billing-finance-core/docs/billing-flow.md new file mode 100644 index 0000000..3480d55 --- /dev/null +++ b/billing-finance-core/docs/billing-flow.md @@ -0,0 +1,18 @@ +# Fluxo de cobrança + +1. **Criação de plano** + - Define preço, ciclo de cobrança e limites. +2. **Assinatura** + - Relaciona tenant e plano, define datas de ciclo e status. +3. **Invoice** + - Conta a receber com vencimento e status. +4. **Pagamento** + - Gateway escolhido gera pagamento (Pix, boleto, cartão). +5. **Webhook** + - Gateway envia evento de pagamento. +6. **Conciliação** + - Atualiza status do pagamento e invoice. + +## Status principais +- **Invoice**: PENDING, PAID, OVERDUE, CANCELED +- **Payment**: PENDING, CONFIRMED, FAILED, CANCELED diff --git a/billing-finance-core/docs/fiscal.md b/billing-finance-core/docs/fiscal.md new file mode 100644 index 0000000..caac2e4 --- /dev/null +++ b/billing-finance-core/docs/fiscal.md @@ -0,0 +1,12 @@ +# Fiscal (base) + +O módulo fiscal mantém informações básicas para emissão de NFS-e. + +## Campos +- Número da nota +- Status (DRAFT, ISSUED, CANCELED) +- Links de PDF/XML + +## Integração futura +- Preparado para integrar com provedor externo. +- Não inclui regras municipais complexas. diff --git a/billing-finance-core/nest-cli.json b/billing-finance-core/nest-cli.json new file mode 100644 index 0000000..56167b3 --- /dev/null +++ b/billing-finance-core/nest-cli.json @@ -0,0 +1,4 @@ +{ + "collection": "@nestjs/schematics", + "sourceRoot": "src" +} diff --git a/billing-finance-core/package.json b/billing-finance-core/package.json new file mode 100644 index 0000000..86cf50a --- /dev/null +++ b/billing-finance-core/package.json @@ -0,0 +1,38 @@ +{ + "name": "billing-finance-core", + "version": "1.0.0", + "description": "Core financeiro, billing, fiscal e CRM para plataforma SaaS multi-tenant.", + "main": "dist/main.js", + "scripts": { + "build": "nest build", + "start": "nest start", + "start:dev": "nest start --watch", + "start:prod": "node dist/main.js", + "lint": "eslint \"src/**/*.ts\"", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy", + "prisma:studio": "prisma studio" + }, + "dependencies": { + "@nestjs/common": "^10.3.0", + "@nestjs/core": "^10.3.0", + "@nestjs/platform-express": "^10.3.0", + "@prisma/client": "^5.20.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "dotenv": "^16.4.5", + "jsonwebtoken": "^9.0.2", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.2", + "@nestjs/schematics": "^10.1.2", + "@nestjs/testing": "^10.3.0", + "@types/jsonwebtoken": "^9.0.6", + "@types/node": "^20.14.11", + "prisma": "^5.20.0", + "ts-node": "^10.9.2", + "typescript": "^5.5.4" + } +} diff --git a/billing-finance-core/prisma/migrations/000_init/migration.sql b/billing-finance-core/prisma/migrations/000_init/migration.sql new file mode 100644 index 0000000..c677820 --- /dev/null +++ b/billing-finance-core/prisma/migrations/000_init/migration.sql @@ -0,0 +1,166 @@ +-- CreateEnum +CREATE TYPE "BillingCycle" AS ENUM ('MONTHLY', 'YEARLY'); +CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'PAST_DUE', 'CANCELED', 'TRIAL'); +CREATE TYPE "InvoiceStatus" AS ENUM ('PENDING', 'PAID', 'OVERDUE', 'CANCELED'); +CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'CONFIRMED', 'FAILED', 'CANCELED'); +CREATE TYPE "FiscalStatus" AS ENUM ('DRAFT', 'ISSUED', 'CANCELED'); +CREATE TYPE "DealStage" AS ENUM ('LEAD', 'PROPOSAL', 'NEGOTIATION', 'WON', 'LOST'); + +-- CreateTable +CREATE TABLE "Tenant" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "taxId" TEXT, + "status" TEXT NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Tenant_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Plan" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "priceCents" INTEGER NOT NULL, + "billingCycle" "BillingCycle" NOT NULL, + "softLimit" INTEGER, + "hardLimit" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Plan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE', + "startDate" TIMESTAMP(3) NOT NULL, + "nextDueDate" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Invoice" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "subscriptionId" TEXT, + "amountCents" INTEGER NOT NULL, + "dueDate" TIMESTAMP(3) NOT NULL, + "status" "InvoiceStatus" NOT NULL DEFAULT 'PENDING', + "paidAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Invoice_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "invoiceId" TEXT NOT NULL, + "gateway" TEXT NOT NULL, + "method" TEXT NOT NULL, + "externalId" TEXT, + "status" "PaymentStatus" NOT NULL DEFAULT 'PENDING', + "amountCents" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FiscalDocument" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "invoiceId" TEXT, + "number" TEXT, + "status" "FiscalStatus" NOT NULL DEFAULT 'DRAFT', + "pdfUrl" TEXT, + "xmlUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FiscalDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CrmCompany" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "segment" TEXT, + "website" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CrmCompany_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CrmContact" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "companyId" TEXT, + "name" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CrmContact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CrmDeal" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "companyId" TEXT, + "name" TEXT NOT NULL, + "stage" "DealStage" NOT NULL DEFAULT 'LEAD', + "valueCents" INTEGER NOT NULL, + "expectedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "CrmDeal_pkey" PRIMARY KEY ("id") +); + +-- Indexes +CREATE INDEX "Subscription_tenantId_idx" ON "Subscription"("tenantId"); +CREATE INDEX "Subscription_planId_idx" ON "Subscription"("planId"); +CREATE INDEX "Invoice_tenantId_idx" ON "Invoice"("tenantId"); +CREATE INDEX "Invoice_subscriptionId_idx" ON "Invoice"("subscriptionId"); +CREATE INDEX "Payment_tenantId_idx" ON "Payment"("tenantId"); +CREATE INDEX "Payment_invoiceId_idx" ON "Payment"("invoiceId"); +CREATE UNIQUE INDEX "Payment_gateway_externalId_key" ON "Payment"("gateway", "externalId"); +CREATE INDEX "FiscalDocument_tenantId_idx" ON "FiscalDocument"("tenantId"); +CREATE INDEX "FiscalDocument_invoiceId_idx" ON "FiscalDocument"("invoiceId"); +CREATE INDEX "CrmCompany_tenantId_idx" ON "CrmCompany"("tenantId"); +CREATE INDEX "CrmContact_tenantId_idx" ON "CrmContact"("tenantId"); +CREATE INDEX "CrmContact_companyId_idx" ON "CrmContact"("companyId"); +CREATE INDEX "CrmDeal_tenantId_idx" ON "CrmDeal"("tenantId"); +CREATE INDEX "CrmDeal_companyId_idx" ON "CrmDeal"("companyId"); + +-- Foreign Keys +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Invoice" ADD CONSTRAINT "Invoice_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "FiscalDocument" ADD CONSTRAINT "FiscalDocument_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "FiscalDocument" ADD CONSTRAINT "FiscalDocument_invoiceId_fkey" FOREIGN KEY ("invoiceId") REFERENCES "Invoice"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CrmCompany" ADD CONSTRAINT "CrmCompany_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "CrmContact" ADD CONSTRAINT "CrmContact_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "CrmContact" ADD CONSTRAINT "CrmContact_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "CrmCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "CrmDeal" ADD CONSTRAINT "CrmDeal_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "CrmDeal" ADD CONSTRAINT "CrmDeal_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "CrmCompany"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/billing-finance-core/prisma/schema.prisma b/billing-finance-core/prisma/schema.prisma new file mode 100644 index 0000000..4fe7f95 --- /dev/null +++ b/billing-finance-core/prisma/schema.prisma @@ -0,0 +1,195 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum BillingCycle { + MONTHLY + YEARLY +} + +enum SubscriptionStatus { + ACTIVE + PAST_DUE + CANCELED + TRIAL +} + +enum InvoiceStatus { + PENDING + PAID + OVERDUE + CANCELED +} + +enum PaymentStatus { + PENDING + CONFIRMED + FAILED + CANCELED +} + +enum FiscalStatus { + DRAFT + ISSUED + CANCELED +} + +enum DealStage { + LEAD + PROPOSAL + NEGOTIATION + WON + LOST +} + +model Tenant { + id String @id @default(uuid()) + name String + taxId String? + status String @default("ACTIVE") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + subscriptions Subscription[] + invoices Invoice[] + payments Payment[] + fiscalDocs FiscalDocument[] + crmCompanies CrmCompany[] + crmContacts CrmContact[] + crmDeals CrmDeal[] +} + +model Plan { + id String @id @default(uuid()) + name String + priceCents Int + billingCycle BillingCycle + softLimit Int? + hardLimit Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + subscriptions Subscription[] +} + +model Subscription { + id String @id @default(uuid()) + tenantId String + planId String + status SubscriptionStatus @default(ACTIVE) + startDate DateTime + nextDueDate DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tenant Tenant @relation(fields: [tenantId], references: [id]) + plan Plan @relation(fields: [planId], references: [id]) + invoices Invoice[] + + @@index([tenantId]) + @@index([planId]) +} + +model Invoice { + id String @id @default(uuid()) + tenantId String + subscriptionId String? + amountCents Int + dueDate DateTime + status InvoiceStatus @default(PENDING) + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tenant Tenant @relation(fields: [tenantId], references: [id]) + subscription Subscription? @relation(fields: [subscriptionId], references: [id]) + payments Payment[] + + @@index([tenantId]) + @@index([subscriptionId]) +} + +model Payment { + id String @id @default(uuid()) + tenantId String + invoiceId String + gateway String + method String + externalId String? + status PaymentStatus @default(PENDING) + amountCents Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + invoice Invoice @relation(fields: [invoiceId], references: [id]) + tenant Tenant @relation(fields: [tenantId], references: [id]) + + @@index([tenantId]) + @@index([invoiceId]) + @@unique([gateway, externalId]) +} + +model FiscalDocument { + id String @id @default(uuid()) + tenantId String + invoiceId String? + number String? + status FiscalStatus @default(DRAFT) + pdfUrl String? + xmlUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tenant Tenant @relation(fields: [tenantId], references: [id]) + invoice Invoice? @relation(fields: [invoiceId], references: [id]) + + @@index([tenantId]) + @@index([invoiceId]) +} + +model CrmCompany { + id String @id @default(uuid()) + tenantId String + name String + segment String? + website String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tenant Tenant @relation(fields: [tenantId], references: [id]) + contacts CrmContact[] + deals CrmDeal[] + + @@index([tenantId]) +} + +model CrmContact { + id String @id @default(uuid()) + tenantId String + companyId String? + name String + email String? + phone String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tenant Tenant @relation(fields: [tenantId], references: [id]) + company CrmCompany? @relation(fields: [companyId], references: [id]) + + @@index([tenantId]) + @@index([companyId]) +} + +model CrmDeal { + id String @id @default(uuid()) + tenantId String + companyId String? + name String + stage DealStage @default(LEAD) + valueCents Int + expectedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tenant Tenant @relation(fields: [tenantId], references: [id]) + company CrmCompany? @relation(fields: [companyId], references: [id]) + + @@index([tenantId]) + @@index([companyId]) +} diff --git a/billing-finance-core/src/app.controller.ts b/billing-finance-core/src/app.controller.ts new file mode 100644 index 0000000..8765d7e --- /dev/null +++ b/billing-finance-core/src/app.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class AppController { + @Get('health') + getHealth() { + return { status: 'ok' }; + } +} diff --git a/billing-finance-core/src/app.module.ts b/billing-finance-core/src/app.module.ts new file mode 100644 index 0000000..8a238ed --- /dev/null +++ b/billing-finance-core/src/app.module.ts @@ -0,0 +1,35 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { AppController } from './app.controller'; +import { AuthGuard } from './core/auth.guard'; +import { PrismaService } from './lib/postgres'; +import { TenantModule } from './modules/tenants/tenant.module'; +import { PlanModule } from './modules/plans/plan.module'; +import { SubscriptionModule } from './modules/subscriptions/subscription.module'; +import { InvoiceModule } from './modules/invoices/invoice.module'; +import { PaymentModule } from './modules/payments/payment.module'; +import { WebhookModule } from './modules/webhooks/webhook.module'; +import { FiscalModule } from './modules/fiscal/fiscal.module'; +import { CrmModule } from './modules/crm/crm.module'; + +@Module({ + controllers: [AppController], + imports: [ + TenantModule, + PlanModule, + SubscriptionModule, + InvoiceModule, + PaymentModule, + WebhookModule, + FiscalModule, + CrmModule, + ], + providers: [ + PrismaService, + { + provide: APP_GUARD, + useClass: AuthGuard, + }, + ], +}) +export class AppModule {} diff --git a/billing-finance-core/src/core/auth.guard.ts b/billing-finance-core/src/core/auth.guard.ts new file mode 100644 index 0000000..b1820c3 --- /dev/null +++ b/billing-finance-core/src/core/auth.guard.ts @@ -0,0 +1,58 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import jwt, { JwtPayload } from 'jsonwebtoken'; +import { env } from '../lib/env'; + +interface IdentityGatewayPayload extends JwtPayload { + tenantId: string; + userId: string; + roles: string[]; +} + +@Injectable() +export class AuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + if (request.path.startsWith('/webhooks')) { + const secret = request.headers['x-webhook-secret']; + if (secret && secret === env.webhookSecret) { + return true; + } + } + const authHeader = request.headers.authorization; + + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing bearer token'); + } + + const token = authHeader.replace('Bearer ', '').trim(); + try { + const verified = jwt.verify( + token, + env.jwtPublicKey || env.jwtSecret, + { + issuer: env.jwtIssuer, + }, + ) as IdentityGatewayPayload; + + if (!verified.tenantId || !verified.userId) { + throw new UnauthorizedException('Token missing tenant/user'); + } + + request.user = { + tenantId: verified.tenantId, + userId: verified.userId, + roles: verified.roles ?? [], + } as IdentityGatewayPayload; + + return true; + } catch (error) { + throw new UnauthorizedException('Invalid token'); + } + } +} diff --git a/billing-finance-core/src/core/enums.ts b/billing-finance-core/src/core/enums.ts new file mode 100644 index 0000000..34cf3b4 --- /dev/null +++ b/billing-finance-core/src/core/enums.ts @@ -0,0 +1,39 @@ +export enum BillingCycle { + MONTHLY = 'MONTHLY', + YEARLY = 'YEARLY', +} + +export enum SubscriptionStatus { + ACTIVE = 'ACTIVE', + PAST_DUE = 'PAST_DUE', + CANCELED = 'CANCELED', + TRIAL = 'TRIAL', +} + +export enum InvoiceStatus { + PENDING = 'PENDING', + PAID = 'PAID', + OVERDUE = 'OVERDUE', + CANCELED = 'CANCELED', +} + +export enum PaymentStatus { + PENDING = 'PENDING', + CONFIRMED = 'CONFIRMED', + FAILED = 'FAILED', + CANCELED = 'CANCELED', +} + +export enum FiscalStatus { + DRAFT = 'DRAFT', + ISSUED = 'ISSUED', + CANCELED = 'CANCELED', +} + +export enum DealStage { + LEAD = 'LEAD', + PROPOSAL = 'PROPOSAL', + NEGOTIATION = 'NEGOTIATION', + WON = 'WON', + LOST = 'LOST', +} diff --git a/billing-finance-core/src/core/tenant.context.ts b/billing-finance-core/src/core/tenant.context.ts new file mode 100644 index 0000000..c58197b --- /dev/null +++ b/billing-finance-core/src/core/tenant.context.ts @@ -0,0 +1,15 @@ +import { Request } from 'express'; + +export interface TenantContextPayload { + tenantId: string; + userId: string; + roles: string[]; +} + +export const getTenantContext = (request: Request): TenantContextPayload => { + const user = request.user as TenantContextPayload | undefined; + if (!user) { + throw new Error('Tenant context missing from request.'); + } + return user; +}; diff --git a/billing-finance-core/src/database/prisma.schema b/billing-finance-core/src/database/prisma.schema new file mode 100644 index 0000000..46fb117 --- /dev/null +++ b/billing-finance-core/src/database/prisma.schema @@ -0,0 +1,2 @@ +// Source of truth for database schema is /prisma/schema.prisma +// This file documents the required structure inside src/database. diff --git a/billing-finance-core/src/lib/env.ts b/billing-finance-core/src/lib/env.ts new file mode 100644 index 0000000..0f87600 --- /dev/null +++ b/billing-finance-core/src/lib/env.ts @@ -0,0 +1,15 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export const env = { + nodeEnv: process.env.NODE_ENV ?? 'development', + port: Number(process.env.PORT ?? 3000), + databaseUrl: process.env.DATABASE_URL ?? '', + jwtSecret: process.env.JWT_SECRET ?? '', + jwtPublicKey: process.env.JWT_PUBLIC_KEY ?? '', + jwtIssuer: process.env.JWT_ISSUER ?? 'identity-gateway', + webhookSecret: process.env.PAYMENT_WEBHOOK_SECRET ?? '', + stripeApiKey: process.env.STRIPE_API_KEY ?? '', + appLogLevel: process.env.APP_LOG_LEVEL ?? 'info', +}; diff --git a/billing-finance-core/src/lib/logger.ts b/billing-finance-core/src/lib/logger.ts new file mode 100644 index 0000000..0598917 --- /dev/null +++ b/billing-finance-core/src/lib/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@nestjs/common'; + +export const appLogger = new Logger('billing-finance-core'); diff --git a/billing-finance-core/src/lib/postgres.ts b/billing-finance-core/src/lib/postgres.ts new file mode 100644 index 0000000..623d5e0 --- /dev/null +++ b/billing-finance-core/src/lib/postgres.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/billing-finance-core/src/main.ts b/billing-finance-core/src/main.ts new file mode 100644 index 0000000..19fb8b7 --- /dev/null +++ b/billing-finance-core/src/main.ts @@ -0,0 +1,15 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; +import { env } from './lib/env'; +import { appLogger } from './lib/logger'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule, { logger: appLogger }); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + await app.listen(env.port); + appLogger.log(`billing-finance-core running on port ${env.port}`); +} + +bootstrap(); diff --git a/billing-finance-core/src/modules/crm/company.entity.ts b/billing-finance-core/src/modules/crm/company.entity.ts new file mode 100644 index 0000000..481a070 --- /dev/null +++ b/billing-finance-core/src/modules/crm/company.entity.ts @@ -0,0 +1,9 @@ +export class CompanyEntity { + id: string; + tenantId: string; + name: string; + segment?: string; + website?: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/crm/contact.entity.ts b/billing-finance-core/src/modules/crm/contact.entity.ts new file mode 100644 index 0000000..f235567 --- /dev/null +++ b/billing-finance-core/src/modules/crm/contact.entity.ts @@ -0,0 +1,10 @@ +export class ContactEntity { + id: string; + tenantId: string; + companyId?: string; + name: string; + email?: string; + phone?: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/crm/crm.controller.ts b/billing-finance-core/src/modules/crm/crm.controller.ts new file mode 100644 index 0000000..7629f5c --- /dev/null +++ b/billing-finance-core/src/modules/crm/crm.controller.ts @@ -0,0 +1,93 @@ +import { Body, Controller, Get, Post, Req } from '@nestjs/common'; +import { IsDateString, IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { Request } from 'express'; +import { DealStage } from '../../core/enums'; +import { getTenantContext } from '../../core/tenant.context'; +import { CrmService } from './crm.service'; + +class CreateCompanyDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + segment?: string; + + @IsOptional() + @IsString() + website?: string; +} + +class CreateContactDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + companyId?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsString() + phone?: string; +} + +class CreateDealDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + companyId?: string; + + @IsOptional() + @IsEnum(DealStage) + stage?: DealStage; + + @IsInt() + @Min(0) + valueCents: number; + + @IsOptional() + @IsDateString() + expectedAt?: string; +} + +@Controller('crm') +export class CrmController { + constructor(private readonly crmService: CrmService) {} + + @Get('companies') + listCompanies(@Req() req: Request) { + const { tenantId } = getTenantContext(req); + return this.crmService.listCompanies(tenantId); + } + + @Post('companies') + createCompany(@Req() req: Request, @Body() body: CreateCompanyDto) { + const { tenantId } = getTenantContext(req); + return this.crmService.createCompany({ tenantId, ...body }); + } + + @Post('contacts') + createContact(@Req() req: Request, @Body() body: CreateContactDto) { + const { tenantId } = getTenantContext(req); + return this.crmService.createContact({ tenantId, ...body }); + } + + @Post('deals') + createDeal(@Req() req: Request, @Body() body: CreateDealDto) { + const { tenantId } = getTenantContext(req); + return this.crmService.createDeal({ + tenantId, + companyId: body.companyId, + name: body.name, + stage: body.stage, + valueCents: body.valueCents, + expectedAt: body.expectedAt ? new Date(body.expectedAt) : undefined, + }); + } +} diff --git a/billing-finance-core/src/modules/crm/crm.module.ts b/billing-finance-core/src/modules/crm/crm.module.ts new file mode 100644 index 0000000..8400f46 --- /dev/null +++ b/billing-finance-core/src/modules/crm/crm.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { CrmController } from './crm.controller'; +import { CrmService } from './crm.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + controllers: [CrmController], + providers: [CrmService, PrismaService], + exports: [CrmService], +}) +export class CrmModule {} diff --git a/billing-finance-core/src/modules/crm/crm.service.ts b/billing-finance-core/src/modules/crm/crm.service.ts new file mode 100644 index 0000000..0437638 --- /dev/null +++ b/billing-finance-core/src/modules/crm/crm.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { DealStage } from '../../core/enums'; +import { PrismaService } from '../../lib/postgres'; + +interface CreateCompanyInput { + tenantId: string; + name: string; + segment?: string; + website?: string; +} + +interface CreateContactInput { + tenantId: string; + companyId?: string; + name: string; + email?: string; + phone?: string; +} + +interface CreateDealInput { + tenantId: string; + companyId?: string; + name: string; + stage?: DealStage; + valueCents: number; + expectedAt?: Date; +} + +@Injectable() +export class CrmService { + constructor(private readonly prisma: PrismaService) {} + + createCompany(data: CreateCompanyInput) { + return this.prisma.crmCompany.create({ data }); + } + + listCompanies(tenantId: string) { + return this.prisma.crmCompany.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + }); + } + + createContact(data: CreateContactInput) { + return this.prisma.crmContact.create({ data }); + } + + createDeal(data: CreateDealInput) { + return this.prisma.crmDeal.create({ + data: { + tenantId: data.tenantId, + companyId: data.companyId, + name: data.name, + stage: data.stage ?? DealStage.LEAD, + valueCents: data.valueCents, + expectedAt: data.expectedAt, + }, + }); + } +} diff --git a/billing-finance-core/src/modules/crm/deal.entity.ts b/billing-finance-core/src/modules/crm/deal.entity.ts new file mode 100644 index 0000000..29f8e4f --- /dev/null +++ b/billing-finance-core/src/modules/crm/deal.entity.ts @@ -0,0 +1,13 @@ +import { DealStage } from '../../core/enums'; + +export class DealEntity { + id: string; + tenantId: string; + companyId?: string; + name: string; + stage: DealStage; + valueCents: number; + expectedAt?: Date; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/fiscal/fiscal.entity.ts b/billing-finance-core/src/modules/fiscal/fiscal.entity.ts new file mode 100644 index 0000000..4056344 --- /dev/null +++ b/billing-finance-core/src/modules/fiscal/fiscal.entity.ts @@ -0,0 +1,13 @@ +import { FiscalStatus } from '../../core/enums'; + +export class FiscalEntity { + id: string; + tenantId: string; + invoiceId?: string; + number?: string; + status: FiscalStatus; + pdfUrl?: string; + xmlUrl?: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/fiscal/fiscal.module.ts b/billing-finance-core/src/modules/fiscal/fiscal.module.ts new file mode 100644 index 0000000..1e20ccf --- /dev/null +++ b/billing-finance-core/src/modules/fiscal/fiscal.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { FiscalService } from './fiscal.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + providers: [FiscalService, PrismaService], + exports: [FiscalService], +}) +export class FiscalModule {} diff --git a/billing-finance-core/src/modules/fiscal/fiscal.service.ts b/billing-finance-core/src/modules/fiscal/fiscal.service.ts new file mode 100644 index 0000000..1c44787 --- /dev/null +++ b/billing-finance-core/src/modules/fiscal/fiscal.service.ts @@ -0,0 +1,37 @@ +import { Injectable } from '@nestjs/common'; +import { FiscalStatus } from '../../core/enums'; +import { PrismaService } from '../../lib/postgres'; + +interface CreateFiscalInput { + tenantId: string; + invoiceId?: string; + number?: string; + status?: FiscalStatus; + pdfUrl?: string; + xmlUrl?: string; +} + +@Injectable() +export class FiscalService { + constructor(private readonly prisma: PrismaService) {} + + create(data: CreateFiscalInput) { + return this.prisma.fiscalDocument.create({ + data: { + tenantId: data.tenantId, + invoiceId: data.invoiceId, + number: data.number, + status: data.status ?? FiscalStatus.DRAFT, + pdfUrl: data.pdfUrl, + xmlUrl: data.xmlUrl, + }, + }); + } + + list(tenantId: string) { + return this.prisma.fiscalDocument.findMany({ + where: { tenantId }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/billing-finance-core/src/modules/invoices/invoice.controller.ts b/billing-finance-core/src/modules/invoices/invoice.controller.ts new file mode 100644 index 0000000..a36711d --- /dev/null +++ b/billing-finance-core/src/modules/invoices/invoice.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Get, Post, Req } from '@nestjs/common'; +import { IsDateString, IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { Request } from 'express'; +import { InvoiceStatus } from '../../core/enums'; +import { getTenantContext } from '../../core/tenant.context'; +import { InvoiceService } from './invoice.service'; + +class CreateInvoiceDto { + @IsOptional() + @IsString() + subscriptionId?: string; + + @IsInt() + @Min(1) + amountCents: number; + + @IsDateString() + dueDate: string; + + @IsOptional() + @IsEnum(InvoiceStatus) + status?: InvoiceStatus; +} + +@Controller('invoices') +export class InvoiceController { + constructor(private readonly invoiceService: InvoiceService) {} + + @Post() + create(@Req() req: Request, @Body() body: CreateInvoiceDto) { + const { tenantId } = getTenantContext(req); + return this.invoiceService.create({ + tenantId, + subscriptionId: body.subscriptionId, + amountCents: body.amountCents, + dueDate: new Date(body.dueDate), + status: body.status, + }); + } + + @Get() + list(@Req() req: Request) { + const { tenantId } = getTenantContext(req); + return this.invoiceService.list(tenantId); + } +} diff --git a/billing-finance-core/src/modules/invoices/invoice.entity.ts b/billing-finance-core/src/modules/invoices/invoice.entity.ts new file mode 100644 index 0000000..708e0fa --- /dev/null +++ b/billing-finance-core/src/modules/invoices/invoice.entity.ts @@ -0,0 +1,13 @@ +import { InvoiceStatus } from '../../core/enums'; + +export class InvoiceEntity { + id: string; + tenantId: string; + subscriptionId?: string; + amountCents: number; + dueDate: Date; + status: InvoiceStatus; + paidAt?: Date; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/invoices/invoice.module.ts b/billing-finance-core/src/modules/invoices/invoice.module.ts new file mode 100644 index 0000000..7095377 --- /dev/null +++ b/billing-finance-core/src/modules/invoices/invoice.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { InvoiceController } from './invoice.controller'; +import { InvoiceService } from './invoice.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + controllers: [InvoiceController], + providers: [InvoiceService, PrismaService], + exports: [InvoiceService], +}) +export class InvoiceModule {} diff --git a/billing-finance-core/src/modules/invoices/invoice.service.ts b/billing-finance-core/src/modules/invoices/invoice.service.ts new file mode 100644 index 0000000..306ca81 --- /dev/null +++ b/billing-finance-core/src/modules/invoices/invoice.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { InvoiceStatus } from '../../core/enums'; +import { PrismaService } from '../../lib/postgres'; + +interface CreateInvoiceInput { + tenantId: string; + subscriptionId?: string; + amountCents: number; + dueDate: Date; + status?: InvoiceStatus; +} + +@Injectable() +export class InvoiceService { + constructor(private readonly prisma: PrismaService) {} + + create(data: CreateInvoiceInput) { + return this.prisma.invoice.create({ + data: { + tenantId: data.tenantId, + subscriptionId: data.subscriptionId, + amountCents: data.amountCents, + dueDate: data.dueDate, + status: data.status ?? InvoiceStatus.PENDING, + }, + }); + } + + list(tenantId: string) { + return this.prisma.invoice.findMany({ + where: { tenantId }, + include: { payments: true }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/billing-finance-core/src/modules/payments/gateways/boleto.gateway.ts b/billing-finance-core/src/modules/payments/gateways/boleto.gateway.ts new file mode 100644 index 0000000..6f874e4 --- /dev/null +++ b/billing-finance-core/src/modules/payments/gateways/boleto.gateway.ts @@ -0,0 +1,30 @@ +import { randomUUID } from 'crypto'; +import { Invoice, Payment } from '@prisma/client'; +import { PaymentStatus } from '../../../core/enums'; +import { PaymentGateway, PaymentGatewayResult, PaymentWebhookResult } from './gateway.interface'; + +export class BoletoGateway implements PaymentGateway { + name = 'boleto'; + + async createPayment(invoice: Invoice): Promise { + const externalId = `boleto_${randomUUID()}`; + return { + externalId, + status: PaymentStatus.PENDING, + metadata: { + barcode: `34191.79001 ${externalId.slice(0, 8)}`, + }, + }; + } + + async handleWebhook(payload: any): Promise { + return { + externalId: payload?.externalId ?? '', + status: payload?.status ?? PaymentStatus.CONFIRMED, + }; + } + + async reconcile(payment: Payment): Promise { + return payment.status; + } +} diff --git a/billing-finance-core/src/modules/payments/gateways/card.gateway.ts b/billing-finance-core/src/modules/payments/gateways/card.gateway.ts new file mode 100644 index 0000000..6bd3550 --- /dev/null +++ b/billing-finance-core/src/modules/payments/gateways/card.gateway.ts @@ -0,0 +1,59 @@ +import { randomUUID } from 'crypto'; +import { Invoice, Payment } from '@prisma/client'; +import { PaymentStatus } from '../../../core/enums'; +import { env } from '../../../lib/env'; +import { appLogger } from '../../../lib/logger'; +import { PaymentGateway, PaymentGatewayResult, PaymentWebhookResult } from './gateway.interface'; + +export class CardGateway implements PaymentGateway { + name = 'card'; + + async createPayment(invoice: Invoice): Promise { + if (!env.stripeApiKey) { + const externalId = `card_${randomUUID()}`; + return { + externalId, + status: PaymentStatus.PENDING, + metadata: { provider: 'mock' }, + }; + } + + const response = await fetch('https://api.stripe.com/v1/payment_intents', { + method: 'POST', + headers: { + Authorization: `Bearer ${env.stripeApiKey}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + amount: invoice.amountCents.toString(), + currency: 'brl', + 'metadata[invoiceId]': invoice.id, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + appLogger.error(`Stripe error: ${errorText}`); + return { + status: PaymentStatus.FAILED, + }; + } + + const payload = (await response.json()) as { id: string }; + return { + externalId: payload.id, + status: PaymentStatus.PENDING, + }; + } + + async handleWebhook(payload: any): Promise { + return { + externalId: payload?.data?.object?.id ?? payload?.externalId ?? '', + status: payload?.status ?? PaymentStatus.CONFIRMED, + }; + } + + async reconcile(payment: Payment): Promise { + return payment.status; + } +} diff --git a/billing-finance-core/src/modules/payments/gateways/gateway.interface.ts b/billing-finance-core/src/modules/payments/gateways/gateway.interface.ts new file mode 100644 index 0000000..193a61e --- /dev/null +++ b/billing-finance-core/src/modules/payments/gateways/gateway.interface.ts @@ -0,0 +1,22 @@ +import { Invoice, Payment } from '@prisma/client'; +import { PaymentStatus } from '../../../core/enums'; + +export interface PaymentGatewayResult { + externalId?: string; + status: PaymentStatus; + redirectUrl?: string; + qrCode?: string; + metadata?: Record; +} + +export interface PaymentWebhookResult { + externalId: string; + status: PaymentStatus; +} + +export interface PaymentGateway { + name: string; + createPayment(invoice: Invoice): Promise; + handleWebhook(payload: unknown, signature?: string): Promise; + reconcile(payment: Payment): Promise; +} diff --git a/billing-finance-core/src/modules/payments/gateways/pix.gateway.ts b/billing-finance-core/src/modules/payments/gateways/pix.gateway.ts new file mode 100644 index 0000000..8f5273a --- /dev/null +++ b/billing-finance-core/src/modules/payments/gateways/pix.gateway.ts @@ -0,0 +1,28 @@ +import { randomUUID } from 'crypto'; +import { Invoice, Payment } from '@prisma/client'; +import { PaymentStatus } from '../../../core/enums'; +import { PaymentGateway, PaymentGatewayResult, PaymentWebhookResult } from './gateway.interface'; + +export class PixGateway implements PaymentGateway { + name = 'pix'; + + async createPayment(invoice: Invoice): Promise { + const externalId = `pix_${randomUUID()}`; + return { + externalId, + status: PaymentStatus.PENDING, + qrCode: `PIX-QRCODE-${externalId}`, + }; + } + + async handleWebhook(payload: any): Promise { + return { + externalId: payload?.externalId ?? '', + status: payload?.status ?? PaymentStatus.CONFIRMED, + }; + } + + async reconcile(payment: Payment): Promise { + return payment.status; + } +} diff --git a/billing-finance-core/src/modules/payments/payment.controller.ts b/billing-finance-core/src/modules/payments/payment.controller.ts new file mode 100644 index 0000000..19fdd04 --- /dev/null +++ b/billing-finance-core/src/modules/payments/payment.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Param, Post, Req } from '@nestjs/common'; +import { IsEnum, IsString } from 'class-validator'; +import { Request } from 'express'; +import { getTenantContext } from '../../core/tenant.context'; +import { PaymentService } from './payment.service'; + +enum PaymentMethod { + PIX = 'pix', + BOLETO = 'boleto', + CARD = 'card', +} + +class CreatePaymentDto { + @IsEnum(PaymentMethod) + method: PaymentMethod; + + @IsString() + gateway: string; +} + +@Controller('payments') +export class PaymentController { + constructor(private readonly paymentService: PaymentService) {} + + @Post(':invoiceId') + create( + @Req() req: Request, + @Param('invoiceId') invoiceId: string, + @Body() body: CreatePaymentDto, + ) { + const { tenantId } = getTenantContext(req); + return this.paymentService.createPayment({ + tenantId, + invoiceId, + method: body.method, + gateway: body.gateway, + }); + } +} diff --git a/billing-finance-core/src/modules/payments/payment.entity.ts b/billing-finance-core/src/modules/payments/payment.entity.ts new file mode 100644 index 0000000..126d6d3 --- /dev/null +++ b/billing-finance-core/src/modules/payments/payment.entity.ts @@ -0,0 +1,14 @@ +import { PaymentStatus } from '../../core/enums'; + +export class PaymentEntity { + id: string; + tenantId: string; + invoiceId: string; + gateway: string; + method: string; + externalId?: string; + status: PaymentStatus; + amountCents: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/payments/payment.module.ts b/billing-finance-core/src/modules/payments/payment.module.ts new file mode 100644 index 0000000..5ee4890 --- /dev/null +++ b/billing-finance-core/src/modules/payments/payment.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PaymentController } from './payment.controller'; +import { PaymentService } from './payment.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + controllers: [PaymentController], + providers: [PaymentService, PrismaService], + exports: [PaymentService], +}) +export class PaymentModule {} diff --git a/billing-finance-core/src/modules/payments/payment.service.ts b/billing-finance-core/src/modules/payments/payment.service.ts new file mode 100644 index 0000000..5853b5c --- /dev/null +++ b/billing-finance-core/src/modules/payments/payment.service.ts @@ -0,0 +1,82 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../lib/postgres'; +import { PaymentStatus } from '../../core/enums'; +import { PaymentGateway } from './gateways/gateway.interface'; +import { PixGateway } from './gateways/pix.gateway'; +import { BoletoGateway } from './gateways/boleto.gateway'; +import { CardGateway } from './gateways/card.gateway'; + +interface CreatePaymentInput { + tenantId: string; + invoiceId: string; + method: string; + gateway: string; +} + +@Injectable() +export class PaymentService { + private readonly gateways: Record; + + constructor(private readonly prisma: PrismaService) { + const available = [new PixGateway(), new BoletoGateway(), new CardGateway()]; + this.gateways = available.reduce((acc, gateway) => { + acc[gateway.name] = gateway; + return acc; + }, {} as Record); + } + + private getGateway(name: string): PaymentGateway { + const gateway = this.gateways[name]; + if (!gateway) { + throw new NotFoundException(`Gateway ${name} not supported`); + } + return gateway; + } + + async createPayment(data: CreatePaymentInput) { + const invoice = await this.prisma.invoice.findFirst({ + where: { id: data.invoiceId, tenantId: data.tenantId }, + }); + + if (!invoice) { + throw new NotFoundException('Invoice not found'); + } + + const gateway = this.getGateway(data.gateway); + const gatewayResult = await gateway.createPayment(invoice); + + return this.prisma.payment.create({ + data: { + tenantId: data.tenantId, + invoiceId: invoice.id, + gateway: gateway.name, + method: data.method, + externalId: gatewayResult.externalId, + status: gatewayResult.status ?? PaymentStatus.PENDING, + amountCents: invoice.amountCents, + }, + }); + } + + async reconcileByWebhook(gatewayName: string, payload: unknown) { + const gateway = this.getGateway(gatewayName); + const webhook = await gateway.handleWebhook(payload); + + const existing = await this.prisma.payment.findFirst({ + where: { gateway: gatewayName, externalId: webhook.externalId }, + }); + + if (!existing) { + return { ignored: true, reason: 'payment-not-found' }; + } + + if (existing.status === webhook.status) { + return existing; + } + + return this.prisma.payment.update({ + where: { id: existing.id }, + data: { status: webhook.status }, + }); + } +} diff --git a/billing-finance-core/src/modules/plans/plan.controller.ts b/billing-finance-core/src/modules/plans/plan.controller.ts new file mode 100644 index 0000000..97273e6 --- /dev/null +++ b/billing-finance-core/src/modules/plans/plan.controller.ts @@ -0,0 +1,39 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; +import { BillingCycle } from '../../core/enums'; +import { PlanService } from './plan.service'; + +class CreatePlanDto { + @IsString() + name: string; + + @IsInt() + @Min(0) + priceCents: number; + + @IsEnum(BillingCycle) + billingCycle: BillingCycle; + + @IsOptional() + @IsInt() + softLimit?: number; + + @IsOptional() + @IsInt() + hardLimit?: number; +} + +@Controller('plans') +export class PlanController { + constructor(private readonly planService: PlanService) {} + + @Post() + create(@Body() body: CreatePlanDto) { + return this.planService.create(body); + } + + @Get() + list() { + return this.planService.list(); + } +} diff --git a/billing-finance-core/src/modules/plans/plan.entity.ts b/billing-finance-core/src/modules/plans/plan.entity.ts new file mode 100644 index 0000000..82fdcea --- /dev/null +++ b/billing-finance-core/src/modules/plans/plan.entity.ts @@ -0,0 +1,12 @@ +import { BillingCycle } from '../../core/enums'; + +export class PlanEntity { + id: string; + name: string; + priceCents: number; + billingCycle: BillingCycle; + softLimit?: number; + hardLimit?: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/plans/plan.module.ts b/billing-finance-core/src/modules/plans/plan.module.ts new file mode 100644 index 0000000..5dfb920 --- /dev/null +++ b/billing-finance-core/src/modules/plans/plan.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PlanController } from './plan.controller'; +import { PlanService } from './plan.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + controllers: [PlanController], + providers: [PlanService, PrismaService], + exports: [PlanService], +}) +export class PlanModule {} diff --git a/billing-finance-core/src/modules/plans/plan.service.ts b/billing-finance-core/src/modules/plans/plan.service.ts new file mode 100644 index 0000000..26b84c4 --- /dev/null +++ b/billing-finance-core/src/modules/plans/plan.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { BillingCycle } from '../../core/enums'; +import { PrismaService } from '../../lib/postgres'; + +interface CreatePlanInput { + name: string; + priceCents: number; + billingCycle: BillingCycle; + softLimit?: number; + hardLimit?: number; +} + +@Injectable() +export class PlanService { + constructor(private readonly prisma: PrismaService) {} + + create(data: CreatePlanInput) { + return this.prisma.plan.create({ data }); + } + + list() { + return this.prisma.plan.findMany({ orderBy: { createdAt: 'desc' } }); + } +} diff --git a/billing-finance-core/src/modules/subscriptions/subscription.controller.ts b/billing-finance-core/src/modules/subscriptions/subscription.controller.ts new file mode 100644 index 0000000..944ca61 --- /dev/null +++ b/billing-finance-core/src/modules/subscriptions/subscription.controller.ts @@ -0,0 +1,44 @@ +import { Body, Controller, Get, Post, Req } from '@nestjs/common'; +import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator'; +import { Request } from 'express'; +import { SubscriptionStatus } from '../../core/enums'; +import { getTenantContext } from '../../core/tenant.context'; +import { SubscriptionService } from './subscription.service'; + +class CreateSubscriptionDto { + @IsString() + planId: string; + + @IsDateString() + startDate: string; + + @IsDateString() + nextDueDate: string; + + @IsOptional() + @IsEnum(SubscriptionStatus) + status?: SubscriptionStatus; +} + +@Controller('subscriptions') +export class SubscriptionController { + constructor(private readonly subscriptionService: SubscriptionService) {} + + @Post() + create(@Req() req: Request, @Body() body: CreateSubscriptionDto) { + const { tenantId } = getTenantContext(req); + return this.subscriptionService.create({ + tenantId, + planId: body.planId, + startDate: new Date(body.startDate), + nextDueDate: new Date(body.nextDueDate), + status: body.status, + }); + } + + @Get() + list(@Req() req: Request) { + const { tenantId } = getTenantContext(req); + return this.subscriptionService.list(tenantId); + } +} diff --git a/billing-finance-core/src/modules/subscriptions/subscription.entity.ts b/billing-finance-core/src/modules/subscriptions/subscription.entity.ts new file mode 100644 index 0000000..0dc88a1 --- /dev/null +++ b/billing-finance-core/src/modules/subscriptions/subscription.entity.ts @@ -0,0 +1,12 @@ +import { SubscriptionStatus } from '../../core/enums'; + +export class SubscriptionEntity { + id: string; + tenantId: string; + planId: string; + status: SubscriptionStatus; + startDate: Date; + nextDueDate: Date; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/subscriptions/subscription.module.ts b/billing-finance-core/src/modules/subscriptions/subscription.module.ts new file mode 100644 index 0000000..9a6d0eb --- /dev/null +++ b/billing-finance-core/src/modules/subscriptions/subscription.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SubscriptionController } from './subscription.controller'; +import { SubscriptionService } from './subscription.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + controllers: [SubscriptionController], + providers: [SubscriptionService, PrismaService], + exports: [SubscriptionService], +}) +export class SubscriptionModule {} diff --git a/billing-finance-core/src/modules/subscriptions/subscription.service.ts b/billing-finance-core/src/modules/subscriptions/subscription.service.ts new file mode 100644 index 0000000..4be69f0 --- /dev/null +++ b/billing-finance-core/src/modules/subscriptions/subscription.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { SubscriptionStatus } from '../../core/enums'; +import { PrismaService } from '../../lib/postgres'; + +interface CreateSubscriptionInput { + tenantId: string; + planId: string; + startDate: Date; + nextDueDate: Date; + status?: SubscriptionStatus; +} + +@Injectable() +export class SubscriptionService { + constructor(private readonly prisma: PrismaService) {} + + create(data: CreateSubscriptionInput) { + return this.prisma.subscription.create({ + data: { + tenantId: data.tenantId, + planId: data.planId, + startDate: data.startDate, + nextDueDate: data.nextDueDate, + status: data.status ?? SubscriptionStatus.ACTIVE, + }, + }); + } + + list(tenantId: string) { + return this.prisma.subscription.findMany({ + where: { tenantId }, + include: { plan: true }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/billing-finance-core/src/modules/tenants/tenant.controller.ts b/billing-finance-core/src/modules/tenants/tenant.controller.ts new file mode 100644 index 0000000..9474ecd --- /dev/null +++ b/billing-finance-core/src/modules/tenants/tenant.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Post } from '@nestjs/common'; +import { IsOptional, IsString } from 'class-validator'; +import { TenantService } from './tenant.service'; + +class CreateTenantDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + taxId?: string; + + @IsOptional() + @IsString() + status?: string; +} + +@Controller('tenants') +export class TenantController { + constructor(private readonly tenantService: TenantService) {} + + @Post() + create(@Body() body: CreateTenantDto) { + return this.tenantService.create(body); + } + + @Get() + list() { + return this.tenantService.list(); + } +} diff --git a/billing-finance-core/src/modules/tenants/tenant.entity.ts b/billing-finance-core/src/modules/tenants/tenant.entity.ts new file mode 100644 index 0000000..1ee4bb5 --- /dev/null +++ b/billing-finance-core/src/modules/tenants/tenant.entity.ts @@ -0,0 +1,8 @@ +export class TenantEntity { + id: string; + name: string; + taxId?: string; + status: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/billing-finance-core/src/modules/tenants/tenant.module.ts b/billing-finance-core/src/modules/tenants/tenant.module.ts new file mode 100644 index 0000000..eb1e370 --- /dev/null +++ b/billing-finance-core/src/modules/tenants/tenant.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TenantController } from './tenant.controller'; +import { TenantService } from './tenant.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + controllers: [TenantController], + providers: [TenantService, PrismaService], + exports: [TenantService], +}) +export class TenantModule {} diff --git a/billing-finance-core/src/modules/tenants/tenant.service.ts b/billing-finance-core/src/modules/tenants/tenant.service.ts new file mode 100644 index 0000000..3f94802 --- /dev/null +++ b/billing-finance-core/src/modules/tenants/tenant.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../lib/postgres'; + +interface CreateTenantInput { + name: string; + taxId?: string; + status?: string; +} + +@Injectable() +export class TenantService { + constructor(private readonly prisma: PrismaService) {} + + create(data: CreateTenantInput) { + return this.prisma.tenant.create({ + data: { + name: data.name, + taxId: data.taxId, + status: data.status ?? 'ACTIVE', + }, + }); + } + + list() { + return this.prisma.tenant.findMany({ + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/billing-finance-core/src/modules/webhooks/webhook.controller.ts b/billing-finance-core/src/modules/webhooks/webhook.controller.ts new file mode 100644 index 0000000..fc70198 --- /dev/null +++ b/billing-finance-core/src/modules/webhooks/webhook.controller.ts @@ -0,0 +1,12 @@ +import { Body, Controller, Param, Post } from '@nestjs/common'; +import { WebhookService } from './webhook.service'; + +@Controller('webhooks') +export class WebhookController { + constructor(private readonly webhookService: WebhookService) {} + + @Post(':gateway') + handle(@Param('gateway') gateway: string, @Body() payload: unknown) { + return this.webhookService.handleGatewayWebhook(gateway, payload); + } +} diff --git a/billing-finance-core/src/modules/webhooks/webhook.module.ts b/billing-finance-core/src/modules/webhooks/webhook.module.ts new file mode 100644 index 0000000..4eec987 --- /dev/null +++ b/billing-finance-core/src/modules/webhooks/webhook.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { WebhookController } from './webhook.controller'; +import { WebhookService } from './webhook.service'; +import { PaymentService } from '../payments/payment.service'; +import { PrismaService } from '../../lib/postgres'; + +@Module({ + controllers: [WebhookController], + providers: [WebhookService, PaymentService, PrismaService], +}) +export class WebhookModule {} diff --git a/billing-finance-core/src/modules/webhooks/webhook.service.ts b/billing-finance-core/src/modules/webhooks/webhook.service.ts new file mode 100644 index 0000000..be2a1c5 --- /dev/null +++ b/billing-finance-core/src/modules/webhooks/webhook.service.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { PaymentService } from '../payments/payment.service'; + +@Injectable() +export class WebhookService { + constructor(private readonly paymentService: PaymentService) {} + + handleGatewayWebhook(gateway: string, payload: unknown) { + return this.paymentService.reconcileByWebhook(gateway, payload); + } +} diff --git a/billing-finance-core/tsconfig.json b/billing-finance-core/tsconfig.json new file mode 100644 index 0000000..cb9e902 --- /dev/null +++ b/billing-finance-core/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +}