diff --git a/.github/buf.gen.yaml b/.github/buf.gen.yaml deleted file mode 100644 index 65fbc42..0000000 --- a/.github/buf.gen.yaml +++ /dev/null @@ -1,2 +0,0 @@ -version: v2 -# plugins will be added in Task 2.3 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f39c19b..14942f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,26 +1,62 @@ name: CI on: - workflow_dispatch: # manual trigger only — re-add push/pull_request when team grows + pull_request: + branches: [master, develop] + push: + branches: [master, develop] jobs: + generate: + name: Generate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: '1.26' + cache: true + - name: Install protoc plugins + run: | + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway@latest + go install github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest + - name: Install buf + run: | + curl -sSL "https://github.com/bufbuild/buf/releases/latest/download/buf-Linux-x86_64" \ + -o /usr/local/bin/buf + chmod +x /usr/local/bin/buf + - run: buf generate + - uses: actions/upload-artifact@v4 + with: + name: generated-proto + path: api/proto/ledger/v1/ + retention-days: 1 + lint: name: Lint runs-on: ubuntu-latest + needs: generate steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: generated-proto + path: api/proto/ledger/v1 - uses: actions/setup-go@v5 with: go-version: '1.26' cache: true - - uses: golangci/golangci-lint-action@v8 + - uses: golangci/golangci-lint-action@v7 with: - version: v1.59.1 + version: v2.1.6 + install-mode: goinstall test: name: Test runs-on: ubuntu-latest - needs: lint + needs: generate services: postgres: image: postgres:16-alpine @@ -37,21 +73,36 @@ jobs: --health-retries 5 steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: generated-proto + path: api/proto/ledger/v1 - uses: actions/setup-go@v5 with: go-version: '1.26' cache: true - run: go mod download - - run: go test -race -count=1 -timeout 120s ./... + - name: Install migrate + run: | + curl -sSL https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz | tar xz + sudo mv migrate /usr/local/bin/migrate + - name: Run migrations + run: migrate -path migrations -database "postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable" up + - name: Run tests + run: go test -race -count=1 -timeout 120s ./... env: - DB_DSN: postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable + DB_DSN_TEST: postgres://postgres:postgres@localhost:5432/ledger_test?sslmode=disable build: name: Build runs-on: ubuntu-latest - needs: test + needs: [lint, test] steps: - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: generated-proto + path: api/proto/ledger/v1 - uses: actions/setup-go@v5 with: go-version: '1.26' diff --git a/.github/workflows/proto.yml b/.github/workflows/proto.yml index 7c5d038..72a9554 100644 --- a/.github/workflows/proto.yml +++ b/.github/workflows/proto.yml @@ -1,17 +1,30 @@ name: Proto on: - workflow_dispatch: # manual trigger only + pull_request: + paths: + - 'api/proto/**' + - 'buf.yaml' + - 'buf.gen.yaml' + push: + branches: [master, develop] + paths: + - 'api/proto/**' + - 'buf.yaml' + - 'buf.gen.yaml' + workflow_dispatch: jobs: lint: name: Buf lint runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - uses: actions/checkout@v4 - uses: bufbuild/buf-action@v1 with: - version: '1.34.0' lint: true - format: true - breaking: false + format: false + breaking: ${{ github.event_name == 'pull_request' }} diff --git a/.gitignore b/.gitignore index db524fd..e49c5dd 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ configs/config.yaml # Editor/IDE .idea/ .vscode/ +graphify-out/ \ No newline at end of file diff --git a/.golangci-lint.yaml b/.golangci.yaml similarity index 100% rename from .golangci-lint.yaml rename to .golangci.yaml diff --git a/Makefile b/Makefile index 062edae..948cee5 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,6 @@ export migrate migrate-down migrate-create \ gen help -# Development - ## lint: run golangci-lint lint: golangci-lint run ./... @@ -26,9 +24,7 @@ build: ## run: run the service locally run: - go run ./cmd/ledger - -# Infrastructure + -go run ./cmd/ledger ## up: start containers up: @@ -52,7 +48,6 @@ logs: db: docker exec -it ledger-postgres psql -U postgres -d ledger -# Migrations ## migrate: run all pending migrations (up) migrate: @@ -72,16 +67,12 @@ migrate-test: migrate-down-test: migrate -path migrations -database "$(DB_DSN_TEST)" down 1 -# Code Generation ## gen: generate Go code and Swagger from .proto files gen: buf generate cp api/openapi/ledger.swagger.json internal/transport/http/swagger.json -# ============================================================ -# Help -# ============================================================ ## help: print this help message help: diff --git a/README.md b/README.md index c302ab4..3af2211 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ graph TD B --> G[ledger.Service] G -->|Store interface| H[postgres.Store] - G -->|Auditor interface| I[audit.WebhookAuditor] + G -->|OutboxStore interface| I[audit.Worker] H -->|SELECT FOR UPDATE| J[(PostgreSQL 16)] - I -->|POST /ingest| K[go-ingestor] + I -->|polls outbox, POST /ingest| K[go-ingestor] ``` ### Why hexagonal architecture @@ -29,8 +29,8 @@ go-ledger has three input transports: gRPC, HTTP Gateway, and a future event con ``` cmd/ledger (composition root) ↓ -internal/ledger (domain: types + Store port + Auditor port + Service) - ↓ implements Store ↓ implements Auditor +internal/ledger (domain: types + Store port + OutboxStore port + Service) + ↓ implements Store ↓ implements OutboxStore internal/store/postgres internal/audit ↓ PostgreSQL @@ -86,6 +86,14 @@ Server → sees ON CONFLICT DO NOTHING, returns original transaction Money → moved exactly once ``` +## API Documentation + +Swagger UI is available at `http://localhost:8080/swagger/` when the service is running. The raw OpenAPI spec is at `http://localhost:8080/swagger.json`. + +## i18n + +Error messages are translated based on the client's `Accept-Language` header. Supported languages: **English** (default) and **Farsi**. Every error response includes a `messageCode` field (e.g. `INSUFFICIENT_FUNDS`) that clients can use to render their own localized strings independently of the server-side translations. + ## Tech Stack | Layer | Technology | @@ -99,31 +107,32 @@ Money → moved exactly once | Logging | `log/slog` (JSON) | | Metrics | Prometheus | | Config | koanf | +| i18n | Built-in (English + Farsi) | ## Quick Start **Prerequisites:** Go 1.26+, Docker, `buf`, `golang-migrate`, `grpcurl` ```bash -# Clone and set up environment +# Clone and configure git clone https://github.com/mohammad-farrokhnia/go-ledger.git cd go-ledger -cp .env.example .env cp configs/config.example.yaml configs/config.yaml +# Edit configs/config.yaml — at minimum set database.dsn # Start Postgres make up -# Run migrations +# Run migrations (requires DB_DSN env var or set in .env) make migrate # Start the service make run ``` -The service starts three ports: +The service starts two ports: - `:9090` — gRPC -- `:8080` — HTTP gateway + Swagger UI (`/swagger/`) + health (`/healthz`) + metrics (`/metrics`) +- `:8080` — HTTP gateway + Swagger UI (`/swagger/`) + health (`/health`) + metrics (`/metrics`) ## API Examples @@ -179,14 +188,22 @@ grpcurl -plaintext -d '{ ### Health check ```bash -curl http://localhost:8080/health -# {"status":"ok"} +curl http://localhost:8080/health | jq . +# { +# "data": {"status": "ok", "version": "1.0.0", "app": "go-ledger", "checks": {"postgres": "ok"}}, +# "meta": {"appName": "go-ledger", "version": "1.0.0", "timestamp": "...", "messageCode": "HEALTH_OK", "message": "healthy"} +# } ``` ### Prometheus metrics ```bash curl http://localhost:8080/metrics | grep ledger +# ledger_transactions_total{status="success",error_type=""} +# ledger_transactions_total{status="fail",error_type="insufficient_funds"} +# ledger_transaction_duration_seconds_* +# ledger_db_errors_total +# ledger_grpc_active_connections ``` ## Developer Commands @@ -207,18 +224,23 @@ make help # list all targets ## Configuration -Copy `.env.example` → `.env`. Never commit `.env`. +The service loads `configs/config.yaml` first, then overlays any matching environment variables. Create your config by copying the template: + +```bash +cp configs/config.example.yaml configs/config.yaml +``` + +The Makefile (`make migrate`, `make db`) reads a `.env` file for convenience — create one with `DB_DSN=...` if needed. Never commit `.env`. | Variable | Default | Description | |---|---|---| | `DB_DSN` | required | Postgres connection string | | `GRPC_PORT` | `9090` | gRPC server port | -| `HTTP_PORT` | `8080` | HTTP gateway port | -| `METRICS_PORT` | `9091` | Prometheus metrics port | +| `HTTP_PORT` | `8080` | HTTP gateway port (also serves `/metrics`, `/health`, `/swagger/`) | | `AUDIT_MODE` | `async` | `sync` blocks, `async` fire-and-forget | | `AUDIT_HOOK_URL` | — | go-ingestor endpoint, disabled if empty | | `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` | -| `COMPOSE_PROJECT_NAME` | `docker` | the group name of continer in docker | +| `COMPOSE_PROJECT_NAME` | `docker` | Docker Compose project name | ## Running Tests @@ -251,10 +273,12 @@ docker compose -f deployments/docker/docker-compose.yml up | `account not found` | `NOT_FOUND` | Account ID does not exist | | `transaction not found` | `NOT_FOUND` | Transaction ID does not exist | | `insufficient funds` | `FAILED_PRECONDITION` | Sender balance too low | -| `currency mismatch` | `INVALID_ARGUMENT` | Accounts have different currencies | +| `currency mismatch between accounts` | `INVALID_ARGUMENT` | Accounts have different currencies | | `transaction with this idempotency key already exists` | `ALREADY_EXISTS` | Duplicate — fetch original | | `amount must be greater than zero` | `INVALID_ARGUMENT` | Invalid amount | | `source and destination accounts must be different` | `INVALID_ARGUMENT` | Same account | +| `invalid account type` | `INVALID_ARGUMENT` | Account type is not USER or SYSTEM | +| `currency code must be a 3-letter ISO 4217 code` | `INVALID_ARGUMENT` | Malformed currency code | | `an internal error occurred` | `INTERNAL` | Server error (details logged server-side) | ## Project Structure @@ -264,13 +288,18 @@ api/proto/ledger/v1/ proto definition + generated Go code cmd/ledger/ entrypoint — composition root only configs/ config.yaml template deployments/docker/ Dockerfile + docker-compose +docs/ API documentation internal/ - audit/ Auditor implementations (NoOp, Webhook) + audit/ outbox worker — polls DB, POSTs events to webhook config/ koanf config loader + i18n/ error message translation (English + Farsi) ledger/ domain: types, errors, Store/Auditor ports, Service metrics/ Prometheus metric definitions store/postgres/ Postgres implementation of ledger.Store transport/grpc/ gRPC server, handler, interceptors, mappers - transport/http/ gRPC-Gateway, health check, Swagger UI + transport/http/ gRPC-Gateway, health check, Swagger UI, error handler migrations/ SQL migration files (up + down) +pkg/ + apperr/ structured application error type + response/ HTTP response envelope helpers ``` diff --git a/api/proto/ledger/v1/ledger.proto b/api/proto/ledger/v1/ledger.proto index 589fd35..41f240d 100644 --- a/api/proto/ledger/v1/ledger.proto +++ b/api/proto/ledger/v1/ledger.proto @@ -2,12 +2,11 @@ syntax = "proto3"; package ledger.v1; -option go_package = "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1;ledgerv1"; - import "google/api/annotations.proto"; import "google/protobuf/timestamp.proto"; import "protoc-gen-openapiv2/options/annotations.proto"; +option go_package = "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1;ledgerv1"; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { info: { title: "go-ledger API" @@ -116,9 +115,7 @@ service LedgerService { } rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse) { - option (google.api.http) = { - get: "/v1/wallets/{wallet_id}/balance" - }; + option (google.api.http) = {get: "/v1/wallets/{wallet_id}/balance"}; } rpc CreateTransaction(CreateTransactionRequest) returns (CreateTransactionResponse) { @@ -129,8 +126,6 @@ service LedgerService { } rpc GetWalletHistory(GetWalletHistoryRequest) returns (GetWalletHistoryResponse) { - option (google.api.http) = { - get: "/v1/wallets/{wallet_id}/history" - }; + option (google.api.http) = {get: "/v1/wallets/{wallet_id}/history"}; } } diff --git a/buf.gen.yaml b/buf.gen.yaml index 9c6c517..35b12cf 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -17,8 +17,8 @@ plugins: - paths=source_relative - local: protoc-gen-openapiv2 - out: api/openapi + out: internal/transport/http opt: - output_format=json - allow_merge=true - - merge_file_name=ledger \ No newline at end of file + - merge_file_name=swagger diff --git a/cmd/ledger/main.go b/cmd/ledger/main.go index 9473e3f..82eaa19 100644 --- a/cmd/ledger/main.go +++ b/cmd/ledger/main.go @@ -8,6 +8,7 @@ import ( "os" "os/signal" "syscall" + "time" "golang.org/x/sync/errgroup" @@ -62,16 +63,7 @@ func run() error { slog.Info("connected to postgres") - var auditor ledger.Auditor - if cfg.Audit.HookURL != "" { - auditor = audit.NewWebhookAuditor(cfg.Audit.HookURL, cfg.Audit.Mode) - slog.Info("audit webhook configured", "url", cfg.Audit.HookURL, "mode", cfg.Audit.Mode) - } else { - auditor = &audit.NoOp{} - slog.Warn("AUDIT_HOOK_URL not set — audit logging disabled") - } - - svc := ledger.NewService(pgStore, auditor) + svc := ledger.NewService(pgStore) grpcServer := transportgrpc.NewServer(svc) gateway, err := transporthttp.NewGateway( @@ -90,6 +82,17 @@ func run() error { g, gCtx := errgroup.WithContext(ctx) + if cfg.Audit.HookURL != "" { + worker := audit.NewWorker(pgStore, cfg.Audit.HookURL, 5*time.Second) + g.Go(func() error { + worker.Run(gCtx) + return nil + }) + slog.Info("audit outbox worker configured", "hook_url", cfg.Audit.HookURL) + } else { + slog.Warn("AUDIT_HOOK_URL not set — audit outbox worker disabled") + } + g.Go(func() error { slog.Info("gRPC server starting", "port", cfg.Server.GRPCPort) return grpcServer.Start(cfg.Server.GRPCPort) diff --git a/go.mod b/go.mod index 79b6b5e..9033e57 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/knadh/koanf/providers/file v1.2.1 github.com/knadh/koanf/v2 v2.3.5 github.com/prometheus/client_golang v1.23.2 + github.com/swaggo/http-swagger/v2 v2.0.2 golang.org/x/sync v0.20.0 google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa google.golang.org/grpc v1.81.1 @@ -17,24 +18,35 @@ require ( ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.6 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + github.com/swaggo/swag v1.8.1 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.51.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.36.0 // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 547b0d8..38b7656 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,12 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/agiledragon/gomonkey/v2 v2.3.1 h1:k+UnUY0EMNYUFUAQVETGY9uUTxjMdnUkP0ARyJS1zzs= +github.com/agiledragon/gomonkey/v2 v2.3.1/go.mod h1:ap1AmDzcVOAz1YpeJ3TCzIgstoaWLA6jbbgxfB4w2iY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -11,6 +16,16 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ= +github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -29,6 +44,8 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -41,18 +58,28 @@ github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= github.com/knadh/koanf/v2 v2.3.5 h1:2dXJUYaKGm4SGYeoAtBviq9+02JZo/pxQ2ssOd60rJg= github.com/knadh/koanf/v2 v2.3.5/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/otiai10/copy v1.7.0 h1:hVoPiN+t+7d2nzzwMiDHPSOogsWAStewq3TwU05+clE= +github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -67,9 +94,16 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/http-swagger/v2 v2.0.2 h1:FKCdLsl+sFCx60KFsyM0rDarwiUSZ8DqbfSyIKC9OBg= +github.com/swaggo/http-swagger/v2 v2.0.2/go.mod h1:r7/GBkAWIfK6E/OLnE8fXnviHiDeAHmgIyooa4xm3AQ= +github.com/swaggo/swag v1.8.1 h1:JuARzFX1Z1njbCGz+ZytBR15TFJwF2Q7fu8puJHhQYI= +github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= @@ -88,14 +122,18 @@ go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= @@ -107,8 +145,14 @@ google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zN google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/audit/noop.go b/internal/audit/noop.go deleted file mode 100644 index b3a7100..0000000 --- a/internal/audit/noop.go +++ /dev/null @@ -1,15 +0,0 @@ -package audit - -import ( - "context" - - "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" -) - -type NoOp struct{} - -func (n *NoOp) Log(_ context.Context, _ ledger.AuditEntry) error { - return nil -} - -var _ ledger.Auditor = (*NoOp)(nil) diff --git a/internal/audit/webhook.go b/internal/audit/webhook.go deleted file mode 100644 index dac4952..0000000 --- a/internal/audit/webhook.go +++ /dev/null @@ -1,88 +0,0 @@ -package audit - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "log/slog" - "net/http" - "time" - - "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" -) - -var ( - WebhookAuditorSyncMode = "sync" - WebhookAuditorAsyncMode = "async" -) - -type WebhookAuditor struct { - hookURL string - mode string - client *http.Client -} - -func NewWebhookAuditor(hookURL, mode string) *WebhookAuditor { - return &WebhookAuditor{ - hookURL: hookURL, - mode: mode, - client: &http.Client{ - Timeout: 5 * time.Second, - }, - } -} - -func (w *WebhookAuditor) Log(ctx context.Context, entry ledger.AuditEntry) error { - if w.mode == "async" { - go func() { - if err := w.post(context.Background(), entry); err != nil { - slog.Error("audit webhook failed", - "action", entry.ActionType, - "error", err, - ) - } - }() - return nil - } - - return w.post(ctx, entry) -} - -func (w *WebhookAuditor) post(ctx context.Context, entry ledger.AuditEntry) error { - body, err := json.Marshal(entry) - if err != nil { - return fmt.Errorf("audit: marshal entry: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.hookURL, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("audit: build request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - resp, err := w.client.Do(req) - if err != nil { - return fmt.Errorf("audit: post to hook: %w", err) - } - defer closeResponseBody(resp) - - if resp.StatusCode >= 300 { - return fmt.Errorf("audit: hook returned status %d", resp.StatusCode) - } - - return nil -} - -func closeResponseBody(resp *http.Response) { - if resp == nil || resp.Body == nil { - return - } - err := resp.Body.Close() - if err != nil { - slog.Error("audit: close response body", "error", err) - } -} - -var _ ledger.Auditor = (*WebhookAuditor)(nil) diff --git a/internal/audit/worker.go b/internal/audit/worker.go new file mode 100644 index 0000000..43520c9 --- /dev/null +++ b/internal/audit/worker.go @@ -0,0 +1,115 @@ +package audit + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +type Worker struct { + store ledger.OutboxStore + hookURL string + client *http.Client + interval time.Duration +} + +func NewWorker(store ledger.OutboxStore, hookURL string, interval time.Duration) *Worker { + return &Worker{ + store: store, + hookURL: hookURL, + client: &http.Client{Timeout: 5 * time.Second}, + interval: interval, + } +} + +func (w *Worker) Run(ctx context.Context) { + slog.Info("audit outbox worker started", "interval", w.interval.String(), "hook_url", w.hookURL) + + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("audit outbox worker stopping") + return + case <-ticker.C: + w.flush(ctx) + } + } +} + +func (w *Worker) flush(ctx context.Context) { + entries, err := w.store.PollAuditOutbox(ctx, 100) + if err != nil { + slog.Error("audit outbox: poll failed", "error", err) + return + } + + for _, entry := range entries { + if err = w.deliver(ctx, entry); err != nil { + slog.Warn("audit outbox: delivery failed", + "id", entry.ID, + "action", entry.ActionType, + "retry_count", entry.RetryCount, + "error", err, + ) + if markErr := w.store.MarkAuditEntryFailed(ctx, entry.ID, err.Error()); markErr != nil { + slog.Error("audit outbox: mark failed error", "error", markErr) + } + continue + } + + slog.Debug("audit outbox: delivered", "id", entry.ID, "action", entry.ActionType) + if markErr := w.store.MarkAuditEntrySent(ctx, entry.ID); markErr != nil { + slog.Error("audit outbox: mark sent error", "error", markErr) + } + } +} + +func (w *Worker) deliver(ctx context.Context, entry ledger.AuditOutboxEntry) error { + body, err := json.Marshal(map[string]any{ + "action_type": entry.ActionType, + "payload": entry.Payload, + "created_at": entry.CreatedAt, + }) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.hookURL, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := w.client.Do(req) + if err != nil { + return fmt.Errorf("post: %w", err) + } + + closeResponseBody(resp) + + if resp.StatusCode >= 300 { + return fmt.Errorf("hook returned %d", resp.StatusCode) + } + + return nil +} + +func closeResponseBody(resp *http.Response) { + if resp == nil || resp.Body == nil { + return + } + err := resp.Body.Close() + if err != nil { + slog.Error("audit: close response body", "error", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 5ddac85..e0d4877 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,13 +43,13 @@ func Load(configPath string) (*Config, error) { } envMap := map[string]string{ - "DB_DSN": "database.dsn", - "GRPC_PORT": "server.grpc_port", - "HTTP_PORT": "server.http_port", - "METRICS_PORT": "server.metrics_port", - "AUDIT_MODE": "audit.mode", + "DB_DSN": "database.dsn", + "GRPC_PORT": "server.grpc_port", + "HTTP_PORT": "server.http_port", + "METRICS_PORT": "server.metrics_port", + "AUDIT_MODE": "audit.mode", "AUDIT_HOOK_URL": "audit.hook_url", - "LOG_LEVEL": "log.level", + "LOG_LEVEL": "log.level", } if err := k.Load(env.ProviderWithValue("", ".", func(s, v string) (string, interface{}) { diff --git a/internal/i18n/codes.go b/internal/i18n/codes.go new file mode 100644 index 0000000..124d823 --- /dev/null +++ b/internal/i18n/codes.go @@ -0,0 +1,34 @@ +package i18n + +type MessageCode string + +const ( + // System. + MsgHealthOK MessageCode = "HEALTH_OK" + + // Generic success. + MsgFetched MessageCode = "FETCHED" + MsgCreated MessageCode = "CREATED" + + // Generic errors. + MsgBadRequest MessageCode = "BAD_REQUEST" + MsgNotFound MessageCode = "NOT_FOUND" + MsgValidationFailed MessageCode = "VALIDATION_FAILED" + MsgInternalError MessageCode = "INTERNAL_ERROR" + MsgConflict MessageCode = "CONFLICT" + + // Domain — accounts. + MsgAccountNotFound MessageCode = "ACCOUNT_NOT_FOUND" + MsgAccountCreated MessageCode = "ACCOUNT_CREATED" + + // Domain — transactions. + MsgTransactionCreated MessageCode = "TRANSACTION_CREATED" + MsgTransactionNotFound MessageCode = "TRANSACTION_NOT_FOUND" + MsgInsufficientFunds MessageCode = "INSUFFICIENT_FUNDS" + MsgCurrencyMismatch MessageCode = "CURRENCY_MISMATCH" + MsgDuplicateTransaction MessageCode = "DUPLICATE_TRANSACTION" + MsgInvalidAmount MessageCode = "INVALID_AMOUNT" + MsgSameAccount MessageCode = "SAME_ACCOUNT" + MsgInvalidAccountType MessageCode = "INVALID_ACCOUNT_TYPE" + MsgInvalidCurrencyCode MessageCode = "INVALID_CURRENCY_CODE" +) diff --git a/internal/i18n/en.go b/internal/i18n/en.go new file mode 100644 index 0000000..0c9c066 --- /dev/null +++ b/internal/i18n/en.go @@ -0,0 +1,23 @@ +package i18n + +var enMessages = map[MessageCode]string{ + MsgHealthOK: "Service is up and running.", + MsgFetched: "Fetched successfully.", + MsgCreated: "Created successfully.", + MsgBadRequest: "Bad request.", + MsgNotFound: "Resource not found.", + MsgValidationFailed: "Validation failed.", + MsgInternalError: "Internal server error.", + MsgConflict: "Resource already exists.", + MsgAccountNotFound: "Account not found.", + MsgAccountCreated: "Account created successfully.", + MsgTransactionCreated: "Transaction completed successfully.", + MsgTransactionNotFound: "Transaction not found.", + MsgInsufficientFunds: "Insufficient funds.", + MsgCurrencyMismatch: "Currency mismatch between accounts.", + MsgDuplicateTransaction: "Transaction with this idempotency key already exists.", + MsgInvalidAmount: "Amount must be greater than zero.", + MsgSameAccount: "Source and destination accounts must be different.", + MsgInvalidAccountType: "Invalid account type.", + MsgInvalidCurrencyCode: "Currency code must be a 3-letter ISO 4217 code.", +} diff --git a/internal/i18n/fa.go b/internal/i18n/fa.go new file mode 100644 index 0000000..090d13f --- /dev/null +++ b/internal/i18n/fa.go @@ -0,0 +1,24 @@ +//nolint:staticcheck // U+200C (ZWNJ) is intentional Persian typography +package i18n + +var faMessages = map[MessageCode]string{ + MsgHealthOK: "سرویس در حال اجرا است.", + MsgFetched: "با موفقیت دریافت شد.", + MsgCreated: "با موفقیت ایجاد شد.", + MsgBadRequest: "درخواست نامعتبر.", + MsgNotFound: "منبع یافت نشد.", + MsgValidationFailed: "اعتبارسنجی ناموفق.", + MsgInternalError: "خطای داخلی سرور.", + MsgConflict: "منبع از قبل وجود دارد.", + MsgAccountNotFound: "حساب یافت نشد.", + MsgAccountCreated: "حساب با موفقیت ایجاد شد.", + MsgTransactionCreated: "تراکنش با موفقیت انجام شد.", + MsgTransactionNotFound: "تراکنش یافت نشد.", + MsgInsufficientFunds: "موجودی کافی نیست.", + MsgCurrencyMismatch: "واحد پولی حساب‌ها با هم مطابقت ندارد.", + MsgDuplicateTransaction: "تراکنشی با این کلید idempotency قبلاً وجود دارد.", + MsgInvalidAmount: "مقدار باید بزرگ‌تر از صفر باشد.", + MsgSameAccount: "حساب مبدأ و مقصد باید متفاوت باشند.", + MsgInvalidAccountType: "نوع حساب نامعتبر است.", + MsgInvalidCurrencyCode: "کد ارز باید یک کد سه‌حرفی ISO 4217 باشد.", +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..1ba23a8 --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,48 @@ +package i18n + +import "strings" + +type Lang string + +const ( + LangEN Lang = "en" + LangFA Lang = "fa" +) + +var translations = map[Lang]map[MessageCode]string{ + LangEN: enMessages, + LangFA: faMessages, +} + +func Translate(acceptLang string, code MessageCode) string { + lang := DetectLang(acceptLang) + + if msgs, ok := translations[lang]; ok { + if msg, ok := msgs[code]; ok { + return msg + } + } + + if msg, ok := translations[LangEN][code]; ok { + return msg + } + + return string(code) +} + +func DetectLang(header string) Lang { + if header == "" { + return LangEN + } + + parts := strings.Split(header, ",") + for _, part := range parts { + tag := strings.Split(strings.TrimSpace(part), ";")[0] + primary := Lang(strings.ToLower(strings.Split(tag, "-")[0])) + if _, ok := translations[primary]; ok { + return primary + } + } + + return LangEN +} diff --git a/internal/i18n/messages.go b/internal/i18n/messages.go new file mode 100644 index 0000000..1709b7b --- /dev/null +++ b/internal/i18n/messages.go @@ -0,0 +1,93 @@ +//nolint:staticcheck // U+200C (ZWNJ) is intentional Persian typography +package i18n + +import "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" + +var catalog = map[string]map[Lang]string{ + "account_not_found": { + LangEN: "account not found", + LangFA: "حساب یافت نشد", + }, + "transaction_not_found": { + LangEN: "transaction not found", + LangFA: "تراکنش یافت نشد", + }, + "insufficient_funds": { + LangEN: "insufficient funds", + LangFA: "موجودی کافی نیست", + }, + "currency_mismatch": { + LangEN: "currency mismatch between accounts", + LangFA: "واحد پولی حساب‌ها با هم مطابقت ندارد", + }, + "duplicate_transaction": { + LangEN: "transaction with this idempotency key already exists", + LangFA: "تراکنشی با این کلید idempotency قبلاً وجود دارد", + }, + "invalid_amount": { + LangEN: "amount must be greater than zero", + LangFA: "مقدار باید بزرگتر از صفر باشد", + }, + "same_account": { + LangEN: "source and destination accounts must be different", + LangFA: "حساب مبدا و مقصد باید متفاوت باشند", + }, + "invalid_account_type": { + LangEN: "invalid account type", + LangFA: "نوع حساب نامعتبر است", + }, + "invalid_currency_code": { + LangEN: "currency code must be a 3-letter ISO 4217 code", + LangFA: "کد ارز باید یک کد سه‌حرفی ISO 4217 باشد", + }, + "internal_error": { + LangEN: "an internal error occurred", + LangFA: "خطای داخلی رخ داده است", + }, +} + +var errorKey = map[error]string{ + ledger.ErrAccountNotFound: "account_not_found", + ledger.ErrTransactionNotFound: "transaction_not_found", + ledger.ErrInsufficientFunds: "insufficient_funds", + ledger.ErrCurrencyMismatch: "currency_mismatch", + ledger.ErrDuplicateTransaction: "duplicate_transaction", + ledger.ErrInvalidAmount: "invalid_amount", + ledger.ErrSameAccount: "same_account", + ledger.ErrInvalidAccountType: "invalid_account_type", + ledger.ErrInvalidCurrencyCode: "invalid_currency_code", +} + +func Message(err error, lang Lang) string { + if lang == "" { + lang = LangEN + } + + key, ok := errorKey[err] + if !ok { + key = "internal_error" + } + + msgs, ok := catalog[key] + if !ok { + return err.Error() + } + + if msg, ok := msgs[lang]; ok { + return msg + } + + return msgs[LangEN] +} + +func Parse(acceptLanguage string) Lang { + if len(acceptLanguage) >= 2 { + switch acceptLanguage[:2] { + case "fa", "ir": + return LangFA + case "en": + return LangEN + } + } + return LangEN +} diff --git a/internal/ledger/auditor.go b/internal/ledger/auditor.go deleted file mode 100644 index d3c5b6d..0000000 --- a/internal/ledger/auditor.go +++ /dev/null @@ -1,12 +0,0 @@ -package ledger - -import "context" - -type AuditEntry struct { - ActionType string - Payload map[string]any -} - -type Auditor interface { - Log(ctx context.Context, entry AuditEntry) error -} diff --git a/internal/ledger/errors.go b/internal/ledger/errors.go index 2cc4d67..7b6af25 100644 --- a/internal/ledger/errors.go +++ b/internal/ledger/errors.go @@ -3,15 +3,15 @@ package ledger import "errors" var ( - ErrAccountNotFound = errors.New("account not found") - ErrTransactionNotFound = errors.New("transaction not found") - ErrInsufficientFunds = errors.New("insufficient funds") - ErrCurrencyMismatch = errors.New("currency mismatch between accounts") + ErrAccountNotFound = errors.New("account not found") + ErrTransactionNotFound = errors.New("transaction not found") + ErrInsufficientFunds = errors.New("insufficient funds") + ErrCurrencyMismatch = errors.New("currency mismatch between accounts") ErrDuplicateTransaction = errors.New("transaction with this idempotency key already exists") - ErrInvalidAmount = errors.New("amount must be greater than zero") - ErrSameAccount = errors.New("source and destination accounts must be different") - ErrInvalidAccountType = errors.New("invalid account type") - ErrInvalidCurrencyCode = errors.New("currency code must be a 3-letter ISO 4217 code") + ErrInvalidAmount = errors.New("amount must be greater than zero") + ErrSameAccount = errors.New("source and destination accounts must be different") + ErrInvalidAccountType = errors.New("invalid account type") + ErrInvalidCurrencyCode = errors.New("currency code must be a 3-letter ISO 4217 code") ) type ValidationError struct { diff --git a/internal/ledger/outbox.go b/internal/ledger/outbox.go new file mode 100644 index 0000000..d767afc --- /dev/null +++ b/internal/ledger/outbox.go @@ -0,0 +1,21 @@ +package ledger + +import ( + "context" + "time" +) + +type AuditOutboxEntry struct { + ID string + ActionType string + Payload map[string]any + RetryCount int + MaxRetries int + CreatedAt time.Time +} + +type OutboxStore interface { + PollAuditOutbox(ctx context.Context, limit int) ([]AuditOutboxEntry, error) + MarkAuditEntrySent(ctx context.Context, id string) error + MarkAuditEntryFailed(ctx context.Context, id, errMsg string) error +} diff --git a/internal/ledger/service.go b/internal/ledger/service.go index 0c30c32..aa60f27 100644 --- a/internal/ledger/service.go +++ b/internal/ledger/service.go @@ -2,9 +2,12 @@ package ledger import ( "context" + "regexp" "strings" +) - "log/slog" +var uuidRegex = regexp.MustCompile( + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, ) type ( @@ -23,13 +26,12 @@ type ( CurrencyCode string } Service struct { - store Store - auditor Auditor + store Store } ) -func NewService(s Store, a Auditor) *Service { - return &Service{store: s, auditor: a} +func NewService(s Store) *Service { + return &Service{store: s} } func (s *Service) CreateAccount(ctx context.Context, name string, accountType AccountType, currencyCode string) (Account, error) { @@ -56,6 +58,9 @@ func (s *Service) GetAccount(ctx context.Context, id string) (Account, error) { if id == "" { return Account{}, NewValidationError("account ID cannot be empty") } + if err := validateUUID(id, "account ID"); err != nil { + return Account{}, err + } return s.store.GetAccount(ctx, id) } @@ -64,6 +69,9 @@ func (s *Service) GetBalance(ctx context.Context, accountID string) (int64, erro if accountID == "" { return 0, NewValidationError("account ID cannot be empty") } + if err := validateUUID(accountID, "account ID"); err != nil { + return 0, err + } return s.store.GetBalance(ctx, accountID) } @@ -85,6 +93,13 @@ func (s *Service) CreateTransaction(ctx context.Context, params CreateTransactio return Transaction{}, err } + if err := validateUUID(params.FromAccountID, "from account ID"); err != nil { + return Transaction{}, err + } + if err := validateUUID(params.ToAccountID, "to account ID"); err != nil { + return Transaction{}, err + } + tx, err := s.store.CreateTransaction(ctx, CreateTransactionParams{ IdempotencyKey: params.IdempotencyKey, FromAccountID: params.FromAccountID, @@ -95,7 +110,6 @@ func (s *Service) CreateTransaction(ctx context.Context, params CreateTransactio if err != nil { return Transaction{}, err } - s.logAudit(ctx, tx) return tx, nil } @@ -123,23 +137,6 @@ func (s *Service) GetWalletHistory(ctx context.Context, accountID string, limit, }) } -func (s *Service) logAudit(ctx context.Context, tx Transaction) { - err := s.auditor.Log(ctx, AuditEntry{ - ActionType: "transaction.created", - Payload: map[string]any{ - "transaction_id": tx.ID, - "from_account_id": tx.FromAccountID, - "to_account_id": tx.ToAccountID, - "amount": tx.Amount, - "currency_code": tx.CurrencyCode, - "status": string(tx.Status), - }, - }) - if err != nil { - slog.Error("failed to log audit entry", "error", err) - } -} - func validateCurrencyCode(code string) error { upper := strings.ToUpper(code) if len(upper) != 3 { @@ -164,4 +161,14 @@ func validateAccountType(t AccountType) error { } } +func validateUUID(id, field string) error { + if id == "" { + return NewValidationError(field + " cannot be empty") + } + if !uuidRegex.MatchString(strings.ToLower(id)) { + return NewValidationError(field + " must be a valid UUID") + } + return nil +} + var _ Servicer = (*Service)(nil) diff --git a/internal/ledger/service_test.go b/internal/ledger/service_test.go new file mode 100644 index 0000000..b7c9c3d --- /dev/null +++ b/internal/ledger/service_test.go @@ -0,0 +1,216 @@ +package ledger_test + +import ( + "context" + "errors" + "testing" + + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +type mockStore struct { + accounts map[string]ledger.Account + transactions map[string]ledger.Transaction + createErr error + getErr error +} + +func newMockStore() *mockStore { + return &mockStore{ + accounts: make(map[string]ledger.Account), + transactions: make(map[string]ledger.Transaction), + } +} + +func (m *mockStore) CreateAccount(_ context.Context, p ledger.CreateAccountParams) (ledger.Account, error) { + if m.createErr != nil { + return ledger.Account{}, m.createErr + } + acc := ledger.Account{ + ID: "acc-" + p.Name, + Name: p.Name, + Type: p.Type, + CurrencyCode: p.CurrencyCode, + Balance: 0, + } + m.accounts[acc.ID] = acc + return acc, nil +} + +func (m *mockStore) GetAccount(_ context.Context, id string) (ledger.Account, error) { + if m.getErr != nil { + return ledger.Account{}, m.getErr + } + acc, ok := m.accounts[id] + if !ok { + return ledger.Account{}, ledger.ErrAccountNotFound + } + return acc, nil +} + +func (m *mockStore) GetBalance(_ context.Context, id string) (int64, error) { + acc, ok := m.accounts[id] + if !ok { + return 0, ledger.ErrAccountNotFound + } + return acc.Balance, nil +} + +func (m *mockStore) CreateTransaction(_ context.Context, p ledger.CreateTransactionParams) (ledger.Transaction, error) { + if m.createErr != nil { + return ledger.Transaction{}, m.createErr + } + tx := ledger.Transaction{ + ID: "tx-" + p.IdempotencyKey, + IdempotencyKey: p.IdempotencyKey, + FromAccountID: p.FromAccountID, + ToAccountID: p.ToAccountID, + Amount: p.Amount, + CurrencyCode: p.CurrencyCode, + Status: ledger.TransactionStatusCompleted, + } + m.transactions[p.IdempotencyKey] = tx + return tx, nil +} + +func (m *mockStore) GetTransaction(_ context.Context, id string) (ledger.Transaction, error) { + for _, tx := range m.transactions { + if tx.ID == id { + return tx, nil + } + } + return ledger.Transaction{}, ledger.ErrTransactionNotFound +} + +func (m *mockStore) GetTransactionByIdempotencyKey(_ context.Context, key string) (ledger.Transaction, error) { + tx, ok := m.transactions[key] + if !ok { + return ledger.Transaction{}, ledger.ErrTransactionNotFound + } + return tx, nil +} + +func (m *mockStore) GetWalletHistory(_ context.Context, _ ledger.GetWalletHistoryParams) ([]ledger.Entry, error) { + return nil, nil +} + +func TestService_CreateAccount_EmptyName(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateAccount(context.Background(), "", ledger.AccountTypeUser, "USD") + if err == nil { + t.Fatal("expected error for empty name") + } + + var valErr *ledger.ValidationError + if !errors.As(err, &valErr) { + t.Errorf("expected ValidationError, got %T: %v", err, err) + } +} + +func TestService_CreateAccount_InvalidCurrency(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + cases := []string{"", "US", "USDD", "123", "u$d"} + for _, code := range cases { + _, err := svc.CreateAccount(context.Background(), "test", ledger.AccountTypeUser, code) + if err == nil { + t.Errorf("expected error for currency %q, got nil", code) + } + } +} + +func TestService_CreateAccount_InvalidType(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateAccount(context.Background(), "test", "UNKNOWN_TYPE", "USD") + if !errors.Is(err, ledger.ErrInvalidAccountType) { + t.Errorf("expected ErrInvalidAccountType, got %v", err) + } +} + +func TestService_CreateTransaction_ZeroAmount(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "key-1", + FromAccountID: "a", + ToAccountID: "b", + Amount: 0, + CurrencyCode: "USD", + }) + if !errors.Is(err, ledger.ErrInvalidAmount) { + t.Errorf("expected ErrInvalidAmount, got %v", err) + } +} + +func TestService_CreateTransaction_NegativeAmount(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "key-2", + FromAccountID: "a", + ToAccountID: "b", + Amount: -100, + CurrencyCode: "USD", + }) + if !errors.Is(err, ledger.ErrInvalidAmount) { + t.Errorf("expected ErrInvalidAmount, got %v", err) + } +} + +func TestService_CreateTransaction_SameAccount(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "key-3", + FromAccountID: "same", + ToAccountID: "same", + Amount: 100, + CurrencyCode: "USD", + }) + if !errors.Is(err, ledger.ErrSameAccount) { + t.Errorf("expected ErrSameAccount, got %v", err) + } +} + +func TestService_CreateTransaction_EmptyIdempotencyKey(t *testing.T) { + svc := ledger.NewService(newMockStore()) + + _, err := svc.CreateTransaction(context.Background(), ledger.CreateTransactionInput{ + IdempotencyKey: "", + FromAccountID: "a", + ToAccountID: "b", + Amount: 100, + CurrencyCode: "USD", + }) + + var valErr *ledger.ValidationError + if !errors.As(err, &valErr) { + t.Errorf("expected ValidationError, got %v", err) + } +} + +func TestService_GetWalletHistory_DefaultLimit(t *testing.T) { + store := newMockStore() + svc := ledger.NewService(store) + + store.accounts["test-id"] = ledger.Account{ID: "test-id", CurrencyCode: "USD"} + + _, err := svc.GetWalletHistory(context.Background(), "test-id", 0, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestService_GetWalletHistory_CapLimit(t *testing.T) { + store := newMockStore() + svc := ledger.NewService(store) + + store.accounts["test-id"] = ledger.Account{ID: "test-id"} + + _, err := svc.GetWalletHistory(context.Background(), "test-id", 200, 0) + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 6213ebc..abb92f2 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -36,3 +36,13 @@ var ( }, ) ) + +func init() { + TransactionsTotal.WithLabelValues("success", "").Add(0) + TransactionsTotal.WithLabelValues("fail", "insufficient_funds").Add(0) + TransactionsTotal.WithLabelValues("fail", "currency_mismatch").Add(0) + TransactionsTotal.WithLabelValues("fail", "duplicate").Add(0) + TransactionsTotal.WithLabelValues("fail", "invalid_input").Add(0) + TransactionsTotal.WithLabelValues("fail", "not_found").Add(0) + TransactionsTotal.WithLabelValues("fail", "internal").Add(0) +} diff --git a/internal/store/postgres/outbox.go b/internal/store/postgres/outbox.go new file mode 100644 index 0000000..5aa0640 --- /dev/null +++ b/internal/store/postgres/outbox.go @@ -0,0 +1,76 @@ +package postgres + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +func (s *Store) PollAuditOutbox(ctx context.Context, limit int) ([]ledger.AuditOutboxEntry, error) { + const q = ` + SELECT id, action_type, payload, retry_count, max_retries, created_at + FROM audit_outbox + WHERE status = 'PENDING' + ORDER BY created_at ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + ` + + rows, err := s.pool.Query(ctx, q, limit) + if err != nil { + return nil, fmt.Errorf("postgres: poll audit outbox: %w", err) + } + defer rows.Close() + + var entries []ledger.AuditOutboxEntry + for rows.Next() { + var e ledger.AuditOutboxEntry + var rawPayload []byte + + if err = rows.Scan(&e.ID, &e.ActionType, &rawPayload, &e.RetryCount, &e.MaxRetries, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("postgres: scan audit outbox row: %w", err) + } + + if err = json.Unmarshal(rawPayload, &e.Payload); err != nil { + return nil, fmt.Errorf("postgres: unmarshal audit payload: %w", err) + } + + entries = append(entries, e) + } + + return entries, rows.Err() +} + +func (s *Store) MarkAuditEntrySent(ctx context.Context, id string) error { + const q = ` + UPDATE audit_outbox + SET status = 'SENT', processed_at = NOW() + WHERE id = $1 + ` + _, err := s.pool.Exec(ctx, q, id) + return err +} + +func (s *Store) MarkAuditEntryFailed(ctx context.Context, id, errMsg string) error { + const q = ` + UPDATE audit_outbox + SET + retry_count = retry_count + 1, + last_error = $2, + status = CASE + WHEN retry_count + 1 >= max_retries THEN 'DEAD_LETTERED'::audit_status + ELSE 'PENDING'::audit_status + END, + processed_at = CASE + WHEN retry_count + 1 >= max_retries THEN NOW() + ELSE NULL + END + WHERE id = $1 + ` + _, err := s.pool.Exec(ctx, q, id, errMsg) + return err +} + +var _ ledger.OutboxStore = (*Store)(nil) diff --git a/internal/store/postgres/transaction.go b/internal/store/postgres/transaction.go index c12b96d..d0a0f1f 100644 --- a/internal/store/postgres/transaction.go +++ b/internal/store/postgres/transaction.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -139,6 +140,25 @@ func (s *Store) CreateTransaction(ctx context.Context, params ledger.CreateTrans return ledger.Transaction{}, fmt.Errorf("postgres: add balance: %w", err) } + auditPayload, err := json.Marshal(map[string]any{ + "transaction_id": txID, + "from_account_id": params.FromAccountID, + "to_account_id": params.ToAccountID, + "amount": params.Amount, + "currency_code": params.CurrencyCode, + }) + if err != nil { + return ledger.Transaction{}, fmt.Errorf("postgres: marshal audit payload: %w", err) + } + + const insertOutboxQ = ` + INSERT INTO audit_outbox (action_type, payload) + VALUES ($1, $2) + ` + if _, err = tx.Exec(ctx, insertOutboxQ, "transaction.created", auditPayload); err != nil { + return ledger.Transaction{}, fmt.Errorf("postgres: insert audit outbox: %w", err) + } + const completeTxQ = ` UPDATE transactions SET status = 'COMPLETED' diff --git a/internal/store/postgres/transaction_test.go b/internal/store/postgres/transaction_test.go index b8f8b24..a5d6556 100644 --- a/internal/store/postgres/transaction_test.go +++ b/internal/store/postgres/transaction_test.go @@ -2,6 +2,7 @@ package postgres_test import ( "context" + "crypto/rand" "errors" "fmt" "os" @@ -12,6 +13,16 @@ import ( "github.com/mohammad-farrokhnia/go-ledger/internal/store/postgres" ) +func newKey() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +} + func setupStore(t *testing.T) *postgres.Store { t.Helper() @@ -61,14 +72,13 @@ func seedWallets(t *testing.T, s *postgres.Store, amount int64) (string, string) return sys.ID, usr.ID } - func TestCreateTransaction_Success(t *testing.T) { s := setupStore(t) ctx := context.Background() sysID, userID := seedWallets(t, s, 1000) tx, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ - IdempotencyKey: "a0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: userID, ToAccountID: sysID, Amount: 100, @@ -96,7 +106,7 @@ func TestCreateTransaction_InsufficientFunds(t *testing.T) { sysID, userID := seedWallets(t, s, 50) _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ - IdempotencyKey: "b0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: userID, ToAccountID: sysID, Amount: 100, @@ -118,7 +128,7 @@ func TestCreateTransaction_Idempotency(t *testing.T) { sysID, userID := seedWallets(t, s, 1000) params := ledger.CreateTransactionParams{ - IdempotencyKey: "d0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: userID, ToAccountID: sysID, Amount: 100, @@ -157,7 +167,7 @@ func TestCreateTransaction_CurrencyMismatch(t *testing.T) { }) _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ - IdempotencyKey: "e0000000-0000-0000-0000-000000000001", + IdempotencyKey: newKey(), FromAccountID: usd.ID, ToAccountID: irr.ID, Amount: 100, @@ -189,7 +199,7 @@ func TestCreateTransaction_ConcurrentDeductions(t *testing.T) { go func(i int) { defer wg.Done() - key := fmt.Sprintf("f0000000-0000-0000-0000-%012d", i) + key := newKey() _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ IdempotencyKey: key, @@ -250,7 +260,7 @@ func TestCreateTransaction_ConcurrentContention(t *testing.T) { go func(i int) { defer wg.Done() - key := fmt.Sprintf("a1000000-0000-0000-0000-%012d", i) + key := newKey() _, err := s.CreateTransaction(ctx, ledger.CreateTransactionParams{ IdempotencyKey: key, diff --git a/internal/transport/grpc/errors.go b/internal/transport/grpc/errors.go index 5140c6b..508e4a0 100644 --- a/internal/transport/grpc/errors.go +++ b/internal/transport/grpc/errors.go @@ -1,7 +1,9 @@ package grpc import ( + "context" "errors" + "log/slog" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -46,4 +48,23 @@ func domainErrorToGRPC(err error) error { default: return status.Error(codes.Internal, "an internal error occurred") } -} \ No newline at end of file +} + +func logHandlerError(ctx context.Context, method string, err error) { + var valErr *ledger.ValidationError + switch { + case errors.As(err, &valErr), + errors.Is(err, ledger.ErrAccountNotFound), + errors.Is(err, ledger.ErrTransactionNotFound), + errors.Is(err, ledger.ErrInsufficientFunds), + errors.Is(err, ledger.ErrCurrencyMismatch), + errors.Is(err, ledger.ErrDuplicateTransaction), + errors.Is(err, ledger.ErrInvalidAmount), + errors.Is(err, ledger.ErrSameAccount), + errors.Is(err, ledger.ErrInvalidAccountType), + errors.Is(err, ledger.ErrInvalidCurrencyCode): + slog.WarnContext(ctx, method+" rejected", "reason", err.Error()) + default: + slog.ErrorContext(ctx, method+" failed", "error", err) + } +} diff --git a/internal/transport/grpc/handler.go b/internal/transport/grpc/handler.go index 424d198..1e0c56e 100644 --- a/internal/transport/grpc/handler.go +++ b/internal/transport/grpc/handler.go @@ -31,7 +31,7 @@ func (h *Handler) CreateWallet(ctx context.Context, req *ledgerv1.CreateWalletRe acc, err := h.svc.CreateAccount(ctx, req.Name, protoAccountTypeToDomain(req.Type), req.CurrencyCode) if err != nil { - slog.ErrorContext(ctx, "CreateWallet failed", "error", err) + logHandlerError(ctx, "CreateWallet", err) return nil, domainErrorToGRPC(err) } @@ -149,6 +149,9 @@ func classifyError(err error) string { errors.Is(err, ledger.ErrInvalidCurrencyCode), errors.Is(err, ledger.ErrInvalidAccountType): return "invalid_input" + case errors.Is(err, ledger.ErrAccountNotFound), + errors.Is(err, ledger.ErrTransactionNotFound): + return "not_found" default: return "internal" } diff --git a/internal/transport/grpc/server.go b/internal/transport/grpc/server.go index d9ffdd6..5ee8d56 100644 --- a/internal/transport/grpc/server.go +++ b/internal/transport/grpc/server.go @@ -1,6 +1,7 @@ package grpc import ( + "context" "fmt" "net" @@ -30,7 +31,8 @@ func NewServer(svc ledger.Servicer) *Server { } func (s *Server) Start(port string) error { - lis, err := net.Listen("tcp", fmt.Sprintf(":%s", port)) + lc := net.ListenConfig{} + lis, err := lc.Listen(context.Background(), "tcp", fmt.Sprintf(":%s", port)) if err != nil { return fmt.Errorf("grpc: listen on port %s: %w", port, err) } diff --git a/internal/transport/http/gateway.go b/internal/transport/http/gateway.go index 3053d44..0b30ea7 100644 --- a/internal/transport/http/gateway.go +++ b/internal/transport/http/gateway.go @@ -2,25 +2,35 @@ package http import ( "context" - "encoding/json" _ "embed" "fmt" "net/http" "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" "github.com/prometheus/client_golang/prometheus/promhttp" + httpSwagger "github.com/swaggo/http-swagger/v2" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/encoding/protojson" ledgerv1 "github.com/mohammad-farrokhnia/go-ledger/api/proto/ledger/v1" ) +//go:embed swagger.json var swaggerJSON []byte type PingFunc func(ctx context.Context) error func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handler, error) { - gwMux := runtime.NewServeMux() + gwMux := runtime.NewServeMux( + runtime.WithErrorHandler(CustomErrorHandler), + runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{ + MarshalOptions: protojson.MarshalOptions{ + EmitUnpopulated: true, + UseProtoNames: false, + }, + }), + ) opts := []grpc.DialOption{ grpc.WithTransportCredentials(insecure.NewCredentials()), @@ -31,65 +41,22 @@ func NewGateway(ctx context.Context, grpcAddr string, ping PingFunc) (http.Handl } mux := http.NewServeMux() + mux.Handle("/v1/", gwMux) mux.Handle("/metrics", promhttp.Handler()) mux.HandleFunc("/health", healthHandler(ping)) - mux.HandleFunc("/swagger.json", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - writeResponse(w, swaggerJSON) - }) - mux.HandleFunc("/swagger/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - writeResponse(w, []byte(swaggerUIHTML)) - }) - - return mux, nil -} -func healthHandler(ping PingFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { + mux.HandleFunc("/swagger.json", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - - if err := ping(r.Context()); err != nil { - w.WriteHeader(http.StatusServiceUnavailable) - json.NewEncoder(w).Encode(map[string]string{ //nolint:errcheck - "status": "degraded", - "reason": "database unreachable", - }) - return + w.Header().Set("Access-Control-Allow-Origin", "*") + if _, err := w.Write(swaggerJSON); err != nil { + http.Error(w, "failed to serve swagger spec", http.StatusInternalServerError) } + }) - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) //nolint:errcheck - } -} + mux.Handle("/swagger/", httpSwagger.Handler( + httpSwagger.URL("/swagger.json"), + )) -const swaggerUIHTML = ` - - - go-ledger API - - - - - -
- - - -` -func writeResponse(w http.ResponseWriter, data []byte) { - _, err := w.Write(data) - if err != nil { - panic(err) - } + return mux, nil } - diff --git a/internal/transport/http/response.go b/internal/transport/http/response.go new file mode 100644 index 0000000..59f6658 --- /dev/null +++ b/internal/transport/http/response.go @@ -0,0 +1,137 @@ +package http + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/mohammad-farrokhnia/go-ledger/internal/i18n" + "github.com/mohammad-farrokhnia/go-ledger/internal/ledger" +) + +var grpcToHTTP = map[codes.Code]int{ + codes.OK: http.StatusOK, + codes.NotFound: http.StatusNotFound, + codes.InvalidArgument: http.StatusBadRequest, + codes.FailedPrecondition: http.StatusUnprocessableEntity, + codes.AlreadyExists: http.StatusConflict, + codes.Internal: http.StatusInternalServerError, + codes.Unauthenticated: http.StatusUnauthorized, + codes.PermissionDenied: http.StatusForbidden, + codes.Unavailable: http.StatusServiceUnavailable, +} + +var grpcToMessageCode = map[string]i18n.MessageCode{ + ledger.ErrAccountNotFound.Error(): i18n.MsgAccountNotFound, + ledger.ErrTransactionNotFound.Error(): i18n.MsgTransactionNotFound, + ledger.ErrInsufficientFunds.Error(): i18n.MsgInsufficientFunds, + ledger.ErrCurrencyMismatch.Error(): i18n.MsgCurrencyMismatch, + ledger.ErrDuplicateTransaction.Error(): i18n.MsgDuplicateTransaction, + ledger.ErrInvalidAmount.Error(): i18n.MsgInvalidAmount, + ledger.ErrSameAccount.Error(): i18n.MsgSameAccount, + ledger.ErrInvalidAccountType.Error(): i18n.MsgInvalidAccountType, + ledger.ErrInvalidCurrencyCode.Error(): i18n.MsgInvalidCurrencyCode, +} + +type Meta struct { + AppName string `json:"appName"` + Version string `json:"version"` + Timestamp string `json:"timestamp"` + MessageCode string `json:"messageCode"` + Message string `json:"message"` +} + +func newMeta(acceptLang string, code i18n.MessageCode) Meta { + return Meta{ + AppName: appName, + Version: appVersion, + Timestamp: time.Now().UTC().Format(time.RFC3339), + MessageCode: string(code), + Message: i18n.Translate(acceptLang, code), + } +} + +const ( + appName = "go-ledger" + appVersion = "1.0.0" +) + +func CustomErrorHandler( + _ context.Context, + _ *runtime.ServeMux, + _ runtime.Marshaler, + w http.ResponseWriter, + r *http.Request, + err error, +) { + lang := r.Header.Get("Accept-Language") + s, _ := status.FromError(err) + + httpCode, ok := grpcToHTTP[s.Code()] + if !ok { + httpCode = http.StatusInternalServerError + } + + msgCode, errCode := resolveErrorCodes(s.Code(), s.Message()) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpCode) + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "error": map[string]any{"code": errCode}, + "meta": newMeta(lang, msgCode), + }) +} +func resolveErrorCodes(code codes.Code, message string) (i18n.MessageCode, string) { + if msgCode, ok := grpcToMessageCode[message]; ok { + return msgCode, string(msgCode) + } + + switch code { + case codes.InvalidArgument: + return i18n.MsgValidationFailed, string(i18n.MsgValidationFailed) + case codes.NotFound: + return i18n.MsgNotFound, string(i18n.MsgNotFound) + case codes.AlreadyExists: + return i18n.MsgConflict, string(i18n.MsgConflict) + case codes.FailedPrecondition: + return i18n.MsgInsufficientFunds, string(i18n.MsgInsufficientFunds) + default: + return i18n.MsgInternalError, string(i18n.MsgInternalError) + } +} + +func healthHandler(ping PingFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + lang := r.Header.Get("Accept-Language") + dbOK := ping(r.Context()) == nil + + w.Header().Set("Content-Type", "application/json") + + if !dbOK { + w.WriteHeader(http.StatusServiceUnavailable) + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "data": map[string]any{ + "status": "unavailable", + "checks": map[string]string{"postgres": "unavailable"}, + }, + "meta": newMeta(lang, i18n.MsgInternalError), + }) + return + } + + json.NewEncoder(w).Encode(map[string]any{ //nolint:errcheck + "data": map[string]any{ + "status": "ok", + "version": appVersion, + "app": appName, + "checks": map[string]string{"postgres": "ok"}, + }, + "meta": newMeta(lang, i18n.MsgHealthOK), + }) + } +} diff --git a/internal/transport/http/swagger.swagger.json b/internal/transport/http/swagger.swagger.json new file mode 100644 index 0000000..3c9a34a --- /dev/null +++ b/internal/transport/http/swagger.swagger.json @@ -0,0 +1,379 @@ +{ + "swagger": "2.0", + "info": { + "title": "go-ledger API", + "description": "A high-consistency, bank-grade financial transaction service.", + "version": "1.0" + }, + "tags": [ + { + "name": "LedgerService" + } + ], + "schemes": [ + "http", + "https" + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "paths": { + "/v1/transactions": { + "post": { + "operationId": "LedgerService_CreateTransaction", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1CreateTransactionResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1CreateTransactionRequest" + } + } + ], + "tags": [ + "LedgerService" + ] + } + }, + "/v1/wallets": { + "post": { + "operationId": "LedgerService_CreateWallet", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1CreateWalletResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/v1CreateWalletRequest" + } + } + ], + "tags": [ + "LedgerService" + ] + } + }, + "/v1/wallets/{walletId}/balance": { + "get": { + "operationId": "LedgerService_GetBalance", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetBalanceResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "walletId", + "in": "path", + "required": true, + "type": "string" + } + ], + "tags": [ + "LedgerService" + ] + } + }, + "/v1/wallets/{walletId}/history": { + "get": { + "operationId": "LedgerService_GetWalletHistory", + "responses": { + "200": { + "description": "A successful response.", + "schema": { + "$ref": "#/definitions/v1GetWalletHistoryResponse" + } + }, + "default": { + "description": "An unexpected error response.", + "schema": { + "$ref": "#/definitions/rpcStatus" + } + } + }, + "parameters": [ + { + "name": "walletId", + "in": "path", + "required": true, + "type": "string" + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "type": "integer", + "format": "int32" + }, + { + "name": "pageToken", + "in": "query", + "required": false, + "type": "string" + } + ], + "tags": [ + "LedgerService" + ] + } + } + }, + "definitions": { + "protobufAny": { + "type": "object", + "properties": { + "@type": { + "type": "string" + } + }, + "additionalProperties": {} + }, + "rpcStatus": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "details": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/protobufAny" + } + } + } + }, + "v1AccountType": { + "type": "string", + "enum": [ + "ACCOUNT_TYPE_UNSPECIFIED", + "ACCOUNT_TYPE_USER", + "ACCOUNT_TYPE_SYSTEM", + "ACCOUNT_TYPE_PROVIDER" + ], + "default": "ACCOUNT_TYPE_UNSPECIFIED" + }, + "v1CreateTransactionRequest": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string" + }, + "fromAccountId": { + "type": "string" + }, + "toAccountId": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "int64" + }, + "currencyCode": { + "type": "string" + } + } + }, + "v1CreateTransactionResponse": { + "type": "object", + "properties": { + "transaction": { + "$ref": "#/definitions/v1Transaction" + } + } + }, + "v1CreateWalletRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1AccountType" + }, + "currencyCode": { + "type": "string" + } + } + }, + "v1CreateWalletResponse": { + "type": "object", + "properties": { + "wallet": { + "$ref": "#/definitions/v1Wallet" + } + } + }, + "v1Entry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "accountId": { + "type": "string" + }, + "transactionId": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "int64" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "v1GetBalanceResponse": { + "type": "object", + "properties": { + "walletId": { + "type": "string" + }, + "balance": { + "type": "string", + "format": "int64" + }, + "currencyCode": { + "type": "string" + } + } + }, + "v1GetWalletHistoryResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/v1Entry" + } + }, + "nextPageToken": { + "type": "string" + } + } + }, + "v1Transaction": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "idempotencyKey": { + "type": "string" + }, + "fromAccountId": { + "type": "string" + }, + "toAccountId": { + "type": "string" + }, + "amount": { + "type": "string", + "format": "int64" + }, + "currencyCode": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/v1TransactionStatus" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + }, + "v1TransactionStatus": { + "type": "string", + "enum": [ + "TRANSACTION_STATUS_UNSPECIFIED", + "TRANSACTION_STATUS_PENDING", + "TRANSACTION_STATUS_COMPLETED", + "TRANSACTION_STATUS_FAILED" + ], + "default": "TRANSACTION_STATUS_UNSPECIFIED" + }, + "v1Wallet": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "type": { + "$ref": "#/definitions/v1AccountType" + }, + "currencyCode": { + "type": "string" + }, + "balance": { + "type": "string", + "format": "int64" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + } + } + } + } +} diff --git a/migrations/000004_create_audit_outbox_table.down.sql b/migrations/000004_create_audit_outbox_table.down.sql new file mode 100644 index 0000000..ba67b98 --- /dev/null +++ b/migrations/000004_create_audit_outbox_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS audit_outbox; +DROP TYPE IF EXISTS audit_status; diff --git a/migrations/000004_create_audit_outbox_table.up.sql b/migrations/000004_create_audit_outbox_table.up.sql new file mode 100644 index 0000000..7054f08 --- /dev/null +++ b/migrations/000004_create_audit_outbox_table.up.sql @@ -0,0 +1,23 @@ +-- audit_outbox implements the transactional outbox pattern. +-- Every CreateTransaction inserts a row here WITHIN THE SAME DB TRANSACTION. +-- This guarantees: if the ledger transaction commits, the audit record exists. +-- A background worker reads PENDING rows and ships them to the webhook. +-- If the webhook is down, rows stay PENDING and are retried — no audit is lost. + +CREATE TYPE audit_status AS ENUM ('PENDING', 'SENT', 'FAILED', 'DEAD_LETTERED'); + +CREATE TABLE audit_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action_type VARCHAR(100) NOT NULL, + payload JSONB NOT NULL, + status audit_status NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + max_retries INT NOT NULL DEFAULT 3, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ +); + +-- Partial index — only scans PENDING rows, which is the hot path. +CREATE INDEX idx_audit_outbox_pending ON audit_outbox (created_at) + WHERE status = 'PENDING'; diff --git a/pkg/apperr/apperr.go b/pkg/apperr/apperr.go new file mode 100644 index 0000000..333fa3c --- /dev/null +++ b/pkg/apperr/apperr.go @@ -0,0 +1,120 @@ +// Package apperr provides the AppError type for go-ledger. +// Mirrors LibreCore's pkg/errors: typed error codes, error types, +// HTTP status mapping, and context for additional details. +package apperr + +import ( + "fmt" + "net/http" + + "github.com/mohammad-farrokhnia/go-ledger/internal/i18n" +) + +// Type classifies the error for logging and HTTP status mapping. +type Type string + +const ( + TypeNotFound Type = "not_found" + TypeValidation Type = "validation" + TypeConflict Type = "conflict" + TypeInternal Type = "internal" + TypeUnauthorized Type = "unauthorized" + TypeForbidden Type = "forbidden" +) + +// Code is a stable string identifier for each error condition. +// Clients can rely on these — they never change. +const ( + ErrAccountNotFound = "ACCOUNT_NOT_FOUND" + ErrTransactionNotFound = "TRANSACTION_NOT_FOUND" + ErrInsufficientFunds = "INSUFFICIENT_FUNDS" + ErrCurrencyMismatch = "CURRENCY_MISMATCH" + ErrDuplicateTransaction = "DUPLICATE_TRANSACTION" + ErrInvalidAmount = "INVALID_AMOUNT" + ErrSameAccount = "SAME_ACCOUNT" + ErrInvalidAccountType = "INVALID_ACCOUNT_TYPE" + ErrInvalidCurrencyCode = "INVALID_CURRENCY_CODE" + ErrInternal = "INTERNAL_ERROR" + ErrBadRequest = "BAD_REQUEST" +) + +// codeToMessageCode maps error codes to i18n message codes. +var codeToMessageCode = map[string]i18n.MessageCode{ + ErrAccountNotFound: i18n.MsgAccountNotFound, + ErrTransactionNotFound: i18n.MsgTransactionNotFound, + ErrInsufficientFunds: i18n.MsgInsufficientFunds, + ErrCurrencyMismatch: i18n.MsgCurrencyMismatch, + ErrDuplicateTransaction: i18n.MsgDuplicateTransaction, + ErrInvalidAmount: i18n.MsgInvalidAmount, + ErrSameAccount: i18n.MsgSameAccount, + ErrInvalidAccountType: i18n.MsgInvalidAccountType, + ErrInvalidCurrencyCode: i18n.MsgInvalidCurrencyCode, + ErrInternal: i18n.MsgInternalError, + ErrBadRequest: i18n.MsgBadRequest, +} + +// MessageCode returns the i18n code for this error code. +func MessageCode(errCode string) i18n.MessageCode { + if code, ok := codeToMessageCode[errCode]; ok { + return code + } + return i18n.MsgInternalError +} + +// AppError is the single error type used across all layers. +type AppError struct { + code string + errType Type + underlying error + ctx map[string]any +} + +func New(code string, t Type) *AppError { + return &AppError{code: code, errType: t, ctx: make(map[string]any)} +} + +func NewWithErr(code string, t Type, underlying error) *AppError { + return &AppError{code: code, errType: t, underlying: underlying, ctx: make(map[string]any)} +} + +func (e *AppError) Error() string { + if e.underlying != nil { + return fmt.Sprintf("%s: %v", e.code, e.underlying) + } + return e.code +} + +func (e *AppError) Code() string { return e.code } +func (e *AppError) Type() Type { return e.errType } +func (e *AppError) Unwrap() error { return e.underlying } + +func (e *AppError) WithContext(key string, val any) *AppError { + e.ctx[key] = val + return e +} + +func (e *AppError) Context() map[string]any { return e.ctx } + +func (e *AppError) HTTPStatus() int { + switch e.errType { + case TypeNotFound: + return http.StatusNotFound + case TypeValidation: + return http.StatusBadRequest + case TypeConflict: + return http.StatusConflict + case TypeUnauthorized: + return http.StatusUnauthorized + case TypeForbidden: + return http.StatusForbidden + default: + return http.StatusInternalServerError + } +} + +// Constructors. +func NotFound(code string) *AppError { return New(code, TypeNotFound) } +func Validation(code string) *AppError { return New(code, TypeValidation) } +func Conflict(code string) *AppError { return New(code, TypeConflict) } +func Internal(code string) *AppError { return New(code, TypeInternal) } +func InternalErr(code string, err error) *AppError { return NewWithErr(code, TypeInternal, err) } diff --git a/pkg/response/response.go b/pkg/response/response.go new file mode 100644 index 0000000..1a9687e --- /dev/null +++ b/pkg/response/response.go @@ -0,0 +1,86 @@ +// Package response provides the unified HTTP response envelope for go-ledger. +// Every response — success or error — uses the same {data, meta} shape. +// This mirrors the pattern from LibreCore's pkg/response package. +package response + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/mohammad-farrokhnia/go-ledger/internal/i18n" +) + +var ( + appName string + version string +) + +// Init sets the application name and version embedded in every Meta. +// Call once from main() before starting the server. +func Init(name, ver string) { + appName = name + version = ver +} + +// Meta is present on every response. +// MessageCode lets clients do their own i18n if needed. +// Message is already translated to the caller's Accept-Language. +type Meta struct { + AppName string `json:"appName"` + Version string `json:"version"` + RequestID string `json:"requestId"` + Timestamp string `json:"timestamp"` + MessageCode string `json:"messageCode"` + Message string `json:"message"` +} + +func newMeta(requestID, acceptLang string, code i18n.MessageCode) Meta { + return Meta{ + AppName: appName, + Version: version, + RequestID: requestID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + MessageCode: string(code), + Message: i18n.Translate(acceptLang, code), + } +} + +// OK writes a 200 success response. +func OK(w http.ResponseWriter, r *http.Request, data any, code i18n.MessageCode) { + write(w, http.StatusOK, map[string]any{ + "data": data, + "meta": newMeta(requestID(r), r.Header.Get("Accept-Language"), code), + }) +} + +// Created writes a 201 created response. +func Created(w http.ResponseWriter, r *http.Request, data any, code i18n.MessageCode) { + write(w, http.StatusCreated, map[string]any{ + "data": data, + "meta": newMeta(requestID(r), r.Header.Get("Accept-Language"), code), + }) +} + +// Error writes an error response with the given HTTP status. +func Error(w http.ResponseWriter, r *http.Request, status int, code i18n.MessageCode) { + write(w, status, map[string]any{ + "error": map[string]any{}, + "meta": newMeta(requestID(r), r.Header.Get("Accept-Language"), code), + }) +} + +func write(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(body); err != nil { + http.Error(w, "response encode error", http.StatusInternalServerError) + } +} + +func requestID(r *http.Request) string { + if id := r.Header.Get("X-Request-Id"); id != "" { + return id + } + return "—" +}