Add billing-finance-core service

This commit is contained in:
Tiago Yamamoto 2025-12-27 13:58:47 -03:00
parent a6d1b24c01
commit d5d89258c3
58 changed files with 1754 additions and 0 deletions

View 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

View 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"]

View 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

View 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:

View 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.

View 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

View 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.

View file

@ -0,0 +1,4 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src"
}

View 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"
}
}

View 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;

View 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])
}

View file

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get('health')
getHealth() {
return { status: 'ok' };
}
}

View 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 {}

View 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');
}
}
}

View 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',
}

View 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;
};

View file

@ -0,0 +1,2 @@
// Source of truth for database schema is /prisma/schema.prisma
// This file documents the required structure inside src/database.

View 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',
};

View file

@ -0,0 +1,3 @@
import { Logger } from '@nestjs/common';
export const appLogger = new Logger('billing-finance-core');

View 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();
}
}

View 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();

View file

@ -0,0 +1,9 @@
export class CompanyEntity {
id: string;
tenantId: string;
name: string;
segment?: string;
website?: string;
createdAt: Date;
updatedAt: Date;
}

View file

@ -0,0 +1,10 @@
export class ContactEntity {
id: string;
tenantId: string;
companyId?: string;
name: string;
email?: string;
phone?: string;
createdAt: Date;
updatedAt: Date;
}

View 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,
});
}
}

View 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 {}

View 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,
},
});
}
}

View 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;
}

View 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;
}

View 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 {}

View 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' },
});
}
}

View file

@ -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);
}
}

View 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;
}

View 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 {}

View 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' },
});
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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>;
}

View file

@ -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;
}
}

View file

@ -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,
});
}
}

View 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;
}

View 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 {}

View 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 },
});
}
}

View 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();
}
}

View 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;
}

View 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 {}

View 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' } });
}
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -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 {}

View file

@ -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' },
});
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,8 @@
export class TenantEntity {
id: string;
name: string;
taxId?: string;
status: string;
createdAt: Date;
updatedAt: Date;
}

View 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 {}

View 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' },
});
}
}

View file

@ -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);
}
}

View 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 {}

View 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);
}
}

View 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"]
}