From faedfadea77e1f113345c8cd7653d955d2bcde90 Mon Sep 17 00:00:00 2001 From: Keerthivasan Venkitajalam Date: Fri, 26 Jun 2026 13:06:23 +0530 Subject: [PATCH 1/6] fix(payu): replace fake GET with real verify_payment POST + SHA-512 request hash and nested transaction_details parsing --- internal/gateway/payu/client.go | 139 +++++++++++++++++++++----------- 1 file changed, 94 insertions(+), 45 deletions(-) diff --git a/internal/gateway/payu/client.go b/internal/gateway/payu/client.go index d3053d4..14f7d97 100644 --- a/internal/gateway/payu/client.go +++ b/internal/gateway/payu/client.go @@ -2,6 +2,8 @@ package payu import ( "context" + "crypto/sha512" + "encoding/hex" "encoding/json" "fmt" "io" @@ -13,86 +15,133 @@ import ( "time" ) -// Client is a thin HTTP client for PayU status lookups. BaseURL should be configured -// by the environment or main program. If BaseURL is empty the client returns an error -// from Status so callers can decide how to proceed. +// Client calls the PayU Transaction Status Check API (verify_payment command). +// Docs: https://docs.payu.in/reference/transaction-status-check-api-2 type Client struct { - BaseURL string - APIKey string - HTTP *http.Client + BaseURL string + MerchantKey string + Salt string + HTTP *http.Client } -// NewClient returns a new PayU client. baseURL may be empty in which case Status returns an error. -func NewClient(baseURL, apiKey string) *Client { +// NewClient returns a PayU client ready to call verify_payment. +// baseURL = PAYU_STATUS_URL +// merchantKey = GATEWAY_API_KEY (the merchant key, not the salt) +// salt = WEBHOOK_SECRET (the PayU salt, same one used for webhook HMAC) +func NewClient(baseURL, merchantKey, salt string) *Client { return &Client{ - BaseURL: baseURL, - APIKey: apiKey, - HTTP: &http.Client{Timeout: 10 * time.Second}, + BaseURL: baseURL, + MerchantKey: merchantKey, + Salt: salt, + HTTP: &http.Client{Timeout: 10 * time.Second}, } } -// Status queries PayU status API for txnID and returns a normalized status, amount, raw response, error. -// The exact endpoint and response format varies by gateway integration; callers should configure BaseURL accordingly. +// payuEnvelope is the outer PayU verify_payment response shape. +type payuEnvelope struct { + Status int `json:"status"` + Msg string `json:"msg"` + TransactionDetails map[string]json.RawMessage `json:"transaction_details"` +} + +// payuTxnDetail holds the fields we read from inside transaction_details. +type payuTxnDetail struct { + Status string `json:"status"` + Amount string `json:"amount"` +} + +// Status implements gateway.GatewayClient. +// It POSTs the verify_payment command to PayU, parses the nested +// transaction_details, and returns a normalized status, amount in paise, +// raw response, and error. func (c *Client) Status(ctx context.Context, txnID string) (string, int64, json.RawMessage, error) { if c.BaseURL == "" { return "", 0, nil, fmt.Errorf("payu status base URL not configured") } - u, err := url.Parse(c.BaseURL) - if err != nil { - return "", 0, nil, err - } - q := u.Query() - q.Set("txnid", txnID) - u.RawQuery = q.Encode() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + form := url.Values{} + form.Set("key", c.MerchantKey) + form.Set("command", "verify_payment") + form.Set("var1", txnID) + form.Set("hash", requestHash(c.MerchantKey, txnID, c.Salt)) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.BaseURL, strings.NewReader(form.Encode())) if err != nil { return "", 0, nil, err } - if c.APIKey != "" { - req.Header.Set("Authorization", "Bearer "+c.APIKey) - } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := c.HTTP.Do(req) if err != nil { return "", 0, nil, err } - defer func() { - _ = resp.Body.Close() - }() + defer func() { _ = resp.Body.Close() }() + b, err := io.ReadAll(resp.Body) if err != nil { return "", 0, nil, err } + raw := json.RawMessage(b) + + if resp.StatusCode != http.StatusOK { + return "", 0, raw, fmt.Errorf("payu status API returned HTTP %d", resp.StatusCode) + } - var m map[string]interface{} - if err := json.Unmarshal(b, &m); err != nil { - // return raw body and parsing error - return "", 0, json.RawMessage(b), fmt.Errorf("invalid response JSON: %w", err) + var envelope payuEnvelope + if err := json.Unmarshal(b, &envelope); err != nil { + return "", 0, raw, fmt.Errorf("invalid response JSON: %w", err) } - // normalize some common fields - status := "" - if v, ok := m["status"].(string); ok { - status = v - } else if v, ok := m["payment_status"].(string); ok { - status = v + txnRaw, ok := envelope.TransactionDetails[txnID] + if !ok || txnRaw == nil || string(txnRaw) == "null" { + return "not_found", 0, raw, nil } - var amount int64 - if v, ok := m["amount"].(float64); ok { - amount = int64(v) - } else if v, ok := m["amount"].(string); ok { - if parsed, err := parseAmount(v); err == nil { - amount = parsed - } + var detail payuTxnDetail + if err := json.Unmarshal(txnRaw, &detail); err != nil { + return "", 0, raw, fmt.Errorf("invalid transaction detail JSON: %w", err) } - return status, amount, json.RawMessage(b), nil + amount, _ := parseAmount(detail.Amount) + return normalizeStatus(detail.Status), amount, raw, nil +} + +// requestHash computes sha512(merchantKey|"verify_payment"|txnID|salt). +// This is the hash PayU requires on the verify_payment request. +func requestHash(merchantKey, txnID, salt string) string { + s := merchantKey + "|verify_payment|" + txnID + "|" + salt + h := sha512.New() + h.Write([]byte(s)) + return hex.EncodeToString(h.Sum(nil)) } +// normalizeStatus maps raw PayU status strings to the four values the +// stabilizer reasons about: "success", "failed", "pending", "not_found". +// Unknown statuses are treated as "pending" — never as "failed" — so +// we don't mark a payment dead on a string we don't recognise. +func normalizeStatus(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "success", "captured", "completed": + return "success" + case "failure", "failed": + return "failed" + case "pending": + return "pending" + case "": + return "not_found" + default: + return "pending" + } +} + +// parseAmount converts a PayU amount value to paise (smallest INR unit). +// PayU returns decimal rupees as strings ("499.00"). Integer values are +// assumed to already be in paise. func parseAmount(v string) (int64, error) { v = strings.TrimSpace(v) + if v == "" { + return 0, nil + } f, err := strconv.ParseFloat(v, 64) if err != nil { return 0, err From 6a85164fc33979df5bddd8556cf0a81e9e2f5428 Mon Sep 17 00:00:00 2001 From: Keerthivasan Venkitajalam Date: Fri, 26 Jun 2026 13:06:42 +0530 Subject: [PATCH 2/6] test(payu): rewrite client tests with PayU-shaped fixtures covering all status, amount, error, and hash cases --- internal/gateway/payu/client_test.go | 449 ++++++++++++++++++++++++--- 1 file changed, 406 insertions(+), 43 deletions(-) diff --git a/internal/gateway/payu/client_test.go b/internal/gateway/payu/client_test.go index 1e258f5..10762ae 100644 --- a/internal/gateway/payu/client_test.go +++ b/internal/gateway/payu/client_test.go @@ -2,91 +2,454 @@ package payu import ( "context" - "encoding/json" + "fmt" "io" "net/http" "strings" "testing" + "time" ) +// roundTripFunc lets us plug a function in as an http.RoundTripper. type roundTripFunc func(*http.Request) (*http.Response, error) -func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { - return f(r) +func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// payuResponse builds a PayU-shaped transaction_details response body. +func payuResponse(txnID, status, amount string) string { + if txnID == "" { + return `{"status":0,"msg":"No transaction found","transaction_details":{}}` + } + return `{"status":1,"msg":"Transaction Fetched Successfully","transaction_details":{"` + + txnID + `":{"status":"` + status + `","amount":"` + amount + `","txnid":"` + txnID + `"}}}` +} + +// captureRequest returns a round-tripper that stores the request and replies +// with the given status code and body. +func captureRequest(code int, body string) (roundTripFunc, *http.Request) { + var captured *http.Request + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + // Buffer the body so we can assert on it after the call. + _ = r.ParseForm() + captured = r + return &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + _ = captured // will be set after the call + return rt, captured +} + +func newTestClient(rt http.RoundTripper) *Client { + c := NewClient("https://payu.test/merchant/postservice.php", "test_key", "test_salt") + c.HTTP = &http.Client{Transport: rt} + return c +} + +// ── Request shape ───────────────────────────────────────────────────────────── + +func TestStatus_SendsPOSTWithFormParams(t *testing.T) { + var got *http.Request + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + _ = r.ParseForm() + got = r + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_001", "success", "499.00"))), + Header: make(http.Header), + }, nil + }) + + c := newTestClient(rt) + _, _, _, _ = c.Status(context.Background(), "txn_001") + + if got.Method != http.MethodPost { + t.Errorf("method = %q, want POST", got.Method) + } + if got.FormValue("key") != "test_key" { + t.Errorf("form key = %q, want test_key", got.FormValue("key")) + } + if got.FormValue("command") != "verify_payment" { + t.Errorf("form command = %q, want verify_payment", got.FormValue("command")) + } + if got.FormValue("var1") != "txn_001" { + t.Errorf("form var1 = %q, want txn_001", got.FormValue("var1")) + } + if got.FormValue("hash") == "" { + t.Error("hash field must not be empty") + } +} + +func TestStatus_HashMatchesExpected(t *testing.T) { + var gotHash string + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + _ = r.ParseForm() + gotHash = r.FormValue("hash") + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_hash", "success", "100.00"))), + Header: make(http.Header), + }, nil + }) + + c := newTestClient(rt) + _, _, _, _ = c.Status(context.Background(), "txn_hash") + + expected := requestHash("test_key", "txn_hash", "test_salt") + if gotHash != expected { + t.Errorf("request hash = %q, want %q", gotHash, expected) + } +} + +func TestStatus_SetsFormURLEncodedContentType(t *testing.T) { + var gotCT string + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + gotCT = r.Header.Get("Content-Type") + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_ct", "success", "100.00"))), + Header: make(http.Header), + }, nil + }) + newTestClient(rt).Status(context.Background(), "txn_ct") //nolint:errcheck + if !strings.HasPrefix(gotCT, "application/x-www-form-urlencoded") { + t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT) + } } -func TestClientStatusParsesPayUAmountString(t *testing.T) { - var gotTxnID string - var gotAuth string - client := NewClient("https://payu.test/status", "test-key") - client.HTTP = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { - gotTxnID = r.URL.Query().Get("txnid") - gotAuth = r.Header.Get("Authorization") - body, _ := json.Marshal(map[string]string{"status": "success", "amount": "499.00"}) +// ── Status normalization ────────────────────────────────────────────────────── + +func TestStatus_SuccessNormalized(t *testing.T) { + for _, raw := range []string{"success", "captured", "completed", "SUCCESS", "Captured"} { + t.Run(raw, func(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_s", raw, "100.00"))), + Header: make(http.Header), + }, nil + }) + status, _, _, err := newTestClient(rt).Status(context.Background(), "txn_s") + if err != nil { + t.Fatalf("err = %v", err) + } + if status != "success" { + t.Errorf("raw=%q → status=%q, want success", raw, status) + } + }) + } +} + +func TestStatus_FailedNormalized(t *testing.T) { + for _, raw := range []string{"failed", "failure", "FAILED", "Failure"} { + t.Run(raw, func(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_f", raw, "0"))), + Header: make(http.Header), + }, nil + }) + status, _, _, err := newTestClient(rt).Status(context.Background(), "txn_f") + if err != nil { + t.Fatalf("err = %v", err) + } + if status != "failed" { + t.Errorf("raw=%q → status=%q, want failed", raw, status) + } + }) + } +} + +func TestStatus_PendingNormalized(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(string(body))), + Body: io.NopCloser(strings.NewReader(payuResponse("txn_p", "pending", "100.00"))), Header: make(http.Header), }, nil - })} + }) + status, _, _, err := newTestClient(rt).Status(context.Background(), "txn_p") + if err != nil { + t.Fatalf("err = %v", err) + } + if status != "pending" { + t.Errorf("status = %q, want pending", status) + } +} - status, amount, raw, err := client.Status(context.Background(), "txn_123") +func TestStatus_UnknownStatusTreatedAsPending(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_u", "bounced", "100.00"))), + Header: make(http.Header), + }, nil + }) + status, _, _, err := newTestClient(rt).Status(context.Background(), "txn_u") if err != nil { - t.Fatalf("Status returned error: %v", err) + t.Fatalf("err = %v", err) } - if gotTxnID != "txn_123" { - t.Fatalf("txnid = %q, want txn_123", gotTxnID) + if status != "pending" { + t.Errorf("unknown status should be pending, got %q", status) + } +} + +// ── Not found ──────────────────────────────────────────────────────────────── + +func TestStatus_EmptyTransactionDetails_NotFound(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("", "", ""))), + Header: make(http.Header), + }, nil + }) + status, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_nf") + if err != nil { + t.Fatalf("err = %v", err) } - if gotAuth != "Bearer test-key" { - t.Fatalf("Authorization = %q, want Bearer test-key", gotAuth) + if status != "not_found" { + t.Errorf("status = %q, want not_found", status) } - if status != "success" { - t.Fatalf("status = %q, want success", status) + if amount != 0 { + t.Errorf("amount = %d, want 0", amount) } - if amount != 49900 { - t.Fatalf("amount = %d, want 49900", amount) +} + +func TestStatus_TxnIDKeyMissingInDetails_NotFound(t *testing.T) { + body := `{"status":1,"msg":"ok","transaction_details":{"other_txn":{"status":"success","amount":"100.00"}}}` + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + status, _, _, err := newTestClient(rt).Status(context.Background(), "txn_missing") + if err != nil { + t.Fatalf("err = %v", err) } - if len(raw) == 0 { - t.Fatal("raw response is empty") + if status != "not_found" { + t.Errorf("status = %q, want not_found when txnID key absent from details", status) } } -func TestClientStatusKeepsIntegerAmountAsSmallestUnit(t *testing.T) { - client := NewClient("https://payu.test/status", "") - client.HTTP = &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { - body, _ := json.Marshal(map[string]any{"payment_status": "captured", "amount": 49900}) +// ── Amount parsing ──────────────────────────────────────────────────────────── + +func TestStatus_DecimalRupeesConvertedToPaise(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(string(body))), + Body: io.NopCloser(strings.NewReader(payuResponse("txn_amt", "success", "499.00"))), Header: make(http.Header), }, nil - })} - status, amount, _, err := client.Status(context.Background(), "txn_123") + }) + _, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_amt") if err != nil { - t.Fatalf("Status returned error: %v", err) + t.Fatalf("err = %v", err) } - if status != "captured" { - t.Fatalf("status = %q, want captured", status) + if amount != 49900 { + t.Errorf("amount = %d, want 49900 (499.00 rupees → paise)", amount) + } +} + +func TestStatus_IntegerAmountKeptAsPaise(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_int", "success", "49900"))), + Header: make(http.Header), + }, nil + }) + _, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_int") + if err != nil { + t.Fatalf("err = %v", err) } if amount != 49900 { - t.Fatalf("amount = %d, want 49900", amount) + t.Errorf("amount = %d, want 49900", amount) } } -func TestClientStatusReturnsRawOnInvalidJSON(t *testing.T) { - client := NewClient("https://payu.test/status", "") - client.HTTP = &http.Client{Transport: roundTripFunc(func(_ *http.Request) (*http.Response, error) { +func TestStatus_ZeroAmountOnFailed(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_zero", "failed", "0.00"))), + Header: make(http.Header), + }, nil + }) + _, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_zero") + if err != nil { + t.Fatalf("err = %v", err) + } + if amount != 0 { + t.Errorf("amount = %d, want 0", amount) + } +} + +// ── Error paths ─────────────────────────────────────────────────────────────── + +func TestStatus_MalformedJSON_ReturnsErrorAndRaw(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("not-json")), Header: make(http.Header), }, nil - })} - _, _, raw, err := client.Status(context.Background(), "txn_123") + }) + _, _, raw, err := newTestClient(rt).Status(context.Background(), "txn_bad") if err == nil { - t.Fatal("expected invalid JSON error") + t.Fatal("expected error for malformed JSON") } if string(raw) != "not-json" { - t.Fatalf("raw = %q, want not-json", string(raw)) + t.Errorf("raw = %q, want not-json", string(raw)) + } +} + +func TestStatus_HTTPError_ReturnsError(t *testing.T) { + for _, code := range []int{http.StatusBadRequest, http.StatusInternalServerError, http.StatusServiceUnavailable} { + t.Run(http.StatusText(code), func(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: code, + Body: io.NopCloser(strings.NewReader("error")), + Header: make(http.Header), + }, nil + }) + _, _, raw, err := newTestClient(rt).Status(context.Background(), "txn_err") + if err == nil { + t.Errorf("expected error for HTTP %d", code) + } + if len(raw) == 0 { + t.Error("raw response should be preserved even on HTTP error") + } + }) + } +} + +func TestStatus_NetworkError_ReturnsError(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("connection refused") + }) + _, _, _, err := newTestClient(rt).Status(context.Background(), "txn_net") + if err == nil { + t.Fatal("expected error on network failure") + } +} + +func TestStatus_Timeout_ReturnsError(t *testing.T) { + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + select { + case <-r.Context().Done(): + return nil, r.Context().Err() + case <-time.After(5 * time.Second): + return nil, fmt.Errorf("should not reach here") + } + }) + c := NewClient("https://payu.test/merchant/postservice.php", "k", "s") + c.HTTP = &http.Client{ + Transport: rt, + Timeout: 10 * time.Millisecond, + } + _, _, _, err := c.Status(context.Background(), "txn_timeout") + if err == nil { + t.Fatal("expected error on timeout") + } +} + +func TestStatus_EmptyBaseURL_ReturnsError(t *testing.T) { + c := NewClient("", "k", "s") + _, _, _, err := c.Status(context.Background(), "txn_nourl") + if err == nil { + t.Fatal("expected error when BaseURL is empty") + } +} + +// ── Raw response ────────────────────────────────────────────────────────────── + +func TestStatus_RawResponsePreservedOnSuccess(t *testing.T) { + body := payuResponse("txn_raw", "success", "100.00") + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + _, _, raw, err := newTestClient(rt).Status(context.Background(), "txn_raw") + if err != nil { + t.Fatalf("err = %v", err) + } + if string(raw) != body { + t.Errorf("raw = %q, want %q", string(raw), body) + } +} + +// ── Unit tests for helpers ──────────────────────────────────────────────────── + +func TestNormalizeStatus(t *testing.T) { + cases := []struct{ in, want string }{ + {"success", "success"}, + {"SUCCESS", "success"}, + {"captured", "success"}, + {"completed", "success"}, + {"failed", "failed"}, + {"failure", "failed"}, + {"FAILED", "failed"}, + {"pending", "pending"}, + {"PENDING", "pending"}, + {"", "not_found"}, + {" ", "not_found"}, + {"unknown_state", "pending"}, + } + for _, tc := range cases { + if got := normalizeStatus(tc.in); got != tc.want { + t.Errorf("normalizeStatus(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestParseAmount(t *testing.T) { + cases := []struct { + in string + want int64 + }{ + {"499.00", 49900}, + {"499.99", 49999}, + {"1.00", 100}, + {"0.00", 0}, + {"49900", 49900}, + {"100", 100}, + {"0", 0}, + {"", 0}, + } + for _, tc := range cases { + got, err := parseAmount(tc.in) + if err != nil { + t.Errorf("parseAmount(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("parseAmount(%q) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestRequestHash_Deterministic(t *testing.T) { + h1 := requestHash("key1", "txn_abc", "salt1") + h2 := requestHash("key1", "txn_abc", "salt1") + if h1 != h2 { + t.Error("requestHash must be deterministic") + } +} + +func TestRequestHash_DifferentInputsDifferentHash(t *testing.T) { + h1 := requestHash("key1", "txn_abc", "salt1") + h2 := requestHash("key1", "txn_xyz", "salt1") + if h1 == h2 { + t.Error("different txnIDs must produce different hashes") } } From 2ca54e97dbe3684c824d840d855ead74c83c006a Mon Sep 17 00:00:00 2001 From: Keerthivasan Venkitajalam Date: Fri, 26 Jun 2026 13:06:48 +0530 Subject: [PATCH 3/6] fix(main): pass WebhookSecret as salt to payu.NewClient for verify_payment hash --- cmd/paystable/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/paystable/main.go b/cmd/paystable/main.go index 7ef3682..976bc0b 100644 --- a/cmd/paystable/main.go +++ b/cmd/paystable/main.go @@ -59,7 +59,7 @@ func main() { holdHandler := hold.NewHandler(holdStore, cfg.HoldMaxTTLS, cfg.AdminAPIKey) lag := stabilizer.NewLagEstimator() - payuClient := payu.NewClient(cfg.PayuStatusURL, cfg.GatewayAPIKey) + payuClient := payu.NewClient(cfg.PayuStatusURL, cfg.GatewayAPIKey, cfg.WebhookSecret) gatewayFactory := func(g string) gateway.GatewayClient { if g == "payu" { return payuClient From 5df56d8fb92e8af9f90a84ba8eeb2037f9855005 Mon Sep 17 00:00:00 2001 From: Keerthivasan Venkitajalam Date: Fri, 26 Jun 2026 13:07:00 +0530 Subject: [PATCH 4/6] fix(mockgateway): read var1 from POST body and return PayU-shaped transaction_details response --- testkit/mockgateway/main.go | 43 +++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/testkit/mockgateway/main.go b/testkit/mockgateway/main.go index 4b714ac..ffa1730 100644 --- a/testkit/mockgateway/main.go +++ b/testkit/mockgateway/main.go @@ -117,25 +117,46 @@ func handleFireWebhook(w http.ResponseWriter, r *http.Request) { } func handleStatus(w http.ResponseWriter, r *http.Request) { - txnID := r.URL.Query().Get("txnid") + // Real PayU sends var1=txnid in a POST form body; fall back to query param for tooling. + _ = r.ParseForm() + txnID := r.FormValue("var1") if txnID == "" { - _ = r.ParseForm() - txnID = r.FormValue("txnid") + txnID = r.URL.Query().Get("txnid") } + mu.RLock() s, ok := states[txnID] mu.RUnlock() - status, amount := "not_found", 0.0 - if ok { - status, amount = s.Status, s.Amount/100 - if !s.FailUntil.IsZero() && time.Now().Before(s.FailUntil) { - status = "failed" - } + + w.Header().Set("Content-Type", "application/json") + + if !ok || txnID == "" { + slog.Info("status polled", "txn_id", txnID, "returning", "not_found") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "status": 0, + "msg": "No transaction found", + "transaction_details": map[string]interface{}{}, + }) + return } + + status := s.Status + if !s.FailUntil.IsZero() && time.Now().Before(s.FailUntil) { + status = "failed" + } + amountStr := fmt.Sprintf("%.2f", s.Amount/100) + slog.Info("status polled", "txn_id", txnID, "returning", status) - w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "status": status, "amount": amount, "txnid": txnID, + "status": 1, + "msg": "Transaction Fetched Successfully", + "transaction_details": map[string]interface{}{ + txnID: map[string]interface{}{ + "status": status, + "amount": amountStr, + "txnid": txnID, + }, + }, }) } From 293c828203f4927afdddbfc87b9c7b67e074d06b Mon Sep 17 00:00:00 2001 From: Keerthivasan Venkitajalam Date: Sun, 28 Jun 2026 18:03:40 +0530 Subject: [PATCH 5/6] fix(payu): parse amt/transaction_amount first, surface bad amount as error, fix doc link, drop unused captureRequest --- internal/gateway/payu/client.go | 24 ++++-- internal/gateway/payu/client_test.go | 109 ++++++++++++++++++++++----- 2 files changed, 110 insertions(+), 23 deletions(-) diff --git a/internal/gateway/payu/client.go b/internal/gateway/payu/client.go index 14f7d97..bb11383 100644 --- a/internal/gateway/payu/client.go +++ b/internal/gateway/payu/client.go @@ -15,8 +15,8 @@ import ( "time" ) -// Client calls the PayU Transaction Status Check API (verify_payment command). -// Docs: https://docs.payu.in/reference/transaction-status-check-api-2 +// Client calls the PayU Verify Payment API (verify_payment command). +// Docs: https://docs.payu.in/reference/verify-payment type Client struct { BaseURL string MerchantKey string @@ -45,9 +45,13 @@ type payuEnvelope struct { } // payuTxnDetail holds the fields we read from inside transaction_details. +// PayU's verify_payment response uses amt as the primary amount field, +// falling back to transaction_amount, then amount (used by the mock gateway). type payuTxnDetail struct { - Status string `json:"status"` - Amount string `json:"amount"` + Status string `json:"status"` + Amt string `json:"amt"` + TransactionAmount string `json:"transaction_amount"` + Amount string `json:"amount"` } // Status implements gateway.GatewayClient. @@ -102,7 +106,17 @@ func (c *Client) Status(ctx context.Context, txnID string) (string, int64, json. return "", 0, raw, fmt.Errorf("invalid transaction detail JSON: %w", err) } - amount, _ := parseAmount(detail.Amount) + rawAmt := detail.Amt + if rawAmt == "" { + rawAmt = detail.TransactionAmount + } + if rawAmt == "" { + rawAmt = detail.Amount + } + amount, err := parseAmount(rawAmt) + if err != nil { + return "", 0, raw, fmt.Errorf("payu: unparseable amount %q: %w", rawAmt, err) + } return normalizeStatus(detail.Status), amount, raw, nil } diff --git a/internal/gateway/payu/client_test.go b/internal/gateway/payu/client_test.go index 10762ae..8a17c9e 100644 --- a/internal/gateway/payu/client_test.go +++ b/internal/gateway/payu/client_test.go @@ -24,24 +24,6 @@ func payuResponse(txnID, status, amount string) string { txnID + `":{"status":"` + status + `","amount":"` + amount + `","txnid":"` + txnID + `"}}}` } -// captureRequest returns a round-tripper that stores the request and replies -// with the given status code and body. -func captureRequest(code int, body string) (roundTripFunc, *http.Request) { - var captured *http.Request - rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { - // Buffer the body so we can assert on it after the call. - _ = r.ParseForm() - captured = r - return &http.Response{ - StatusCode: code, - Body: io.NopCloser(strings.NewReader(body)), - Header: make(http.Header), - }, nil - }) - _ = captured // will be set after the call - return rt, captured -} - func newTestClient(rt http.RoundTripper) *Client { c := NewClient("https://payu.test/merchant/postservice.php", "test_key", "test_salt") c.HTTP = &http.Client{Transport: rt} @@ -388,6 +370,97 @@ func TestStatus_RawResponsePreservedOnSuccess(t *testing.T) { } } +// ── Amount field priority (real PayU response shape) ───────────────────────── + +// PayU's actual verify_payment response uses "amt", not "amount". +// This test uses the documented response shape to catch field-name drift. +func TestStatus_RealPayUResponseShape_UsesAmt(t *testing.T) { + body := `{ + "status": 1, + "msg": "Transaction Fetched Successfully", + "transaction_details": { + "txn_real": { + "mihpayid": "403993715530530", + "status": "success", + "amt": "499.00", + "transaction_amount": "499.00", + "txnid": "txn_real" + } + } + }` + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + status, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_real") + if err != nil { + t.Fatalf("err = %v", err) + } + if status != "success" { + t.Errorf("status = %q, want success", status) + } + if amount != 49900 { + t.Errorf("amount = %d, want 49900 (amt field takes priority)", amount) + } +} + +func TestStatus_FallsBackToTransactionAmount(t *testing.T) { + body := `{"status":1,"msg":"ok","transaction_details":{"txn_ta":{"status":"success","transaction_amount":"100.00","txnid":"txn_ta"}}}` + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + _, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_ta") + if err != nil { + t.Fatalf("err = %v", err) + } + if amount != 10000 { + t.Errorf("amount = %d, want 10000", amount) + } +} + +func TestStatus_FallsBackToAmount(t *testing.T) { + // "amount" is what the mock gateway returns — keep it as last fallback. + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(payuResponse("txn_fb", "success", "200.00"))), + Header: make(http.Header), + }, nil + }) + _, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_fb") + if err != nil { + t.Fatalf("err = %v", err) + } + if amount != 20000 { + t.Errorf("amount = %d, want 20000", amount) + } +} + +func TestStatus_BadAmountReturnsError(t *testing.T) { + body := `{"status":1,"msg":"ok","transaction_details":{"txn_bad_amt":{"status":"success","amt":"not-a-number","txnid":"txn_bad_amt"}}}` + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + _, _, raw, err := newTestClient(rt).Status(context.Background(), "txn_bad_amt") + if err == nil { + t.Fatal("expected error for unparseable amount") + } + if len(raw) == 0 { + t.Error("raw response should be preserved on amount parse error") + } +} + // ── Unit tests for helpers ──────────────────────────────────────────────────── func TestNormalizeStatus(t *testing.T) { From 3f8db08e22d86f2b27e9befc573e4297889e8a6f Mon Sep 17 00:00:00 2001 From: Keerthivasan Venkitajalam Date: Thu, 2 Jul 2026 21:04:48 +0530 Subject: [PATCH 6/6] fix(payu): error loud on missing transaction_details, normalize Not Found to not_found, enforce form=2, fix doc link --- internal/gateway/payu/client.go | 50 ++++++++++++- internal/gateway/payu/client_test.go | 107 +++++++++++++++++++++++++++ 2 files changed, 154 insertions(+), 3 deletions(-) diff --git a/internal/gateway/payu/client.go b/internal/gateway/payu/client.go index bb11383..40f7c8b 100644 --- a/internal/gateway/payu/client.go +++ b/internal/gateway/payu/client.go @@ -16,7 +16,7 @@ import ( ) // Client calls the PayU Verify Payment API (verify_payment command). -// Docs: https://docs.payu.in/reference/verify-payment +// Docs: https://docs.payu.in/reference/verify_payment_api type Client struct { BaseURL string MerchantKey string @@ -28,15 +28,40 @@ type Client struct { // baseURL = PAYU_STATUS_URL // merchantKey = GATEWAY_API_KEY (the merchant key, not the salt) // salt = WEBHOOK_SECRET (the PayU salt, same one used for webhook HMAC) +// +// postservice.php returns a PHP-serialized body unless the request carries +// form=2, which asks for JSON. Rather than trust every deployment's env var +// to include it, we inject it here if it's missing so a bare +// PAYU_STATUS_URL doesn't silently break JSON parsing in production. func NewClient(baseURL, merchantKey, salt string) *Client { return &Client{ - BaseURL: baseURL, + BaseURL: ensureFormParam(baseURL), MerchantKey: merchantKey, Salt: salt, HTTP: &http.Client{Timeout: 10 * time.Second}, } } +// ensureFormParam appends form=2 to the URL if not already present. +// Malformed URLs are returned unchanged; the request itself will fail +// with a clear error rather than this constructor silently swallowing it. +func ensureFormParam(raw string) string { + if raw == "" { + return raw + } + u, err := url.Parse(raw) + if err != nil { + return raw + } + q := u.Query() + if q.Get("form") != "" { + return raw + } + q.Set("form", "2") + u.RawQuery = q.Encode() + return u.String() +} + // payuEnvelope is the outer PayU verify_payment response shape. type payuEnvelope struct { Status int `json:"status"` @@ -96,6 +121,15 @@ func (c *Client) Status(ctx context.Context, txnID string) (string, int64, json. return "", 0, raw, fmt.Errorf("invalid response JSON: %w", err) } + // transaction_details missing from the envelope entirely is a schema + // violation (wrong command, flat check_payment-shaped body, a routing + // error, ...), not a "this txn doesn't exist yet" signal. Fail loud + // with the raw payload attached instead of quietly mapping it to + // not_found — those two situations need very different handling. + if envelope.TransactionDetails == nil { + return "", 0, raw, fmt.Errorf("payu: response missing transaction_details, expected nested verify_payment shape") + } + txnRaw, ok := envelope.TransactionDetails[txnID] if !ok || txnRaw == nil || string(txnRaw) == "null" { return "not_found", 0, raw, nil @@ -106,6 +140,16 @@ func (c *Client) Status(ctx context.Context, txnID string) (string, int64, json. return "", 0, raw, fmt.Errorf("invalid transaction detail JSON: %w", err) } + // PayU represents "no record of this txn yet" as a present entry with + // status: "Not Found" rather than an absent key. Treat it the same as + // a missing key (not_found) and skip amount parsing entirely — there + // is no real amount to extract, and committing a 0 here would pollute + // verification_polls.gateway_amount with a value that looks like a + // genuine gateway response instead of "we have nothing yet". + if normalizeStatus(detail.Status) == "not_found" { + return "not_found", 0, raw, nil + } + rawAmt := detail.Amt if rawAmt == "" { rawAmt = detail.TransactionAmount @@ -141,7 +185,7 @@ func normalizeStatus(s string) string { return "failed" case "pending": return "pending" - case "": + case "", "not found": return "not_found" default: return "pending" diff --git a/internal/gateway/payu/client_test.go b/internal/gateway/payu/client_test.go index 8a17c9e..6c7ca30 100644 --- a/internal/gateway/payu/client_test.go +++ b/internal/gateway/payu/client_test.go @@ -219,6 +219,113 @@ func TestStatus_TxnIDKeyMissingInDetails_NotFound(t *testing.T) { } } +// PayU represents an unrecognised txnid as a present entry with +// status: "Not Found", not an absent key. Must normalize to not_found +// with amount 0 and no error, same as the missing-key case. +func TestStatus_NotFoundStatusString_NormalizedToNotFound(t *testing.T) { + body := `{"status":1,"msg":"ok","transaction_details":{"txn_nf":{"status":"Not Found","txnid":"txn_nf"}}}` + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + status, amount, _, err := newTestClient(rt).Status(context.Background(), "txn_nf") + if err != nil { + t.Fatalf("err = %v", err) + } + if status != "not_found" { + t.Errorf("status = %q, want not_found for PayU's \"Not Found\" status string", status) + } + if amount != 0 { + t.Errorf("amount = %d, want 0 — must not attempt to parse an amount off a Not Found record", amount) + } +} + +// A response with no transaction_details field at all is a schema +// violation (wrong command, flat check_payment shape, misrouted +// request), not a legitimate "txn not found yet" signal. Must fail +// loud with the raw payload attached instead of silently returning +// not_found. +func TestStatus_MissingTransactionDetailsField_ReturnsSchemaError(t *testing.T) { + body := `{"status":0,"msg":"invalid command"}` + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + status, _, raw, err := newTestClient(rt).Status(context.Background(), "txn_schema") + if err == nil { + t.Fatal("expected schema error when transaction_details is absent from the envelope") + } + if status != "" { + t.Errorf("status = %q, want empty on schema error", status) + } + if len(raw) == 0 { + t.Error("raw response must be preserved on schema error") + } +} + +// A present-but-empty transaction_details ({}) is a legitimate +// not_found response (this is what the mock gateway sends for unknown +// txns) and must NOT be treated as the schema-violation case above. +func TestStatus_EmptyButPresentTransactionDetails_IsNotFoundNotSchemaError(t *testing.T) { + body := `{"status":0,"msg":"No transaction found","transaction_details":{}}` + rt := roundTripFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + status, _, _, err := newTestClient(rt).Status(context.Background(), "txn_empty") + if err != nil { + t.Fatalf("err = %v, want nil — an empty object is a valid envelope, not a schema violation", err) + } + if status != "not_found" { + t.Errorf("status = %q, want not_found", status) + } +} + +// ── form=2 enforcement ───────────────────────────────────────────────────────── + +func TestNewClient_AppendsFormParamWhenMissing(t *testing.T) { + c := NewClient("https://info.payu.in/merchant/postservice.php", "key", "salt") + if !strings.Contains(c.BaseURL, "form=2") { + t.Errorf("BaseURL = %q, want form=2 appended", c.BaseURL) + } +} + +func TestNewClient_PreservesExistingFormParam(t *testing.T) { + c := NewClient("https://info.payu.in/merchant/postservice.php?form=1", "key", "salt") + if strings.Count(c.BaseURL, "form=") != 1 { + t.Errorf("BaseURL = %q, want exactly one form param, existing value preserved", c.BaseURL) + } + if !strings.Contains(c.BaseURL, "form=1") { + t.Errorf("BaseURL = %q, want existing form=1 left untouched", c.BaseURL) + } +} + +func TestNewClient_PreservesOtherQueryParams(t *testing.T) { + c := NewClient("https://info.payu.in/merchant/postservice.php?env=prod", "key", "salt") + if !strings.Contains(c.BaseURL, "env=prod") { + t.Errorf("BaseURL = %q, want env=prod preserved", c.BaseURL) + } + if !strings.Contains(c.BaseURL, "form=2") { + t.Errorf("BaseURL = %q, want form=2 appended alongside existing params", c.BaseURL) + } +} + +func TestNewClient_EmptyBaseURLLeftEmpty(t *testing.T) { + c := NewClient("", "key", "salt") + if c.BaseURL != "" { + t.Errorf("BaseURL = %q, want empty string preserved so Status() returns its configured error", c.BaseURL) + } +} + // ── Amount parsing ──────────────────────────────────────────────────────────── func TestStatus_DecimalRupeesConvertedToPaise(t *testing.T) {