From a8c351f3c0997095feb24a303fd2df1f93b869bf Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Mon, 21 Sep 2026 08:41:29 +0330 Subject: [PATCH] feat(zarinhub): implement card to iban client --- .env.sample | 3 + backend/internal/zarinhub/client.go | 151 +++++++++ backend/internal/zarinhub/client_test.go | 383 +++++++++++++++++++++++ backend/internal/zarinhub/errors.go | 11 + backend/internal/zarinhub/models.go | 32 ++ 5 files changed, 580 insertions(+) create mode 100644 .env.sample create mode 100644 backend/internal/zarinhub/client.go create mode 100644 backend/internal/zarinhub/client_test.go create mode 100644 backend/internal/zarinhub/errors.go create mode 100644 backend/internal/zarinhub/models.go diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..707b603 --- /dev/null +++ b/.env.sample @@ -0,0 +1,3 @@ +ZARINHUB_ACCESS_TOKEN= +ZARINHUB_ENDPOINT=https://zarin-hub.com/api/v5/KYC/CardToIban +ZARINHUB_TIMEOUT=5s \ No newline at end of file diff --git a/backend/internal/zarinhub/client.go b/backend/internal/zarinhub/client.go new file mode 100644 index 0000000..d3d6e41 --- /dev/null +++ b/backend/internal/zarinhub/client.go @@ -0,0 +1,151 @@ +package zarinhub + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + + "card-to-iban/internal/service" +) + +type Client struct { + httpClient *http.Client + endpointURL string + accessToken string +} + +var _ service.CardToIBANClient = (*Client)(nil) + +func NewClient( + httpClient *http.Client, + endpointURL string, + accessToken string, +) *Client { + return &Client{ + httpClient: httpClient, + endpointURL: endpointURL, + accessToken: accessToken, + } +} + +func (c *Client) Convert( + ctx context.Context, + cardNumber string, +) (service.ConversionResult, error) { + payload, err := json.Marshal(convertRequest{ + Card: cardNumber, + }) + if err != nil { + return service.ConversionResult{}, + fmt.Errorf("marshal zarinhub request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.endpointURL, + bytes.NewReader(payload), + ) + if err != nil { + return service.ConversionResult{}, + fmt.Errorf("create zarinhub request: %w", err) + } + + req.Header.Set( + "Authorization", + "Bearer "+c.accessToken, + ) + + req.Header.Set( + "Content-Type", + "application/json", + ) + + resp, err := c.httpClient.Do(req) + if err != nil { + switch { + case errors.Is(err, context.DeadlineExceeded): + return service.ConversionResult{}, + fmt.Errorf("%w: %v", ErrTimeout, err) + + case errors.Is(err, context.Canceled): + return service.ConversionResult{}, + fmt.Errorf("zarinhub request canceled: %w", err) + } + + var netErr net.Error + + if errors.As(err, &netErr) && netErr.Timeout() { + return service.ConversionResult{}, + fmt.Errorf("%w: %v", ErrTimeout, err) + } + + return service.ConversionResult{}, + fmt.Errorf("%w: %v", ErrUnavailable, err) + } + + defer resp.Body.Close() + + result := service.ConversionResult{ + UpstreamHTTPStatus: resp.StatusCode, + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return result, + fmt.Errorf( + "%w: failed to read response body: %v", + ErrInvalidResponse, + err, + ) + } + + result.RawResponse = body + + switch { + case resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden: + return result, ErrAuthentication + + case resp.StatusCode == http.StatusRequestTimeout: + return result, ErrTimeout + + case resp.StatusCode == http.StatusTooManyRequests: + return result, ErrUnavailable + + case resp.StatusCode >= http.StatusInternalServerError: + return result, ErrUnavailable + + case resp.StatusCode < http.StatusOK || + resp.StatusCode >= http.StatusMultipleChoices: + return result, ErrRejected + } + + var decoded convertResponse + + if err := json.Unmarshal(body, &decoded); err != nil { + return result, + fmt.Errorf("%w: %v", ErrInvalidResponse, err) + } + + if !decoded.Meta.IsSuccess { + return result, ErrRejected + } + + if decoded.Data.IBAN == "" { + return result, + fmt.Errorf( + "%w: successful response did not contain iban", + ErrInvalidResponse, + ) + } + + result.IBAN = decoded.Data.IBAN + + return result, nil +} diff --git a/backend/internal/zarinhub/client_test.go b/backend/internal/zarinhub/client_test.go new file mode 100644 index 0000000..86ccdf1 --- /dev/null +++ b/backend/internal/zarinhub/client_test.go @@ -0,0 +1,383 @@ +package zarinhub + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestClientConvertSuccess(t *testing.T) { + const cardNumber = "6037997512345678" + const expectedIBAN = "IR820540102680020817909002" + const token = "test-token" + + server := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + if r.Method != http.MethodPost { + t.Errorf( + "method = %s, want POST", + r.Method, + ) + } + + if got := r.Header.Get("Authorization"); got != "Bearer "+token { + t.Errorf( + "Authorization = %q, want %q", + got, + "Bearer "+token, + ) + } + + if got := r.Header.Get("Content-Type"); got != "application/json" { + t.Errorf( + "Content-Type = %q, want application/json", + got, + ) + } + + var req convertRequest + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf( + "decode request: %v", + err, + ) + } + + if req.Card != cardNumber { + t.Errorf( + "card = %q, want %q", + req.Card, + cardNumber, + ) + } + + w.Header().Set( + "Content-Type", + "application/json", + ) + + fmt.Fprint(w, `{ + "data": { + "bankName": "bankName_value", + "card": "6037997512345678", + "deposit": "0101234567001", + "depositDescription": "depositDescription_value", + "depositOwners": "depositOwners_value", + "depositStatus": "depositStatus_value", + "englishBankName": "englishBankName_value", + "iban": "IR820540102680020817909002" + }, + "meta": { + "code": 0, + "errorMessage": null, + "errorType": null, + "errors": [], + "isSuccess": true, + "message": "success", + "status": "success", + "trackId": "test-track-id" + } + }`) + }), + ) + + defer server.Close() + + client := NewClient( + &http.Client{ + Timeout: time.Second, + }, + server.URL, + token, + ) + + result, err := client.Convert( + context.Background(), + cardNumber, + ) + + if err != nil { + t.Fatalf( + "Convert() unexpected error: %v", + err, + ) + } + + if result.IBAN != expectedIBAN { + t.Errorf( + "IBAN = %q, want %q", + result.IBAN, + expectedIBAN, + ) + } + + if result.UpstreamHTTPStatus != http.StatusOK { + t.Errorf( + "status = %d, want %d", + result.UpstreamHTTPStatus, + http.StatusOK, + ) + } + + if len(result.RawResponse) == 0 { + t.Error( + "expected raw response to be preserved", + ) + } +} + +func TestClientReturnsRejectedForApplicationFailure( + t *testing.T, +) { + server := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + w.Header().Set( + "Content-Type", + "application/json", + ) + + fmt.Fprint(w, `{ + "data": {}, + "meta": { + "trackId": null, + "status": "error", + "isSuccess": false, + "code": -1, + "message": "request failed", + "errorMessage": "invalid parameters", + "errorType": "validation_error", + "errors": [] + } + }`) + }), + ) + + defer server.Close() + + client := NewClient( + &http.Client{ + Timeout: time.Second, + }, + server.URL, + "test-token", + ) + + result, err := client.Convert( + context.Background(), + "6037997512345678", + ) + + if !errors.Is(err, ErrRejected) { + t.Fatalf( + "error = %v, want ErrRejected", + err, + ) + } + + if result.UpstreamHTTPStatus != http.StatusOK { + t.Errorf( + "status = %d, want 200", + result.UpstreamHTTPStatus, + ) + } + + if len(result.RawResponse) == 0 { + t.Error( + "expected raw response to be preserved", + ) + } +} + +func TestClientMapsHTTPStatusErrors(t *testing.T) { + tests := []struct { + name string + status int + wantErr error + }{ + { + name: "unauthorized", + status: http.StatusUnauthorized, + wantErr: ErrAuthentication, + }, + { + name: "forbidden", + status: http.StatusForbidden, + wantErr: ErrAuthentication, + }, + { + name: "request timeout", + status: http.StatusRequestTimeout, + wantErr: ErrTimeout, + }, + { + name: "rate limited", + status: http.StatusTooManyRequests, + wantErr: ErrUnavailable, + }, + { + name: "server error", + status: http.StatusInternalServerError, + wantErr: ErrUnavailable, + }, + { + name: "bad request", + status: http.StatusBadRequest, + wantErr: ErrRejected, + }, + } + + for _, tt := range tests { + t.Run( + tt.name, + func(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + w.WriteHeader(tt.status) + + fmt.Fprint( + w, + `{"error":"test"}`, + ) + }), + ) + + defer server.Close() + + client := NewClient( + &http.Client{ + Timeout: time.Second, + }, + server.URL, + "test-token", + ) + + result, err := client.Convert( + context.Background(), + "6037997512345678", + ) + + if !errors.Is(err, tt.wantErr) { + t.Fatalf( + "error = %v, want %v", + err, + tt.wantErr, + ) + } + + if result.UpstreamHTTPStatus != tt.status { + t.Errorf( + "status = %d, want %d", + result.UpstreamHTTPStatus, + tt.status, + ) + } + + if len(result.RawResponse) == 0 { + t.Error( + "expected raw response to be preserved", + ) + } + }, + ) + } +} + +func TestClientReturnsInvalidResponseForMalformedJSON( + t *testing.T, +) { + server := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + w.WriteHeader(http.StatusOK) + + fmt.Fprint( + w, + `{not-json}`, + ) + }), + ) + + defer server.Close() + + client := NewClient( + &http.Client{ + Timeout: time.Second, + }, + server.URL, + "test-token", + ) + + result, err := client.Convert( + context.Background(), + "6037997512345678", + ) + + if !errors.Is(err, ErrInvalidResponse) { + t.Fatalf( + "error = %v, want ErrInvalidResponse", + err, + ) + } + + if result.UpstreamHTTPStatus != http.StatusOK { + t.Errorf( + "status = %d, want 200", + result.UpstreamHTTPStatus, + ) + } +} + +func TestClientReturnsTimeout(t *testing.T) { + server := httptest.NewServer( + http.HandlerFunc(func( + w http.ResponseWriter, + r *http.Request, + ) { + time.Sleep(200 * time.Millisecond) + + fmt.Fprint( + w, + `{}`, + ) + }), + ) + + defer server.Close() + + client := NewClient( + &http.Client{ + Timeout: 50 * time.Millisecond, + }, + server.URL, + "test-token", + ) + + _, err := client.Convert( + context.Background(), + "6037997512345678", + ) + + if !errors.Is(err, ErrTimeout) { + t.Fatalf( + "error = %v, want ErrTimeout", + err, + ) + } +} diff --git a/backend/internal/zarinhub/errors.go b/backend/internal/zarinhub/errors.go new file mode 100644 index 0000000..937757d --- /dev/null +++ b/backend/internal/zarinhub/errors.go @@ -0,0 +1,11 @@ +package zarinhub + +import "errors" + +var ( + ErrAuthentication = errors.New("zarinhub authentication failed") + ErrTimeout = errors.New("zarinhub request timed out") + ErrUnavailable = errors.New("zarinhub service unavailable") + ErrRejected = errors.New("zarinhub rejected request") + ErrInvalidResponse = errors.New("zarinhub returned invalid response") +) diff --git a/backend/internal/zarinhub/models.go b/backend/internal/zarinhub/models.go new file mode 100644 index 0000000..aa14c5e --- /dev/null +++ b/backend/internal/zarinhub/models.go @@ -0,0 +1,32 @@ +package zarinhub + +type convertRequest struct { + Card string `json:"card"` +} + +type convertResponse struct { + Data responseData `json:"data"` + Meta responseMeta `json:"meta"` +} + +type responseData struct { + BankName string `json:"bankName"` + Card string `json:"card"` + Deposit string `json:"deposit"` + DepositDescription string `json:"depositDescription"` + DepositOwners string `json:"depositOwners"` + DepositStatus string `json:"depositStatus"` + EnglishBankName string `json:"englishBankName"` + IBAN string `json:"iban"` +} + +type responseMeta struct { + Code int `json:"code"` + ErrorMessage *string `json:"errorMessage"` + ErrorType *string `json:"errorType"` + Errors []any `json:"errors"` + IsSuccess bool `json:"isSuccess"` + Message string `json:"message"` + Status string `json:"status"` + TrackID *string `json:"trackId"` +}