Add billing-finance-core service
This commit is contained in:
parent
a6d1b24c01
commit
d5d89258c3
58 changed files with 1754 additions and 0 deletions
9
billing-finance-core/.env.example
Normal file
9
billing-finance-core/.env.example
Normal file
|
|
@ -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
|
||||||
18
billing-finance-core/Dockerfile
Normal file
18
billing-finance-core/Dockerfile
Normal file
|
|
@ -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"]
|
||||||
77
billing-finance-core/README.md
Normal file
77
billing-finance-core/README.md
Normal file
|
|
@ -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
|
||||||
30
billing-finance-core/docker-compose.yml
Normal file
30
billing-finance-core/docker-compose.yml
Normal file
|
|
@ -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:
|
||||||
18
billing-finance-core/docs/architecture.md
Normal file
18
billing-finance-core/docs/architecture.md
Normal file
|
|
@ -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.
|
||||||
18
billing-finance-core/docs/billing-flow.md
Normal file
18
billing-finance-core/docs/billing-flow.md
Normal file
|
|
@ -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
|
||||||
12
billing-finance-core/docs/fiscal.md
Normal file
12
billing-finance-core/docs/fiscal.md
Normal file
|
|
@ -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.
|
||||||
4
billing-finance-core/nest-cli.json
Normal file
4
billing-finance-core/nest-cli.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src"
|
||||||
|
}
|
||||||
38
billing-finance-core/package.json
Normal file
38
billing-finance-core/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
166
billing-finance-core/prisma/migrations/000_init/migration.sql
Normal file
166
billing-finance-core/prisma/migrations/000_init/migration.sql
Normal file
|
|
@ -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;
|
||||||
195
billing-finance-core/prisma/schema.prisma
Normal file
195
billing-finance-core/prisma/schema.prisma
Normal file
|
|
@ -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])
|
||||||
|
}
|
||||||
9
billing-finance-core/src/app.controller.ts
Normal file
9
billing-finance-core/src/app.controller.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AppController {
|
||||||
|
@Get('health')
|
||||||
|
getHealth() {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
35
billing-finance-core/src/app.module.ts
Normal file
35
billing-finance-core/src/app.module.ts
Normal file
|
|
@ -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 {}
|
||||||
58
billing-finance-core/src/core/auth.guard.ts
Normal file
58
billing-finance-core/src/core/auth.guard.ts
Normal file
|
|
@ -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<Request>();
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
billing-finance-core/src/core/enums.ts
Normal file
39
billing-finance-core/src/core/enums.ts
Normal file
|
|
@ -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',
|
||||||
|
}
|
||||||
15
billing-finance-core/src/core/tenant.context.ts
Normal file
15
billing-finance-core/src/core/tenant.context.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
2
billing-finance-core/src/database/prisma.schema
Normal file
2
billing-finance-core/src/database/prisma.schema
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Source of truth for database schema is /prisma/schema.prisma
|
||||||
|
// This file documents the required structure inside src/database.
|
||||||
15
billing-finance-core/src/lib/env.ts
Normal file
15
billing-finance-core/src/lib/env.ts
Normal file
|
|
@ -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',
|
||||||
|
};
|
||||||
3
billing-finance-core/src/lib/logger.ts
Normal file
3
billing-finance-core/src/lib/logger.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const appLogger = new Logger('billing-finance-core');
|
||||||
13
billing-finance-core/src/lib/postgres.ts
Normal file
13
billing-finance-core/src/lib/postgres.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
15
billing-finance-core/src/main.ts
Normal file
15
billing-finance-core/src/main.ts
Normal file
|
|
@ -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();
|
||||||
9
billing-finance-core/src/modules/crm/company.entity.ts
Normal file
9
billing-finance-core/src/modules/crm/company.entity.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export class CompanyEntity {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
name: string;
|
||||||
|
segment?: string;
|
||||||
|
website?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
10
billing-finance-core/src/modules/crm/contact.entity.ts
Normal file
10
billing-finance-core/src/modules/crm/contact.entity.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export class ContactEntity {
|
||||||
|
id: string;
|
||||||
|
tenantId: string;
|
||||||
|
companyId?: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
93
billing-finance-core/src/modules/crm/crm.controller.ts
Normal file
93
billing-finance-core/src/modules/crm/crm.controller.ts
Normal file
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
11
billing-finance-core/src/modules/crm/crm.module.ts
Normal file
11
billing-finance-core/src/modules/crm/crm.module.ts
Normal file
|
|
@ -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 {}
|
||||||
60
billing-finance-core/src/modules/crm/crm.service.ts
Normal file
60
billing-finance-core/src/modules/crm/crm.service.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
13
billing-finance-core/src/modules/crm/deal.entity.ts
Normal file
13
billing-finance-core/src/modules/crm/deal.entity.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
13
billing-finance-core/src/modules/fiscal/fiscal.entity.ts
Normal file
13
billing-finance-core/src/modules/fiscal/fiscal.entity.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
9
billing-finance-core/src/modules/fiscal/fiscal.module.ts
Normal file
9
billing-finance-core/src/modules/fiscal/fiscal.module.ts
Normal file
|
|
@ -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 {}
|
||||||
37
billing-finance-core/src/modules/fiscal/fiscal.service.ts
Normal file
37
billing-finance-core/src/modules/fiscal/fiscal.service.ts
Normal file
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
billing-finance-core/src/modules/invoices/invoice.entity.ts
Normal file
13
billing-finance-core/src/modules/invoices/invoice.entity.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
11
billing-finance-core/src/modules/invoices/invoice.module.ts
Normal file
11
billing-finance-core/src/modules/invoices/invoice.module.ts
Normal file
|
|
@ -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 {}
|
||||||
36
billing-finance-core/src/modules/invoices/invoice.service.ts
Normal file
36
billing-finance-core/src/modules/invoices/invoice.service.ts
Normal file
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PaymentGatewayResult> {
|
||||||
|
const externalId = `boleto_${randomUUID()}`;
|
||||||
|
return {
|
||||||
|
externalId,
|
||||||
|
status: PaymentStatus.PENDING,
|
||||||
|
metadata: {
|
||||||
|
barcode: `34191.79001 ${externalId.slice(0, 8)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleWebhook(payload: any): Promise<PaymentWebhookResult> {
|
||||||
|
return {
|
||||||
|
externalId: payload?.externalId ?? '',
|
||||||
|
status: payload?.status ?? PaymentStatus.CONFIRMED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcile(payment: Payment): Promise<PaymentStatus> {
|
||||||
|
return payment.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PaymentGatewayResult> {
|
||||||
|
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<PaymentWebhookResult> {
|
||||||
|
return {
|
||||||
|
externalId: payload?.data?.object?.id ?? payload?.externalId ?? '',
|
||||||
|
status: payload?.status ?? PaymentStatus.CONFIRMED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcile(payment: Payment): Promise<PaymentStatus> {
|
||||||
|
return payment.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentWebhookResult {
|
||||||
|
externalId: string;
|
||||||
|
status: PaymentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentGateway {
|
||||||
|
name: string;
|
||||||
|
createPayment(invoice: Invoice): Promise<PaymentGatewayResult>;
|
||||||
|
handleWebhook(payload: unknown, signature?: string): Promise<PaymentWebhookResult>;
|
||||||
|
reconcile(payment: Payment): Promise<PaymentStatus>;
|
||||||
|
}
|
||||||
|
|
@ -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<PaymentGatewayResult> {
|
||||||
|
const externalId = `pix_${randomUUID()}`;
|
||||||
|
return {
|
||||||
|
externalId,
|
||||||
|
status: PaymentStatus.PENDING,
|
||||||
|
qrCode: `PIX-QRCODE-${externalId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleWebhook(payload: any): Promise<PaymentWebhookResult> {
|
||||||
|
return {
|
||||||
|
externalId: payload?.externalId ?? '',
|
||||||
|
status: payload?.status ?? PaymentStatus.CONFIRMED,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async reconcile(payment: Payment): Promise<PaymentStatus> {
|
||||||
|
return payment.status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
14
billing-finance-core/src/modules/payments/payment.entity.ts
Normal file
14
billing-finance-core/src/modules/payments/payment.entity.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
11
billing-finance-core/src/modules/payments/payment.module.ts
Normal file
11
billing-finance-core/src/modules/payments/payment.module.ts
Normal file
|
|
@ -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 {}
|
||||||
82
billing-finance-core/src/modules/payments/payment.service.ts
Normal file
82
billing-finance-core/src/modules/payments/payment.service.ts
Normal file
|
|
@ -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<string, PaymentGateway>;
|
||||||
|
|
||||||
|
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<string, PaymentGateway>);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
billing-finance-core/src/modules/plans/plan.controller.ts
Normal file
39
billing-finance-core/src/modules/plans/plan.controller.ts
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
billing-finance-core/src/modules/plans/plan.entity.ts
Normal file
12
billing-finance-core/src/modules/plans/plan.entity.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
11
billing-finance-core/src/modules/plans/plan.module.ts
Normal file
11
billing-finance-core/src/modules/plans/plan.module.ts
Normal file
|
|
@ -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 {}
|
||||||
24
billing-finance-core/src/modules/plans/plan.service.ts
Normal file
24
billing-finance-core/src/modules/plans/plan.service.ts
Normal file
|
|
@ -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' } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 {}
|
||||||
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export class TenantEntity {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
taxId?: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
11
billing-finance-core/src/modules/tenants/tenant.module.ts
Normal file
11
billing-finance-core/src/modules/tenants/tenant.module.ts
Normal file
|
|
@ -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 {}
|
||||||
29
billing-finance-core/src/modules/tenants/tenant.service.ts
Normal file
29
billing-finance-core/src/modules/tenants/tenant.service.ts
Normal file
|
|
@ -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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
billing-finance-core/src/modules/webhooks/webhook.module.ts
Normal file
11
billing-finance-core/src/modules/webhooks/webhook.module.ts
Normal file
|
|
@ -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 {}
|
||||||
11
billing-finance-core/src/modules/webhooks/webhook.service.ts
Normal file
11
billing-finance-core/src/modules/webhooks/webhook.service.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
billing-finance-core/tsconfig.json
Normal file
17
billing-finance-core/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue