Phase 03 — Product Delivery
Architecture, ADRs, API, database, business rules, security and QA.
Architecture — Provider Pattern
ECP Pay uses the Provider Pattern to abstract payment gateways. Ecosystem apps call a single API — the factory selects the active adapter (Internal or Asaas) via feature flag, without the apps knowing which is active.
graph TD
A["ecp-bank :3333"] -->|X-API-Key| B["ECP Pay API :3335"]
C["ecp-emps :3334"] -->|X-API-Key| B
D["ecp-food :3000"] -->|X-API-Key| B
B --> E["Payment Service"]
E --> F["Provider Factory"]
F -->|"feature flag"| G["Internal Adapter"]
F -->|"feature flag"| H["Asaas Adapter"]
G --> I[("SQLite")]
H --> J["Asaas API"]
B --> K["Admin Panel :5176"]
Bounded Contexts
Architecture Decision Records (ADRs)
Decision: PaymentProvider interface with two adapters (InternalAdapter, AsaasAdapter). Factory selects via feature flag PAYMENT_PROVIDER, switchable at runtime via admin panel.
Consequences: Adding a new gateway = implementing one adapter. Zero changes in consumer apps.
Decision: better-sqlite3 with WAL mode. Single file database-pay.sqlite. Migrations run at startup.
Consequences: No external DB server. One npm install and the service runs. Trade-off: not suitable for high-scale horizontal deployment.
Decision: Apps authenticate via X-API-Key. Admin via JWT issued at login. Separate middlewares.
Consequences: Clean separation of concerns. Apps never need user credentials.
Decision: Mandatory X-Idempotency-Key header on all mutation endpoints. Stored as UNIQUE in transactions table. Duplicate returns existing result.
Consequences: Safe retries for all apps. UNIQUE constraint prevents double-charging at database level.
Decision: Only token + last4 + brand + holder_name in card_tokens table. Raw data passed to provider and immediately discarded.
Consequences: Secure by design. Reusable token cross-app. Trade-off: cannot display full card number.
Decision: event_id as UNIQUE in webhook_events table. Check before processing. If duplicate, return 200 without reprocessing.
Consequences: Idempotent webhook handling. No duplicate status changes or callbacks.
Sequence Diagrams
Pix Payment — Full Flow (Internal Provider)
sequenceDiagram
participant App as App (ecp-food)
participant Pay as ECP Pay API :3335
participant Svc as Payment Service
participant Prov as Internal Adapter
participant DB as SQLite
App->>Pay: POST /pay/pix
Note over Pay: Headers: X-API-Key, X-Idempotency-Key
Pay->>Pay: Validate API Key (app_registrations)
Pay->>DB: SELECT WHERE idempotency_key = ?
DB-->>Pay: null (new)
Pay->>Svc: createPixPayment(data)
Svc->>Prov: ProviderFactory.get("internal")
Prov->>Prov: Generate QR Code + copy-paste
Note over Svc,DB: Atomic transaction
Svc->>DB: INSERT transactions (status: pending)
Svc->>DB: INSERT splits (if defined)
DB-->>Svc: transactionId
Prov->>Prov: Configurable delay (3-5s)
Prov->>DB: UPDATE transactions SET status = paid
Svc->>App: POST callback_url (status: paid)
Note over App: Webhook delivery with retry
Pay-->>App: { id, status: pending, qrCode, copyPaste }
Card Payment — Asaas Provider (External)
sequenceDiagram
participant App as App (ecp-bank)
participant Pay as ECP Pay API
participant Svc as Payment Service
participant Prov as Asaas Adapter
participant Asaas as Asaas API
participant DB as SQLite
App->>Pay: POST /pay/card
Note over Pay: { amount, card: {number, cvv, ...}, customer }
Pay->>Pay: Validate API Key + Idempotency
Pay->>Svc: createCardPayment(data)
Svc->>Prov: ProviderFactory.get("external")
Prov->>DB: SELECT card_tokens WHERE customer_document
alt New card
Prov->>Asaas: POST /payments (card data)
Asaas-->>Prov: { id, status, token }
Prov->>DB: INSERT card_tokens (token, last4, brand)
else Tokenized card
Prov->>Asaas: POST /payments (token)
Asaas-->>Prov: { id, status }
end
Svc->>DB: INSERT transactions (status: pending)
Note over Asaas,Pay: Async webhook
Asaas->>Pay: POST /pay/webhooks/asaas
Pay->>DB: INSERT webhook_events (dedup event_id)
Pay->>DB: UPDATE transactions SET status = paid
Pay->>App: POST callback_url (status: paid)
Provider Switch — Internal ↔ External
sequenceDiagram
participant Admin as Admin (Browser)
participant Panel as Admin Panel :5176
participant API as Admin API
participant DB as SQLite
Admin->>Panel: Opens Providers page
Panel->>API: GET /admin/providers
API->>DB: SELECT feature_flags WHERE key = PAYMENT_PROVIDER
DB-->>API: { value: "internal" }
API-->>Panel: { active: "internal", available: ["internal", "external"] }
Panel-->>Admin: Two-card with toggle (Internal active)
Admin->>Panel: Clicks "Activate External"
Panel-->>Admin: Confirmation modal
Admin->>Panel: Confirms switch
Panel->>API: POST /admin/providers/switch
Note over API: Middleware: requireRole("admin")
API->>DB: UPDATE feature_flags SET value = "external"
API->>DB: INSERT audit_logs (action: provider_switch)
API-->>Panel: { active: "external", switchedAt }
Panel-->>Admin: Updated banner + confirmation toast
Note over Admin: All new transactions use Asaas
Webhook Delivery — Exponential Backoff Retry
sequenceDiagram
participant Pay as ECP Pay
participant DB as SQLite
participant App as App callback_url
Note over Pay,App: Transaction status changed
Pay->>DB: SELECT callback_url FROM transactions
Pay->>App: POST callback_url (attempt 1)
alt Success (2xx)
App-->>Pay: 200 OK
Pay->>DB: UPDATE: delivery_status = delivered
else Failure (timeout/5xx)
App-->>Pay: 500 / timeout
Note over Pay: Retry 1: wait 30s
Pay->>App: POST callback_url (attempt 2)
App-->>Pay: 500 / timeout
Note over Pay: Retry 2: wait 2min
Pay->>App: POST callback_url (attempt 3)
App-->>Pay: 500 / timeout
Note over Pay: Retry 3: wait 10min
Pay->>App: POST callback_url (attempt 4)
alt Success
App-->>Pay: 200 OK
Pay->>DB: UPDATE: delivery_status = delivered
else Final failure
Pay->>DB: UPDATE: delivery_status = failed
Note over Pay: Admin can perform manual retry
end
end
API Endpoints
Payment API (app-facing) — /pay
Authentication via X-API-Key header. 9 endpoints.
Admin API (web panel) — /admin
Authentication via JWT Bearer token. 20 endpoints.
Database Schema
10 tables + 16 indexes. SQLite with WAL mode via better-sqlite3. Migration: 001-initial.sql.
| Table | Description | Key Columns |
|---|---|---|
| transactions | Central registry of all transactions (Pix, Card, Boleto) | id, type, status, amount, source_app, idempotency_key, provider, callback_url |
| refunds | Full or partial refunds linked to transactions | id, transaction_id, amount, status, reason |
| card_tokens | Token vault (never raw data). Cross-app via customer_document | id, customer_document, token, last4, brand, holder_name, is_active |
| splits | Split rules per transaction (platform, seller, delivery) | id, transaction_id, account_id, amount, type |
| webhook_events | Webhooks received from provider (Asaas). Dedup via UNIQUE event_id | id, event_id, event_type, payload, processed, transaction_id |
| app_registrations | Registered ecosystem apps with API key and callback URL | id, app_name, api_key, callback_base_url, is_active |
| admin_users | Admin panel users with roles (admin, operator, viewer) | id, name, email, password_hash, role |
| feature_flags | Runtime-editable feature flags (PAYMENT_PROVIDER, etc.) | id, key, value, description |
| audit_logs | Audit log of all administrative actions | id, user_id, action, resource, metadata, ip_address, timestamp |
| config | General system configuration (simulation delay, etc.) | id, key, value, description |
Entity-Relationship Diagram
erDiagram
transactions {
TEXT id PK
TEXT type "pix|card|boleto"
TEXT status "pending|paid|failed|refunded|cancelled"
INTEGER amount_cents
TEXT source_app FK
TEXT idempotency_key UK
TEXT provider "internal|asaas"
TEXT external_id
TEXT callback_url
TEXT delivery_status
TEXT customer_name
TEXT customer_document
TEXT metadata
TEXT created_at
}
refunds {
TEXT id PK
TEXT transaction_id FK
INTEGER amount_cents
TEXT status
TEXT reason
TEXT created_at
}
card_tokens {
TEXT id PK
TEXT customer_document
TEXT token UK
TEXT last4
TEXT brand
TEXT holder_name
INTEGER is_active
}
splits {
TEXT id PK
TEXT transaction_id FK
TEXT account_id
INTEGER amount_cents
TEXT type "platform|seller|delivery"
}
webhook_events {
TEXT id PK
TEXT event_id UK
TEXT event_type
TEXT payload
INTEGER processed
TEXT transaction_id FK
TEXT created_at
}
app_registrations {
TEXT id PK
TEXT app_name UK
TEXT api_key UK
TEXT callback_base_url
INTEGER is_active
}
admin_users {
TEXT id PK
TEXT name
TEXT email UK
TEXT password_hash
TEXT role "admin|operator|viewer"
}
feature_flags {
TEXT id PK
TEXT key UK
TEXT value
TEXT description
}
audit_logs {
TEXT id PK
TEXT user_id FK
TEXT action
TEXT resource
TEXT metadata
TEXT ip_address
TEXT created_at
}
config {
TEXT id PK
TEXT key UK
TEXT value
TEXT description
}
transactions ||--o{ refunds : "has"
transactions ||--o{ splits : "has"
transactions ||--o{ webhook_events : "triggers"
app_registrations ||--o{ transactions : "creates"
admin_users ||--o{ audit_logs : "performs"
Business Rules
Security Model
- X-API-Key header validated against
app_registrations - X-Source-App header validated against registered app
- Rate limiting: 100 tx/min per app
- Inactive app returns 403
- Login via email/password with bcrypt
- JWT with 24h expiration
- 3 roles: admin, operator, viewer
- requireRole() middleware for RBAC
- Card vault: only token + last4 + brand
- Full number and CVV never persisted
- Soft delete on all records
- Audit log for traceability
- Idempotency key UNIQUE in database
- Webhook dedup via UNIQUE event_id
- Values always in cents (integer)
- UUID v4 for all primary keys
QA Report
ECP Pay v1 is solid and production-ready in internal mode. The core payment flow (Pix, Card, Boleto), provider pattern, security model, database schema and admin panel are well built.
Identified gaps: 4 missing admin endpoints (splits, tokens, webhooks admin, webhook retry), 3 missing frontend pages (splits, card-vault, audit-log as standalone routes), and minor business rule gaps. None block v1 launch in internal mode.
Implemented (14): BR-01 to BR-08, BR-10 to BR-16
Partial (3): BR-09 (split without flow integration), BR-17 (refund without 90-day limit), BR-18 (incomplete callback URL fallback)
Missing (1): 90-day refund limit (BR-17)
Payment endpoints: 9/9 implemented (100%)
Admin endpoints: 17/20 implemented (85%)
Total: 26/29 implemented (90%)
Missing: GET /admin/splits, GET /admin/webhooks, POST /admin/webhooks/:id/retry