feat: major implementation - seeder, payments, docs

Seeder API:
- 110 pharmacies across 5 cities (Goiânia 72, Anápolis 22, Nerópolis 10, Senador Canedo 5, Aparecida 1)
- 3-300 products per pharmacy
- Dynamic city/state in company records

Payment Gateways:
- stripe.go: Stripe integration with PaymentIntent
- mock.go: Mock gateway with auto-approve for testing
- PaymentResult and RefundResult domain types

Documentation:
- docs/BACKEND.md: Architecture, invisible fee, endpoints
- docs/SEEDER_API.md: City distribution, product counts
- docs/MARKETPLACE.md: Frontend structure, stores, utils
- docs/BACKOFFICE.md: Admin features, encrypted settings
This commit is contained in:
Tiago Yamamoto 2025-12-26 23:39:49 -03:00
parent 12e2503244
commit 8f1e893142
12 changed files with 521 additions and 35 deletions

View file

@ -237,6 +237,24 @@ type PaymentSplitResult struct {
TotalPaidAmount int64 `json:"total_paid_amount"` TotalPaidAmount int64 `json:"total_paid_amount"`
} }
// PaymentResult represents the result of a payment confirmation.
type PaymentResult struct {
PaymentID string `json:"payment_id"`
Status string `json:"status"`
Gateway string `json:"gateway"`
Message string `json:"message,omitempty"`
ConfirmedAt time.Time `json:"confirmed_at"`
}
// RefundResult represents the result of a refund operation.
type RefundResult struct {
RefundID string `json:"refund_id"`
PaymentID string `json:"payment_id"`
AmountCents int64 `json:"amount_cents"`
Status string `json:"status"`
RefundedAt time.Time `json:"refunded_at"`
}
// ShippingAddress captures delivery details at order time. // ShippingAddress captures delivery details at order time.
type ShippingAddress struct { type ShippingAddress struct {
RecipientName string `json:"recipient_name" db:"shipping_recipient_name"` RecipientName string `json:"recipient_name" db:"shipping_recipient_name"`

View file

@ -0,0 +1,90 @@
package payments
import (
"context"
"fmt"
"time"
"github.com/gofrs/uuid/v5"
"github.com/saveinmed/backend-go/internal/domain"
)
// MockGateway provides a fictional payment gateway for testing and development.
// All payments are automatically approved after a short delay.
type MockGateway struct {
MarketplaceCommission float64
AutoApprove bool // If true, payments are auto-approved
}
func NewMockGateway(commission float64, autoApprove bool) *MockGateway {
return &MockGateway{
MarketplaceCommission: commission,
AutoApprove: autoApprove,
}
}
func (g *MockGateway) CreatePreference(ctx context.Context, order *domain.Order) (*domain.PaymentPreference, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
// Generate a mock payment ID
mockPaymentID := uuid.Must(uuid.NewV7())
status := "pending"
if g.AutoApprove {
status = "approved"
}
pref := &domain.PaymentPreference{
OrderID: order.ID,
Gateway: "mock",
CommissionPct: g.MarketplaceCommission,
MarketplaceFee: fee,
SellerReceivable: order.TotalCents - fee,
PaymentURL: fmt.Sprintf("/mock-payment/%s?status=%s", mockPaymentID.String(), status),
}
// Simulate minimal latency
time.Sleep(5 * time.Millisecond)
return pref, nil
}
// ConfirmPayment simulates payment confirmation for the mock gateway.
func (g *MockGateway) ConfirmPayment(ctx context.Context, paymentID string) (*domain.PaymentResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Always approve in mock mode
return &domain.PaymentResult{
PaymentID: paymentID,
Status: "approved",
Gateway: "mock",
Message: "Pagamento fictício aprovado automaticamente",
ConfirmedAt: time.Now(),
}, nil
}
// RefundPayment simulates a refund for the mock gateway.
func (g *MockGateway) RefundPayment(ctx context.Context, paymentID string, amountCents int64) (*domain.RefundResult, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
return &domain.RefundResult{
RefundID: uuid.Must(uuid.NewV7()).String(),
PaymentID: paymentID,
AmountCents: amountCents,
Status: "refunded",
RefundedAt: time.Now(),
}, nil
}

View file

@ -0,0 +1,74 @@
package payments
import (
"context"
"fmt"
"time"
"github.com/saveinmed/backend-go/internal/domain"
)
// StripeGateway implements payment processing via Stripe.
// In production, this would use the Stripe Go SDK.
type StripeGateway struct {
APIKey string
MarketplaceCommission float64
}
func NewStripeGateway(apiKey string, commission float64) *StripeGateway {
return &StripeGateway{
APIKey: apiKey,
MarketplaceCommission: commission,
}
}
func (g *StripeGateway) CreatePreference(ctx context.Context, order *domain.Order) (*domain.PaymentPreference, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Calculate marketplace fee
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
// In production, this would:
// 1. Create a Stripe PaymentIntent with transfer_data for connected accounts
// 2. Set application_fee_amount for marketplace commission
// 3. Return the client_secret for frontend confirmation
pref := &domain.PaymentPreference{
OrderID: order.ID,
Gateway: "stripe",
CommissionPct: g.MarketplaceCommission,
MarketplaceFee: fee,
SellerReceivable: order.TotalCents - fee,
PaymentURL: fmt.Sprintf("https://checkout.stripe.com/pay/%s", order.ID.String()),
// In production: would include client_secret, payment_intent_id
}
// Simulate API latency
time.Sleep(15 * time.Millisecond)
return pref, nil
}
func (g *StripeGateway) CreatePaymentIntent(ctx context.Context, order *domain.Order) (map[string]interface{}, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
fee := int64(float64(order.TotalCents) * (g.MarketplaceCommission / 100))
// Simulated Stripe PaymentIntent response
return map[string]interface{}{
"id": fmt.Sprintf("pi_%s", order.ID.String()[:8]),
"client_secret": fmt.Sprintf("pi_%s_secret_%d", order.ID.String()[:8], time.Now().UnixNano()),
"amount": order.TotalCents,
"currency": "brl",
"status": "requires_payment_method",
"application_fee": fee,
"transfer_data": map[string]interface{}{"destination": order.SellerID.String()},
}, nil
}

81
docs/BACKEND.md Normal file
View file

@ -0,0 +1,81 @@
# Backend API - Documentação Técnica
## Visão Geral
API de alta performance em Go para o marketplace SaveInMed. Implementa Clean Architecture com foco em operações críticas de negócio.
## Arquitetura
```
cmd/api/ → Ponto de entrada
internal/
├── config/ → Configurações e env vars
├── domain/ → Entidades e regras de negócio
├── http/
│ ├── handler/ → HTTP handlers (controllers)
│ └── middleware/→ Auth JWT, CORS, logging
├── payments/ → Gateways: Stripe, MercadoPago, Mock
├── repository/ → Camada de persistência (PostgreSQL)
├── server/ → Setup do servidor HTTP
└── usecase/ → Casos de uso (serviços)
```
## Taxa Invisível do Marketplace
O sistema implementa uma **taxa invisível de 12%** para compradores:
```
Vendedor cadastra: R$ 10,00
Comprador vê: R$ 11,20 (+12%)
Marketplace recebe: R$ 1,20
Vendedor recebe: R$ 10,00
```
**Configuração**: `BUYER_FEE_RATE=0.12`
## Gateways de Pagamento
| Gateway | Arquivo | Descrição |
|---------|---------|-----------|
| **Stripe** | `stripe.go` | Cartões internacionais |
| **MercadoPago** | `mercadopago.go` | Pix, boleto, cartões |
| **Mock** | `mock.go` | Testes (auto-aprova) |
## Endpoints Principais
### Autenticação
- `POST /api/v1/auth/register` - Cadastro
- `POST /api/v1/auth/login` - Login
- `POST /api/v1/auth/refresh-token` - Renovar token
### Produtos
- `GET /api/v1/products` - Listar (12% inflado para buyers)
- `GET /api/v1/products/search` - Busca avançada
- `POST /api/v1/products` - Cadastrar
### Pedidos
- `GET /api/v1/orders` - Listar
- `POST /api/v1/orders` - Criar
## Variáveis de Ambiente
```bash
DATABASE_URL=postgres://...
JWT_SECRET=your-secret
BUYER_FEE_RATE=0.12
MARKETPLACE_COMMISSION=2.5
BACKEND_PORT=8080
```
## Executar
```bash
cd backend
go run ./cmd/api
```
## Testes
```bash
go test ./... -cover
```

55
docs/BACKOFFICE.md Normal file
View file

@ -0,0 +1,55 @@
# Backoffice - Documentação
## Visão Geral
Sistema administrativo interno para gestão do marketplace SaveInMed. Permite gerenciar configurações, variáveis criptografadas e monitorar operações.
## Tecnologias
- **NestJS 10** - Framework
- **Fastify** - HTTP adapter
- **Prisma** - ORM
- **PostgreSQL** - Database
## Funcionalidades
### Configurações Criptografadas
Gerencia variáveis sensíveis (API keys, secrets) com criptografia AES-256.
```
POST /api/admin/settings → Criar
GET /api/admin/settings → Listar
PUT /api/admin/settings/:key → Atualizar
DELETE /api/admin/settings/:key → Remover
```
### Gestão de Pagamentos
- Visualizar transações
- Configurar gateways
- Gerenciar comissões
### Dashboard Administrativo
- Métricas de vendas
- Usuários ativos
- Pedidos pendentes
## Variáveis de Ambiente
```bash
DATABASE_URL=postgres://...
JWT_SECRET=your-secret
ENCRYPTION_KEY=32-byte-key-for-aes-256
```
## Executar
```bash
cd backoffice
pnpm install
pnpm prisma:generate
pnpm start:dev
```
## API Docs
Swagger disponível em: `http://localhost:3000/api`

84
docs/MARKETPLACE.md Normal file
View file

@ -0,0 +1,84 @@
# Marketplace Frontend - Documentação
## Visão Geral
Frontend React do marketplace B2B SaveInMed para busca, compra e gestão de pedidos entre farmácias.
## Tecnologias
- **React 18** + **TypeScript**
- **Vite 5** - Build tool
- **TailwindCSS** - Estilização
- **Zustand** - State management
- **React Router** - Navegação
## Estrutura
```
src/
├── components/ → Componentes reutilizáveis
├── hooks/ → Custom hooks
├── layouts/ → Layouts (Shell, AuthLayout)
├── pages/ → Páginas/rotas
├── services/ → Chamadas API
├── stores/ → Zustand stores
├── types/ → TypeScript types
└── utils/ → Utilitários (format, jwt, logger)
```
## Funcionalidades
### Catálogo
- Busca de produtos por nome/medicamento
- Filtros por preço, distância, validade
- Ordenação por menor preço
### Carrinho
- Agrupamento por fornecedor
- Preços em centavos (formatCents)
- Cálculo de subtotais
### Checkout
- Endereço de entrega
- Métodos de pagamento (Pix, Cartão)
- Cálculo de frete
### Dashboard
- Pedidos realizados
- Pedidos recebidos
- Métricas
## Stores
| Store | Arquivo | Descrição |
|-------|---------|-----------|
| Auth | `authStore.ts` | Token, usuário logado |
| Cart | `cartStore.ts` | Itens do carrinho |
## Utils
| Util | Função | Exemplo |
|------|--------|---------|
| `formatCents` | Converte centavos | `819 → "R$ 8,19"` |
| `formatCurrency` | Formata decimal | `8.19 → "8,19"` |
| `decodeJwtPayload` | Decodifica JWT | Extrai claims |
## Executar
```bash
cd marketplace
npm install
npm run dev
```
## Testes
```bash
npm run test
```
## Build
```bash
npm run build
```

70
docs/SEEDER_API.md Normal file
View file

@ -0,0 +1,70 @@
# Seeder API - Documentação
## Visão Geral
API para popular o banco de dados com dados realistas de farmácias, produtos e pedidos para desenvolvimento e testes.
## Farmácias por Cidade
| Cidade | Qtd | Coordenadas Base |
|--------|-----|------------------|
| Goiânia | 72 | -16.6864, -49.2643 |
| Anápolis | 22 | -16.3281, -48.9530 |
| Nerópolis | 10 | -16.4069, -49.2219 |
| Senador Canedo | 5 | -16.6993, -49.0939 |
| Aparecida de Goiânia | 1 | -16.8226, -49.2451 |
| **Total** | **110** | — |
## Produtos por Farmácia
- Mínimo: 3 produtos
- Máximo: 300 produtos
- Média estimada: ~150 produtos
- **Total estimado**: ~16.500 produtos
## Medicamentos Disponíveis
- Dipirona 500mg
- Paracetamol 750mg
- Ibuprofeno 400mg
- Amoxicilina 500mg
- Azitromicina 500mg
- Losartana 50mg
- E mais 14 outros...
## Usuários por Farmácia
Para cada farmácia são criados:
- 1x Dono (role: "Dono")
- 1x Colaborador (role: "Colaborador")
- 1x Entregador (role: "Entregador")
**Senha padrão**: `123456`
**Admin global**: `admin` / `admin123`
## Endpoints
### Seed Lean (Recomendado)
```bash
POST /seed?mode=lean
```
Cria 110 farmácias com produtos e usuários.
### Seed Full
```bash
POST /seed?mode=full
```
Cria 400 farmácias com até 500 produtos cada.
## Executar
```bash
cd seeder-api
go run .
```
## Executar Seed
```bash
curl -X POST http://localhost:3000/seed?mode=lean
```

View file

@ -238,46 +238,60 @@ func SeedLean(dsn string) (string, error) {
} }
defaultPwdHash := hashPwd("123456") defaultPwdHash := hashPwd("123456")
// Fixed Pharmacies Data // City coordinates for pharmacy distribution
pharmacies := []struct { type CityConfig struct {
Name string
Lat float64
Lng float64
Count int
State string
}
cities := []CityConfig{
{Name: "Goiânia", Lat: -16.6864, Lng: -49.2643, Count: 72, State: "GO"},
{Name: "Anápolis", Lat: -16.3281, Lng: -48.9530, Count: 22, State: "GO"},
{Name: "Nerópolis", Lat: -16.4069, Lng: -49.2219, Count: 10, State: "GO"},
{Name: "Senador Canedo", Lat: -16.6993, Lng: -49.0939, Count: 5, State: "GO"},
{Name: "Aparecida de Goiânia", Lat: -16.8226, Lng: -49.2451, Count: 1, State: "GO"},
}
// Generate pharmacies dynamically
type PharmacyData struct {
Name string Name string
CNPJ string CNPJ string
Lat float64 Lat float64
Lng float64 Lng float64
Suffix string // for usernames/emails e.g. "1" -> dono1, email1 City string
}{ State string
{ Suffix string
Name: "Farmácia Central",
CNPJ: "11111111000111",
Lat: AnapolisLat,
Lng: AnapolisLng,
Suffix: "1",
},
{
Name: "Farmácia Jundiaí",
CNPJ: "22222222000122",
Lat: AnapolisLat + 0.015, // Slightly North
Lng: AnapolisLng + 0.010, // East
Suffix: "2",
},
{
Name: "Farmácia Jaiara",
CNPJ: "33333333000133",
Lat: AnapolisLat + 0.030, // More North
Lng: AnapolisLng - 0.010, // West
Suffix: "3",
},
{
Name: "Farmácia Universitária",
CNPJ: "44444444000144",
Lat: AnapolisLat - 0.020, // South
Lng: AnapolisLng + 0.020, // East
Suffix: "4",
},
} }
now := time.Now().UTC() var pharmacies []PharmacyData
pharmacyIndex := 1
rng := rand.New(rand.NewSource(time.Now().UnixNano())) rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for _, city := range cities {
for i := 0; i < city.Count; i++ {
// Vary lat/lng within ~3km of city center
latOffset := (rng.Float64() - 0.5) * 0.05
lngOffset := (rng.Float64() - 0.5) * 0.05
pharmacies = append(pharmacies, PharmacyData{
Name: fmt.Sprintf("Farmácia %s %d", city.Name, i+1),
CNPJ: fmt.Sprintf("%02d%03d%03d%04d%02d", rng.Intn(99), rng.Intn(999), rng.Intn(999), rng.Intn(9999)+1, rng.Intn(99)),
Lat: city.Lat + latOffset,
Lng: city.Lng + lngOffset,
City: city.Name,
State: city.State,
Suffix: fmt.Sprintf("%d", pharmacyIndex),
})
pharmacyIndex++
}
}
log.Printf("🏥 [Lean] Generating %d pharmacies across %d cities", len(pharmacies), len(cities))
now := time.Now().UTC()
createdUsers := []string{} createdUsers := []string{}
var allProducts []map[string]interface{} var allProducts []map[string]interface{}
var pharmacyCompanyIDs []uuid.UUID var pharmacyCompanyIDs []uuid.UUID
@ -288,7 +302,7 @@ func SeedLean(dsn string) (string, error) {
_, err = db.ExecContext(ctx, ` _, err = db.ExecContext(ctx, `
INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, phone, operating_hours, is_24_hours, created_at, updated_at) INSERT INTO companies (id, cnpj, corporate_name, category, license_number, is_verified, latitude, longitude, city, state, phone, operating_hours, is_24_hours, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
companyID, ph.CNPJ, ph.Name, "farmacia", fmt.Sprintf("CRF-GO-%s", ph.Suffix), true, ph.Lat, ph.Lng, "Anápolis", "GO", "(62) 99999-00"+ph.Suffix, "Seg-Sex: 08:00-18:00, Sáb: 08:00-12:00", false, now, now, companyID, ph.CNPJ, ph.Name, "farmacia", fmt.Sprintf("CRF-GO-%s", ph.Suffix), true, ph.Lat, ph.Lng, ph.City, ph.State, "(62) 99999-00"+ph.Suffix[:min(len(ph.Suffix), 2)], "Seg-Sex: 08:00-18:00, Sáb: 08:00-12:00", false, now, now,
) )
if err != nil { if err != nil {
return "", fmt.Errorf("create library %s: %v", ph.Name, err) return "", fmt.Errorf("create library %s: %v", ph.Name, err)
@ -330,7 +344,7 @@ func SeedLean(dsn string) (string, error) {
} }
// 3. Create Products (20-50 products per pharmacy) // 3. Create Products (20-50 products per pharmacy)
numProds := 20 + rng.Intn(31) // 20-50 numProds := 3 + rng.Intn(298) // 3-300 products per pharmacy
prods := generateProducts(rng, companyID, numProds) prods := generateProducts(rng, companyID, numProds)
for _, p := range prods { for _, p := range prods {
_, err := db.NamedExecContext(ctx, ` _, err := db.NamedExecContext(ctx, `