├── README.md ├── cmd ├── app │ ├── deps.go │ ├── health.go │ └── main.go └── clients │ └── wallet.go ├── docker-compose.yaml ├── docs ├── docs.go ├── swagger.json └── swagger.yaml ├── go.mod ├── go.sum ├── internal ├── config │ ├── config.go │ └── env │ │ └── dev.yaml ├── core │ ├── application │ │ ├── ports │ │ │ ├── voucher_code_ports.go │ │ │ ├── voucher_redeemed_history_ports.go │ │ │ └── wallet_ports_external.go │ │ └── services │ │ │ ├── voucher_code.go │ │ │ └── voucher_redeemed_history.go │ ├── domain │ │ ├── entity │ │ │ ├── voucher_code.go │ │ │ └── voucher_redemption_history.go │ │ └── services │ │ │ ├── voucher_code.go │ │ │ ├── voucher_code_test.go │ │ │ ├── voucher_redeemed_history.go │ │ │ └── voucher_redeemed_history_test.go │ └── mocks │ │ ├── voucher_code_persistence.go │ │ └── voucher_redeemed_history_persistence.go ├── infrastructure │ ├── db │ │ ├── migrate.go │ │ ├── migrations │ │ │ ├── 00001_create_voucher_codes_table.down.sql │ │ │ ├── 00001_create_voucher_codes_table.up.sql │ │ │ ├── 00002_create_voucher_redemption_history_table.down.sql │ │ │ └── 00002_create_voucher_redemption_history_table.up.sql │ │ └── postgres.go │ └── persistence │ │ ├── models │ │ ├── voucher_code.go │ │ └── voucher_redeemed_history.go │ │ ├── voucher_code_adapter.go │ │ └── voucher_redeemed_history_adapter.go ├── interfaces │ └── api │ │ ├── dto │ │ ├── voucher_dto.go │ │ └── voucher_dto_validation.go │ │ ├── helper.go │ │ ├── routes.go │ │ ├── voucher_code_handler.go │ │ └── voucher_redeemed_history_handler.go └── server │ ├── middleware.go │ └── server.go └── pkg ├── logger └── logger.go └── serr ├── error.go └── error_test.go /README.md: -------------------------------------------------------------------------------- 1 | # Golang Hexagonal Architecture Example Microservice 2 | 3 | This repository is an example implementation of a microservice built with **Golang**, following the principles of **Hexagonal Architecture** (also known as Ports and Adapters). The service is designed to be clean, maintainable, and scalable, with a focus on separation of concerns and high testability. 4 | 5 | ## Overview 6 | 7 | This microservice demonstrates how to structure a Go application using hexagonal architecture, integrating with **PostgreSQL** as the primary database. The core business logic is decoupled from external dependencies, making it easier to adapt, test, and maintain. 8 | 9 | ### Key Features 10 | 11 | - **Hexagonal Architecture**: The application is structured using the hexagonal architecture pattern, which separates the core business logic from external services like databases, APIs, etc. 12 | - **PostgreSQL Integration**: The microservice uses PostgreSQL as its database, with all interactions managed through a repository layer. 13 | - **Application Service Layer**: This implementation uses an application service layer to coordinate domain services, manage transactions, and handle the orchestration between different components of the application. 14 | 15 | ## Architecture 16 | 17 | The project follows a well-defined directory structure based on hexagonal architecture principles: 18 | 19 | - **/cmd**: Contains the application's entry point (main package). 20 | - **/internal**: Houses the core business logic, domain services and models(entity), and application services. 21 | - **/pkg**: Contains public and shared packages code. 22 | - **/docs**: Contains swagger's related documents. 23 | 24 | ## Getting Started 25 | 26 | To get started with this project, ensure you have Go and PostgreSQL installed on your machine. 27 | 28 | ### Prerequisites 29 | 30 | - Go 1.23+ installed 31 | - PostgreSQL installed and running 32 | 33 | ### Installation 34 | 35 | 1. Clone the repository: 36 | 37 | ```bash 38 | git clone https://github.com/ashkanabbasii/golang_hexagonal_architecture.git 39 | cd golang_hexagonal_architecture 40 | -------------------------------------------------------------------------------- /cmd/app/deps.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | "log" 6 | "voucher/cmd/clients" 7 | "voucher/internal/config" 8 | "voucher/internal/core/application/ports" 9 | "voucher/internal/infrastructure/db" 10 | "voucher/internal/server" 11 | ) 12 | 13 | func postgresDB() *sql.DB { 14 | psql, err := db.NewPostgres( 15 | config.DBName(), config.DBUser(), config.DBPassword(), config.DBHost(), config.DBPort(), 16 | config.DBMaxOpenConn(), config.DBMaxIdleConn(), 17 | ) 18 | if err != nil { 19 | log.Fatalf("failed to initalize db: %v", err) 20 | } 21 | return psql 22 | } 23 | 24 | func externalClients() ports.WalletPort { 25 | walletClient := clients.NewWallet(config.APIWalletInternal(), config.APIWalletExternal()) 26 | 27 | return walletClient 28 | } 29 | 30 | func setupServer(s *server.Server, psql *sql.DB) { 31 | s.SetHealthFunc(healthFunc(psql)).SetupRoutes() 32 | } 33 | -------------------------------------------------------------------------------- /cmd/app/health.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "database/sql" 5 | ) 6 | 7 | func healthFunc(db *sql.DB) func() error { 8 | return func() error { 9 | if err := db.Ping(); err != nil { 10 | return err 11 | } 12 | return nil 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /cmd/app/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "go.uber.org/fx" 5 | "voucher/internal/config" 6 | appService "voucher/internal/core/application/services" 7 | "voucher/internal/core/domain/services" 8 | "voucher/internal/infrastructure/db" 9 | "voucher/internal/infrastructure/persistence" 10 | "voucher/internal/interfaces/api" 11 | "voucher/internal/server" 12 | "voucher/pkg/logger" 13 | ) 14 | 15 | func main() { 16 | fx.New( 17 | fx.Provide( 18 | // external clients 19 | externalClients, 20 | 21 | // postgres 22 | postgresDB, 23 | 24 | // persistence 25 | persistence.NewPostgresVoucherCodePersistence, 26 | persistence.NewVoucherRedemptionPersistenceAdapter, 27 | 28 | // domain services 29 | services.NewVoucherCodeService, 30 | services.NewVoucherRedeemedHistoryService, 31 | 32 | // application services 33 | appService.NewVoucherApplicationService, 34 | appService.NewVoucherRedemptionHistoryApplicationService, 35 | 36 | // handlers 37 | api.NewVoucherCodeHandler, 38 | api.NewVoucherRedeemedHistoryHandler, 39 | 40 | // server 41 | server.NewServer, 42 | ), 43 | 44 | fx.Supply(), 45 | 46 | fx.Invoke( 47 | config.Init, 48 | logger.SetupLogger, 49 | setupServer, 50 | db.Migrate, 51 | api.SetupVoucherCodeRoutes, 52 | api.SetupVoucherRedeemedHistoryRoutes, 53 | server.Run, 54 | ), 55 | ).Run() 56 | 57 | } 58 | -------------------------------------------------------------------------------- /cmd/clients/wallet.go: -------------------------------------------------------------------------------- 1 | package clients 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "time" 11 | ) 12 | 13 | // WalletClient handles communication with wallet-related APIs. 14 | type WalletClient struct { 15 | internalAddress string 16 | externalAddress string 17 | net *http.Client 18 | } 19 | 20 | // NewWallet creates a new instance of WalletClient. 21 | func NewWallet(internalAddress, externalAddress string) *WalletClient { 22 | return &WalletClient{ 23 | internalAddress: internalAddress, 24 | externalAddress: externalAddress, 25 | net: &http.Client{Timeout: time.Minute * 10}, 26 | } 27 | } 28 | 29 | // SetNet sets a custom HTTP client for the WalletClient. 30 | func (c *WalletClient) SetNet(net *http.Client) *WalletClient { 31 | c.net = net 32 | return c 33 | } 34 | 35 | // UpdateWalletBalanceRequest represents the request payload for updating wallet balance. 36 | type UpdateWalletBalanceRequest struct { 37 | UserID string `json:"user_id"` 38 | Amount float64 `json:"amount"` 39 | } 40 | 41 | // Error represents an error response from the API. 42 | type Error struct { 43 | Message string `json:"message"` 44 | } 45 | 46 | func (e *Error) Error() string { 47 | return e.Message 48 | } 49 | 50 | // DecreaseWalletBalance decreases the wallet balance for a specific user. 51 | func (c *WalletClient) DecreaseWalletBalance(ctx context.Context, req *UpdateWalletBalanceRequest) error { 52 | return c.callAPI(ctx, "/wallet/decrease", http.MethodPatch, req) 53 | } 54 | 55 | // IncreaseWalletBalance increases the wallet balance for a specific user. 56 | func (c *WalletClient) IncreaseWalletBalance(ctx context.Context, req *UpdateWalletBalanceRequest) error { 57 | return c.callAPI(ctx, "/wallet/increase", http.MethodPatch, req) 58 | } 59 | 60 | // callAPI performs a generic API call. 61 | func (c *WalletClient) callAPI(ctx context.Context, url string, method string, body any) error { 62 | var bodyReader io.Reader 63 | if body != nil { 64 | jsonBody, err := json.Marshal(body) 65 | if err != nil { 66 | return fmt.Errorf("failed to marshal body: %w", err) 67 | } 68 | bodyReader = bytes.NewReader(jsonBody) 69 | } 70 | 71 | req, err := http.NewRequestWithContext(ctx, method, fmt.Sprintf("%s%s", c.externalAddress, url), bodyReader) 72 | if err != nil { 73 | return err 74 | } 75 | req.Header.Set("Content-Type", "application/json") 76 | 77 | resp, err := c.net.Do(req) 78 | if err != nil { 79 | return err 80 | } 81 | defer resp.Body.Close() 82 | 83 | if resp.StatusCode != http.StatusOK { 84 | var apiErr Error 85 | if err = json.NewDecoder(resp.Body).Decode(&apiErr); err != nil { 86 | return fmt.Errorf("unknown error: %w", err) 87 | } 88 | return &apiErr 89 | } 90 | 91 | return nil 92 | } 93 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:14 6 | container_name: voucher_db 7 | environment: 8 | POSTGRES_DB: voucher 9 | POSTGRES_USER: pgsql 10 | POSTGRES_PASSWORD: 123456 11 | ports: 12 | - "5432:5432" # Maps host port 5432 to container port 5432 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | 16 | volumes: 17 | postgres_data: -------------------------------------------------------------------------------- /docs/docs.go: -------------------------------------------------------------------------------- 1 | // Package docs Code generated by swaggo/swag. DO NOT EDIT 2 | package docs 3 | 4 | import "github.com/swaggo/swag" 5 | 6 | const docTemplate = `{ 7 | "schemes": {{ marshal .Schemes }}, 8 | "swagger": "2.0", 9 | "info": { 10 | "description": "{{escape .Description}}", 11 | "title": "{{.Title}}", 12 | "contact": {}, 13 | "version": "{{.Version}}" 14 | }, 15 | "host": "{{.Host}}", 16 | "basePath": "{{.BasePath}}", 17 | "paths": { 18 | "/vouchers": { 19 | "post": { 20 | "description": "Create a new voucher with code, description, usage limit, and expiry date.", 21 | "consumes": [ 22 | "application/json" 23 | ], 24 | "produces": [ 25 | "application/json" 26 | ], 27 | "tags": [ 28 | "Vouchers" 29 | ], 30 | "summary": "Create a new voucher", 31 | "parameters": [ 32 | { 33 | "description": "Create Voucher Request", 34 | "name": "request", 35 | "in": "body", 36 | "required": true, 37 | "schema": { 38 | "$ref": "#/definitions/dto.CreateVoucherRequest" 39 | } 40 | } 41 | ], 42 | "responses": { 43 | "201": { 44 | "description": "Created" 45 | }, 46 | "400": { 47 | "description": "Bad Request", 48 | "schema": { 49 | "$ref": "#/definitions/api.Error" 50 | } 51 | }, 52 | "500": { 53 | "description": "Internal Server Error", 54 | "schema": { 55 | "$ref": "#/definitions/api.Error" 56 | } 57 | } 58 | } 59 | } 60 | }, 61 | "/vouchers/redeem": { 62 | "patch": { 63 | "description": "Redeem a voucher code for a user.", 64 | "consumes": [ 65 | "application/json" 66 | ], 67 | "produces": [ 68 | "application/json" 69 | ], 70 | "tags": [ 71 | "Vouchers" 72 | ], 73 | "summary": "Redeem a voucher", 74 | "parameters": [ 75 | { 76 | "description": "Redeem Voucher Request", 77 | "name": "request", 78 | "in": "body", 79 | "required": true, 80 | "schema": { 81 | "$ref": "#/definitions/dto.RedeemVoucherRequest" 82 | } 83 | } 84 | ], 85 | "responses": { 86 | "200": { 87 | "description": "OK" 88 | }, 89 | "400": { 90 | "description": "Bad Request", 91 | "schema": { 92 | "$ref": "#/definitions/api.Error" 93 | } 94 | }, 95 | "500": { 96 | "description": "Internal Server Error", 97 | "schema": { 98 | "$ref": "#/definitions/api.Error" 99 | } 100 | } 101 | } 102 | } 103 | }, 104 | "/vouchers/users/{user_id}/history": { 105 | "get": { 106 | "description": "Get a list of voucher redemption histories filtered by user ID.", 107 | "consumes": [ 108 | "application/json" 109 | ], 110 | "produces": [ 111 | "application/json" 112 | ], 113 | "tags": [ 114 | "Vouchers" 115 | ], 116 | "summary": "List redeemed voucher histories by user ID", 117 | "parameters": [ 118 | { 119 | "type": "string", 120 | "description": "User ID", 121 | "name": "user_id", 122 | "in": "path", 123 | "required": true 124 | } 125 | ], 126 | "responses": { 127 | "200": { 128 | "description": "OK", 129 | "schema": { 130 | "type": "array", 131 | "items": { 132 | "$ref": "#/definitions/dto.VoucherRedemptionHistoryResponse" 133 | } 134 | } 135 | }, 136 | "500": { 137 | "description": "Internal Server Error", 138 | "schema": { 139 | "$ref": "#/definitions/api.Error" 140 | } 141 | } 142 | } 143 | } 144 | }, 145 | "/vouchers/{code}/history": { 146 | "get": { 147 | "description": "Get a list of voucher redemption histories filtered by voucher code.", 148 | "consumes": [ 149 | "application/json" 150 | ], 151 | "produces": [ 152 | "application/json" 153 | ], 154 | "tags": [ 155 | "Vouchers" 156 | ], 157 | "summary": "List redeemed voucher histories by code", 158 | "parameters": [ 159 | { 160 | "type": "string", 161 | "description": "Voucher Code", 162 | "name": "code", 163 | "in": "path", 164 | "required": true 165 | } 166 | ], 167 | "responses": { 168 | "200": { 169 | "description": "OK", 170 | "schema": { 171 | "type": "array", 172 | "items": { 173 | "$ref": "#/definitions/dto.VoucherRedemptionHistoryResponse" 174 | } 175 | } 176 | }, 177 | "500": { 178 | "description": "Internal Server Error", 179 | "schema": { 180 | "$ref": "#/definitions/api.Error" 181 | } 182 | } 183 | } 184 | } 185 | } 186 | }, 187 | "definitions": { 188 | "api.Error": { 189 | "type": "object", 190 | "properties": { 191 | "code": { 192 | "$ref": "#/definitions/serr.ErrorCode" 193 | }, 194 | "message": { 195 | "type": "string" 196 | }, 197 | "trace_id": { 198 | "type": "string" 199 | } 200 | } 201 | }, 202 | "dto.CreateVoucherRequest": { 203 | "type": "object", 204 | "required": [ 205 | "amount", 206 | "code", 207 | "description", 208 | "expiry_date", 209 | "usage_limit" 210 | ], 211 | "properties": { 212 | "amount": { 213 | "type": "integer" 214 | }, 215 | "code": { 216 | "type": "string" 217 | }, 218 | "description": { 219 | "type": "string" 220 | }, 221 | "expiry_date": { 222 | "type": "string" 223 | }, 224 | "usage_limit": { 225 | "type": "integer" 226 | } 227 | } 228 | }, 229 | "dto.RedeemVoucherRequest": { 230 | "type": "object", 231 | "required": [ 232 | "code", 233 | "user_id" 234 | ], 235 | "properties": { 236 | "code": { 237 | "type": "string" 238 | }, 239 | "user_id": { 240 | "type": "string" 241 | } 242 | } 243 | }, 244 | "dto.VoucherRedemptionHistoryResponse": { 245 | "type": "object", 246 | "properties": { 247 | "amount": { 248 | "type": "integer" 249 | }, 250 | "id": { 251 | "type": "integer" 252 | }, 253 | "redeemed_at": { 254 | "type": "string" 255 | }, 256 | "user_id": { 257 | "type": "string" 258 | }, 259 | "voucher_id": { 260 | "type": "integer" 261 | } 262 | } 263 | }, 264 | "serr.ErrorCode": { 265 | "type": "string", 266 | "enum": [ 267 | "INTERNAL", 268 | "INVALID_VOUCHER", 269 | "REACH_LIMIT", 270 | "INVALID_USER", 271 | "INVALID_TIME", 272 | "INVALID_INPUT" 273 | ], 274 | "x-enum-varnames": [ 275 | "ErrInternal", 276 | "ErrInvalidVoucher", 277 | "ErrReachLimit", 278 | "ErrInvalidUser", 279 | "ErrInvalidTime", 280 | "ErrInvalidInput" 281 | ] 282 | } 283 | } 284 | }` 285 | 286 | // SwaggerInfo holds exported Swagger Info so clients can modify it 287 | var SwaggerInfo = &swag.Spec{ 288 | Version: "", 289 | Host: "", 290 | BasePath: "", 291 | Schemes: []string{}, 292 | Title: "", 293 | Description: "", 294 | InfoInstanceName: "swagger", 295 | SwaggerTemplate: docTemplate, 296 | LeftDelim: "{{", 297 | RightDelim: "}}", 298 | } 299 | 300 | func init() { 301 | swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) 302 | } 303 | -------------------------------------------------------------------------------- /docs/swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": "2.0", 3 | "info": { 4 | "contact": {} 5 | }, 6 | "paths": { 7 | "/vouchers": { 8 | "post": { 9 | "description": "Create a new voucher with code, description, usage limit, and expiry date.", 10 | "consumes": [ 11 | "application/json" 12 | ], 13 | "produces": [ 14 | "application/json" 15 | ], 16 | "tags": [ 17 | "Vouchers" 18 | ], 19 | "summary": "Create a new voucher", 20 | "parameters": [ 21 | { 22 | "description": "Create Voucher Request", 23 | "name": "request", 24 | "in": "body", 25 | "required": true, 26 | "schema": { 27 | "$ref": "#/definitions/dto.CreateVoucherRequest" 28 | } 29 | } 30 | ], 31 | "responses": { 32 | "201": { 33 | "description": "Created" 34 | }, 35 | "400": { 36 | "description": "Bad Request", 37 | "schema": { 38 | "$ref": "#/definitions/api.Error" 39 | } 40 | }, 41 | "500": { 42 | "description": "Internal Server Error", 43 | "schema": { 44 | "$ref": "#/definitions/api.Error" 45 | } 46 | } 47 | } 48 | } 49 | }, 50 | "/vouchers/redeem": { 51 | "patch": { 52 | "description": "Redeem a voucher code for a user.", 53 | "consumes": [ 54 | "application/json" 55 | ], 56 | "produces": [ 57 | "application/json" 58 | ], 59 | "tags": [ 60 | "Vouchers" 61 | ], 62 | "summary": "Redeem a voucher", 63 | "parameters": [ 64 | { 65 | "description": "Redeem Voucher Request", 66 | "name": "request", 67 | "in": "body", 68 | "required": true, 69 | "schema": { 70 | "$ref": "#/definitions/dto.RedeemVoucherRequest" 71 | } 72 | } 73 | ], 74 | "responses": { 75 | "200": { 76 | "description": "OK" 77 | }, 78 | "400": { 79 | "description": "Bad Request", 80 | "schema": { 81 | "$ref": "#/definitions/api.Error" 82 | } 83 | }, 84 | "500": { 85 | "description": "Internal Server Error", 86 | "schema": { 87 | "$ref": "#/definitions/api.Error" 88 | } 89 | } 90 | } 91 | } 92 | }, 93 | "/vouchers/users/{user_id}/history": { 94 | "get": { 95 | "description": "Get a list of voucher redemption histories filtered by user ID.", 96 | "consumes": [ 97 | "application/json" 98 | ], 99 | "produces": [ 100 | "application/json" 101 | ], 102 | "tags": [ 103 | "Vouchers" 104 | ], 105 | "summary": "List redeemed voucher histories by user ID", 106 | "parameters": [ 107 | { 108 | "type": "string", 109 | "description": "User ID", 110 | "name": "user_id", 111 | "in": "path", 112 | "required": true 113 | } 114 | ], 115 | "responses": { 116 | "200": { 117 | "description": "OK", 118 | "schema": { 119 | "type": "array", 120 | "items": { 121 | "$ref": "#/definitions/dto.VoucherRedemptionHistoryResponse" 122 | } 123 | } 124 | }, 125 | "500": { 126 | "description": "Internal Server Error", 127 | "schema": { 128 | "$ref": "#/definitions/api.Error" 129 | } 130 | } 131 | } 132 | } 133 | }, 134 | "/vouchers/{code}/history": { 135 | "get": { 136 | "description": "Get a list of voucher redemption histories filtered by voucher code.", 137 | "consumes": [ 138 | "application/json" 139 | ], 140 | "produces": [ 141 | "application/json" 142 | ], 143 | "tags": [ 144 | "Vouchers" 145 | ], 146 | "summary": "List redeemed voucher histories by code", 147 | "parameters": [ 148 | { 149 | "type": "string", 150 | "description": "Voucher Code", 151 | "name": "code", 152 | "in": "path", 153 | "required": true 154 | } 155 | ], 156 | "responses": { 157 | "200": { 158 | "description": "OK", 159 | "schema": { 160 | "type": "array", 161 | "items": { 162 | "$ref": "#/definitions/dto.VoucherRedemptionHistoryResponse" 163 | } 164 | } 165 | }, 166 | "500": { 167 | "description": "Internal Server Error", 168 | "schema": { 169 | "$ref": "#/definitions/api.Error" 170 | } 171 | } 172 | } 173 | } 174 | } 175 | }, 176 | "definitions": { 177 | "api.Error": { 178 | "type": "object", 179 | "properties": { 180 | "code": { 181 | "$ref": "#/definitions/serr.ErrorCode" 182 | }, 183 | "message": { 184 | "type": "string" 185 | }, 186 | "trace_id": { 187 | "type": "string" 188 | } 189 | } 190 | }, 191 | "dto.CreateVoucherRequest": { 192 | "type": "object", 193 | "required": [ 194 | "amount", 195 | "code", 196 | "description", 197 | "expiry_date", 198 | "usage_limit" 199 | ], 200 | "properties": { 201 | "amount": { 202 | "type": "integer" 203 | }, 204 | "code": { 205 | "type": "string" 206 | }, 207 | "description": { 208 | "type": "string" 209 | }, 210 | "expiry_date": { 211 | "type": "string" 212 | }, 213 | "usage_limit": { 214 | "type": "integer" 215 | } 216 | } 217 | }, 218 | "dto.RedeemVoucherRequest": { 219 | "type": "object", 220 | "required": [ 221 | "code", 222 | "user_id" 223 | ], 224 | "properties": { 225 | "code": { 226 | "type": "string" 227 | }, 228 | "user_id": { 229 | "type": "string" 230 | } 231 | } 232 | }, 233 | "dto.VoucherRedemptionHistoryResponse": { 234 | "type": "object", 235 | "properties": { 236 | "amount": { 237 | "type": "integer" 238 | }, 239 | "id": { 240 | "type": "integer" 241 | }, 242 | "redeemed_at": { 243 | "type": "string" 244 | }, 245 | "user_id": { 246 | "type": "string" 247 | }, 248 | "voucher_id": { 249 | "type": "integer" 250 | } 251 | } 252 | }, 253 | "serr.ErrorCode": { 254 | "type": "string", 255 | "enum": [ 256 | "INTERNAL", 257 | "INVALID_VOUCHER", 258 | "REACH_LIMIT", 259 | "INVALID_USER", 260 | "INVALID_TIME", 261 | "INVALID_INPUT" 262 | ], 263 | "x-enum-varnames": [ 264 | "ErrInternal", 265 | "ErrInvalidVoucher", 266 | "ErrReachLimit", 267 | "ErrInvalidUser", 268 | "ErrInvalidTime", 269 | "ErrInvalidInput" 270 | ] 271 | } 272 | } 273 | } -------------------------------------------------------------------------------- /docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | definitions: 2 | api.Error: 3 | properties: 4 | code: 5 | $ref: '#/definitions/serr.ErrorCode' 6 | message: 7 | type: string 8 | trace_id: 9 | type: string 10 | type: object 11 | dto.CreateVoucherRequest: 12 | properties: 13 | amount: 14 | type: integer 15 | code: 16 | type: string 17 | description: 18 | type: string 19 | expiry_date: 20 | type: string 21 | usage_limit: 22 | type: integer 23 | required: 24 | - amount 25 | - code 26 | - description 27 | - expiry_date 28 | - usage_limit 29 | type: object 30 | dto.RedeemVoucherRequest: 31 | properties: 32 | code: 33 | type: string 34 | user_id: 35 | type: string 36 | required: 37 | - code 38 | - user_id 39 | type: object 40 | dto.VoucherRedemptionHistoryResponse: 41 | properties: 42 | amount: 43 | type: integer 44 | id: 45 | type: integer 46 | redeemed_at: 47 | type: string 48 | user_id: 49 | type: string 50 | voucher_id: 51 | type: integer 52 | type: object 53 | serr.ErrorCode: 54 | enum: 55 | - INTERNAL 56 | - INVALID_VOUCHER 57 | - REACH_LIMIT 58 | - INVALID_USER 59 | - INVALID_TIME 60 | - INVALID_INPUT 61 | type: string 62 | x-enum-varnames: 63 | - ErrInternal 64 | - ErrInvalidVoucher 65 | - ErrReachLimit 66 | - ErrInvalidUser 67 | - ErrInvalidTime 68 | - ErrInvalidInput 69 | info: 70 | contact: {} 71 | paths: 72 | /vouchers: 73 | post: 74 | consumes: 75 | - application/json 76 | description: Create a new voucher with code, description, usage limit, and expiry 77 | date. 78 | parameters: 79 | - description: Create Voucher Request 80 | in: body 81 | name: request 82 | required: true 83 | schema: 84 | $ref: '#/definitions/dto.CreateVoucherRequest' 85 | produces: 86 | - application/json 87 | responses: 88 | "201": 89 | description: Created 90 | "400": 91 | description: Bad Request 92 | schema: 93 | $ref: '#/definitions/api.Error' 94 | "500": 95 | description: Internal Server Error 96 | schema: 97 | $ref: '#/definitions/api.Error' 98 | summary: Create a new voucher 99 | tags: 100 | - Vouchers 101 | /vouchers/{code}/history: 102 | get: 103 | consumes: 104 | - application/json 105 | description: Get a list of voucher redemption histories filtered by voucher 106 | code. 107 | parameters: 108 | - description: Voucher Code 109 | in: path 110 | name: code 111 | required: true 112 | type: string 113 | produces: 114 | - application/json 115 | responses: 116 | "200": 117 | description: OK 118 | schema: 119 | items: 120 | $ref: '#/definitions/dto.VoucherRedemptionHistoryResponse' 121 | type: array 122 | "500": 123 | description: Internal Server Error 124 | schema: 125 | $ref: '#/definitions/api.Error' 126 | summary: List redeemed voucher histories by code 127 | tags: 128 | - Vouchers 129 | /vouchers/redeem: 130 | patch: 131 | consumes: 132 | - application/json 133 | description: Redeem a voucher code for a user. 134 | parameters: 135 | - description: Redeem Voucher Request 136 | in: body 137 | name: request 138 | required: true 139 | schema: 140 | $ref: '#/definitions/dto.RedeemVoucherRequest' 141 | produces: 142 | - application/json 143 | responses: 144 | "200": 145 | description: OK 146 | "400": 147 | description: Bad Request 148 | schema: 149 | $ref: '#/definitions/api.Error' 150 | "500": 151 | description: Internal Server Error 152 | schema: 153 | $ref: '#/definitions/api.Error' 154 | summary: Redeem a voucher 155 | tags: 156 | - Vouchers 157 | /vouchers/users/{user_id}/history: 158 | get: 159 | consumes: 160 | - application/json 161 | description: Get a list of voucher redemption histories filtered by user ID. 162 | parameters: 163 | - description: User ID 164 | in: path 165 | name: user_id 166 | required: true 167 | type: string 168 | produces: 169 | - application/json 170 | responses: 171 | "200": 172 | description: OK 173 | schema: 174 | items: 175 | $ref: '#/definitions/dto.VoucherRedemptionHistoryResponse' 176 | type: array 177 | "500": 178 | description: Internal Server Error 179 | schema: 180 | $ref: '#/definitions/api.Error' 181 | summary: List redeemed voucher histories by user ID 182 | tags: 183 | - Vouchers 184 | swagger: "2.0" 185 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module voucher 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/gin-contrib/cors v1.7.2 7 | github.com/gin-gonic/gin v1.10.0 8 | github.com/go-playground/validator/v10 v10.22.0 9 | github.com/golang-migrate/migrate/v4 v4.17.1 10 | github.com/rs/zerolog v1.33.0 11 | github.com/spf13/viper v1.19.0 12 | github.com/stretchr/testify v1.9.0 13 | github.com/swaggo/files v1.0.1 14 | github.com/swaggo/gin-swagger v1.6.0 15 | github.com/swaggo/swag v1.16.3 16 | go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.53.0 17 | go.uber.org/fx v1.22.2 18 | ) 19 | 20 | require ( 21 | github.com/KyleBanks/depth v1.2.1 // indirect 22 | github.com/bytedance/sonic v1.12.2 // indirect 23 | github.com/bytedance/sonic/loader v0.2.0 // indirect 24 | github.com/cloudwego/base64x v0.1.4 // indirect 25 | github.com/cloudwego/iasm v0.2.0 // indirect 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 27 | github.com/fsnotify/fsnotify v1.7.0 // indirect 28 | github.com/gabriel-vasile/mimetype v1.4.5 // indirect 29 | github.com/gin-contrib/sse v0.1.0 // indirect 30 | github.com/go-logr/logr v1.4.2 // indirect 31 | github.com/go-logr/stdr v1.2.2 // indirect 32 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 33 | github.com/go-openapi/jsonreference v0.21.0 // indirect 34 | github.com/go-openapi/spec v0.21.0 // indirect 35 | github.com/go-openapi/swag v0.23.0 // indirect 36 | github.com/go-playground/locales v0.14.1 // indirect 37 | github.com/go-playground/universal-translator v0.18.1 // indirect 38 | github.com/goccy/go-json v0.10.3 // indirect 39 | github.com/hashicorp/errwrap v1.1.0 // indirect 40 | github.com/hashicorp/go-multierror v1.1.1 // indirect 41 | github.com/hashicorp/hcl v1.0.0 // indirect 42 | github.com/josharian/intern v1.0.0 // indirect 43 | github.com/json-iterator/go v1.1.12 // indirect 44 | github.com/klauspost/cpuid/v2 v2.2.8 // indirect 45 | github.com/leodido/go-urn v1.4.0 // indirect 46 | github.com/lib/pq v1.10.9 // indirect 47 | github.com/magiconair/properties v1.8.7 // indirect 48 | github.com/mailru/easyjson v0.7.7 // indirect 49 | github.com/mattn/go-colorable v0.1.13 // indirect 50 | github.com/mattn/go-isatty v0.0.20 // indirect 51 | github.com/mitchellh/mapstructure v1.5.0 // indirect 52 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 53 | github.com/modern-go/reflect2 v1.0.2 // indirect 54 | github.com/pelletier/go-toml/v2 v2.2.3 // indirect 55 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 56 | github.com/sagikazarmark/locafero v0.4.0 // indirect 57 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 58 | github.com/sourcegraph/conc v0.3.0 // indirect 59 | github.com/spf13/afero v1.11.0 // indirect 60 | github.com/spf13/cast v1.6.0 // indirect 61 | github.com/spf13/pflag v1.0.5 // indirect 62 | github.com/stretchr/objx v0.5.2 // indirect 63 | github.com/subosito/gotenv v1.6.0 // indirect 64 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 65 | github.com/ugorji/go/codec v1.2.12 // indirect 66 | go.opentelemetry.io/otel v1.28.0 // indirect 67 | go.opentelemetry.io/otel/metric v1.28.0 // indirect 68 | go.opentelemetry.io/otel/trace v1.28.0 // indirect 69 | go.uber.org/atomic v1.9.0 // indirect 70 | go.uber.org/dig v1.18.0 // indirect 71 | go.uber.org/multierr v1.10.0 // indirect 72 | go.uber.org/zap v1.26.0 // indirect 73 | golang.org/x/arch v0.9.0 // indirect 74 | golang.org/x/crypto v0.26.0 // indirect 75 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 76 | golang.org/x/net v0.28.0 // indirect 77 | golang.org/x/sys v0.24.0 // indirect 78 | golang.org/x/text v0.17.0 // indirect 79 | golang.org/x/tools v0.24.0 // indirect 80 | google.golang.org/protobuf v1.34.2 // indirect 81 | gopkg.in/ini.v1 v1.67.0 // indirect 82 | gopkg.in/yaml.v3 v3.0.1 // indirect 83 | ) 84 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= 2 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= 3 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= 4 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 5 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 6 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 7 | github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg= 8 | github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= 9 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 10 | github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= 11 | github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 12 | github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= 13 | github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 14 | github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= 15 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 16 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 20 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= 22 | github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= 23 | github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8= 24 | github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= 25 | github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0= 26 | github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= 27 | github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= 28 | github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= 29 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= 30 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 31 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 32 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 33 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 34 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 35 | github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= 36 | github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= 37 | github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= 38 | github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= 39 | github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= 40 | github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= 41 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 42 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 43 | github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= 44 | github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= 45 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 46 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 47 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 48 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 49 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 50 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 51 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 52 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 53 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 54 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= 55 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 56 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 57 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 58 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 59 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 60 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 61 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 62 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 63 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 64 | github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= 65 | github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 66 | github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= 67 | github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= 68 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 69 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 70 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 71 | github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= 72 | github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= 73 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 74 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 75 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 76 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 77 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 78 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 79 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 80 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 81 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 82 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 83 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 84 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 85 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 86 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 87 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 88 | github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= 89 | github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 90 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 91 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 92 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 93 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 94 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 95 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 96 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 97 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= 98 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= 99 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 100 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 101 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 102 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 103 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 104 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 105 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 106 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 107 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 108 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 109 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 110 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 111 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= 112 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= 113 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 114 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 115 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 116 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 117 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 118 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= 119 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= 120 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= 121 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 122 | github.com/opencontainers/image-spec v1.0.2 h1:9yCKha/T5XdGtO0q9Q9a6T5NUCsTn/DrBg0D7ufOcFM= 123 | github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= 124 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 125 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 126 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 127 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 128 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 129 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 130 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 131 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 132 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 133 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 134 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 135 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 136 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 137 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 138 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 139 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 140 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 141 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 142 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 143 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 144 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 145 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 146 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 147 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 148 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 149 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 150 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 151 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 152 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 153 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 154 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 155 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 156 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 157 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 158 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 159 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 160 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 161 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 162 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 163 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 164 | github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= 165 | github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= 166 | github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M= 167 | github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo= 168 | github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= 169 | github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= 170 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 171 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 172 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 173 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 174 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 175 | go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.53.0 h1:ktt8061VV/UU5pdPF6AcEFyuPxMizf/vU6eD1l+13LI= 176 | go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.53.0/go.mod h1:JSRiHPV7E3dbOAP0N6SRPg2nC/cugJnVXRqP018ejtY= 177 | go.opentelemetry.io/contrib/propagators/b3 v1.28.0 h1:XR6CFQrQ/ttAYmTBX2loUEFGdk1h17pxYI8828dk/1Y= 178 | go.opentelemetry.io/contrib/propagators/b3 v1.28.0/go.mod h1:DWRkzJONLquRz7OJPh2rRbZ7MugQj62rk7g6HRnEqh0= 179 | go.opentelemetry.io/otel v1.28.0 h1:/SqNcYk+idO0CxKEUOtKQClMK/MimZihKYMruSMViUo= 180 | go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIjUIX9z4= 181 | go.opentelemetry.io/otel/metric v1.28.0 h1:f0HGvSl1KRAU1DLgLGFjrwVyismPlnuU6JD6bOeuA5Q= 182 | go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUisDsbJoL/+uQW4s= 183 | go.opentelemetry.io/otel/trace v1.28.0 h1:GhQ9cUuQGmNDd5BTCP2dAvv75RdMxEfTmYejp+lkx9g= 184 | go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVfM3/yv2OlIHaI= 185 | go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= 186 | go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 187 | go.uber.org/dig v1.18.0 h1:imUL1UiY0Mg4bqbFfsRQO5G4CGRBec/ZujWTvSVp3pw= 188 | go.uber.org/dig v1.18.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= 189 | go.uber.org/fx v1.22.2 h1:iPW+OPxv0G8w75OemJ1RAnTUrF55zOJlXlo1TbJ0Buw= 190 | go.uber.org/fx v1.22.2/go.mod h1:o/D9n+2mLP6v1EG+qsdT1O8wKopYAsqZasju97SDFCU= 191 | go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= 192 | go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 193 | go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= 194 | go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 195 | go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= 196 | go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 197 | golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k= 198 | golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 199 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 200 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 201 | golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= 202 | golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= 203 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 204 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 205 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 206 | golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= 207 | golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 208 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 209 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 210 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 211 | golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 212 | golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= 213 | golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= 214 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 215 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 216 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 217 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 218 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 219 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 220 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 221 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 222 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 223 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 224 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 225 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 226 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 227 | golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= 228 | golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 229 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 230 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 231 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 232 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 233 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 234 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 235 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 236 | golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= 237 | golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 238 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 239 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 240 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 241 | golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= 242 | golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= 243 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 244 | google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= 245 | google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 246 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 247 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 248 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 249 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 250 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 251 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 252 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 253 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 254 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 255 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "github.com/rs/zerolog/log" 6 | "github.com/spf13/viper" 7 | "os" 8 | ) 9 | 10 | type Environment string 11 | 12 | const ( 13 | LOCAL Environment = "dev" 14 | BETA Environment = "beta" 15 | PROD Environment = "prod" 16 | ) 17 | 18 | func Env() Environment { 19 | return Environment(viper.GetString("env")) 20 | } 21 | 22 | func ServiceName() string { 23 | return viper.GetString("domain.name") 24 | } 25 | 26 | func ServerInternalPort() int { 27 | return viper.GetInt("server.ports.internal") 28 | } 29 | 30 | func ServerExternalPort() int { 31 | return viper.GetInt("server.ports.external") 32 | } 33 | 34 | func ServerDebug() bool { 35 | return viper.GetBool("server.debug") 36 | } 37 | 38 | func ServerAddress() string { 39 | return viper.GetString("server.address") 40 | } 41 | 42 | func DBName() string { 43 | return viper.GetString("db.postgres.name") 44 | } 45 | 46 | func DBPassword() string { 47 | return viper.GetString("db.postgres.password") 48 | } 49 | 50 | func DBUser() string { 51 | return viper.GetString("db.postgres.user") 52 | } 53 | 54 | func DBPort() string { 55 | return viper.GetString("db.postgres.port") 56 | } 57 | 58 | func DBHost() string { 59 | return viper.GetString("db.postgres.host") 60 | } 61 | 62 | func DBMaxIdleConn() int { 63 | return viper.GetInt("db.postgres.maxIdleConn") 64 | } 65 | 66 | func DBMaxOpenConn() int { 67 | return viper.GetInt("db.postgres.maxOpenConn") 68 | } 69 | 70 | func DBMigrationsPath() string { 71 | return viper.GetString("db.postgres.migrationsPath") 72 | } 73 | 74 | func APIWalletExternal() string { 75 | return viper.GetString("api.wallet.external") 76 | } 77 | 78 | func APIWalletInternal() string { 79 | return viper.GetString("api.wallet.internal") 80 | } 81 | 82 | func CORSAllowedOrigins() []string { 83 | return viper.GetStringSlice("app.cors.allow-origins") 84 | } 85 | 86 | func CORSAllowedMethods() []string { 87 | return viper.GetStringSlice("app.cors.allow-methods") 88 | } 89 | 90 | func CORSAllowedHeaders() []string { 91 | return viper.GetStringSlice("app.cors.allow-headers") 92 | } 93 | 94 | func CORSAllowCredentials() bool { 95 | return viper.GetBool("app.cors.allow-credentials") 96 | } 97 | 98 | func Init() { 99 | viper.SetConfigName(getEnv("CONFIG_NAME", "dev")) 100 | viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name 101 | viper.AddConfigPath("./internal/config/env") 102 | err := viper.ReadInConfig() // Find and read the config file 103 | if err != nil { // Handle errors reading the config file 104 | panic(fmt.Errorf("Fatal error config file: %w \n", err)) 105 | } 106 | } 107 | 108 | func getEnv(key, fallback string) string { 109 | log.Info().Msg("getting environment") 110 | if value, ok := os.LookupEnv(key); ok { 111 | return value 112 | } 113 | return fallback 114 | } 115 | -------------------------------------------------------------------------------- /internal/config/env/dev.yaml: -------------------------------------------------------------------------------- 1 | env: "dev" 2 | name: "voucher-services" 3 | server: 4 | ports: 5 | external: "9000" 6 | internal: "9001" 7 | debug: true 8 | #DATABASE 9 | db: 10 | postgres: 11 | name: "voucher" 12 | host: "localhost" 13 | port: "5432" 14 | user: "pgsql" 15 | password: "123456" 16 | debug: true 17 | maxIdleConn: "5" 18 | maxOpenConn: "10" 19 | migrationsPath: "internal/infrastructure/db/migrations" 20 | app: 21 | cors: 22 | allow-origins: "*" 23 | allow-methods: "GET,POST,PUT,DELETE,OPTIONS" 24 | allow-headers: "Accept,Authorization,Content-Type,Origin,channel,product,x-auth-id,experiment-keys,user-tracking-key" 25 | allow-credentials: "true" 26 | consumer-group-hotel: "hotel.search.consumer" 27 | consumer-group-villa: "villa.search.consumer" 28 | api: 29 | wallet: 30 | internal: "http://localhost:9001" 31 | external: "http://localhost:9000" 32 | -------------------------------------------------------------------------------- /internal/core/application/ports/voucher_code_ports.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "voucher/internal/core/domain/entity" 7 | ) 8 | 9 | type ( 10 | // VoucherPersistencePort defines the methods for interacting with voucher data 11 | VoucherPersistencePort interface { 12 | // CreateVoucher saves a new voucher to the database 13 | CreateVoucher(ctx context.Context, voucher *entity.VoucherCode) error 14 | 15 | // GetVoucher retrieves a voucher by its code 16 | GetVoucher(ctx context.Context, code string) (*entity.VoucherCode, error) 17 | 18 | // GetVoucherWithLock retrieves a voucher by its code and lock the row 19 | GetVoucherWithLock(ctx context.Context, code string, tx *sql.Tx) (*entity.VoucherCode, error) 20 | 21 | // UpdateVoucher updates an existing voucher in the database 22 | UpdateVoucher(ctx context.Context, voucher *entity.VoucherCode, tx *sql.Tx) error 23 | } 24 | 25 | // VoucherCodeServicePort defines the methods for interacting with voucher code services 26 | VoucherCodeServicePort interface { 27 | // CreateVoucher create new voucher entity 28 | CreateVoucher(ctx context.Context, code string, maxUsages int) error 29 | 30 | // RedeemVoucher redeem voucher by code 31 | RedeemVoucher(ctx context.Context, code string, tx *sql.Tx) (*entity.VoucherCode, error) 32 | } 33 | ) 34 | -------------------------------------------------------------------------------- /internal/core/application/ports/voucher_redeemed_history_ports.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "voucher/internal/core/domain/entity" 7 | ) 8 | 9 | type ( 10 | // VoucherRedemptionPersistencePort defines the methods for interacting with voucher redeemed history data 11 | VoucherRedemptionPersistencePort interface { 12 | // CreateRedeemedHistory saves a new redemption record to the database 13 | CreateRedeemedHistory(ctx context.Context, history *entity.VoucherRedemptionHistory, tx *sql.Tx) error 14 | 15 | // ListRedeemedHistoriesByUser retrieves redemption records based on user id 16 | ListRedeemedHistoriesByUser(ctx context.Context, userID string) ([]*entity.VoucherRedemptionHistory, error) 17 | 18 | // ListRedeemedHistoriesByCode retrieves redemption records based on code 19 | ListRedeemedHistoriesByCode(ctx context.Context, code string) ([]*entity.VoucherRedemptionHistory, error) 20 | 21 | // ListRedeemedHistoryUsage retrieves redemption history by code and userID 22 | ListRedeemedHistoryUsage(ctx context.Context, code, userID string) ([]*entity.VoucherRedemptionHistory, error) 23 | } 24 | 25 | // VoucherRedeemedHistoryServicePort defines the methods for interacting with voucher redeemed history services 26 | VoucherRedeemedHistoryServicePort interface { 27 | // RecordRedemption create new voucher redeemed history entity 28 | RecordRedemption(ctx context.Context, voucherID int, userID string, tx *sql.Tx) error 29 | 30 | // ListRedeemedHistoriesByCode retrieves redemption history by code 31 | ListRedeemedHistoriesByCode(ctx context.Context, code string) ([]*entity.VoucherRedemptionHistory, error) 32 | 33 | // ListRedeemedHistoriesByUser retrieves redemption history by userID 34 | ListRedeemedHistoriesByUser(ctx context.Context, userID string) ([]*entity.VoucherRedemptionHistory, error) 35 | 36 | // ListRedeemedHistoryUsage retrieves redemption history by userID and code 37 | ListRedeemedHistoryUsage(ctx context.Context, code, userID string) ([]*entity.VoucherRedemptionHistory, error) 38 | } 39 | ) 40 | -------------------------------------------------------------------------------- /internal/core/application/ports/wallet_ports_external.go: -------------------------------------------------------------------------------- 1 | package ports 2 | 3 | import ( 4 | "context" 5 | "voucher/cmd/clients" 6 | ) 7 | 8 | type WalletPort interface { 9 | DecreaseWalletBalance(ctx context.Context, req *clients.UpdateWalletBalanceRequest) error 10 | IncreaseWalletBalance(ctx context.Context, req *clients.UpdateWalletBalanceRequest) error 11 | } 12 | -------------------------------------------------------------------------------- /internal/core/application/services/voucher_code.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "net/http" 7 | "voucher/cmd/clients" 8 | "voucher/internal/core/application/ports" 9 | "voucher/internal/core/domain/entity" 10 | "voucher/internal/infrastructure/db" 11 | api "voucher/internal/interfaces/api/dto" 12 | "voucher/pkg/serr" 13 | ) 14 | 15 | // VoucherApplicationService provides application logic for vouchers. 16 | type VoucherApplicationService struct { 17 | voucherCodeDomainService ports.VoucherCodeServicePort 18 | redemptionHistoryDomainService ports.VoucherRedeemedHistoryServicePort 19 | walletClient ports.WalletPort 20 | } 21 | 22 | // NewVoucherApplicationService creates a new instance of VoucherApplicationService. 23 | func NewVoucherApplicationService( 24 | voucherDomainService ports.VoucherCodeServicePort, 25 | redemptionHistoryDomainService ports.VoucherRedeemedHistoryServicePort, 26 | walletClient ports.WalletPort, 27 | 28 | ) *VoucherApplicationService { 29 | return &VoucherApplicationService{ 30 | voucherCodeDomainService: voucherDomainService, 31 | redemptionHistoryDomainService: redemptionHistoryDomainService, 32 | walletClient: walletClient, 33 | } 34 | } 35 | 36 | // RedeemVoucher handles the redemption process of a voucher and interacts with the domain services. 37 | func (s *VoucherApplicationService) RedeemVoucher(ctx context.Context, request *api.RedeemVoucherRequest) error { 38 | // Perform basic validation, e.g., check if the code is empty or invalid 39 | err := request.Validate() 40 | if err != nil { 41 | return serr.ValidationErr("VoucherApplicationService.RedeemVoucher", 42 | err.Error(), serr.ErrInvalidInput) 43 | } 44 | // Get usage of voucher by user 45 | usage, err := s.redemptionHistoryDomainService.ListRedeemedHistoryUsage(ctx, request.Code, request.UserID) 46 | if err != nil { 47 | return err 48 | } 49 | // todo: if we need dynamic limitation , we can check the user limit with our persistence 50 | // todo: but right now we just check already usage of voucher by user 51 | if len(usage) > 0 { 52 | return serr.ValidationErr("VoucherApplicationService.RedeemVoucher", 53 | "you've been reach to the limit", serr.ErrReachLimit) 54 | } 55 | var voucher *entity.VoucherCode 56 | // Call the domain services method to redeem the voucher by transaction 57 | err = db.Transaction(ctx, sql.LevelReadCommitted, func(tx *sql.Tx) error { 58 | // redeem a voucher 59 | voucher, err = s.voucherCodeDomainService.RedeemVoucher(ctx, request.Code, tx) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | // record a redemption 65 | err = s.redemptionHistoryDomainService.RecordRedemption(ctx, voucher.ID, request.UserID, tx) 66 | if err != nil { 67 | return err 68 | } 69 | 70 | return nil 71 | }) 72 | 73 | if err != nil { 74 | return serr.ServiceErr("VoucherApplicationService.RedeemVoucher", 75 | err.Error(), err, http.StatusInternalServerError) 76 | } 77 | 78 | //todo: implement API call failure here 79 | // increase user wallet 80 | err = s.walletClient.IncreaseWalletBalance(ctx, &clients.UpdateWalletBalanceRequest{ 81 | UserID: request.UserID, 82 | Amount: float64(voucher.Amount), 83 | }) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | return nil 89 | } 90 | 91 | // CreateVoucher create a new voucher 92 | func (s *VoucherApplicationService) CreateVoucher(ctx context.Context, request *api.CreateVoucherRequest) error { 93 | err := request.Validate() 94 | if err != nil { 95 | return serr.ValidationErr("VoucherApplicationService.CreateVoucher", 96 | err.Error(), serr.ErrInvalidInput) 97 | } 98 | return s.voucherCodeDomainService.CreateVoucher(ctx, request.Code, request.UsageLimit) 99 | } 100 | -------------------------------------------------------------------------------- /internal/core/application/services/voucher_redeemed_history.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "net/http" 7 | "voucher/internal/core/application/ports" 8 | "voucher/internal/interfaces/api/dto" 9 | "voucher/pkg/serr" 10 | ) 11 | 12 | type VoucherRedemptionHistoryApplicationService struct { 13 | domainService ports.VoucherRedeemedHistoryServicePort 14 | } 15 | 16 | // NewVoucherRedemptionHistoryApplicationService creates a new instance of VoucherRedemptionHistoryApplicationService. 17 | func NewVoucherRedemptionHistoryApplicationService(domainService ports.VoucherRedeemedHistoryServicePort) *VoucherRedemptionHistoryApplicationService { 18 | return &VoucherRedemptionHistoryApplicationService{ 19 | domainService: domainService, 20 | } 21 | } 22 | 23 | // RecordRedemption records a new voucher redemption in the history. 24 | func (s *VoucherRedemptionHistoryApplicationService) RecordRedemption(ctx context.Context, voucherID int, userID string, tx *sql.Tx) error { 25 | return s.domainService.RecordRedemption(ctx, voucherID, userID, tx) 26 | } 27 | 28 | // ListRedeemedHistoriesByCode retrieves the redemption history for a specific voucher's code. 29 | func (s *VoucherRedemptionHistoryApplicationService) ListRedeemedHistoriesByCode(ctx context.Context, request *dto.ListRedeemVoucherByCodeRequest) ([]*dto.VoucherRedemptionHistoryResponse, error) { 30 | err := request.Validate() 31 | if err != nil { 32 | return nil, serr.ValidationErr("VoucherRedemptionHistoryApplicationService.ListRedeemedHistoriesByCode", 33 | err.Error(), serr.ErrInvalidInput) 34 | } 35 | 36 | result, err := s.domainService.ListRedeemedHistoriesByCode(ctx, request.Code) 37 | if err != nil { 38 | return nil, serr.ServiceErr("VoucherRedemptionHistoryApplicationService.ListRedeemedHistoriesByCode", 39 | err.Error(), err, http.StatusInternalServerError) 40 | } 41 | 42 | response := make([]*dto.VoucherRedemptionHistoryResponse, 0, len(result)) 43 | for _, v := range result { 44 | response = append(response, dto.ToVoucherRedemptionHistoryEntity(v)) 45 | } 46 | 47 | return response, nil 48 | } 49 | 50 | // ListRedeemedHistoriesByUser retrieves the redemption history for a specific user. 51 | func (s *VoucherRedemptionHistoryApplicationService) ListRedeemedHistoriesByUser(ctx context.Context, request *dto.ListRedeemVoucherByUserIDRequest) ([]*dto.VoucherRedemptionHistoryResponse, error) { 52 | err := request.Validate() 53 | if err != nil { 54 | return nil, serr.ValidationErr("VoucherRedemptionHistoryApplicationService.ListRedeemedHistoriesByUser", 55 | err.Error(), serr.ErrInvalidInput) 56 | } 57 | 58 | result, err := s.domainService.ListRedeemedHistoriesByUser(ctx, request.UserID) 59 | if err != nil { 60 | return nil, serr.ServiceErr("VoucherRedemptionHistoryApplicationService.ListRedeemedHistoriesByUser", 61 | err.Error(), err, http.StatusInternalServerError) 62 | } 63 | 64 | response := make([]*dto.VoucherRedemptionHistoryResponse, 0, len(result)) 65 | for _, v := range result { 66 | response = append(response, dto.ToVoucherRedemptionHistoryEntity(v)) 67 | } 68 | 69 | return response, nil 70 | 71 | } 72 | -------------------------------------------------------------------------------- /internal/core/domain/entity/voucher_code.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | "voucher/pkg/serr" 6 | ) 7 | 8 | type ( 9 | VoucherCode struct { 10 | ID int // Unique identifier for the voucher code 11 | Code string // The voucher code itself 12 | Amount int // Amount of credit 13 | State VoucherState // State of the voucher (e.g., 'Available', 'Redeemed', 'Expired') 14 | UsageLimit int // Maximum number of redemptions allowed 15 | UserLimit int // Maximum number of user allowed 16 | CurrentUsage int // Number of times the code has been Redeemed 17 | CreatedAt time.Time // When the code was created 18 | UpdatedAt time.Time // Last updated time 19 | } 20 | 21 | VoucherState string 22 | ) 23 | 24 | const ( 25 | Available VoucherState = "Available" 26 | Redeemed VoucherState = "Redeemed" 27 | Expired VoucherState = "Expired" 28 | ) 29 | 30 | // Validate checks if the VoucherCode entity is valid 31 | func (vc *VoucherCode) Validate() error { 32 | if vc.Code == "" { 33 | return serr.ValidationErr("VoucherCode.Validate", 34 | "voucher code cannot be empty", 35 | serr.ErrInvalidVoucher) 36 | } 37 | if vc.UsageLimit <= 0 { 38 | return serr.ValidationErr("VoucherCode.Validate", 39 | "usage limit must be greater than zero", 40 | serr.ErrInvalidVoucher) 41 | } 42 | if vc.UserLimit <= 0 { 43 | return serr.ValidationErr("VoucherCode.Validate", 44 | "user limit must be greater than zero", 45 | serr.ErrInvalidVoucher) 46 | } 47 | if vc.CurrentUsage < 0 { 48 | return serr.ValidationErr("VoucherCode.Validate", 49 | "current usage cannot be negative", 50 | serr.ErrInvalidVoucher) 51 | } 52 | if vc.State != Available && vc.State != Redeemed && vc.State != Expired { 53 | return serr.ValidationErr("VoucherCode.Validate", 54 | "invalid state", 55 | serr.ErrInvalidVoucher) 56 | } 57 | 58 | if vc.CurrentUsage >= vc.UsageLimit { 59 | return serr.ValidationErr("VoucherCode.Validate", 60 | "voucher usage is limited", 61 | serr.ErrReachLimit) 62 | } 63 | if vc.Amount <= 0 { 64 | return serr.ValidationErr("VoucherCode.Validate", 65 | "invalid amount", 66 | serr.ErrInvalidVoucher) 67 | } 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/core/domain/entity/voucher_redemption_history.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | "voucher/pkg/serr" 6 | ) 7 | 8 | // VoucherRedemptionHistory represents the redemption record of a voucher code 9 | type VoucherRedemptionHistory struct { 10 | ID int // Unique identifier for the redemption record 11 | VoucherID int // ID of the Redeemed voucher 12 | Amount int // Amount of voucher 13 | RedeemedAt time.Time // When the voucher was Redeemed 14 | UserID string // ID of the user who Redeemed the voucher 15 | } 16 | 17 | func (vrh *VoucherRedemptionHistory) Validate() error { 18 | if vrh.VoucherID <= 0 { 19 | return serr.ValidationErr("VoucherRedemptionHistory.Validate", 20 | "invalid voucher ID", 21 | serr.ErrInvalidVoucher) 22 | } 23 | if vrh.UserID == "" { 24 | return serr.ValidationErr("VoucherRedemptionHistory.Validate", 25 | "invalid user ID", 26 | serr.ErrInvalidUser) 27 | } 28 | if vrh.RedeemedAt.IsZero() { 29 | return serr.ValidationErr("VoucherRedemptionHistory.Validate", 30 | "Redeemed at timestamp cannot be zero", 31 | serr.ErrInvalidTime) 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/core/domain/services/voucher_code.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "time" 8 | "voucher/internal/core/application/ports" 9 | "voucher/internal/core/domain/entity" 10 | "voucher/pkg/serr" 11 | ) 12 | 13 | // VoucherCodeService provides domain logic related to vouchers 14 | type VoucherCodeService struct { 15 | persistencePort ports.VoucherPersistencePort 16 | } 17 | 18 | // NewVoucherCodeService creates a new instance of VoucherCodeService 19 | func NewVoucherCodeService(persistencePort ports.VoucherPersistencePort) ports.VoucherCodeServicePort { 20 | return &VoucherCodeService{persistencePort: persistencePort} 21 | } 22 | 23 | // CreateVoucher creates a new voucher 24 | func (s *VoucherCodeService) CreateVoucher(ctx context.Context, code string, maxUsages int) error { 25 | // Check if voucher already exists 26 | existingVoucher, err := s.persistencePort.GetVoucher(ctx, code) 27 | if err != nil && !errors.Is(err, sql.ErrNoRows) { 28 | return err 29 | } 30 | if existingVoucher != nil { 31 | return serr.ValidationErr("VoucherCodeService.CreateVoucher", 32 | "voucher already exists", serr.ErrInvalidVoucher) 33 | } 34 | 35 | // Create and save new voucher 36 | voucher := &entity.VoucherCode{ 37 | Code: code, 38 | State: entity.Available, 39 | UsageLimit: maxUsages, 40 | CurrentUsage: 0, 41 | CreatedAt: time.Now(), 42 | UpdatedAt: time.Now(), 43 | } 44 | return s.persistencePort.CreateVoucher(ctx, voucher) 45 | } 46 | 47 | // RedeemVoucher handles the redemption process of a voucher 48 | func (s *VoucherCodeService) RedeemVoucher(ctx context.Context, code string, tx *sql.Tx) (*entity.VoucherCode, error) { 49 | // todo: here add acquiring application level lock something like redis distribute lock 50 | // todo: for preventing parallel race condition 51 | // Retrieve the voucher 52 | voucher, err := s.persistencePort.GetVoucherWithLock(ctx, code, tx) 53 | if err != nil { 54 | return nil, err 55 | } 56 | // check voucher is not nil 57 | if voucher == nil { 58 | return nil, serr.ValidationErr("VoucherCodeService.RedeemVoucher", 59 | "voucher not found", serr.ErrInvalidVoucher) 60 | } 61 | 62 | // Update voucher usage count 63 | voucher.CurrentUsage++ 64 | 65 | // validate voucher entity before update 66 | err = voucher.Validate() 67 | if err != nil { 68 | return nil, err 69 | } 70 | if err := s.persistencePort.UpdateVoucher(ctx, voucher, tx); err != nil { 71 | return nil, err 72 | } 73 | 74 | return voucher, nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/core/domain/services/voucher_code_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "testing" 8 | "time" 9 | "voucher/internal/core/domain/entity" 10 | "voucher/internal/core/domain/services" 11 | "voucher/internal/core/mocks" 12 | "voucher/pkg/serr" 13 | 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/mock" 16 | ) 17 | 18 | func TestVoucherCodeService_CreateVoucher(t *testing.T) { 19 | mockPersistence := new(mocks.MockVoucherPersistencePort) 20 | svc := services.NewVoucherCodeService(mockPersistence) 21 | 22 | ctx := context.Background() 23 | code := "TESTVOUCHER" 24 | maxUsages := 10 25 | 26 | t.Run("Success", func(t *testing.T) { 27 | mockPersistence.On("GetVoucher", ctx, code).Return(nil, sql.ErrNoRows).Once() 28 | mockPersistence.On("CreateVoucher", ctx, mock.AnythingOfType("*entity.VoucherCode")).Return(nil).Once() 29 | 30 | err := svc.CreateVoucher(ctx, code, maxUsages) 31 | assert.NoError(t, err) 32 | 33 | mockPersistence.AssertExpectations(t) 34 | }) 35 | 36 | t.Run("VoucherAlreadyExists", func(t *testing.T) { 37 | existingVoucher := &entity.VoucherCode{Code: code} 38 | expectedErr := serr.ValidationErr("VoucherCodeService.CreateVoucher", "voucher already exists", serr.ErrInvalidVoucher) 39 | 40 | mockPersistence.On("GetVoucher", ctx, code).Return(existingVoucher, expectedErr).Once() 41 | 42 | err := svc.CreateVoucher(ctx, code, maxUsages) 43 | assert.Error(t, err) 44 | assert.True(t, errors.Is(err, expectedErr)) 45 | 46 | mockPersistence.AssertExpectations(t) 47 | }) 48 | } 49 | 50 | func TestVoucherCodeService_RedeemVoucher(t *testing.T) { 51 | mockPersistence := new(mocks.MockVoucherPersistencePort) 52 | svc := services.NewVoucherCodeService(mockPersistence) 53 | 54 | ctx := context.Background() 55 | code := "TESTVOUCHER" 56 | tx := &sql.Tx{} // Assume tx is properly initialized for the test 57 | 58 | t.Run("Success", func(t *testing.T) { 59 | voucher := &entity.VoucherCode{ 60 | Code: code, 61 | State: entity.Available, 62 | Amount: 1000000, 63 | UsageLimit: 10, 64 | UserLimit: 1, 65 | CurrentUsage: 0, 66 | CreatedAt: time.Now(), 67 | UpdatedAt: time.Now(), 68 | } 69 | 70 | mockPersistence.On("GetVoucherWithLock", ctx, code, tx).Return(voucher, nil).Once() 71 | mockPersistence.On("UpdateVoucher", ctx, voucher, tx).Return(nil).Once() 72 | 73 | redeemedVoucher, err := svc.RedeemVoucher(ctx, code, tx) 74 | assert.NoError(t, err) 75 | assert.Equal(t, voucher.Code, redeemedVoucher.Code) 76 | assert.Equal(t, 1, redeemedVoucher.CurrentUsage) 77 | 78 | mockPersistence.AssertExpectations(t) 79 | }) 80 | 81 | t.Run("VoucherNotFound", func(t *testing.T) { 82 | expectedErr := serr.ValidationErr("VoucherCodeService.RedeemVoucher", "voucher not found", serr.ErrInvalidVoucher) 83 | mockPersistence.On("GetVoucherWithLock", ctx, code, tx).Return(nil, expectedErr).Once() 84 | voucher, err := svc.RedeemVoucher(ctx, code, tx) 85 | assert.Error(t, err) 86 | assert.Nil(t, voucher) 87 | assert.True(t, errors.Is(err, expectedErr)) 88 | 89 | mockPersistence.AssertExpectations(t) 90 | }) 91 | 92 | t.Run("UpdateVoucherFails", func(t *testing.T) { 93 | voucher := &entity.VoucherCode{ 94 | Code: code, 95 | State: entity.Available, 96 | UsageLimit: 10, 97 | UserLimit: 1, 98 | Amount: 1000000, 99 | CurrentUsage: 0, 100 | CreatedAt: time.Now(), 101 | UpdatedAt: time.Now(), 102 | } 103 | 104 | mockPersistence.On("GetVoucherWithLock", ctx, code, tx).Return(voucher, nil).Once() 105 | mockPersistence.On("UpdateVoucher", ctx, voucher, tx).Return(errors.New("update failed")).Once() 106 | 107 | redeemedVoucher, err := svc.RedeemVoucher(ctx, code, tx) 108 | assert.Error(t, err) 109 | assert.Nil(t, redeemedVoucher) 110 | 111 | mockPersistence.AssertExpectations(t) 112 | }) 113 | } 114 | -------------------------------------------------------------------------------- /internal/core/domain/services/voucher_redeemed_history.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | "voucher/internal/core/application/ports" 8 | "voucher/internal/core/domain/entity" 9 | ) 10 | 11 | // VoucherRedeemedHistoryService provides domain logic related to voucher redemption history. 12 | type VoucherRedeemedHistoryService struct { 13 | redemptionPort ports.VoucherRedemptionPersistencePort 14 | } 15 | 16 | // NewVoucherRedeemedHistoryService creates a new instance of VoucherRedeemedHistoryService. 17 | func NewVoucherRedeemedHistoryService(redemptionPort ports.VoucherRedemptionPersistencePort) ports.VoucherRedeemedHistoryServicePort { 18 | return &VoucherRedeemedHistoryService{ 19 | redemptionPort: redemptionPort, 20 | } 21 | } 22 | 23 | // RecordRedemption records a new voucher redemption in the history. 24 | func (s *VoucherRedeemedHistoryService) RecordRedemption(ctx context.Context, voucherID int, userID string, tx *sql.Tx) error { 25 | history := &entity.VoucherRedemptionHistory{ 26 | VoucherID: voucherID, 27 | UserID: userID, 28 | RedeemedAt: time.Now(), 29 | } 30 | err := history.Validate() 31 | if err != nil { 32 | return err 33 | } 34 | return s.redemptionPort.CreateRedeemedHistory(ctx, history, tx) 35 | } 36 | 37 | // ListRedeemedHistoriesByCode retrieves the redemption history for a specific voucher's code. 38 | func (s *VoucherRedeemedHistoryService) ListRedeemedHistoriesByCode(ctx context.Context, code string) ([]*entity.VoucherRedemptionHistory, error) { 39 | return s.redemptionPort.ListRedeemedHistoriesByCode(ctx, code) 40 | } 41 | 42 | // ListRedeemedHistoriesByUser retrieves the redemption history for a specific user. 43 | func (s *VoucherRedeemedHistoryService) ListRedeemedHistoriesByUser(ctx context.Context, userID string) ([]*entity.VoucherRedemptionHistory, error) { 44 | return s.redemptionPort.ListRedeemedHistoriesByUser(ctx, userID) 45 | } 46 | 47 | // ListRedeemedHistoryUsage retrieves the redemption history usage by code and userID 48 | func (s *VoucherRedeemedHistoryService) ListRedeemedHistoryUsage(ctx context.Context, code, userID string) ([]*entity.VoucherRedemptionHistory, error) { 49 | return s.redemptionPort.ListRedeemedHistoryUsage(ctx, code, userID) 50 | } 51 | -------------------------------------------------------------------------------- /internal/core/domain/services/voucher_redeemed_history_test.go: -------------------------------------------------------------------------------- 1 | package services_test 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "testing" 7 | "time" 8 | "voucher/internal/core/domain/entity" 9 | "voucher/internal/core/domain/services" 10 | "voucher/internal/core/mocks" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/mock" 14 | ) 15 | 16 | func TestVoucherRedeemedHistoryService_RecordRedemption(t *testing.T) { 17 | mockRedemptionPort := new(mocks.MockVoucherRedemptionPersistencePort) 18 | svc := services.NewVoucherRedeemedHistoryService(mockRedemptionPort) 19 | 20 | ctx := context.Background() 21 | voucherID := 1 22 | userID := "user123" 23 | tx := &sql.Tx{} // Assume tx is properly initialized for the test 24 | 25 | t.Run("Success", func(t *testing.T) { 26 | mockRedemptionPort.On("CreateRedeemedHistory", ctx, mock.AnythingOfType("*entity.VoucherRedemptionHistory"), tx).Return(nil).Once() 27 | 28 | err := svc.RecordRedemption(ctx, voucherID, userID, tx) 29 | assert.NoError(t, err) 30 | 31 | mockRedemptionPort.AssertExpectations(t) 32 | }) 33 | 34 | t.Run("ValidationError", func(t *testing.T) { 35 | // Example: Missing userID (assuming Validate would fail on this) 36 | mockRedemptionPort.On("CreateRedeemedHistory", ctx, mock.AnythingOfType("*entity.VoucherRedemptionHistory"), tx).Return(nil).Maybe() 37 | 38 | err := svc.RecordRedemption(ctx, voucherID, "", tx) 39 | assert.Error(t, err) 40 | 41 | mockRedemptionPort.AssertNotCalled(t, "CreateRedeemedHistory") 42 | }) 43 | } 44 | 45 | func TestVoucherRedeemedHistoryService_ListRedeemedHistoriesByCode(t *testing.T) { 46 | mockRedemptionPort := new(mocks.MockVoucherRedemptionPersistencePort) 47 | svc := services.NewVoucherRedeemedHistoryService(mockRedemptionPort) 48 | 49 | ctx := context.Background() 50 | code := "VOUCHERCODE" 51 | 52 | t.Run("Success", func(t *testing.T) { 53 | histories := []*entity.VoucherRedemptionHistory{ 54 | { 55 | VoucherID: 1, 56 | UserID: "user123", 57 | RedeemedAt: time.Now(), 58 | }, 59 | } 60 | 61 | mockRedemptionPort.On("ListRedeemedHistoriesByCode", ctx, code).Return(histories, nil).Once() 62 | 63 | result, err := svc.ListRedeemedHistoriesByCode(ctx, code) 64 | assert.NoError(t, err) 65 | assert.Equal(t, histories, result) 66 | 67 | mockRedemptionPort.AssertExpectations(t) 68 | }) 69 | 70 | t.Run("NoHistoriesFound", func(t *testing.T) { 71 | mockRedemptionPort.On("ListRedeemedHistoriesByCode", ctx, code).Return(nil, nil).Once() 72 | 73 | result, err := svc.ListRedeemedHistoriesByCode(ctx, code) 74 | assert.NoError(t, err) 75 | assert.Nil(t, result) 76 | 77 | mockRedemptionPort.AssertExpectations(t) 78 | }) 79 | } 80 | 81 | func TestVoucherRedeemedHistoryService_ListRedeemedHistoriesByUser(t *testing.T) { 82 | mockRedemptionPort := new(mocks.MockVoucherRedemptionPersistencePort) 83 | svc := services.NewVoucherRedeemedHistoryService(mockRedemptionPort) 84 | 85 | ctx := context.Background() 86 | userID := "user123" 87 | 88 | t.Run("Success", func(t *testing.T) { 89 | histories := []*entity.VoucherRedemptionHistory{ 90 | { 91 | VoucherID: 1, 92 | UserID: userID, 93 | RedeemedAt: time.Now(), 94 | }, 95 | } 96 | 97 | mockRedemptionPort.On("ListRedeemedHistoriesByUser", ctx, userID).Return(histories, nil).Once() 98 | 99 | result, err := svc.ListRedeemedHistoriesByUser(ctx, userID) 100 | assert.NoError(t, err) 101 | assert.Equal(t, histories, result) 102 | 103 | mockRedemptionPort.AssertExpectations(t) 104 | }) 105 | 106 | t.Run("NoHistoriesFound", func(t *testing.T) { 107 | mockRedemptionPort.On("ListRedeemedHistoriesByUser", ctx, userID).Return(nil, nil).Once() 108 | 109 | result, err := svc.ListRedeemedHistoriesByUser(ctx, userID) 110 | assert.NoError(t, err) 111 | assert.Nil(t, result) 112 | 113 | mockRedemptionPort.AssertExpectations(t) 114 | }) 115 | } 116 | 117 | func TestVoucherRedeemedHistoryService_ListRedeemedHistoryUsage(t *testing.T) { 118 | mockRedemptionPort := new(mocks.MockVoucherRedemptionPersistencePort) 119 | svc := services.NewVoucherRedeemedHistoryService(mockRedemptionPort) 120 | 121 | ctx := context.Background() 122 | code := "VOUCHERCODE" 123 | userID := "user123" 124 | 125 | t.Run("Success", func(t *testing.T) { 126 | histories := []*entity.VoucherRedemptionHistory{ 127 | { 128 | VoucherID: 1, 129 | UserID: userID, 130 | RedeemedAt: time.Now(), 131 | }, 132 | } 133 | 134 | mockRedemptionPort.On("ListRedeemedHistoryUsage", ctx, code, userID).Return(histories, nil).Once() 135 | 136 | result, err := svc.ListRedeemedHistoryUsage(ctx, code, userID) 137 | assert.NoError(t, err) 138 | assert.Equal(t, histories, result) 139 | 140 | mockRedemptionPort.AssertExpectations(t) 141 | }) 142 | 143 | t.Run("NoHistoriesFound", func(t *testing.T) { 144 | mockRedemptionPort.On("ListRedeemedHistoryUsage", ctx, code, userID).Return(nil, nil).Once() 145 | 146 | result, err := svc.ListRedeemedHistoryUsage(ctx, code, userID) 147 | assert.NoError(t, err) 148 | assert.Nil(t, result) 149 | 150 | mockRedemptionPort.AssertExpectations(t) 151 | }) 152 | 153 | } 154 | -------------------------------------------------------------------------------- /internal/core/mocks/voucher_code_persistence.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "voucher/internal/core/domain/entity" 7 | 8 | "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockVoucherPersistencePort is a mock implementation of the VoucherPersistencePort interface 12 | type MockVoucherPersistencePort struct { 13 | mock.Mock 14 | } 15 | 16 | func (m *MockVoucherPersistencePort) CreateVoucher(ctx context.Context, voucher *entity.VoucherCode) error { 17 | args := m.Called(ctx, voucher) 18 | return args.Error(0) 19 | } 20 | 21 | func (m *MockVoucherPersistencePort) GetVoucher(ctx context.Context, code string) (*entity.VoucherCode, error) { 22 | args := m.Called(ctx, code) 23 | if v := args.Get(0); v != nil { 24 | return v.(*entity.VoucherCode), args.Error(1) 25 | } 26 | return nil, args.Error(1) 27 | } 28 | 29 | func (m *MockVoucherPersistencePort) GetVoucherWithLock(ctx context.Context, code string, tx *sql.Tx) (*entity.VoucherCode, error) { 30 | args := m.Called(ctx, code, tx) 31 | if v := args.Get(0); v != nil { 32 | return v.(*entity.VoucherCode), args.Error(1) 33 | } 34 | return nil, args.Error(1) 35 | } 36 | 37 | func (m *MockVoucherPersistencePort) UpdateVoucher(ctx context.Context, voucher *entity.VoucherCode, tx *sql.Tx) error { 38 | args := m.Called(ctx, voucher, tx) 39 | return args.Error(0) 40 | } 41 | -------------------------------------------------------------------------------- /internal/core/mocks/voucher_redeemed_history_persistence.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "voucher/internal/core/domain/entity" 7 | 8 | "github.com/stretchr/testify/mock" 9 | ) 10 | 11 | // MockVoucherRedemptionPersistencePort is a mock implementation of the VoucherRedemptionPersistencePort interface 12 | type MockVoucherRedemptionPersistencePort struct { 13 | mock.Mock 14 | } 15 | 16 | func (m *MockVoucherRedemptionPersistencePort) CreateRedeemedHistory(ctx context.Context, history *entity.VoucherRedemptionHistory, tx *sql.Tx) error { 17 | args := m.Called(ctx, history, tx) 18 | return args.Error(0) 19 | } 20 | 21 | func (m *MockVoucherRedemptionPersistencePort) ListRedeemedHistoriesByUser(ctx context.Context, userID string) ([]*entity.VoucherRedemptionHistory, error) { 22 | args := m.Called(ctx, userID) 23 | if v := args.Get(0); v != nil { 24 | return v.([]*entity.VoucherRedemptionHistory), args.Error(1) 25 | } 26 | return nil, args.Error(1) 27 | } 28 | 29 | func (m *MockVoucherRedemptionPersistencePort) ListRedeemedHistoriesByCode(ctx context.Context, code string) ([]*entity.VoucherRedemptionHistory, error) { 30 | args := m.Called(ctx, code) 31 | if v := args.Get(0); v != nil { 32 | return v.([]*entity.VoucherRedemptionHistory), args.Error(1) 33 | } 34 | return nil, args.Error(1) 35 | } 36 | 37 | func (m *MockVoucherRedemptionPersistencePort) ListRedeemedHistoryUsage(ctx context.Context, code, userID string) ([]*entity.VoucherRedemptionHistory, error) { 38 | args := m.Called(ctx, code, userID) 39 | if v := args.Get(0); v != nil { 40 | return v.([]*entity.VoucherRedemptionHistory), args.Error(1) 41 | } 42 | return nil, args.Error(1) 43 | } 44 | -------------------------------------------------------------------------------- /internal/infrastructure/db/migrate.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "github.com/golang-migrate/migrate/v4" 8 | "github.com/golang-migrate/migrate/v4/database/postgres" 9 | _ "github.com/golang-migrate/migrate/v4/source/file" 10 | "voucher/internal/config" 11 | ) 12 | 13 | func Migrate(db *sql.DB) error { 14 | driver, err := postgres.WithInstance(db, &postgres.Config{}) 15 | if err != nil { 16 | return fmt.Errorf("failed to get db instance: %w", err) 17 | } 18 | m, err := migrate.NewWithDatabaseInstance( 19 | fmt.Sprintf("file://%s", config.DBMigrationsPath()), config.DBName(), driver) 20 | if err != nil { 21 | return fmt.Errorf("failed to initialize db migrations: %w", err) 22 | } 23 | if err = m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { 24 | return fmt.Errorf("failed to migrate database: %w", err) 25 | } 26 | return nil 27 | } 28 | -------------------------------------------------------------------------------- /internal/infrastructure/db/migrations/00001_create_voucher_codes_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS voucher_codes; 2 | -------------------------------------------------------------------------------- /internal/infrastructure/db/migrations/00001_create_voucher_codes_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS voucher_codes ( 2 | id SERIAL PRIMARY KEY, 3 | code VARCHAR(255) UNIQUE NOT NULL, 4 | amount INT NOT NULL DEFAULT 0, 5 | state VARCHAR(50) NOT NULL, 6 | usage_limit INT NOT NULL DEFAULT 0, 7 | user_limit INT NOT NULL DEFAULT 0, 8 | current_usage INT NOT NULL DEFAULT 0, 9 | created_at TIMESTAMP DEFAULT NOW(), 10 | updated_at TIMESTAMP DEFAULT NOW() 11 | ); -------------------------------------------------------------------------------- /internal/infrastructure/db/migrations/00002_create_voucher_redemption_history_table.down.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE IF EXISTS voucher_redemption_history; 2 | -------------------------------------------------------------------------------- /internal/infrastructure/db/migrations/00002_create_voucher_redemption_history_table.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS voucher_redemption_history ( 2 | id SERIAL PRIMARY KEY, 3 | voucher_id INT REFERENCES voucher_codes(id) ON DELETE CASCADE, 4 | amount INT NOT NULL DEFAULT 0, 5 | code VARCHAR(255) NOT NULL, 6 | redeemed_at TIMESTAMP DEFAULT NOW(), 7 | user_id INT 8 | ); 9 | 10 | CREATE INDEX idx_redemption_code ON voucher_redemption_history(code); 11 | CREATE INDEX idx_redemption_user_id ON voucher_redemption_history(user_id); -------------------------------------------------------------------------------- /internal/infrastructure/db/postgres.go: -------------------------------------------------------------------------------- 1 | package db 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "fmt" 8 | ) 9 | 10 | var ( 11 | ErrDBNoTInitiated = errors.New("db not initiated") 12 | ) 13 | 14 | var globalDB *sql.DB 15 | 16 | func sqlDB() (*sql.DB, error) { 17 | if globalDB == nil { 18 | return nil, ErrDBNoTInitiated 19 | } 20 | return globalDB, nil 21 | } 22 | 23 | func NewPostgres( 24 | dbName, username, password, host, port string, maxOpenConnections, maxIdleConnections int, 25 | ) (*sql.DB, error) { 26 | db, err := sql.Open("postgres", fmt.Sprintf( 27 | "postgres://%s:%s@%s:%s/%s?sslmode=disable", 28 | username, password, host, port, dbName, 29 | )) 30 | if err != nil { 31 | return nil, err 32 | } 33 | if err = db.Ping(); err != nil { 34 | return nil, fmt.Errorf("failed to ping db: %w", err) 35 | } 36 | db.SetMaxIdleConns(maxIdleConnections) 37 | db.SetMaxOpenConns(maxOpenConnections) 38 | if globalDB == nil { 39 | globalDB = db 40 | } 41 | return db, nil 42 | } 43 | 44 | func Transaction(ctx context.Context, isolationLevel sql.IsolationLevel, fn func(tx *sql.Tx) error) error { 45 | db, err := sqlDB() 46 | if err != nil { 47 | return err 48 | } 49 | tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: isolationLevel}) 50 | if err != nil { 51 | return err 52 | } 53 | if err = fn(tx); err != nil { 54 | _ = tx.Rollback() 55 | return err 56 | } 57 | return tx.Commit() 58 | } 59 | -------------------------------------------------------------------------------- /internal/infrastructure/persistence/models/voucher_code.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | "voucher/internal/core/domain/entity" 7 | "voucher/pkg/serr" 8 | ) 9 | 10 | // VoucherCodeDB represents the database model for the voucher_codes table 11 | type VoucherCodeDB struct { 12 | ID int 13 | Code string 14 | Amount int 15 | State string 16 | UsageLimit int 17 | UserLimit int 18 | CurrentUsage int 19 | CreatedAt time.Time 20 | UpdatedAt time.Time 21 | } 22 | 23 | // Constants for voucher_codes table column names 24 | const ( 25 | VoucherColumns = "id, code, amount, state, usage_limit, user_limit, current_usage, created_at, updated_at" 26 | VoucherColumnsNoID = "code, amount, state, usage_limit, user_limit, current_usage, created_at, updated_at" 27 | ) 28 | 29 | // ScanVoucherCode scans a row into a VoucherCodeDB model 30 | func ScanVoucherCode(row *sql.Row) (*VoucherCodeDB, error) { 31 | voucher := &VoucherCodeDB{} 32 | err := row.Scan( 33 | &voucher.ID, 34 | &voucher.Code, 35 | &voucher.Amount, 36 | &voucher.State, 37 | &voucher.UsageLimit, 38 | &voucher.UserLimit, 39 | &voucher.CurrentUsage, 40 | &voucher.CreatedAt, 41 | &voucher.UpdatedAt, 42 | ) 43 | if err != nil { 44 | return nil, serr.DBError("ScanVoucherCode", "voucher_code", err) 45 | } 46 | return voucher, nil 47 | } 48 | 49 | // ToVoucherCodeEntity converts the database model to the core entity 50 | func (v *VoucherCodeDB) ToVoucherCodeEntity() *entity.VoucherCode { 51 | return &entity.VoucherCode{ 52 | ID: v.ID, 53 | Code: v.Code, 54 | Amount: v.Amount, 55 | State: entity.VoucherState(v.State), 56 | UsageLimit: v.UsageLimit, 57 | UserLimit: v.UserLimit, 58 | CurrentUsage: v.CurrentUsage, 59 | CreatedAt: v.CreatedAt, 60 | UpdatedAt: v.UpdatedAt, 61 | } 62 | } 63 | 64 | // ToVoucherCodeDB converts the core entity to the database model 65 | func ToVoucherCodeDB(v *entity.VoucherCode) *VoucherCodeDB { 66 | return &VoucherCodeDB{ 67 | ID: v.ID, 68 | Code: v.Code, 69 | Amount: v.Amount, 70 | State: string(v.State), 71 | UsageLimit: v.UsageLimit, 72 | UserLimit: v.UserLimit, 73 | CurrentUsage: v.CurrentUsage, 74 | CreatedAt: v.CreatedAt, 75 | UpdatedAt: v.UpdatedAt, 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /internal/infrastructure/persistence/models/voucher_redeemed_history.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | "voucher/internal/core/domain/entity" 7 | ) 8 | 9 | // VoucherRedemptionHistoryDB represents the database model for the voucher_redemption_history table 10 | type VoucherRedemptionHistoryDB struct { 11 | ID int 12 | VoucherID int 13 | Amount int 14 | RedeemedAt time.Time 15 | UserID string 16 | } 17 | 18 | // VoucherRedemptionHistoryColumns Constants representing the column names for easier usage in queries. 19 | const ( 20 | VoucherRedemptionHistoryColumns = "id, amount, voucher_id, redeemed_at, user_id" 21 | ) 22 | 23 | // ScanVoucherRedemptionHistory scans a database row into a VoucherRedemptionHistoryDB model. 24 | func ScanVoucherRedemptionHistory(row *sql.Row) (*VoucherRedemptionHistoryDB, error) { 25 | var history VoucherRedemptionHistoryDB 26 | err := row.Scan(&history.ID, &history.Amount, &history.VoucherID, &history.RedeemedAt, &history.UserID) 27 | if err != nil { 28 | return nil, err 29 | } 30 | return &history, nil 31 | } 32 | 33 | // ScanVoucherRedemptionHistories scans multiple database rows into a slice of VoucherRedemptionHistoryDB models. 34 | func ScanVoucherRedemptionHistories(rows *sql.Rows) ([]*VoucherRedemptionHistoryDB, error) { 35 | var histories []*VoucherRedemptionHistoryDB 36 | for rows.Next() { 37 | var history VoucherRedemptionHistoryDB 38 | err := rows.Scan(&history.ID, &history.Amount, &history.VoucherID, &history.RedeemedAt, &history.UserID) 39 | if err != nil { 40 | return nil, err 41 | } 42 | histories = append(histories, &history) 43 | } 44 | if err := rows.Err(); err != nil { 45 | return nil, err 46 | } 47 | return histories, nil 48 | } 49 | 50 | // ToVoucherRedemptionHistoryEntity converts the database model to the core entity 51 | func (v *VoucherRedemptionHistoryDB) ToVoucherRedemptionHistoryEntity() *entity.VoucherRedemptionHistory { 52 | return &entity.VoucherRedemptionHistory{ 53 | ID: v.ID, 54 | VoucherID: v.VoucherID, 55 | Amount: v.Amount, 56 | RedeemedAt: v.RedeemedAt, 57 | UserID: v.UserID, 58 | } 59 | } 60 | 61 | // ToVoucherRedemptionHistoryDB converts the core entity to the database model 62 | func ToVoucherRedemptionHistoryDB(v *entity.VoucherRedemptionHistory) *VoucherRedemptionHistoryDB { 63 | return &VoucherRedemptionHistoryDB{ 64 | ID: v.ID, 65 | VoucherID: v.VoucherID, 66 | Amount: v.Amount, 67 | RedeemedAt: v.RedeemedAt, 68 | UserID: v.UserID, 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/infrastructure/persistence/voucher_code_adapter.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "errors" 7 | "voucher/internal/core/application/ports" 8 | "voucher/internal/core/domain/entity" 9 | "voucher/internal/infrastructure/persistence/models" 10 | "voucher/pkg/serr" 11 | ) 12 | 13 | type PostgresVoucherPersistenceAdapter struct { 14 | db *sql.DB 15 | } 16 | 17 | func NewPostgresVoucherCodePersistence(db *sql.DB) ports.VoucherPersistencePort { 18 | return &PostgresVoucherPersistenceAdapter{db: db} 19 | } 20 | 21 | // CreateVoucher saves a new voucher to the database 22 | func (r *PostgresVoucherPersistenceAdapter) CreateVoucher(ctx context.Context, voucher *entity.VoucherCode) error { 23 | dbModel := models.ToVoucherCodeDB(voucher) 24 | query := ` 25 | INSERT INTO voucher_codes (` + models.VoucherColumnsNoID + `) 26 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 27 | RETURNING id` 28 | err := r.db.QueryRowContext(ctx, query, dbModel.Code, dbModel.Amount, dbModel.State, dbModel.UsageLimit, dbModel.UserLimit, dbModel.CurrentUsage, dbModel.CreatedAt, dbModel.UpdatedAt).Scan(&dbModel.ID) 29 | if err != nil { 30 | return serr.DBError("CreateVoucher", "voucher_code", err) 31 | } 32 | // Update the entity ID after successful creation 33 | voucher.ID = dbModel.ID 34 | return nil 35 | } 36 | 37 | // GetVoucher retrieves a voucher by its code 38 | func (r *PostgresVoucherPersistenceAdapter) GetVoucher(ctx context.Context, code string) (*entity.VoucherCode, error) { 39 | query := ` 40 | SELECT ` + models.VoucherColumns + ` 41 | FROM voucher_codes 42 | WHERE code = $1` 43 | row := r.db.QueryRowContext(ctx, query, code) 44 | voucher, err := models.ScanVoucherCode(row) 45 | if err != nil { 46 | if errors.Is(err, sql.ErrNoRows) { 47 | return nil, nil 48 | } 49 | return nil, serr.DBError("GetVoucher", "voucher_code", err) 50 | } 51 | return voucher.ToVoucherCodeEntity(), nil 52 | } 53 | 54 | // GetVoucherWithLock retrieves a voucher by its code and locks the row 55 | func (r *PostgresVoucherPersistenceAdapter) GetVoucherWithLock(ctx context.Context, code string, tx *sql.Tx) (*entity.VoucherCode, error) { 56 | query := ` 57 | SELECT ` + models.VoucherColumns + ` 58 | FROM voucher_codes 59 | WHERE code = $1 60 | FOR UPDATE` 61 | var row *sql.Row 62 | row = tx.QueryRowContext(ctx, query, code) 63 | voucher, err := models.ScanVoucherCode(row) 64 | if err != nil { 65 | if errors.Is(err, sql.ErrNoRows) { 66 | return nil, nil 67 | } 68 | return nil, serr.DBError("GetVoucherWithLock", "voucher_code", err) 69 | } 70 | return voucher.ToVoucherCodeEntity(), nil 71 | } 72 | 73 | // UpdateVoucher updates an existing voucher in the database 74 | func (r *PostgresVoucherPersistenceAdapter) UpdateVoucher(ctx context.Context, voucher *entity.VoucherCode, tx *sql.Tx) error { 75 | dbModel := models.ToVoucherCodeDB(voucher) 76 | query := ` 77 | UPDATE voucher_codes 78 | SET code = $1, amount = $2, state = $3, usage_limit = $4, user_limit = $5, current_usage = $6, updated_at = $7 79 | WHERE id = $8` 80 | var err error 81 | if tx != nil { 82 | _, err = tx.ExecContext(ctx, query, dbModel.Code, dbModel.Amount, dbModel.State, dbModel.UsageLimit, dbModel.UserLimit, dbModel.CurrentUsage, dbModel.UpdatedAt, dbModel.ID) 83 | } else { 84 | _, err = r.db.ExecContext(ctx, query, dbModel.Code, dbModel.Amount, dbModel.State, dbModel.UsageLimit, dbModel.UserLimit, dbModel.CurrentUsage, dbModel.UpdatedAt, dbModel.ID) 85 | } 86 | if err != nil { 87 | return serr.DBError("UpdateVoucher", "voucher_code", err) 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /internal/infrastructure/persistence/voucher_redeemed_history_adapter.go: -------------------------------------------------------------------------------- 1 | package persistence 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "voucher/internal/core/application/ports" 7 | "voucher/internal/core/domain/entity" 8 | "voucher/internal/infrastructure/persistence/models" 9 | "voucher/pkg/serr" 10 | ) 11 | 12 | type VoucherRedemptionPersistenceAdapter struct { 13 | db *sql.DB 14 | } 15 | 16 | // NewVoucherRedemptionPersistenceAdapter creates a new instance of VoucherRedemptionPersistenceAdapter. 17 | func NewVoucherRedemptionPersistenceAdapter(db *sql.DB) ports.VoucherRedemptionPersistencePort { 18 | return &VoucherRedemptionPersistenceAdapter{db: db} 19 | } 20 | 21 | // CreateRedeemedHistory saves a new redemption record to the database. 22 | func (r *VoucherRedemptionPersistenceAdapter) CreateRedeemedHistory(ctx context.Context, history *entity.VoucherRedemptionHistory, tx *sql.Tx) error { 23 | historyDB := models.ToVoucherRedemptionHistoryDB(history) 24 | 25 | query := `INSERT INTO voucher_redemption_history (voucher_id, amount, redeemed_at, user_id) VALUES ($1, $2, $3, $4)` 26 | var err error 27 | if tx != nil { 28 | _, err = tx.ExecContext(ctx, query, historyDB.VoucherID, &history.Amount, historyDB.RedeemedAt, historyDB.UserID) 29 | } else { 30 | _, err = r.db.ExecContext(ctx, query, historyDB.VoucherID, &history.Amount, historyDB.RedeemedAt, historyDB.UserID) 31 | } 32 | if err != nil { 33 | return serr.DBError("CreateRedeemedHistory", "voucher_redeemed_history", err) 34 | } 35 | 36 | return nil 37 | } 38 | 39 | // ListRedeemedHistoriesByUser retrieves redemption records based on user id. 40 | func (r *VoucherRedemptionPersistenceAdapter) ListRedeemedHistoriesByUser(ctx context.Context, userID string) ([]*entity.VoucherRedemptionHistory, error) { 41 | query := `SELECT ` + models.VoucherRedemptionHistoryColumns + ` FROM voucher_redemption_history WHERE user_id = $1` 42 | rows, err := r.db.QueryContext(ctx, query, userID) 43 | if err != nil { 44 | return nil, err 45 | } 46 | defer rows.Close() 47 | 48 | historiesDB, err := models.ScanVoucherRedemptionHistories(rows) 49 | if err != nil { 50 | return nil, serr.DBError("ListRedeemedHistoriesByUser", "voucher_redeemed_history", err) 51 | } 52 | 53 | var histories []*entity.VoucherRedemptionHistory 54 | for _, historyDB := range historiesDB { 55 | histories = append(histories, historyDB.ToVoucherRedemptionHistoryEntity()) 56 | } 57 | return histories, nil 58 | } 59 | 60 | // ListRedeemedHistoriesByCode retrieves redemption records based on voucher code. 61 | func (r *VoucherRedemptionPersistenceAdapter) ListRedeemedHistoriesByCode(ctx context.Context, code string) ([]*entity.VoucherRedemptionHistory, error) { 62 | query := `SELECT ` + models.VoucherRedemptionHistoryColumns + ` FROM voucher_redemption_history WHERE code = $1` 63 | rows, err := r.db.QueryContext(ctx, query, code) 64 | if err != nil { 65 | return nil, serr.DBError("ListRedeemedHistoriesByCode", "voucher_redeemed_history", err) 66 | } 67 | defer rows.Close() 68 | 69 | historiesDB, err := models.ScanVoucherRedemptionHistories(rows) 70 | if err != nil { 71 | return nil, serr.DBError("ListRedeemedHistoriesByCode", "voucher_redeemed_history", err) 72 | } 73 | 74 | var histories []*entity.VoucherRedemptionHistory 75 | for _, historyDB := range historiesDB { 76 | histories = append(histories, historyDB.ToVoucherRedemptionHistoryEntity()) 77 | } 78 | return histories, nil 79 | } 80 | 81 | // ListRedeemedHistoryUsage retrieves redemption records based on voucher code and userID. 82 | func (r *VoucherRedemptionPersistenceAdapter) ListRedeemedHistoryUsage(ctx context.Context, code, userID string) ([]*entity.VoucherRedemptionHistory, error) { 83 | query := `SELECT ` + models.VoucherRedemptionHistoryColumns + ` FROM voucher_redemption_history WHERE code = $1 AND user_id = $2` 84 | rows, err := r.db.QueryContext(ctx, query, code, userID) 85 | if err != nil { 86 | return nil, serr.DBError("ListRedeemedHistoryUsage", "voucher_redeemed_history", err) 87 | } 88 | defer rows.Close() 89 | 90 | historiesDB, err := models.ScanVoucherRedemptionHistories(rows) 91 | if err != nil { 92 | return nil, serr.DBError("ListRedeemedHistoryUsage", "voucher_redeemed_history", err) 93 | } 94 | 95 | var histories []*entity.VoucherRedemptionHistory 96 | for _, historyDB := range historiesDB { 97 | histories = append(histories, historyDB.ToVoucherRedemptionHistoryEntity()) 98 | } 99 | return histories, nil 100 | } 101 | -------------------------------------------------------------------------------- /internal/interfaces/api/dto/voucher_dto.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "time" 5 | "voucher/internal/core/domain/entity" 6 | ) 7 | 8 | type ( 9 | CreateVoucherRequest struct { 10 | Code string `json:"code" validate:"required"` 11 | Amount int `json:"amount" validate:"required,gt=0"` 12 | Description string `json:"description" validate:"required"` 13 | UsageLimit int `json:"usage_limit" validate:"required,gt=0"` 14 | ExpiryDate time.Time `json:"expiry_date" validate:"required"` 15 | } 16 | 17 | RedeemVoucherRequest struct { 18 | Code string `json:"code" validate:"required"` 19 | UserID string `json:"user_id" validate:"required"` 20 | } 21 | 22 | ListRedeemVoucherByCodeRequest struct { 23 | Code string `json:"code" validate:"required"` 24 | } 25 | 26 | ListRedeemVoucherByUserIDRequest struct { 27 | UserID string `json:"user_id" validate:"required"` 28 | } 29 | 30 | VoucherRedemptionHistoryResponse struct { 31 | ID int `json:"id"` 32 | VoucherID int `json:"voucher_id"` 33 | Amount int `json:"amount"` 34 | RedeemedAt time.Time `json:"redeemed_at"` 35 | UserID string `json:"user_id"` 36 | } 37 | ) 38 | 39 | func ToVoucherRedemptionHistoryEntity(entity *entity.VoucherRedemptionHistory) *VoucherRedemptionHistoryResponse { 40 | return &VoucherRedemptionHistoryResponse{ 41 | ID: entity.ID, 42 | VoucherID: entity.VoucherID, 43 | Amount: entity.Amount, 44 | RedeemedAt: entity.RedeemedAt, 45 | UserID: entity.UserID, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /internal/interfaces/api/dto/voucher_dto_validation.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/go-playground/validator/v10" 5 | ) 6 | 7 | // Validator instance 8 | var validate = validator.New() 9 | 10 | // Validate method for CreateVoucherRequest 11 | func (v *CreateVoucherRequest) Validate() error { 12 | return validate.Struct(v) 13 | } 14 | 15 | // Validate method for RedeemVoucherRequest 16 | func (v *RedeemVoucherRequest) Validate() error { 17 | return validate.Struct(v) 18 | } 19 | 20 | // Validate method for ListRedeemVoucherByCodeRequest 21 | func (v *ListRedeemVoucherByCodeRequest) Validate() error { 22 | return validate.Struct(v) 23 | } 24 | 25 | // Validate method for ListRedeemVoucherByUserIDRequest 26 | func (v *ListRedeemVoucherByUserIDRequest) Validate() error { 27 | return validate.Struct(v) 28 | } 29 | -------------------------------------------------------------------------------- /internal/interfaces/api/helper.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "github.com/gin-gonic/gin" 6 | "github.com/rs/zerolog/log" 7 | "net/http" 8 | "voucher/pkg/serr" 9 | ) 10 | 11 | type ( 12 | Error struct { 13 | Message string `json:"message"` 14 | Code serr.ErrorCode `json:"code"` 15 | TraceID string `json:"trace_id"` 16 | } 17 | 18 | Response[D any, S bool, M string] struct { 19 | Data D `json:"data"` 20 | Status S `json:"status"` 21 | Message M `json:"message"` 22 | } 23 | ) 24 | 25 | func handleError(ctx *gin.Context, err error) { 26 | var serviceError *serr.ServiceError 27 | switch { 28 | case errors.As(err, &serviceError): 29 | var e *serr.ServiceError 30 | errors.As(err, &e) 31 | l := log.Error().Str("method", e.Method).Str("code", string(e.ErrorCode)) 32 | if e.Cause != nil { 33 | l.Err(e.Cause) 34 | } 35 | l.Msg(e.Message) 36 | ctx.AbortWithStatusJSON( 37 | e.Code, 38 | Error{Code: e.ErrorCode, Message: e.Message}, 39 | ) 40 | return 41 | default: 42 | log.Error().Err(err).Msg("unknown error") 43 | ctx.AbortWithStatusJSON( 44 | http.StatusInternalServerError, 45 | Error{Code: serr.ErrInternal, Message: "internal server error"}, 46 | ) 47 | return 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /internal/interfaces/api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import "voucher/internal/server" 4 | 5 | func SetupVoucherCodeRoutes(s *server.Server, v *VoucherCodeHandler) { 6 | s.External.POST("vouchers", v.CreateVoucher) 7 | s.External.PATCH("vouchers/redeem", v.RedeemVoucher) 8 | } 9 | 10 | func SetupVoucherRedeemedHistoryRoutes(s *server.Server, v *VoucherRedeemedHistoryHandler) { 11 | s.External.GET("vouchers/:code/history", v.ListRedeemedHistoriesByCode) 12 | s.External.GET("vouchers/users/:user_id/history", v.ListRedeemedHistoriesByUser) 13 | } 14 | -------------------------------------------------------------------------------- /internal/interfaces/api/voucher_code_handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | "voucher/internal/core/application/services" 7 | "voucher/internal/interfaces/api/dto" 8 | "voucher/pkg/serr" 9 | ) 10 | 11 | type VoucherCodeHandler struct { 12 | voucherAppService *services.VoucherApplicationService 13 | } 14 | 15 | func NewVoucherCodeHandler(voucherAppService *services.VoucherApplicationService) *VoucherCodeHandler { 16 | return &VoucherCodeHandler{voucherAppService: voucherAppService} 17 | } 18 | 19 | // CreateVoucher godoc 20 | // @Summary Create a new voucher 21 | // @Description Create a new voucher with code, description, usage limit, and expiry date. 22 | // @Tags Vouchers 23 | // @Accept json 24 | // @Produce json 25 | // @Param request body dto.CreateVoucherRequest true "Create Voucher Request" 26 | // @Success 201 {object} nil 27 | // @Failure 400 {object} Error 28 | // @Failure 500 {object} Error 29 | // @Router /vouchers [post] 30 | func (h *VoucherCodeHandler) CreateVoucher(c *gin.Context) { 31 | var req dto.CreateVoucherRequest 32 | if err := c.ShouldBindJSON(&req); err != nil { 33 | handleError(c, serr.ValidationErr("handler.CreateVoucher", 34 | "invalid input", serr.ErrInvalidInput)) 35 | return 36 | } 37 | 38 | err := h.voucherAppService.CreateVoucher(c.Request.Context(), &req) 39 | if err != nil { 40 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 41 | return 42 | } 43 | 44 | c.JSON(http.StatusCreated, nil) 45 | } 46 | 47 | // RedeemVoucher godoc 48 | // @Summary Redeem a voucher 49 | // @Description Redeem a voucher code for a user. 50 | // @Tags Vouchers 51 | // @Accept json 52 | // @Produce json 53 | // @Param request body dto.RedeemVoucherRequest true "Redeem Voucher Request" 54 | // @Success 200 {object} nil 55 | // @Failure 400 {object} Error 56 | // @Failure 500 {object} Error 57 | // @Router /vouchers/redeem [patch] 58 | func (h *VoucherCodeHandler) RedeemVoucher(c *gin.Context) { 59 | var req dto.RedeemVoucherRequest 60 | if err := c.ShouldBindJSON(&req); err != nil { 61 | c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 62 | return 63 | } 64 | 65 | err := h.voucherAppService.RedeemVoucher(c.Request.Context(), &req) 66 | if err != nil { 67 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 68 | return 69 | } 70 | 71 | c.JSON(http.StatusOK, nil) 72 | } 73 | -------------------------------------------------------------------------------- /internal/interfaces/api/voucher_redeemed_history_handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "net/http" 6 | "voucher/internal/core/application/services" 7 | "voucher/internal/interfaces/api/dto" 8 | ) 9 | 10 | type VoucherRedeemedHistoryHandler struct { 11 | voucherRedeemedHistoryAppService *services.VoucherRedemptionHistoryApplicationService 12 | } 13 | 14 | func NewVoucherRedeemedHistoryHandler(voucherAppService *services.VoucherRedemptionHistoryApplicationService) *VoucherRedeemedHistoryHandler { 15 | return &VoucherRedeemedHistoryHandler{voucherRedeemedHistoryAppService: voucherAppService} 16 | } 17 | 18 | // ListRedeemedHistoriesByCode godoc 19 | // @Summary List redeemed voucher histories by code 20 | // @Description Get a list of voucher redemption histories filtered by voucher code. 21 | // @Tags Vouchers 22 | // @Accept json 23 | // @Produce json 24 | // @Param code path string true "Voucher Code" 25 | // @Success 200 {array} dto.VoucherRedemptionHistoryResponse 26 | // @Failure 500 {object} Error 27 | // @Router /vouchers/{code}/history [get] 28 | func (h *VoucherRedeemedHistoryHandler) ListRedeemedHistoriesByCode(c *gin.Context) { 29 | var req dto.ListRedeemVoucherByCodeRequest 30 | req.Code = c.Param("code") 31 | result, err := h.voucherRedeemedHistoryAppService.ListRedeemedHistoriesByCode(c.Request.Context(), &req) 32 | if err != nil { 33 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 34 | return 35 | } 36 | 37 | c.JSON(http.StatusOK, result) 38 | } 39 | 40 | // ListRedeemedHistoriesByUser godoc 41 | // @Summary List redeemed voucher histories by user ID 42 | // @Description Get a list of voucher redemption histories filtered by user ID. 43 | // @Tags Vouchers 44 | // @Accept json 45 | // @Produce json 46 | // @Param user_id path string true "User ID" 47 | // @Success 200 {array} dto.VoucherRedemptionHistoryResponse 48 | // @Failure 500 {object} Error 49 | // @Router /vouchers/users/{user_id}/history [get] 50 | func (h *VoucherRedeemedHistoryHandler) ListRedeemedHistoriesByUser(c *gin.Context) { 51 | var req dto.ListRedeemVoucherByUserIDRequest 52 | req.UserID = c.Param("user_id") 53 | result, err := h.voucherRedeemedHistoryAppService.ListRedeemedHistoriesByUser(c.Request.Context(), &req) 54 | if err != nil { 55 | c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) 56 | return 57 | } 58 | 59 | c.JSON(http.StatusOK, result) 60 | } 61 | -------------------------------------------------------------------------------- /internal/server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/gin-contrib/cors" 5 | "github.com/gin-gonic/gin" 6 | "time" 7 | "voucher/internal/config" 8 | ) 9 | 10 | func CORS() gin.HandlerFunc { 11 | return cors.New(cors.Config{ 12 | AllowOrigins: config.CORSAllowedOrigins(), 13 | AllowMethods: config.CORSAllowedMethods(), 14 | AllowHeaders: config.CORSAllowedHeaders(), 15 | AllowCredentials: config.CORSAllowCredentials(), 16 | MaxAge: 12 * time.Hour, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/gin-gonic/gin" 8 | "github.com/rs/zerolog/log" 9 | swaggerfiles "github.com/swaggo/files" 10 | ginSwagger "github.com/swaggo/gin-swagger" 11 | "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" 12 | "go.uber.org/fx" 13 | "net/http" 14 | "voucher/docs" 15 | "voucher/internal/config" 16 | ) 17 | 18 | type Server struct { 19 | External *gin.Engine 20 | Internal *gin.Engine 21 | healthFunc func(ctx *gin.Context) 22 | } 23 | 24 | func NewServer() *Server { 25 | if !config.ServerDebug() { 26 | gin.SetMode(gin.ReleaseMode) 27 | } 28 | s := &Server{External: gin.Default(), Internal: gin.Default()} 29 | s.External.Use(otelgin.Middleware(config.ServiceName())) 30 | s.External.Use(CORS()) 31 | s.Internal.Use(otelgin.Middleware(config.ServiceName())) 32 | if config.Env() != config.PROD { 33 | s.External.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 34 | s.setDoc() 35 | } 36 | return s 37 | } 38 | 39 | func (s *Server) WithMiddlewares(middlewares ...gin.HandlerFunc) *Server { 40 | for _, mw := range middlewares { 41 | s.External.Use(mw) 42 | } 43 | return s 44 | } 45 | 46 | func (s *Server) SetHealthFunc(f func() error) *Server { 47 | s.healthFunc = func(ctx *gin.Context) { 48 | if err := f(); err != nil { 49 | ctx.AbortWithStatus(http.StatusInternalServerError) 50 | return 51 | } 52 | ctx.JSON(http.StatusOK, gin.H{"status": "ok"}) 53 | } 54 | return s 55 | } 56 | 57 | func (s *Server) SetupRoutes() { 58 | s.External.GET("/health", s.healthFunc) 59 | s.Internal.GET("/health", s.healthFunc) 60 | } 61 | 62 | func (s *Server) Run(port string) { 63 | err := s.External.Run(":" + port) 64 | if err != nil { 65 | log.Fatal().Err(err).Msg("failed to run web server") 66 | } 67 | } 68 | 69 | func (s *Server) setDoc() { 70 | docs.SwaggerInfo.Title = "Voucher Api" 71 | docs.SwaggerInfo.Version = "1.0" 72 | docs.SwaggerInfo.Host = config.ServerAddress() 73 | } 74 | 75 | func (s *Server) RunAsync(port string) { 76 | go s.Run(port) 77 | } 78 | 79 | func Run(lc fx.Lifecycle, s *Server) { 80 | external := &http.Server{ 81 | Addr: fmt.Sprintf(":%d", config.ServerExternalPort()), 82 | Handler: s.External, 83 | } 84 | internal := &http.Server{ 85 | Addr: fmt.Sprintf(":%d", config.ServerInternalPort()), 86 | Handler: s.Internal, 87 | } 88 | lc.Append(fx.Hook{ 89 | OnStop: func(ctx context.Context) error { 90 | log.Info().Msg("shutting down the server ...") 91 | err := external.Shutdown(ctx) 92 | if err != nil { 93 | return err 94 | } 95 | return internal.Shutdown(ctx) 96 | }, 97 | OnStart: func(ctx context.Context) error { 98 | log.Info().Msg("running server ...") 99 | go func() { 100 | if err := external.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 101 | log.Error().Err(err).Msg("failed to run external web server") 102 | } 103 | }() 104 | go func() { 105 | if err := internal.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 106 | log.Error().Err(err).Msg("failed to run internal web server") 107 | } 108 | }() 109 | return nil 110 | }}, 111 | ) 112 | } 113 | -------------------------------------------------------------------------------- /pkg/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "github.com/rs/zerolog" 6 | ) 7 | 8 | func SetupLogger() error { 9 | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix 10 | lvl, err := zerolog.ParseLevel("info") 11 | if err != nil { 12 | return fmt.Errorf("failed to pars level: %v", err) 13 | } 14 | zerolog.SetGlobalLevel(lvl) 15 | return nil 16 | } 17 | -------------------------------------------------------------------------------- /pkg/serr/error.go: -------------------------------------------------------------------------------- 1 | package serr 2 | 3 | import ( 4 | "database/sql" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | ) 9 | 10 | type ErrorCode string 11 | 12 | const ( 13 | ErrInternal ErrorCode = "INTERNAL" 14 | ErrInvalidVoucher ErrorCode = "INVALID_VOUCHER" 15 | ErrReachLimit ErrorCode = "REACH_LIMIT" 16 | ErrInvalidUser ErrorCode = "INVALID_USER" 17 | ErrInvalidTime ErrorCode = "INVALID_TIME" 18 | ErrInvalidInput ErrorCode = "INVALID_INPUT" 19 | ) 20 | 21 | type ServiceError struct { 22 | Method string 23 | Cause error 24 | Message string 25 | ErrorCode ErrorCode 26 | Code int 27 | } 28 | 29 | func (e ServiceError) Error() string { 30 | return fmt.Sprintf( 31 | "%s (%d) - %s: %s", 32 | e.Method, e.Code, e.Message, e.Cause, 33 | ) 34 | } 35 | 36 | func ValidationErr(method, message string, code ErrorCode) error { 37 | return &ServiceError{ 38 | Method: method, 39 | Message: message, 40 | Code: http.StatusBadRequest, 41 | ErrorCode: code, 42 | } 43 | } 44 | 45 | func ServiceErr(method, message string, cause error, code int) error { 46 | return &ServiceError{ 47 | Method: method, 48 | Cause: cause, 49 | Message: message, 50 | Code: code, 51 | } 52 | } 53 | 54 | func DBError(method, repo string, cause error) error { 55 | err := &ServiceError{ 56 | Method: fmt.Sprintf("%s.%s", repo, method), 57 | Cause: cause, 58 | } 59 | switch { 60 | case errors.Is(cause, sql.ErrNoRows): 61 | err.Code = http.StatusNotFound 62 | err.Message = fmt.Sprintf("%s not found", repo) 63 | default: 64 | err.Code = http.StatusInternalServerError 65 | err.Message = fmt.Sprintf("could not perform action on %s", repo) 66 | } 67 | return err 68 | } 69 | -------------------------------------------------------------------------------- /pkg/serr/error_test.go: -------------------------------------------------------------------------------- 1 | package serr_test 2 | 3 | import ( 4 | "b2bapi/internal/serr" 5 | "database/sql" 6 | "errors" 7 | "github.com/stretchr/testify/assert" 8 | "testing" 9 | ) 10 | 11 | func TestNewDBError(t *testing.T) { 12 | // DB errors' status must be 500 to avoid error leaking unless it's a not found error 13 | t.Run("internal error", func(t *testing.T) { 14 | t.Parallel() 15 | err := errors.New("random err") 16 | dbErr := serr.NewDBError("Test", "Test", err) 17 | assert.Equal(t, dbErr.(*serr.ServiceError).Code, 500) 18 | }) 19 | t.Run("sql not found db error", func(t *testing.T) { 20 | t.Parallel() 21 | dbErr := serr.NewDBError("Test", "Test", sql.ErrNoRows) 22 | assert.Equal(t, dbErr.(*serr.ServiceError).Code, 404) 23 | }) 24 | } 25 | 26 | func TestIsDBNoRows(t *testing.T) { 27 | t.Run("sql no rows", func(t *testing.T) { 28 | t.Parallel() 29 | assert.True(t, serr.IsDBNoRows(sql.ErrNoRows)) 30 | }) 31 | } 32 | --------------------------------------------------------------------------------