fixed fee direction

This commit is contained in:
Stephan D
2026-03-05 13:24:41 +01:00
parent 1e376da719
commit 4a5e26b03a
69 changed files with 8677 additions and 82 deletions

View File

@@ -0,0 +1,78 @@
package provider
import (
"strings"
"time"
)
const (
DefaultRequestTimeout = 15 * time.Second
DefaultStatusSuccess = "success"
DefaultStatusProcessing = "processing"
OutcomeSuccess = "success"
OutcomeProcessing = "processing"
OutcomeDecline = "decline"
)
// Config holds resolved settings for communicating with Aurora.
type Config struct {
BaseURL string
ProjectID int64
SecretKey string
AllowedCurrencies []string
RequireCustomerAddress bool
RequestTimeout time.Duration
StatusSuccess string
StatusProcessing string
}
func DefaultConfig() Config {
return Config{
RequestTimeout: DefaultRequestTimeout,
StatusSuccess: DefaultStatusSuccess,
StatusProcessing: DefaultStatusProcessing,
}
}
func (c Config) timeout() time.Duration {
if c.RequestTimeout <= 0 {
return DefaultRequestTimeout
}
return c.RequestTimeout
}
// Timeout exposes the configured HTTP timeout for external callers.
func (c Config) Timeout() time.Duration {
return c.timeout()
}
func (c Config) CurrencyAllowed(code string) bool {
code = strings.ToUpper(strings.TrimSpace(code))
if code == "" {
return false
}
if len(c.AllowedCurrencies) == 0 {
return true
}
for _, allowed := range c.AllowedCurrencies {
if strings.EqualFold(strings.TrimSpace(allowed), code) {
return true
}
}
return false
}
func (c Config) SuccessStatus() string {
if strings.TrimSpace(c.StatusSuccess) == "" {
return DefaultStatusSuccess
}
return strings.ToLower(strings.TrimSpace(c.StatusSuccess))
}
func (c Config) ProcessingStatus() string {
if strings.TrimSpace(c.StatusProcessing) == "" {
return DefaultStatusProcessing
}
return strings.ToLower(strings.TrimSpace(c.StatusProcessing))
}

View File

@@ -0,0 +1,21 @@
package provider
import "strings"
// MaskPAN redacts a primary account number by keeping the first 6 and last 4 digits.
func MaskPAN(pan string) string {
p := strings.TrimSpace(pan)
if len(p) <= 4 {
return strings.Repeat("*", len(p))
}
if len(p) <= 10 {
return p[:2] + strings.Repeat("*", len(p)-4) + p[len(p)-2:]
}
maskLen := len(p) - 10
if maskLen < 0 {
maskLen = 0
}
return p[:6] + strings.Repeat("*", maskLen) + p[len(p)-4:]
}

View File

@@ -0,0 +1,23 @@
package provider
import "testing"
func TestMaskPAN(t *testing.T) {
cases := []struct {
input string
expected string
}{
{input: "1234", expected: "****"},
{input: "1234567890", expected: "12******90"},
{input: "1234567890123456", expected: "123456******3456"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
got := MaskPAN(tc.input)
if got != tc.expected {
t.Fatalf("expected %q, got %q", tc.expected, got)
}
})
}
}

View File

@@ -0,0 +1,39 @@
package provider
import (
"strings"
"sync"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
metricsOnce sync.Once
cardPayoutCallbacks *prometheus.CounterVec
)
func initMetrics() {
metricsOnce.Do(func() {
cardPayoutCallbacks = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "aurora_gateway",
Name: "card_payout_callbacks_total",
Help: "Aurora card payout callbacks grouped by provider status.",
}, []string{"status"})
})
}
// ObserveCallback records callback status for Aurora card payouts.
func ObserveCallback(status string) {
initMetrics()
status = strings.TrimSpace(status)
if status == "" {
status = "unknown"
}
status = strings.ToLower(status)
if cardPayoutCallbacks != nil {
cardPayoutCallbacks.WithLabelValues(status).Inc()
}
}

View File

@@ -0,0 +1,11 @@
package provider
// CardPayoutSendResult is the minimal provider result contract used by Aurora simulator.
type CardPayoutSendResult struct {
Accepted bool
ProviderRequestID string
ProviderStatus string
StatusCode int
ErrorCode string
ErrorMessage string
}

View File

@@ -0,0 +1,112 @@
package provider
import (
"bytes"
"crypto/hmac"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
)
func signPayload(payload any, secret string) (string, error) {
canonical, err := signaturePayloadString(payload)
if err != nil {
return "", err
}
mac := hmac.New(sha512.New, []byte(secret))
if _, err := mac.Write([]byte(canonical)); err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
}
// SignPayload exposes signature calculation for callback verification.
func SignPayload(payload any, secret string) (string, error) {
return signPayload(payload, secret)
}
func signaturePayloadString(payload any) (string, error) {
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
var root any
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
if err := decoder.Decode(&root); err != nil {
return "", err
}
lines := make([]string, 0)
collectSignatureLines(nil, root, &lines)
sort.Strings(lines)
return strings.Join(lines, ";"), nil
}
func collectSignatureLines(path []string, value any, lines *[]string) {
switch v := value.(type) {
case map[string]any:
for key, child := range v {
if strings.EqualFold(key, "signature") {
continue
}
collectSignatureLines(append(path, key), child, lines)
}
case []any:
if len(v) == 0 {
return
}
for idx, child := range v {
collectSignatureLines(append(path, strconv.Itoa(idx)), child, lines)
}
default:
line := formatSignatureLine(path, v)
if line != "" {
*lines = append(*lines, line)
}
}
}
func formatSignatureLine(path []string, value any) string {
if len(path) == 0 {
return ""
}
val := signatureValueString(value)
segments := append(append([]string{}, path...), val)
return strings.Join(segments, ":")
}
func signatureValueString(value any) string {
switch v := value.(type) {
case nil:
return "null"
case string:
return v
case json.Number:
return v.String()
case bool:
if v {
return "1"
}
return "0"
case float64:
return strconv.FormatFloat(v, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(v), 'f', -1, 32)
case int:
return strconv.Itoa(v)
case int8, int16, int32, int64:
return fmt.Sprint(v)
case uint, uint8, uint16, uint32, uint64:
return fmt.Sprint(v)
default:
return fmt.Sprint(v)
}
}

View File

@@ -0,0 +1,211 @@
package provider
import "testing"
func TestSignaturePayloadString_Example(t *testing.T) {
payload := map[string]any{
"general": map[string]any{
"project_id": 3254,
"payment_id": "id_38202316",
"signature": "<ignored>",
},
"customer": map[string]any{
"id": "585741",
"email": "johndoe@example.com",
"first_name": "John",
"last_name": "Doe",
"address": "Downing str., 23",
"identify": map[string]any{
"doc_number": "54122312544",
},
"ip_address": "198.51.100.47",
},
"payment": map[string]any{
"amount": 10800,
"currency": "USD",
"description": "Computer keyboards",
},
"receipt_data": map[string]any{
"positions": []any{
map[string]any{
"quantity": "10",
"amount": "108",
"description": "Computer keyboard",
},
},
},
"return_url": map[string]any{
"success": "https://paymentpage.example.com/complete-redirect?id=success",
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
},
}
got, err := signaturePayloadString(payload)
if err != nil {
t.Fatalf("failed to build signature string: %v", err)
}
expected := "customer:address:Downing str., 23;customer:email:johndoe@example.com;customer:first_name:John;customer:id:585741;customer:identify:doc_number:54122312544;customer:ip_address:198.51.100.47;customer:last_name:Doe;general:payment_id:id_38202316;general:project_id:3254;payment:amount:10800;payment:currency:USD;payment:description:Computer keyboards;receipt_data:positions:0:amount:108;receipt_data:positions:0:description:Computer keyboard;receipt_data:positions:0:quantity:10;return_url:decline:https://paymentpage.example.com/complete-redirect?id=decline;return_url:success:https://paymentpage.example.com/complete-redirect?id=success"
if got != expected {
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignPayload_Example(t *testing.T) {
payload := map[string]any{
"general": map[string]any{
"project_id": 3254,
"payment_id": "id_38202316",
"signature": "<ignored>",
},
"customer": map[string]any{
"id": "585741",
"email": "johndoe@example.com",
"first_name": "John",
"last_name": "Doe",
"address": "Downing str., 23",
"identify": map[string]any{
"doc_number": "54122312544",
},
"ip_address": "198.51.100.47",
},
"payment": map[string]any{
"amount": 10800,
"currency": "USD",
"description": "Computer keyboards",
},
"receipt_data": map[string]any{
"positions": []any{
map[string]any{
"quantity": "10",
"amount": "108",
"description": "Computer keyboard",
},
},
},
"return_url": map[string]any{
"success": "https://paymentpage.example.com/complete-redirect?id=success",
"decline": "https://paymentpage.example.com/complete-redirect?id=decline",
},
}
got, err := SignPayload(payload, "secret")
if err != nil {
t.Fatalf("failed to sign payload: %v", err)
}
expected := "lagSnuspAn+F6XkmQISqwtBg0PsiTy62fF9x33TM+278mnufIDZyi1yP0BQALuCxyikkIxIMbodBn2F8hMdRwA=="
if got != expected {
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignaturePayloadString_BooleansAndArrays(t *testing.T) {
payload := map[string]any{
"flag": true,
"false_flag": false,
"empty": "",
"zero": 0,
"nested": map[string]any{
"list": []any{},
"items": []any{"alpha", "beta"},
},
}
got, err := signaturePayloadString(payload)
if err != nil {
t.Fatalf("failed to build signature string: %v", err)
}
expected := "empty:;false_flag:0;flag:1;nested:items:0:alpha;nested:items:1:beta;zero:0"
if got != expected {
t.Fatalf("unexpected signature string\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignPayload_EthEstimateGasExample(t *testing.T) {
payload := map[string]any{
"jsonrpc": "2.0",
"id": 3,
"method": "eth_estimateGas",
"params": []any{
map[string]any{
"from": "0xfa89b4d534bdeb2713d4ffd893e79d6535fb58f8",
"to": "0x44162e39eefd9296231e772663a92e72958e182f",
"gasPrice": "0x64",
"data": "0xa9059cbb00000000000000000000000044162e39eefd9296231e772663a92e72958e182f00000000000000000000000000000000000000000000000000000000000f4240",
},
},
}
got, err := SignPayload(payload, "1")
if err != nil {
t.Fatalf("failed to sign payload: %v", err)
}
expected := "C4WbSvXKSMyX8yLamQcUe/Nzr6nSt9m3HYn4jHSyA7yi/FaTiqk0r8BlfIzfxSCoDaRgrSd82ihgZW+DxELhdQ=="
if got != expected {
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
}
}
func TestSignPayload_AuroraCallbackExample(t *testing.T) {
payload := map[string]any{
"customer": map[string]any{
"id": "694ece88df756c2672dc6ce8",
},
"account": map[string]any{
"number": "220070******0161",
"type": "mir",
"card_holder": "STEPHAN",
"expiry_month": "03",
"expiry_year": "2030",
},
"project_id": 157432,
"payment": map[string]any{
"id": "6952d0b307d2916aba87d4e8",
"type": "payout",
"status": "success",
"date": "2025-12-29T19:04:24+0000",
"method": "card",
"sum": map[string]any{
"amount": 10849,
"currency": "RUB",
},
"description": "",
},
"operation": map[string]any{
"sum_initial": map[string]any{
"amount": 10849,
"currency": "RUB",
},
"sum_converted": map[string]any{
"amount": 10849,
"currency": "RUB",
},
"code": "0",
"message": "Success",
"provider": map[string]any{
"id": 26226,
"payment_id": "a3761838-eabc-4c65-aa36-c854c47a226b",
"auth_code": "",
"endpoint_id": 26226,
"date": "2025-12-29T19:04:23+0000",
},
"id": int64(5089807000008124),
"type": "payout",
"status": "success",
"date": "2025-12-29T19:04:24+0000",
"created_date": "2025-12-29T19:04:21+0000",
"request_id": "7c3032f00629c94ad78e399c87da936f1cdc30de-2559ba11d6958d558a9f8ab8c20474d33061c654-05089808",
},
"signature": "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ==",
}
got, err := SignPayload(payload, "1")
if err != nil {
t.Fatalf("failed to sign payload: %v", err)
}
expected := "IBgtwCoxhMUxF15q8DLc7orYOIJomeiaNpWs8JHHsdDYPKJsIKn4T+kYavPnKTO+yibhCLNKeL+hk2oWg9wPCQ=="
if got != expected {
t.Fatalf("unexpected signature\nexpected: %s\ngot: %s", expected, got)
}
}