diff --git a/Makefile b/Makefile index 9594c0aa..c40dca81 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ help: @echo " make build-core Build core services (discovery, ledger, fees, documents)" @echo " make build-fx Build FX services (oracle, ingestor)" @echo " make build-payments Build payment orchestrator" - @echo " make build-gateways Build gateway services (chain, tron, mntx, tgsettle)" + @echo " make build-gateways Build gateway services (chain, tron, aurora, tgsettle)" @echo " make build-api Build API services (notification, callbacks, bff)" @echo " make build-frontend Build Flutter web frontend" @echo "" @@ -222,7 +222,7 @@ services-up: dev-payments-methods \ dev-chain-gateway \ dev-tron-gateway \ - dev-mntx-gateway \ + dev-aurora-gateway \ dev-tgsettle-gateway \ dev-notification \ dev-callbacks \ @@ -252,7 +252,7 @@ list-services: @echo " - dev-payments-methods :50066, :9416 (Payment Methods)" @echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)" @echo " - dev-tron-gateway :50071, :9408 (TRON Blockchain Gateway)" - @echo " - dev-mntx-gateway :50075, :9405, :8084 (Card Payouts)" + @echo " - dev-aurora-gateway :50075, :9405, :8084 (Card Payouts Simulator)" @echo " - dev-tgsettle-gateway :50080, :9406 (Telegram Settlements)" @echo " - dev-notification :8081 (Notifications)" @echo " - dev-callbacks :9420 (Webhook Callbacks)" @@ -283,7 +283,7 @@ build-payments: build-gateways: @echo "$(GREEN)Building gateway services...$(NC)" - @$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-mntx-gateway dev-tgsettle-gateway + @$(COMPOSE) build dev-chain-gateway dev-tron-gateway dev-aurora-gateway dev-tgsettle-gateway build-api: @echo "$(GREEN)Building API services...$(NC)" diff --git a/api/gateway/aurora/.air.toml b/api/gateway/aurora/.air.toml new file mode 100644 index 00000000..16f8c34b --- /dev/null +++ b/api/gateway/aurora/.air.toml @@ -0,0 +1,46 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + entrypoint = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go", "_templ.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + time = false + +[misc] + clean_on_exit = false + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/gateway/aurora/.gitignore b/api/gateway/aurora/.gitignore new file mode 100644 index 00000000..ac7c2afb --- /dev/null +++ b/api/gateway/aurora/.gitignore @@ -0,0 +1,5 @@ +/aurora +internal/generated +.gocache +tmp +app diff --git a/api/gateway/aurora/README.md b/api/gateway/aurora/README.md new file mode 100644 index 00000000..763e06e0 --- /dev/null +++ b/api/gateway/aurora/README.md @@ -0,0 +1,28 @@ +# Aurora Gateway – Simulated Card Payouts + +Aurora is a dev/test-only card payout gateway with the same gRPC contract as `mntx`, but it never sends real funds. + +## Runtime entry points +- gRPC: `MntxGatewayService.CreateCardPayout`, `CreateCardTokenPayout`, `CreateCardToken`, `GetCardPayoutStatus`, `ListGatewayInstances` +- Callback HTTP server (optional): `:8084/aurora/callback` +- Metrics: Prometheus on `:9405/metrics` + +## Behavior +- Card payouts are resolved locally by PAN scenario mapping. +- Token payouts resolve the scenario from the tokenized PAN (or fallback to masked PAN last4). +- No outbound payout/tokenization HTTP calls are made. + +## Built-in test cards +- `2200001111111111`: approved instantly (`success`, code `00`) +- `2200002222222222`: pending issuer review (`waiting`, code `P01`) +- `2200003333333333`: insufficient funds (`failed`, code `51`) +- `2200004444444444`: issuer unavailable retryable (`failed`, code `10101`) +- `2200005555555555`: stolen card (`failed`, code `43`) +- `2200006666666666`: do not honor (`failed`, code `05`) +- `2200007777777777`: expired card (`failed`, code `54`) +- any other PAN: default queued processing (`waiting`, code `P00`) + +## Notes +- PAN is masked in logs. +- Provider settings should be configured under `aurora:` (legacy `mcards:` key is still accepted for backward compatibility). +- `gateway.id` defaults to `mcards` to preserve orchestrator compatibility. diff --git a/api/gateway/aurora/SCENARIOS.md b/api/gateway/aurora/SCENARIOS.md new file mode 100644 index 00000000..c1d72022 --- /dev/null +++ b/api/gateway/aurora/SCENARIOS.md @@ -0,0 +1,37 @@ +# Aurora Test Card Scenarios + +Aurora is a simulated card payout gateway for dev/test. +It does not move real funds; results are determined by PAN scenario mapping. + +## Status/response fields +- `accepted`: whether submit is accepted by the gateway workflow +- `status`: payout state returned/stored (`SUCCESS`, `WAITING`, `FAILED`) +- `code`: simulated provider code +- `message`: simulated provider message + +## PAN scenarios + +| PAN | Scenario | accepted | status | code | message | +|---|---|---:|---|---|---| +| `2200001111111111` | approved_instant | `true` | `SUCCESS` | `00` | Approved by issuer | +| `2200002222222222` | pending_issuer_review | `true` | `WAITING` | `P01` | Pending issuer review | +| `2200003333333333` | insufficient_funds | `false` | `FAILED` | `51` | Insufficient funds | +| `2200004444444444` | issuer_unavailable_retryable | `false` on provider response, but submit is retried | starts `WAITING`, may end `FAILED` after retries | `10101` | Issuer temporary unavailable, retry later | +| `2200005555555555` | stolen_card | `false` | `FAILED` | `43` | Stolen card, pickup | +| `2200006666666666` | do_not_honor | `false` | `FAILED` | `05` | Do not honor | +| `2200007777777777` | expired_card | `false` | `FAILED` | `54` | Expired card | +| `2200008888888888` | provider_timeout_transport | transport failure (no provider acceptance) | starts `WAITING` (retry scheduled), may end `FAILED` | n/a (transport error path) | provider timeout while calling payout endpoint | +| `2200009999999998` | provider_unreachable_transport | transport failure (no provider acceptance) | starts `WAITING` (retry scheduled), may end `FAILED` | n/a (transport error path) | provider host unreachable | +| `2200009999999997` | provider_maintenance | `false` | `FAILED` | `91` | Issuer or switch is inoperative | +| `2200009999999996` | provider_system_malfunction | `false` | `FAILED` | `96` | System malfunction | + +## Default behavior +- Any PAN not listed above -> `default_processing` + - `accepted=true` + - `status=WAITING` + - `code=P00` + - `message=Queued for provider processing` + +## Token payout behavior +- If payout uses a known Aurora token, scenario is resolved from the PAN used during tokenization. +- If token is unknown, Aurora falls back to `masked_pan` last4 matching when available. diff --git a/api/gateway/aurora/client/client.go b/api/gateway/aurora/client/client.go new file mode 100644 index 00000000..e430bde5 --- /dev/null +++ b/api/gateway/aurora/client/client.go @@ -0,0 +1,403 @@ +package client + +import ( + "context" + "strings" + "time" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model/account_role" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" +) + +// Client wraps the Aurora gateway gRPC API. +type Client interface { + CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) + CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) + GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) + ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) + Close() error +} + +type grpcConnectorClient interface { + SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) + GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) +} + +type gatewayClient struct { + conn *grpc.ClientConn + client grpcConnectorClient + cfg Config + logger mlogger.Logger +} + +// New dials the Aurora gateway. +func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) { + cfg.setDefaults() + if strings.TrimSpace(cfg.Address) == "" { + return nil, merrors.InvalidArgument("aurora: address is required") + } + dialOpts := make([]grpc.DialOption, 0, len(opts)+1) + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + dialOpts = append(dialOpts, opts...) + + conn, err := grpc.NewClient(cfg.Address, dialOpts...) + if err != nil { + return nil, merrors.Internal("aurora: dial failed: " + err.Error()) + } + + return &gatewayClient{ + conn: conn, + client: connectorv1.NewConnectorServiceClient(conn), + cfg: cfg, + logger: cfg.Logger, + }, nil +} + +func (g *gatewayClient) Close() error { + if g.conn != nil { + return g.conn.Close() + } + return nil +} + +func (g *gatewayClient) callContext(ctx context.Context, method string) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + timeout := g.cfg.CallTimeout + if timeout <= 0 { + timeout = 5 * time.Second + } + if g.logger != nil { + fields := []zap.Field{ + zap.String("method", method), + zap.Duration("timeout", timeout), + } + if deadline, ok := ctx.Deadline(); ok { + fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline))) + } + g.logger.Info("Aurora gateway client call timeout applied", fields...) + } + return context.WithTimeout(ctx, timeout) +} + +func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + ctx, cancel := g.callContext(ctx, "CreateCardPayout") + defer cancel() + operation, err := operationFromCardPayout(req) + if err != nil { + return nil, err + } + resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil +} + +func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout") + defer cancel() + operation, err := operationFromTokenPayout(req) + if err != nil { + return nil, err + } + resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), req.GetOperationRef(), req.GetParentPaymentRef(), resp.GetReceipt())}, nil +} + +func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { + ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus") + defer cancel() + if req == nil || strings.TrimSpace(req.GetPayoutId()) == "" { + return nil, merrors.InvalidArgument("aurora: payout_id is required") + } + resp, err := g.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetPayoutId())}) + if err != nil { + return nil, err + } + return &mntxv1.GetCardPayoutStatusResponse{Payout: payoutFromOperation(resp.GetOperation())}, nil +} + +func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { + return nil, merrors.NotImplemented("aurora: ListGatewayInstances not supported via connector") +} + +func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operation, error) { + if req == nil { + return nil, merrors.InvalidArgument("aurora: request is required") + } + params := payoutParamsFromCard(req) + money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) + operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId()) + idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef) + op := &connectorv1.Operation{ + Type: connectorv1.OperationType_PAYOUT, + IdempotencyKey: idempotencyKey, + OperationRef: operationRef, + IntentRef: strings.TrimSpace(req.GetIntentRef()), + Money: money, + Params: structFromMap(params), + } + setOperationRolesFromMetadata(op, req.GetMetadata()) + return op, nil +} + +func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1.Operation, error) { + if req == nil { + return nil, merrors.InvalidArgument("aurora: request is required") + } + params := payoutParamsFromToken(req) + money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) + operationRef := fallbackNonEmpty(req.GetOperationRef(), req.GetPayoutId()) + idempotencyKey := fallbackNonEmpty(req.GetIdempotencyKey(), operationRef) + op := &connectorv1.Operation{ + Type: connectorv1.OperationType_PAYOUT, + IdempotencyKey: idempotencyKey, + OperationRef: operationRef, + IntentRef: strings.TrimSpace(req.GetIntentRef()), + Money: money, + Params: structFromMap(params), + } + setOperationRolesFromMetadata(op, req.GetMetadata()) + return op, nil +} + +func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[string]string) { + if op == nil || len(metadata) == 0 { + return + } + if raw := strings.TrimSpace(metadata[account_role.MetadataKeyFromRole]); raw != "" { + if role, ok := account_role.Parse(raw); ok && role != "" { + op.FromRole = account_role.ToProto(role) + } + } + if raw := strings.TrimSpace(metadata[account_role.MetadataKeyToRole]); raw != "" { + if role, ok := account_role.Parse(raw); ok && role != "" { + op.ToRole = account_role.ToProto(role) + } + } +} + +func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} { + metadata := sanitizeMetadata(req.GetMetadata()) + params := map[string]interface{}{ + "project_id": req.GetProjectId(), + "parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()), + "customer_id": strings.TrimSpace(req.GetCustomerId()), + "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), + "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), + "customer_last_name": strings.TrimSpace(req.GetCustomerLastName()), + "customer_ip": strings.TrimSpace(req.GetCustomerIp()), + "customer_zip": strings.TrimSpace(req.GetCustomerZip()), + "customer_country": strings.TrimSpace(req.GetCustomerCountry()), + "customer_state": strings.TrimSpace(req.GetCustomerState()), + "customer_city": strings.TrimSpace(req.GetCustomerCity()), + "customer_address": strings.TrimSpace(req.GetCustomerAddress()), + "amount_minor": req.GetAmountMinor(), + "currency": strings.TrimSpace(req.GetCurrency()), + "card_pan": strings.TrimSpace(req.GetCardPan()), + "card_exp_year": req.GetCardExpYear(), + "card_exp_month": req.GetCardExpMonth(), + "card_holder": strings.TrimSpace(req.GetCardHolder()), + } + if len(metadata) > 0 { + params["metadata"] = mapStringToInterface(metadata) + } + return params +} + +func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} { + metadata := sanitizeMetadata(req.GetMetadata()) + params := map[string]interface{}{ + "project_id": req.GetProjectId(), + "parent_payment_ref": strings.TrimSpace(req.GetParentPaymentRef()), + "customer_id": strings.TrimSpace(req.GetCustomerId()), + "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), + "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), + "customer_last_name": strings.TrimSpace(req.GetCustomerLastName()), + "customer_ip": strings.TrimSpace(req.GetCustomerIp()), + "customer_zip": strings.TrimSpace(req.GetCustomerZip()), + "customer_country": strings.TrimSpace(req.GetCustomerCountry()), + "customer_state": strings.TrimSpace(req.GetCustomerState()), + "customer_city": strings.TrimSpace(req.GetCustomerCity()), + "customer_address": strings.TrimSpace(req.GetCustomerAddress()), + "amount_minor": req.GetAmountMinor(), + "currency": strings.TrimSpace(req.GetCurrency()), + "card_token": strings.TrimSpace(req.GetCardToken()), + "card_holder": strings.TrimSpace(req.GetCardHolder()), + "masked_pan": strings.TrimSpace(req.GetMaskedPan()), + } + if len(metadata) > 0 { + params["metadata"] = mapStringToInterface(metadata) + } + return params +} + +func moneyFromMinor(amount int64, currency string) *moneyv1.Money { + if amount <= 0 { + return nil + } + dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100)) + return &moneyv1.Money{ + Amount: dec.StringFixed(2), + Currency: strings.ToUpper(strings.TrimSpace(currency)), + } +} + +func payoutFromReceipt(payoutID, operationRef, parentPaymentRef string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { + state := &mntxv1.CardPayoutState{ + PayoutId: fallbackNonEmpty(operationRef, payoutID), + ParentPaymentRef: strings.TrimSpace(parentPaymentRef), + } + if receipt == nil { + return state + } + if opID := strings.TrimSpace(receipt.GetOperationId()); opID != "" { + state.PayoutId = opID + } + state.Status = payoutStatusFromOperation(receipt.GetStatus()) + state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef()) + return state +} + +func fallbackNonEmpty(values ...string) string { + for _, value := range values { + clean := strings.TrimSpace(value) + if clean != "" { + return clean + } + } + return "" +} + +func sanitizeMetadata(source map[string]string) map[string]string { + if len(source) == 0 { + return nil + } + out := map[string]string{} + for key, value := range source { + k := strings.TrimSpace(key) + if k == "" { + continue + } + out[k] = strings.TrimSpace(value) + } + if len(out) == 0 { + return nil + } + return out +} + +func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState { + if op == nil { + return nil + } + state := &mntxv1.CardPayoutState{ + PayoutId: strings.TrimSpace(op.GetOperationId()), + Status: payoutStatusFromOperation(op.GetStatus()), + ProviderPaymentId: strings.TrimSpace(op.GetProviderRef()), + } + if money := op.GetMoney(); money != nil { + state.Currency = strings.TrimSpace(money.GetCurrency()) + state.AmountMinor = minorFromMoney(money) + } + return state +} + +func minorFromMoney(m *moneyv1.Money) int64 { + if m == nil { + return 0 + } + amount := strings.TrimSpace(m.GetAmount()) + if amount == "" { + return 0 + } + dec, err := decimal.NewFromString(amount) + if err != nil { + return 0 + } + return dec.Mul(decimal.NewFromInt(100)).IntPart() +} + +func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus { + switch status { + + case connectorv1.OperationStatus_OPERATION_CREATED: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED + + case connectorv1.OperationStatus_OPERATION_WAITING: + return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING + + case connectorv1.OperationStatus_OPERATION_SUCCESS: + return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS + + case connectorv1.OperationStatus_OPERATION_FAILED: + return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + + case connectorv1.OperationStatus_OPERATION_CANCELLED: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED + + default: + return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED + } +} + +func connectorError(err *connectorv1.ConnectorError) error { + if err == nil { + return nil + } + msg := strings.TrimSpace(err.GetMessage()) + switch err.GetCode() { + case connectorv1.ErrorCode_INVALID_PARAMS: + return merrors.InvalidArgument(msg) + case connectorv1.ErrorCode_NOT_FOUND: + return merrors.NoData(msg) + case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND: + return merrors.NotImplemented(msg) + case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE: + return merrors.Internal(msg) + default: + return merrors.Internal(msg) + } +} + +func structFromMap(data map[string]interface{}) *structpb.Struct { + if len(data) == 0 { + return nil + } + result, err := structpb.NewStruct(data) + if err != nil { + return nil + } + return result +} + +func mapStringToInterface(input map[string]string) map[string]interface{} { + if len(input) == 0 { + return nil + } + out := make(map[string]interface{}, len(input)) + for k, v := range input { + out[k] = v + } + return out +} diff --git a/api/gateway/aurora/client/config.go b/api/gateway/aurora/client/config.go new file mode 100644 index 00000000..ff6a3c77 --- /dev/null +++ b/api/gateway/aurora/client/config.go @@ -0,0 +1,28 @@ +package client + +import ( + "time" + + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +// Config holds Aurora gateway client settings. +type Config struct { + Address string + DialTimeout time.Duration + CallTimeout time.Duration + Logger mlogger.Logger +} + +func (c *Config) setDefaults() { + if c.DialTimeout <= 0 { + c.DialTimeout = 5 * time.Second + } + if c.CallTimeout <= 0 { + c.CallTimeout = 10 * time.Second + } + if c.Logger == nil { + c.Logger = zap.NewNop() + } +} diff --git a/api/gateway/aurora/client/fake.go b/api/gateway/aurora/client/fake.go new file mode 100644 index 00000000..26735344 --- /dev/null +++ b/api/gateway/aurora/client/fake.go @@ -0,0 +1,45 @@ +package client + +import ( + "context" + + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +// Fake implements Client for tests. +type Fake struct { + CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) + CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) + GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) + ListGatewayInstancesFn func(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) +} + +func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + if f.CreateCardPayoutFn != nil { + return f.CreateCardPayoutFn(ctx, req) + } + return &mntxv1.CardPayoutResponse{}, nil +} + +func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + if f.CreateCardTokenPayoutFn != nil { + return f.CreateCardTokenPayoutFn(ctx, req) + } + return &mntxv1.CardTokenPayoutResponse{}, nil +} + +func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { + if f.GetCardPayoutStatusFn != nil { + return f.GetCardPayoutStatusFn(ctx, req) + } + return &mntxv1.GetCardPayoutStatusResponse{}, nil +} + +func (f *Fake) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { + if f.ListGatewayInstancesFn != nil { + return f.ListGatewayInstancesFn(ctx, req) + } + return &mntxv1.ListGatewayInstancesResponse{}, nil +} + +func (f *Fake) Close() error { return nil } diff --git a/api/gateway/aurora/config.dev.yml b/api/gateway/aurora/config.dev.yml new file mode 100644 index 00000000..10b1ed14 --- /dev/null +++ b/api/gateway/aurora/config.dev.yml @@ -0,0 +1,62 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50075" + advertise_host: "dev-aurora-gateway" + enable_reflection: true + enable_health: true + +metrics: + address: ":9405" + +database: + driver: mongodb + settings: + host_env: AURORA_GATEWAY_MONGO_HOST + port_env: AURORA_GATEWAY_MONGO_PORT + database_env: AURORA_GATEWAY_MONGO_DATABASE + user_env: AURORA_GATEWAY_MONGO_USER + password_env: AURORA_GATEWAY_MONGO_PASSWORD + auth_source_env: AURORA_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: AURORA_GATEWAY_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Aurora Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +aurora: + base_url: "http://aurora-sim.local" + project_id: 1001 + secret_key: "aurora-dev-simulated" + allowed_currencies: ["RUB"] + require_customer_address: false + request_timeout_seconds: 15 + status_success: "success" + status_processing: "processing" + strict_operation_mode: false + +gateway: + id: "mcards" + is_enabled: true + network: "MIR" + currencies: ["RUB"] + limits: + per_tx_min_amount: "0" + +http: + callback: + address: ":8084" + path: "/aurora/callback" + allowed_cidrs: [] + max_body_bytes: 1048576 diff --git a/api/gateway/aurora/config.yml b/api/gateway/aurora/config.yml new file mode 100644 index 00000000..d13c4b89 --- /dev/null +++ b/api/gateway/aurora/config.yml @@ -0,0 +1,62 @@ +runtime: + shutdown_timeout_seconds: 15 + +grpc: + network: tcp + address: ":50075" + advertise_host: "sendico_aurora_gateway" + enable_reflection: true + enable_health: true + +metrics: + address: ":9404" + +database: + driver: mongodb + settings: + host_env: AURORA_GATEWAY_MONGO_HOST + port_env: AURORA_GATEWAY_MONGO_PORT + database_env: AURORA_GATEWAY_MONGO_DATABASE + user_env: AURORA_GATEWAY_MONGO_USER + password_env: AURORA_GATEWAY_MONGO_PASSWORD + auth_source_env: AURORA_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: AURORA_GATEWAY_MONGO_REPLICA_SET + +messaging: + driver: NATS + settings: + url_env: NATS_URL + host_env: NATS_HOST + port_env: NATS_PORT + username_env: NATS_USER + password_env: NATS_PASSWORD + broker_name: Aurora Gateway Service + max_reconnects: 10 + reconnect_wait: 5 + buffer_size: 1024 + +aurora: + base_url: "http://aurora-sim.local" + project_id: 1001 + secret_key: "aurora-dev-simulated" + allowed_currencies: ["RUB"] + require_customer_address: false + request_timeout_seconds: 15 + status_success: "success" + status_processing: "processing" + strict_operation_mode: false + +gateway: + id: "mcards" + is_enabled: true + network: "MIR" + currencies: ["RUB"] + limits: + per_tx_min_amount: "0.00" + +http: + callback: + address: ":8084" + path: "/aurora/callback" + allowed_cidrs: [] + max_body_bytes: 1048576 diff --git a/api/gateway/aurora/entrypoint.sh b/api/gateway/aurora/entrypoint.sh new file mode 100755 index 00000000..7afdbddd --- /dev/null +++ b/api/gateway/aurora/entrypoint.sh @@ -0,0 +1,4 @@ +#!/bin/sh +set -eu + +exec /app/aurora-gateway "$@" diff --git a/api/gateway/aurora/go.mod b/api/gateway/aurora/go.mod new file mode 100644 index 00000000..a43cfad8 --- /dev/null +++ b/api/gateway/aurora/go.mod @@ -0,0 +1,55 @@ +module github.com/tech/sendico/gateway/aurora + +go 1.25.7 + +replace github.com/tech/sendico/pkg => ../../pkg + +replace github.com/tech/sendico/gateway/common => ../common + +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 + github.com/shopspring/decimal v1.4.0 + github.com/tech/sendico/gateway/common v0.1.0 + github.com/tech/sendico/pkg v0.1.0 + go.mongodb.org/mongo-driver/v2 v2.5.0 + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.79.1 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect + github.com/casbin/casbin/v2 v2.135.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/casbin/mongodb-adapter/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/nats.go v1.49.0 // indirect + github.com/nats-io/nkeys v0.4.15 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/tklauser/go-sysconf v0.3.16 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect +) diff --git a/api/gateway/aurora/go.sum b/api/gateway/aurora/go.sum new file mode 100644 index 00000000..bd4681c8 --- /dev/null +++ b/api/gateway/aurora/go.sum @@ -0,0 +1,223 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= +github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/mongodb-adapter/v4 v4.3.0 h1:yYXky9v1by6vj/0QK7OyHyd/xpz4vzh0lCi7JKrS4qQ= +github.com/casbin/mongodb-adapter/v4 v4.3.0/go.mod h1:bOTSYZUjX7I9E0ExEvgq46m3mcDNRII7g8iWjrM1BHE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI= +github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg= +github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/nats.go v1.49.0 h1:yh/WvY59gXqYpgl33ZI+XoVPKyut/IcEaqtsiuTJpoE= +github.com/nats-io/nats.go v1.49.0/go.mod h1:fDCn3mN5cY8HooHwE2ukiLb4p4G4ImmzvXyJt+tGwdw= +github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= +github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw= +github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY= +github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= +google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/api/gateway/aurora/internal/appversion/version.go b/api/gateway/aurora/internal/appversion/version.go new file mode 100644 index 00000000..1f3a53f0 --- /dev/null +++ b/api/gateway/aurora/internal/appversion/version.go @@ -0,0 +1,27 @@ +package appversion + +import ( + "github.com/tech/sendico/pkg/version" + vf "github.com/tech/sendico/pkg/version/factory" +) + +// Build information. Populated at build-time. +var ( + Version string + Revision string + Branch string + BuildUser string + BuildDate string +) + +func Create() version.Printer { + info := version.Info{ + Program: "Sendico Aurora Gateway Service", + Revision: Revision, + Branch: Branch, + BuildUser: BuildUser, + BuildDate: BuildDate, + Version: Version, + } + return vf.Create(&info) +} diff --git a/api/gateway/aurora/internal/server/internal/serverimp.go b/api/gateway/aurora/internal/server/internal/serverimp.go new file mode 100644 index 00000000..2e21e784 --- /dev/null +++ b/api/gateway/aurora/internal/server/internal/serverimp.go @@ -0,0 +1,604 @@ +package serverimp + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/tech/sendico/gateway/aurora/internal/appversion" + auroraservice "github.com/tech/sendico/gateway/aurora/internal/service/gateway" + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/gateway/aurora/storage" + gatewaymongo "github.com/tech/sendico/gateway/aurora/storage/mongo" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + "github.com/tech/sendico/pkg/server/grpcapp" + "go.uber.org/zap" + "gopkg.in/yaml.v3" +) + +type Imp struct { + logger mlogger.Logger + file string + debug bool + + config *config + app *grpcapp.App[storage.Repository] + http *http.Server + service *auroraservice.Service +} + +type config struct { + *grpcapp.Config `yaml:",inline"` + Provider gatewayProviderConfig `yaml:"aurora"` + LegacyProvider gatewayProviderConfig `yaml:"mcards"` + Gateway gatewayConfig `yaml:"gateway"` + HTTP httpConfig `yaml:"http"` +} + +type gatewayProviderConfig struct { + BaseURL string `yaml:"base_url"` + BaseURLEnv string `yaml:"base_url_env"` + ProjectID int64 `yaml:"project_id"` + ProjectIDEnv string `yaml:"project_id_env"` + SecretKey string `yaml:"secret_key"` + SecretKeyEnv string `yaml:"secret_key_env"` + AllowedCurrencies []string `yaml:"allowed_currencies"` + RequireCustomerAddress bool `yaml:"require_customer_address"` + RequestTimeoutSeconds int `yaml:"request_timeout_seconds"` + StatusSuccess string `yaml:"status_success"` + StatusProcessing string `yaml:"status_processing"` + StrictOperationMode bool `yaml:"strict_operation_mode"` +} + +type gatewayConfig struct { + ID string `yaml:"id"` + Network string `yaml:"network"` + Currencies []string `yaml:"currencies"` + IsEnabled *bool `yaml:"is_enabled"` + Limits limitsConfig `yaml:"limits"` +} + +type limitsConfig struct { + MinAmount string `yaml:"min_amount"` + MaxAmount string `yaml:"max_amount"` + PerTxMaxFee string `yaml:"per_tx_max_fee"` + PerTxMinAmount string `yaml:"per_tx_min_amount"` + PerTxMaxAmount string `yaml:"per_tx_max_amount"` + VolumeLimit map[string]string `yaml:"volume_limit"` + VelocityLimit map[string]int `yaml:"velocity_limit"` + CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"` +} + +type limitsOverrideCfg struct { + MaxVolume string `yaml:"max_volume"` + MinAmount string `yaml:"min_amount"` + MaxAmount string `yaml:"max_amount"` + MaxFee string `yaml:"max_fee"` + MaxOps int `yaml:"max_ops"` +} + +type httpConfig struct { + Callback callbackConfig `yaml:"callback"` +} + +type callbackConfig struct { + Address string `yaml:"address"` + Path string `yaml:"path"` + AllowedCIDRs []string `yaml:"allowed_cidrs"` + MaxBodyBytes int64 `yaml:"max_body_bytes"` +} + +// Create initialises the Aurora gateway server implementation. +func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { + return &Imp{ + logger: logger.Named("server"), + file: file, + debug: debug, + }, nil +} + +func (i *Imp) Shutdown() { + if i.app == nil { + return + } + + timeout := 15 * time.Second + if i.config != nil && i.config.Runtime != nil { + timeout = i.config.Runtime.ShutdownTimeout() + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if i.service != nil { + i.service.Shutdown() + } + if i.http != nil { + _ = i.http.Shutdown(ctx) + i.http = nil + } + + i.app.Shutdown(ctx) +} + +func (i *Imp) Start() error { + i.logger.Info("Starting Aurora gateway", zap.String("config_file", i.file), zap.Bool("debug", i.debug)) + + cfg, err := i.loadConfig() + if err != nil { + return err + } + i.config = cfg + + i.logger.Info("Configuration loaded", + zap.String("grpc_address", cfg.GRPC.Address), + zap.String("metrics_address", cfg.Metrics.Address), + ) + + providerSection := effectiveProviderConfig(cfg.Provider, cfg.LegacyProvider) + providerCfg, err := i.resolveProviderConfig(providerSection) + if err != nil { + i.logger.Error("Failed to resolve provider configuration", zap.Error(err)) + return err + } + + callbackCfg, err := i.resolveCallbackConfig(cfg.HTTP.Callback) + if err != nil { + i.logger.Error("Failed to resolve callback configuration", zap.Error(err)) + return err + } + + i.logger.Info("Provider configuration resolved", + zap.Bool("base_url_set", strings.TrimSpace(providerCfg.BaseURL) != ""), + zap.Int64("project_id", providerCfg.ProjectID), + zap.Bool("secret_key_set", strings.TrimSpace(providerCfg.SecretKey) != ""), + zap.Int("allowed_currencies", len(providerCfg.AllowedCurrencies)), + zap.Bool("require_customer_address", providerCfg.RequireCustomerAddress), + zap.Duration("request_timeout", providerCfg.RequestTimeout), + zap.String("status_success", providerCfg.SuccessStatus()), + zap.String("status_processing", providerCfg.ProcessingStatus()), + zap.Bool("strict_operation_mode", providerSection.StrictOperationMode), + ) + + gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, providerCfg) + if gatewayDescriptor != nil { + i.logger.Info("Gateway descriptor resolved", + zap.String("id", gatewayDescriptor.GetId()), + zap.String("rail", gatewayDescriptor.GetRail().String()), + zap.String("network", gatewayDescriptor.GetNetwork()), + zap.Int("currencies", len(gatewayDescriptor.GetCurrencies())), + zap.Bool("enabled", gatewayDescriptor.GetIsEnabled()), + ) + } + + i.logger.Info("Callback configuration resolved", + zap.String("address", callbackCfg.Address), + zap.String("path", callbackCfg.Path), + zap.Int("allowed_cidrs", len(callbackCfg.AllowedCIDRs)), + zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes), + ) + + serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { + invokeURI := "" + if cfg.GRPC != nil { + invokeURI = cfg.GRPC.DiscoveryInvokeURI() + } + opts := []auroraservice.Option{ + auroraservice.WithDiscoveryInvokeURI(invokeURI), + auroraservice.WithProducer(producer), + auroraservice.WithProviderConfig(providerCfg), + auroraservice.WithStrictOperationIsolation(providerSection.StrictOperationMode), + auroraservice.WithGatewayDescriptor(gatewayDescriptor), + auroraservice.WithHTTPClient(&http.Client{Timeout: providerCfg.Timeout()}), + auroraservice.WithStorage(repo), + } + if cfg.Messaging != nil { + opts = append(opts, auroraservice.WithMessagingSettings(cfg.Messaging.Settings)) + } + svc := auroraservice.NewService(logger, opts...) + i.service = svc + + if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil { + return nil, err + } + + return svc, nil + } + + repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { + return gatewaymongo.New(logger, conn) + } + + app, err := grpcapp.NewApp(i.logger, paymenttypes.DefaultCardsGatewayID, cfg.Config, i.debug, repoFactory, serviceFactory) + if err != nil { + return err + } + i.app = app + + return i.app.Start() +} + +func (i *Imp) loadConfig() (*config, error) { + data, err := os.ReadFile(i.file) + if err != nil { + i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err)) + return nil, err + } + + cfg := &config{ + Config: &grpcapp.Config{}, + } + if err := yaml.Unmarshal(data, cfg); err != nil { + i.logger.Error("Failed to parse configuration", zap.Error(err)) + return nil, err + } + + if cfg.Runtime == nil { + cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15} + } + + if cfg.GRPC == nil { + cfg.GRPC = &routers.GRPCConfig{ + Network: "tcp", + Address: ":50075", + EnableReflection: true, + EnableHealth: true, + } + } + + if cfg.Metrics == nil { + cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9405"} + } + + return cfg, nil +} + +func effectiveProviderConfig(primary, legacy gatewayProviderConfig) gatewayProviderConfig { + if hasProviderConfig(primary) { + return primary + } + return legacy +} + +func hasProviderConfig(cfg gatewayProviderConfig) bool { + return strings.TrimSpace(cfg.BaseURL) != "" || + strings.TrimSpace(cfg.BaseURLEnv) != "" || + cfg.ProjectID != 0 || + strings.TrimSpace(cfg.ProjectIDEnv) != "" || + strings.TrimSpace(cfg.SecretKey) != "" || + strings.TrimSpace(cfg.SecretKeyEnv) != "" || + len(cfg.AllowedCurrencies) > 0 || + cfg.RequireCustomerAddress || + cfg.RequestTimeoutSeconds != 0 || + strings.TrimSpace(cfg.StatusSuccess) != "" || + strings.TrimSpace(cfg.StatusProcessing) != "" || + cfg.StrictOperationMode +} + +func (i *Imp) resolveProviderConfig(cfg gatewayProviderConfig) (provider.Config, error) { + baseURL := strings.TrimSpace(cfg.BaseURL) + if env := strings.TrimSpace(cfg.BaseURLEnv); env != "" { + if val := strings.TrimSpace(os.Getenv(env)); val != "" { + baseURL = val + } + } + + projectID := cfg.ProjectID + if projectID == 0 && strings.TrimSpace(cfg.ProjectIDEnv) != "" { + raw := strings.TrimSpace(os.Getenv(cfg.ProjectIDEnv)) + if raw != "" { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + projectID = id + } else { + return provider.Config{}, merrors.InvalidArgument("invalid project id in env "+cfg.ProjectIDEnv, "aurora.project_id") + } + } + } + + secret := strings.TrimSpace(cfg.SecretKey) + if env := strings.TrimSpace(cfg.SecretKeyEnv); env != "" { + if val := strings.TrimSpace(os.Getenv(env)); val != "" { + secret = val + } + } + + timeout := time.Duration(cfg.RequestTimeoutSeconds) * time.Second + if timeout <= 0 { + timeout = 15 * time.Second + } + + statusSuccess := strings.TrimSpace(cfg.StatusSuccess) + statusProcessing := strings.TrimSpace(cfg.StatusProcessing) + + return provider.Config{ + BaseURL: baseURL, + ProjectID: projectID, + SecretKey: secret, + AllowedCurrencies: cfg.AllowedCurrencies, + RequireCustomerAddress: cfg.RequireCustomerAddress, + RequestTimeout: timeout, + StatusSuccess: statusSuccess, + StatusProcessing: statusProcessing, + }, nil +} + +func resolveGatewayDescriptor(cfg gatewayConfig, providerCfg provider.Config) *gatewayv1.GatewayInstanceDescriptor { + id := strings.TrimSpace(cfg.ID) + if id == "" { + id = paymenttypes.DefaultCardsGatewayID + } + + network := strings.ToUpper(strings.TrimSpace(cfg.Network)) + currencies := normalizeCurrencies(cfg.Currencies) + if len(currencies) == 0 { + currencies = normalizeCurrencies(providerCfg.AllowedCurrencies) + } + + enabled := true + if cfg.IsEnabled != nil { + enabled = *cfg.IsEnabled + } + + limits := buildGatewayLimits(cfg.Limits) + if limits == nil { + limits = &gatewayv1.Limits{MinAmount: "0"} + } + + version := strings.TrimSpace(appversion.Version) + + return &gatewayv1.GatewayInstanceDescriptor{ + Id: id, + Rail: gatewayv1.Rail_RAIL_CARD, + Network: network, + Currencies: currencies, + Capabilities: &gatewayv1.RailCapabilities{ + CanPayOut: true, + CanPayIn: false, + CanReadBalance: false, + CanSendFee: false, + RequiresObserveConfirm: true, + }, + Limits: limits, + Version: version, + IsEnabled: enabled, + } +} + +func normalizeCurrencies(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" || seen[clean] { + continue + } + seen[clean] = true + result = append(result, clean) + } + return result +} + +func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits { + hasValue := strings.TrimSpace(cfg.MinAmount) != "" || + strings.TrimSpace(cfg.MaxAmount) != "" || + strings.TrimSpace(cfg.PerTxMaxFee) != "" || + strings.TrimSpace(cfg.PerTxMinAmount) != "" || + strings.TrimSpace(cfg.PerTxMaxAmount) != "" || + len(cfg.VolumeLimit) > 0 || + len(cfg.VelocityLimit) > 0 || + len(cfg.CurrencyLimits) > 0 + if !hasValue { + return nil + } + + limits := &gatewayv1.Limits{ + MinAmount: strings.TrimSpace(cfg.MinAmount), + MaxAmount: strings.TrimSpace(cfg.MaxAmount), + PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee), + PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount), + PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount), + } + + if len(cfg.VolumeLimit) > 0 { + limits.VolumeLimit = map[string]string{} + for key, value := range cfg.VolumeLimit { + bucket := strings.TrimSpace(key) + amount := strings.TrimSpace(value) + if bucket == "" || amount == "" { + continue + } + limits.VolumeLimit[bucket] = amount + } + } + + if len(cfg.VelocityLimit) > 0 { + limits.VelocityLimit = map[string]int32{} + for key, value := range cfg.VelocityLimit { + bucket := strings.TrimSpace(key) + if bucket == "" { + continue + } + limits.VelocityLimit[bucket] = int32(value) + } + } + + if len(cfg.CurrencyLimits) > 0 { + limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{} + for key, override := range cfg.CurrencyLimits { + currency := strings.ToUpper(strings.TrimSpace(key)) + if currency == "" { + continue + } + limits.CurrencyLimits[currency] = &gatewayv1.LimitsOverride{ + MaxVolume: strings.TrimSpace(override.MaxVolume), + MinAmount: strings.TrimSpace(override.MinAmount), + MaxAmount: strings.TrimSpace(override.MaxAmount), + MaxFee: strings.TrimSpace(override.MaxFee), + MaxOps: int32(override.MaxOps), + } + } + } + + return limits +} + +type callbackRuntimeConfig struct { + Address string + Path string + AllowedCIDRs []*net.IPNet + MaxBodyBytes int64 +} + +func (i *Imp) resolveCallbackConfig(cfg callbackConfig) (callbackRuntimeConfig, error) { + addr := strings.TrimSpace(cfg.Address) + if addr == "" { + addr = ":8084" + } + path := strings.TrimSpace(cfg.Path) + if path == "" { + path = "/" + paymenttypes.DefaultCardsGatewayID + "/callback" + } + maxBody := cfg.MaxBodyBytes + if maxBody <= 0 { + maxBody = 1 << 20 // 1MB + } + + var cidrs []*net.IPNet + for _, raw := range cfg.AllowedCIDRs { + clean := strings.TrimSpace(raw) + if clean == "" { + continue + } + _, block, err := net.ParseCIDR(clean) + if err != nil { + i.logger.Warn("Invalid callback allowlist CIDR skipped", zap.String("cidr", clean), zap.Error(err)) + continue + } + cidrs = append(cidrs, block) + } + + return callbackRuntimeConfig{ + Address: addr, + Path: path, + AllowedCIDRs: cidrs, + MaxBodyBytes: maxBody, + }, nil +} + +func (i *Imp) startHTTPCallbackServer(svc *auroraservice.Service, cfg callbackRuntimeConfig) error { + if svc == nil { + return merrors.InvalidArgument("nil service provided for callback server") + } + if strings.TrimSpace(cfg.Address) == "" { + i.logger.Info("Aurora callback server disabled: address is empty") + return nil + } + + router := chi.NewRouter() + router.Post(cfg.Path, func(w http.ResponseWriter, r *http.Request) { + log := i.logger.Named("callback_http") + log.Debug("Callback request received", + zap.String("remote_addr", strings.TrimSpace(r.RemoteAddr)), + zap.String("path", r.URL.Path), + zap.String("method", r.Method), + ) + + if len(cfg.AllowedCIDRs) > 0 && !clientAllowed(r, cfg.AllowedCIDRs) { + ip := clientIPFromRequest(r) + remoteIP := "" + if ip != nil { + remoteIP = ip.String() + } + log.Warn("Callback rejected by CIDR allowlist", zap.String("remote_ip", remoteIP)) + http.Error(w, "forbidden", http.StatusForbidden) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, cfg.MaxBodyBytes)) + if err != nil { + log.Warn("Callback body read failed", zap.Error(err)) + http.Error(w, "failed to read body", http.StatusBadRequest) + return + } + status, err := svc.ProcessProviderCallback(r.Context(), body) + if err != nil { + log.Warn("Callback processing failed", zap.Error(err), zap.Int("status", status)) + http.Error(w, err.Error(), status) + return + } + log.Debug("Callback processed", zap.Int("status", status)) + w.WriteHeader(status) + }) + + server := &http.Server{ + Addr: cfg.Address, + Handler: router, + } + + ln, err := net.Listen("tcp", cfg.Address) + if err != nil { + return err + } + + i.http = server + + go func() { + if err := server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + i.logger.Warn("Aurora callback server stopped with error", zap.Error(err)) + } + }() + + i.logger.Info("Aurora callback server listening", zap.String("address", cfg.Address), zap.String("path", cfg.Path)) + return nil +} + +func clientAllowed(r *http.Request, cidrs []*net.IPNet) bool { + if len(cidrs) == 0 { + return true + } + + host := clientIPFromRequest(r) + if host == nil { + return false + } + for _, block := range cidrs { + if block.Contains(host) { + return true + } + } + return false +} + +func clientIPFromRequest(r *http.Request) net.IP { + if r == nil { + return nil + } + if xfwd := strings.TrimSpace(r.Header.Get("X-Forwarded-For")); xfwd != "" { + parts := strings.Split(xfwd, ",") + if len(parts) > 0 { + if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil { + return ip + } + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return nil + } + return net.ParseIP(host) +} diff --git a/api/gateway/aurora/internal/server/internal/serverimp_test.go b/api/gateway/aurora/internal/server/internal/serverimp_test.go new file mode 100644 index 00000000..95fae35a --- /dev/null +++ b/api/gateway/aurora/internal/server/internal/serverimp_test.go @@ -0,0 +1,87 @@ +package serverimp + +import ( + "net" + "net/http" + "testing" +) + +func TestEffectiveProviderConfig(t *testing.T) { + primary := gatewayProviderConfig{ + BaseURL: "https://aurora.local", + StrictOperationMode: true, + } + legacy := gatewayProviderConfig{ + BaseURL: "https://legacy.local", + StrictOperationMode: false, + } + + got := effectiveProviderConfig(primary, legacy) + if got.BaseURL != primary.BaseURL { + t.Fatalf("expected primary provider config to be selected, got %q", got.BaseURL) + } + if !got.StrictOperationMode { + t.Fatalf("expected strict operation mode from primary config") + } +} + +func TestEffectiveProviderConfig_FallsBackToLegacy(t *testing.T) { + primary := gatewayProviderConfig{} + legacy := gatewayProviderConfig{ + BaseURL: "https://legacy.local", + StrictOperationMode: true, + } + + got := effectiveProviderConfig(primary, legacy) + if got.BaseURL != legacy.BaseURL { + t.Fatalf("expected legacy provider config to be selected, got %q", got.BaseURL) + } + if !got.StrictOperationMode { + t.Fatalf("expected strict operation mode from legacy config") + } +} + +func TestClientIPFromRequest(t *testing.T) { + req := &http.Request{ + Header: http.Header{"X-Forwarded-For": []string{"1.2.3.4, 5.6.7.8"}}, + RemoteAddr: "9.8.7.6:1234", + } + ip := clientIPFromRequest(req) + if ip == nil || ip.String() != "1.2.3.4" { + t.Fatalf("expected forwarded ip, got %v", ip) + } + + req = &http.Request{RemoteAddr: "9.8.7.6:1234"} + ip = clientIPFromRequest(req) + if ip == nil || ip.String() != "9.8.7.6" { + t.Fatalf("expected remote addr ip, got %v", ip) + } + + req = &http.Request{RemoteAddr: "invalid"} + ip = clientIPFromRequest(req) + if ip != nil { + t.Fatalf("expected nil ip, got %v", ip) + } +} + +func TestClientAllowed(t *testing.T) { + _, cidr, err := net.ParseCIDR("10.0.0.0/8") + if err != nil { + t.Fatalf("failed to parse cidr: %v", err) + } + + allowedReq := &http.Request{RemoteAddr: "10.1.2.3:1234"} + if !clientAllowed(allowedReq, []*net.IPNet{cidr}) { + t.Fatalf("expected allowed request") + } + + deniedReq := &http.Request{RemoteAddr: "8.8.8.8:1234"} + if clientAllowed(deniedReq, []*net.IPNet{cidr}) { + t.Fatalf("expected denied request") + } + + openReq := &http.Request{RemoteAddr: "8.8.8.8:1234"} + if !clientAllowed(openReq, nil) { + t.Fatalf("expected allow when no cidrs are configured") + } +} diff --git a/api/gateway/aurora/internal/server/server.go b/api/gateway/aurora/internal/server/server.go new file mode 100644 index 00000000..a4d676ec --- /dev/null +++ b/api/gateway/aurora/internal/server/server.go @@ -0,0 +1,12 @@ +package server + +import ( + serverimp "github.com/tech/sendico/gateway/aurora/internal/server/internal" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" +) + +// Create constructs the Aurora gateway server implementation. +func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return serverimp.Create(logger, file, debug) +} diff --git a/api/gateway/aurora/internal/service/gateway/aurora_scenarios_test.go b/api/gateway/aurora/internal/service/gateway/aurora_scenarios_test.go new file mode 100644 index 00000000..edf5b444 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/aurora_scenarios_test.go @@ -0,0 +1,321 @@ +package gateway + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/gateway/aurora/storage/model" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" +) + +func TestAuroraCardPayoutScenarios(t *testing.T) { + cfg := provider.Config{ + ProjectID: 1001, + AllowedCurrencies: []string{"RUB"}, + } + now := time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC) + repo := newMockRepository() + processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, nil, nil) + processor.dispatchThrottleInterval = 0 + + tests := []struct { + name string + pan string + wantAccepted bool + wantStatus mntxv1.PayoutStatus + wantErrorCode string + wantProviderCode string + wantProviderMatch string + }{ + { + name: "approved_instant", + pan: "2200001111111111", + wantAccepted: true, + wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS, + wantErrorCode: "00", + wantProviderCode: "00", + wantProviderMatch: "Approved", + }, + { + name: "pending_issuer_review", + pan: "2200002222222222", + wantAccepted: true, + wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING, + wantErrorCode: "P01", + wantProviderCode: "P01", + wantProviderMatch: "Pending issuer review", + }, + { + name: "insufficient_funds", + pan: "2200003333333333", + wantAccepted: false, + wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED, + wantErrorCode: "51", + wantProviderCode: "51", + wantProviderMatch: "Insufficient funds", + }, + { + name: "unknown_card_default_queue", + pan: "2200009999999999", + wantAccepted: true, + wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING, + wantErrorCode: "P00", + wantProviderCode: "P00", + wantProviderMatch: "Queued for provider processing", + }, + { + name: "provider_maintenance", + pan: "2200009999999997", + wantAccepted: false, + wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED, + wantErrorCode: "91", + wantProviderCode: "91", + wantProviderMatch: "inoperative", + }, + { + name: "provider_system_malfunction", + pan: "2200009999999996", + wantAccepted: false, + wantStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED, + wantErrorCode: "96", + wantProviderCode: "96", + wantProviderMatch: "System malfunction", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := validCardPayoutRequest() + req.PayoutId = "" + req.OperationRef = "op-" + tc.name + req.ParentPaymentRef = "parent-" + tc.name + req.IdempotencyKey = "idem-" + tc.name + req.CardPan = tc.pan + + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("submit failed: %v", err) + } + if got, want := resp.GetAccepted(), tc.wantAccepted; got != want { + t.Fatalf("accepted mismatch: got=%v want=%v", got, want) + } + if got, want := resp.GetPayout().GetStatus(), tc.wantStatus; got != want { + t.Fatalf("status mismatch: got=%v want=%v", got, want) + } + if got, want := strings.TrimSpace(resp.GetErrorCode()), tc.wantErrorCode; got != want { + t.Fatalf("error_code mismatch: got=%q want=%q", got, want) + } + + state, ok := repo.payouts.Get(req.GetOperationRef()) + if !ok || state == nil { + t.Fatalf("expected persisted payout state") + } + if got, want := strings.TrimSpace(state.ProviderCode), tc.wantProviderCode; got != want { + t.Fatalf("provider_code mismatch: got=%q want=%q", got, want) + } + if tc.wantProviderMatch != "" && !strings.Contains(state.ProviderMessage, tc.wantProviderMatch) { + t.Fatalf("provider_message mismatch: got=%q expected to contain %q", state.ProviderMessage, tc.wantProviderMatch) + } + }) + } +} + +func TestAuroraTransportFailureScenarioEventuallyFails(t *testing.T) { + cfg := provider.Config{ + ProjectID: 1001, + AllowedCurrencies: []string{"RUB"}, + } + repo := newMockRepository() + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)}, + repo, + nil, + nil, + ) + defer processor.stopRetries() + processor.dispatchThrottleInterval = 0 + processor.dispatchMaxAttempts = 2 + processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond } + + req := validCardPayoutRequest() + req.PayoutId = "" + req.OperationRef = "op-transport-timeout" + req.ParentPaymentRef = "parent-transport-timeout" + req.IdempotencyKey = "idem-transport-timeout" + req.CardPan = "2200008888888888" + + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("submit failed: %v", err) + } + if !resp.GetAccepted() { + t.Fatalf("expected accepted response while transport retry is scheduled") + } + if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING; got != want { + t.Fatalf("initial status mismatch: got=%v want=%v", got, want) + } + + deadline := time.Now().Add(2 * time.Second) + for { + state, ok := repo.payouts.Get(req.GetOperationRef()) + if ok && state != nil && state.Status == model.PayoutStatusFailed { + if !strings.Contains(strings.ToLower(state.FailureReason), "transport error") { + t.Fatalf("expected transport failure reason, got=%q", state.FailureReason) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for transport failure terminal state") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestAuroraRetryableScenarioEventuallyFails(t *testing.T) { + cfg := provider.Config{ + ProjectID: 1001, + AllowedCurrencies: []string{"RUB"}, + } + repo := newMockRepository() + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)}, + repo, + nil, + nil, + ) + defer processor.stopRetries() + processor.dispatchThrottleInterval = 0 + processor.dispatchMaxAttempts = 2 + processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond } + + req := validCardPayoutRequest() + req.PayoutId = "" + req.OperationRef = "op-retryable-issuer-unavailable" + req.ParentPaymentRef = "parent-retryable-issuer-unavailable" + req.IdempotencyKey = "idem-retryable-issuer-unavailable" + req.CardPan = "2200004444444444" + + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("submit failed: %v", err) + } + if !resp.GetAccepted() { + t.Fatalf("expected accepted response while retry is scheduled") + } + if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING; got != want { + t.Fatalf("initial status mismatch: got=%v want=%v", got, want) + } + + deadline := time.Now().Add(2 * time.Second) + for { + state, ok := repo.payouts.Get(req.GetOperationRef()) + if ok && state != nil && state.Status == model.PayoutStatusFailed { + if !strings.Contains(state.FailureReason, "10101") { + t.Fatalf("expected retryable provider code in failure_reason, got=%q", state.FailureReason) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for retryable scenario terminal failure") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestAuroraTokenPayoutUsesTokenizedPANScenario(t *testing.T) { + cfg := provider.Config{ + ProjectID: 1001, + AllowedCurrencies: []string{"RUB", "USD"}, + } + repo := newMockRepository() + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)}, + repo, + nil, + nil, + ) + processor.dispatchThrottleInterval = 0 + + tokenizeReq := validCardTokenizeRequest() + tokenizeReq.RequestId = "tok-req-insufficient" + tokenizeReq.CardPan = "2200003333333333" + + tokenizeResp, err := processor.Tokenize(context.Background(), tokenizeReq) + if err != nil { + t.Fatalf("tokenize failed: %v", err) + } + if tokenizeResp.GetToken() == "" { + t.Fatalf("expected non-empty token") + } + + payoutReq := validCardTokenPayoutRequest() + payoutReq.PayoutId = "" + payoutReq.OperationRef = "op-token-insufficient" + payoutReq.ParentPaymentRef = "parent-token-insufficient" + payoutReq.IdempotencyKey = "idem-token-insufficient" + payoutReq.CardToken = tokenizeResp.GetToken() + payoutReq.MaskedPan = tokenizeResp.GetMaskedPan() + + resp, err := processor.SubmitToken(context.Background(), payoutReq) + if err != nil { + t.Fatalf("submit token payout failed: %v", err) + } + if resp.GetAccepted() { + t.Fatalf("expected declined payout for insufficient funds token scenario") + } + if got, want := resp.GetErrorCode(), "51"; got != want { + t.Fatalf("error_code mismatch: got=%q want=%q", got, want) + } + if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED; got != want { + t.Fatalf("status mismatch: got=%v want=%v", got, want) + } +} + +func TestAuroraTokenPayoutFallsBackToMaskedPANScenario(t *testing.T) { + cfg := provider.Config{ + ProjectID: 1001, + AllowedCurrencies: []string{"RUB", "USD"}, + } + repo := newMockRepository() + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 5, 10, 0, 0, 0, time.UTC)}, + repo, + nil, + nil, + ) + processor.dispatchThrottleInterval = 0 + + req := validCardTokenPayoutRequest() + req.PayoutId = "" + req.OperationRef = "op-token-masked-fallback" + req.ParentPaymentRef = "parent-token-masked-fallback" + req.IdempotencyKey = "idem-token-masked-fallback" + req.CardToken = "unknown-token" + req.MaskedPan = "220000******6666" + + resp, err := processor.SubmitToken(context.Background(), req) + if err != nil { + t.Fatalf("submit token payout failed: %v", err) + } + if resp.GetAccepted() { + t.Fatalf("expected declined payout for masked-pan fallback scenario") + } + if got, want := resp.GetErrorCode(), "05"; got != want { + t.Fatalf("error_code mismatch: got=%q want=%q", got, want) + } + if got, want := resp.GetPayout().GetStatus(), mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED; got != want { + t.Fatalf("status mismatch: got=%v want=%v", got, want) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/callback.go b/api/gateway/aurora/internal/service/gateway/callback.go new file mode 100644 index 00000000..cc2f7db2 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/callback.go @@ -0,0 +1,177 @@ +package gateway + +import ( + "bytes" + "context" + "crypto/hmac" + "encoding/json" + "net/http" + "strings" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type callbackPayment struct { + ID string `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Date string `json:"date"` + Method string `json:"method"` + Description string `json:"description"` + Sum struct { + Amount int64 `json:"amount"` + Currency string `json:"currency"` + } `json:"sum"` +} + +type callbackOperation struct { + ID int64 `json:"id"` + Type string `json:"type"` + Status string `json:"status"` + Date string `json:"date"` + CreatedDate string `json:"created_date"` + RequestID string `json:"request_id"` + SumInitial struct { + Amount int64 `json:"amount"` + Currency string `json:"currency"` + } `json:"sum_initial"` + SumConverted struct { + Amount int64 `json:"amount"` + Currency string `json:"currency"` + } `json:"sum_converted"` + Provider struct { + ID int64 `json:"id"` + PaymentID string `json:"payment_id"` + AuthCode string `json:"auth_code"` + EndpointID int64 `json:"endpoint_id"` + Date string `json:"date"` + } `json:"provider"` + Code string `json:"code"` + Message string `json:"message"` +} + +type providerCallback struct { + ProjectID int64 `json:"project_id"` + Payment callbackPayment `json:"payment"` + Account struct { + Number string `json:"number"` + Type string `json:"type"` + CardHolder string `json:"card_holder"` + ExpiryMonth string `json:"expiry_month"` + ExpiryYear string `json:"expiry_year"` + } `json:"account"` + Customer struct { + ID string `json:"id"` + } `json:"customer"` + Operation callbackOperation `json:"operation"` + Signature string `json:"signature"` +} + +// ProcessProviderCallback ingests provider callbacks and updates payout state. +func (s *Service) ProcessProviderCallback(ctx context.Context, payload []byte) (int, error) { + log := s.logger.Named("callback") + if s.card == nil { + log.Warn("Card payout processor not initialised") + return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") + } + log.Debug("Callback processing requested", zap.Int("payload_bytes", len(payload))) + return s.card.ProcessCallback(ctx, payload) +} + +func mapCallbackToState(clock clockpkg.Clock, cfg provider.Config, cb providerCallback) (*mntxv1.CardPayoutState, string) { + status := strings.ToLower(strings.TrimSpace(cb.Payment.Status)) + opStatus := strings.ToLower(strings.TrimSpace(cb.Operation.Status)) + code := strings.TrimSpace(cb.Operation.Code) + + outcome := provider.OutcomeDecline + internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + + if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") { + internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS + outcome = provider.OutcomeSuccess + } else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() { + internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING + outcome = provider.OutcomeProcessing + } + + now := timestamppb.New(clock.Now()) + state := &mntxv1.CardPayoutState{ + PayoutId: cb.Payment.ID, + ProjectId: cb.ProjectID, + CustomerId: cb.Customer.ID, + AmountMinor: cb.Payment.Sum.Amount, + Currency: strings.ToUpper(strings.TrimSpace(cb.Payment.Sum.Currency)), + Status: internalStatus, + ProviderCode: cb.Operation.Code, + ProviderMessage: cb.Operation.Message, + ProviderPaymentId: fallbackProviderPaymentID(cb), + OperationRef: strings.TrimSpace(cb.Payment.ID), + UpdatedAt: now, + CreatedAt: now, + } + + return state, outcome +} + +func fallbackProviderPaymentID(cb providerCallback) string { + if cb.Operation.Provider.PaymentID != "" { + return cb.Operation.Provider.PaymentID + } + if cb.Operation.RequestID != "" { + return cb.Operation.RequestID + } + return cb.Payment.ID +} + +func verifyCallbackSignature(payload []byte, secret string) (string, error) { + root, err := decodeCallbackPayload(payload) + if err != nil { + return "", err + } + signature, ok := signatureFromPayload(root) + if !ok || strings.TrimSpace(signature) == "" { + return "", merrors.InvalidArgument("signature is missing") + } + calculated, err := provider.SignPayload(root, secret) + if err != nil { + return signature, err + } + if subtleConstantTimeCompare(signature, calculated) { + return signature, nil + } + return signature, merrors.DataConflict("signature mismatch") +} + +func decodeCallbackPayload(payload []byte) (any, error) { + var root any + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.UseNumber() + if err := decoder.Decode(&root); err != nil { + return nil, err + } + return root, nil +} + +func signatureFromPayload(root any) (string, bool) { + payload, ok := root.(map[string]any) + if !ok { + return "", false + } + for key, value := range payload { + if !strings.EqualFold(key, "signature") { + continue + } + signature, ok := value.(string) + return signature, ok + } + return "", false +} + +func subtleConstantTimeCompare(a, b string) bool { + return hmac.Equal([]byte(strings.TrimSpace(a)), []byte(strings.TrimSpace(b))) +} diff --git a/api/gateway/aurora/internal/service/gateway/callback_test.go b/api/gateway/aurora/internal/service/gateway/callback_test.go new file mode 100644 index 00000000..90a3b3da --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/callback_test.go @@ -0,0 +1,139 @@ +package gateway + +import ( + "encoding/json" + "testing" + "time" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +type fixedClock struct { + now time.Time +} + +func (f fixedClock) Now() time.Time { + return f.now +} + +func baseCallback() providerCallback { + cb := providerCallback{ + ProjectID: 42, + } + cb.Payment.ID = "payout-1" + cb.Payment.Status = "success" + cb.Payment.Sum.Amount = 5000 + cb.Payment.Sum.Currency = "usd" + cb.Customer.ID = "cust-1" + cb.Operation.Status = "success" + cb.Operation.Code = "" + cb.Operation.Message = "ok" + cb.Operation.RequestID = "req-1" + cb.Operation.Provider.PaymentID = "prov-1" + return cb +} + +func TestMapCallbackToState_StatusMapping(t *testing.T) { + now := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC) + cfg := provider.DefaultConfig() + + cases := []struct { + name string + paymentStatus string + operationStatus string + code string + expectedStatus mntxv1.PayoutStatus + expectedOutcome string + }{ + { + name: "success", + paymentStatus: "success", + operationStatus: "success", + code: "0", + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS, + expectedOutcome: provider.OutcomeSuccess, + }, + { + name: "processing", + paymentStatus: "processing", + operationStatus: "success", + code: "", + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING, + expectedOutcome: provider.OutcomeProcessing, + }, + { + name: "decline", + paymentStatus: "failed", + operationStatus: "failed", + code: "1", + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED, + expectedOutcome: provider.OutcomeDecline, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cb := baseCallback() + cb.Payment.Status = tc.paymentStatus + cb.Operation.Status = tc.operationStatus + cb.Operation.Code = tc.code + + state, outcome := mapCallbackToState(fixedClock{now: now}, cfg, cb) + if state.Status != tc.expectedStatus { + t.Fatalf("expected status %v, got %v", tc.expectedStatus, state.Status) + } + if outcome != tc.expectedOutcome { + t.Fatalf("expected outcome %q, got %q", tc.expectedOutcome, outcome) + } + if state.Currency != "USD" { + t.Fatalf("expected currency USD, got %q", state.Currency) + } + if !state.UpdatedAt.AsTime().Equal(now) { + t.Fatalf("expected updated_at %v, got %v", now, state.UpdatedAt.AsTime()) + } + }) + } +} + +func TestFallbackProviderPaymentID(t *testing.T) { + cb := baseCallback() + if got := fallbackProviderPaymentID(cb); got != "prov-1" { + t.Fatalf("expected provider payment id, got %q", got) + } + cb.Operation.Provider.PaymentID = "" + if got := fallbackProviderPaymentID(cb); got != "req-1" { + t.Fatalf("expected request id fallback, got %q", got) + } + cb.Operation.RequestID = "" + if got := fallbackProviderPaymentID(cb); got != "payout-1" { + t.Fatalf("expected payment id fallback, got %q", got) + } +} + +func TestVerifyCallbackSignature(t *testing.T) { + secret := "secret" + cb := baseCallback() + + sig, err := provider.SignPayload(cb, secret) + if err != nil { + t.Fatalf("failed to sign payload: %v", err) + } + cb.Signature = sig + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + if _, err := verifyCallbackSignature(payload, secret); err != nil { + t.Fatalf("expected valid signature, got %v", err) + } + + cb.Signature = "invalid" + payload, err = json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + if _, err := verifyCallbackSignature(payload, secret); err == nil { + t.Fatalf("expected signature mismatch error") + } +} diff --git a/api/gateway/aurora/internal/service/gateway/card_payout_handlers.go b/api/gateway/aurora/internal/service/gateway/card_payout_handlers.go new file mode 100644 index 00000000..e986765f --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_payout_handlers.go @@ -0,0 +1,207 @@ +package gateway + +import ( + "context" + "strings" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mservice" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" + "google.golang.org/protobuf/proto" +) + +func (s *Service) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + return executeUnary(ctx, s, "CreateCardPayout", s.handleCreateCardPayout, req) +} + +func (s *Service) handleCreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) gsresponse.Responder[mntxv1.CardPayoutResponse] { + log := s.logger.Named("card_payout") + log.Info("Create card payout request received", + zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + ) + if s.card == nil { + log.Warn("Card payout processor not initialised") + return gsresponse.Internal[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + resp, err := s.card.Submit(ctx, req) + if err != nil { + log.Warn("Card payout submission failed", zap.Error(err)) + return gsresponse.Auto[mntxv1.CardPayoutResponse](s.logger, mservice.MntxGateway, err) + } + log.Info("Card payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted())) + return gsresponse.Success(resp) +} + +func (s *Service) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + return executeUnary(ctx, s, "CreateCardTokenPayout", s.handleCreateCardTokenPayout, req) +} + +func (s *Service) handleCreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) gsresponse.Responder[mntxv1.CardTokenPayoutResponse] { + log := s.logger.Named("card_token_payout") + log.Info("Create card token payout request received", + zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("parent_payment_ref", strings.TrimSpace(req.GetParentPaymentRef())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + ) + if s.card == nil { + log.Warn("Card payout processor not initialised") + return gsresponse.Internal[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + resp, err := s.card.SubmitToken(ctx, req) + if err != nil { + log.Warn("Card token payout submission failed", zap.Error(err)) + return gsresponse.Auto[mntxv1.CardTokenPayoutResponse](s.logger, mservice.MntxGateway, err) + } + log.Info("Card token payout submission completed", zap.String("payout_id", resp.GetPayout().GetPayoutId()), zap.Bool("accepted", resp.GetAccepted())) + return gsresponse.Success(resp) +} + +func (s *Service) CreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) { + return executeUnary(ctx, s, "CreateCardToken", s.handleCreateCardToken, req) +} + +func (s *Service) handleCreateCardToken(ctx context.Context, req *mntxv1.CardTokenizeRequest) gsresponse.Responder[mntxv1.CardTokenizeResponse] { + log := s.logger.Named("card_tokenize") + log.Info("Create card token request received", + zap.String("request_id", strings.TrimSpace(req.GetRequestId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + ) + if s.card == nil { + log.Warn("Card payout processor not initialised") + return gsresponse.Internal[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + resp, err := s.card.Tokenize(ctx, req) + if err != nil { + log.Warn("Card tokenization failed", zap.Error(err)) + return gsresponse.Auto[mntxv1.CardTokenizeResponse](s.logger, mservice.MntxGateway, err) + } + log.Info("Card tokenization completed", zap.String("request_id", resp.GetRequestId()), zap.Bool("success", resp.GetSuccess())) + return gsresponse.Success(resp) +} + +func (s *Service) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { + return executeUnary(ctx, s, "GetCardPayoutStatus", s.handleGetCardPayoutStatus, req) +} + +func (s *Service) handleGetCardPayoutStatus(_ context.Context, req *mntxv1.GetCardPayoutStatusRequest) gsresponse.Responder[mntxv1.GetCardPayoutStatusResponse] { + log := s.logger.Named("card_payout_status") + log.Info("Get card payout status request received", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId()))) + if s.card == nil { + log.Warn("Card payout processor not initialised") + return gsresponse.Internal[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, merrors.Internal("card payout processor not initialised")) + } + + state, err := s.card.Status(context.Background(), req.GetPayoutId()) + if err != nil { + log.Warn("Card payout status lookup failed", zap.Error(err)) + return gsresponse.Auto[mntxv1.GetCardPayoutStatusResponse](s.logger, mservice.MntxGateway, err) + } + log.Info("Card payout status retrieved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String())) + return gsresponse.Success(&mntxv1.GetCardPayoutStatusResponse{Payout: state}) +} + +func sanitizeCardPayoutRequest(req *mntxv1.CardPayoutRequest) *mntxv1.CardPayoutRequest { + if req == nil { + return nil + } + clean := proto.Clone(req) + r, ok := clean.(*mntxv1.CardPayoutRequest) + if !ok { + return req + } + r.PayoutId = strings.TrimSpace(r.GetPayoutId()) + r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef()) + r.CustomerId = strings.TrimSpace(r.GetCustomerId()) + r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) + r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) + r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName()) + r.CustomerIp = strings.TrimSpace(r.GetCustomerIp()) + r.CustomerZip = strings.TrimSpace(r.GetCustomerZip()) + r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry()) + r.CustomerState = strings.TrimSpace(r.GetCustomerState()) + r.CustomerCity = strings.TrimSpace(r.GetCustomerCity()) + r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress()) + r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency())) + r.CardPan = strings.TrimSpace(r.GetCardPan()) + r.CardHolder = strings.TrimSpace(r.GetCardHolder()) + r.OperationRef = strings.TrimSpace(r.GetOperationRef()) + r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey()) + r.IntentRef = strings.TrimSpace(r.GetIntentRef()) + return r +} + +func sanitizeCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest) *mntxv1.CardTokenPayoutRequest { + if req == nil { + return nil + } + clean := proto.Clone(req) + r, ok := clean.(*mntxv1.CardTokenPayoutRequest) + if !ok { + return req + } + r.PayoutId = strings.TrimSpace(r.GetPayoutId()) + r.ParentPaymentRef = strings.TrimSpace(r.GetParentPaymentRef()) + r.CustomerId = strings.TrimSpace(r.GetCustomerId()) + r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) + r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) + r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName()) + r.CustomerIp = strings.TrimSpace(r.GetCustomerIp()) + r.CustomerZip = strings.TrimSpace(r.GetCustomerZip()) + r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry()) + r.CustomerState = strings.TrimSpace(r.GetCustomerState()) + r.CustomerCity = strings.TrimSpace(r.GetCustomerCity()) + r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress()) + r.Currency = strings.ToUpper(strings.TrimSpace(r.GetCurrency())) + r.CardToken = strings.TrimSpace(r.GetCardToken()) + r.CardHolder = strings.TrimSpace(r.GetCardHolder()) + r.MaskedPan = strings.TrimSpace(r.GetMaskedPan()) + r.OperationRef = strings.TrimSpace(r.GetOperationRef()) + r.IdempotencyKey = strings.TrimSpace(r.GetIdempotencyKey()) + r.IntentRef = strings.TrimSpace(r.GetIntentRef()) + return r +} + +func sanitizeCardTokenizeRequest(req *mntxv1.CardTokenizeRequest) *mntxv1.CardTokenizeRequest { + if req == nil { + return nil + } + clean := proto.Clone(req) + r, ok := clean.(*mntxv1.CardTokenizeRequest) + if !ok { + return req + } + r.RequestId = strings.TrimSpace(r.GetRequestId()) + r.CustomerId = strings.TrimSpace(r.GetCustomerId()) + r.CustomerFirstName = strings.TrimSpace(r.GetCustomerFirstName()) + r.CustomerMiddleName = strings.TrimSpace(r.GetCustomerMiddleName()) + r.CustomerLastName = strings.TrimSpace(r.GetCustomerLastName()) + r.CustomerIp = strings.TrimSpace(r.GetCustomerIp()) + r.CustomerZip = strings.TrimSpace(r.GetCustomerZip()) + r.CustomerCountry = strings.TrimSpace(r.GetCustomerCountry()) + r.CustomerState = strings.TrimSpace(r.GetCustomerState()) + r.CustomerCity = strings.TrimSpace(r.GetCustomerCity()) + r.CustomerAddress = strings.TrimSpace(r.GetCustomerAddress()) + r.CardPan = strings.TrimSpace(r.GetCardPan()) + r.CardHolder = strings.TrimSpace(r.GetCardHolder()) + r.CardCvv = strings.TrimSpace(r.GetCardCvv()) + if card := r.GetCard(); card != nil { + card.Pan = strings.TrimSpace(card.GetPan()) + card.CardHolder = strings.TrimSpace(card.GetCardHolder()) + card.Cvv = strings.TrimSpace(card.GetCvv()) + r.Card = card + } + return r +} diff --git a/api/gateway/aurora/internal/service/gateway/card_payout_store_test.go b/api/gateway/aurora/internal/service/gateway/card_payout_store_test.go new file mode 100644 index 00000000..b6d0367e --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_payout_store_test.go @@ -0,0 +1,108 @@ +package gateway + +import ( + "context" + "sync" + + "github.com/tech/sendico/gateway/aurora/storage" + "github.com/tech/sendico/gateway/aurora/storage/model" +) + +// mockRepository implements storage.Repository for tests. +type mockRepository struct { + payouts *cardPayoutStore +} + +func newMockRepository() *mockRepository { + return &mockRepository{ + payouts: newCardPayoutStore(), + } +} + +func (r *mockRepository) Payouts() storage.PayoutsStore { + return r.payouts +} + +// cardPayoutStore implements storage.PayoutsStore for tests. +type cardPayoutStore struct { + mu sync.RWMutex + data map[string]*model.CardPayout +} + +func payoutStoreKey(state *model.CardPayout) string { + if state == nil { + return "" + } + if ref := state.OperationRef; ref != "" { + return ref + } + return state.PaymentRef +} + +func newCardPayoutStore() *cardPayoutStore { + return &cardPayoutStore{ + data: make(map[string]*model.CardPayout), + } +} + +func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*model.CardPayout, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, v := range s.data { + if v.IdempotencyKey == key { + return v, nil + } + } + return nil, nil +} + +func (s *cardPayoutStore) FindByOperationRef(_ context.Context, ref string) (*model.CardPayout, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, v := range s.data { + if v.OperationRef == ref { + return v, nil + } + } + return nil, nil +} + +func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) { + s.mu.RLock() + defer s.mu.RUnlock() + for _, v := range s.data { + if v.PaymentRef == id { + return v, nil + } + } + return nil, nil +} + +func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error { + s.mu.Lock() + defer s.mu.Unlock() + s.data[payoutStoreKey(record)] = record + return nil +} + +// Save is a helper for tests to pre-populate data. +func (s *cardPayoutStore) Save(state *model.CardPayout) { + s.mu.Lock() + defer s.mu.Unlock() + s.data[payoutStoreKey(state)] = state +} + +// Get is a helper for tests to retrieve data. +func (s *cardPayoutStore) Get(id string) (*model.CardPayout, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + if v, ok := s.data[id]; ok { + return v, true + } + for _, v := range s.data { + if v.PaymentRef == id || v.OperationRef == id { + return v, true + } + } + return nil, false +} diff --git a/api/gateway/aurora/internal/service/gateway/card_payout_validation.go b/api/gateway/aurora/internal/service/gateway/card_payout_validation.go new file mode 100644 index 00000000..138dd540 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_payout_validation.go @@ -0,0 +1,99 @@ +package gateway + +import ( + "strconv" + "strings" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func validateCardPayoutRequest(req *mntxv1.CardPayoutRequest, cfg provider.Config) error { + if req == nil { + return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) + } + + if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { + return err + } + if strings.TrimSpace(req.GetParentPaymentRef()) == "" { + return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref")) + } + if strings.TrimSpace(req.GetCustomerId()) == "" { + return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) + } + if strings.TrimSpace(req.GetCustomerFirstName()) == "" { + return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name")) + } + if strings.TrimSpace(req.GetCustomerLastName()) == "" { + return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name")) + } + if strings.TrimSpace(req.GetCustomerIp()) == "" { + return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip")) + } + + if req.GetAmountMinor() <= 0 { + return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor")) + } + + currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency())) + if currency == "" { + return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency")) + } + if !cfg.CurrencyAllowed(currency) { + return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency")) + } + + pan := strings.TrimSpace(req.GetCardPan()) + if pan == "" { + return newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card_pan")) + } + if strings.TrimSpace(req.GetCardHolder()) == "" { + return newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card_holder")) + } + if err := validateCardExpiryFields(req.GetCardExpMonth(), req.GetCardExpYear()); err != nil { + return err + } + + if cfg.RequireCustomerAddress { + if strings.TrimSpace(req.GetCustomerCountry()) == "" { + return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country")) + } + if strings.TrimSpace(req.GetCustomerCity()) == "" { + return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city")) + } + if strings.TrimSpace(req.GetCustomerAddress()) == "" { + return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address")) + } + if strings.TrimSpace(req.GetCustomerZip()) == "" { + return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip")) + } + } + + return nil +} + +func validateCardExpiryFields(month uint32, year uint32) error { + if month == 0 || month > 12 { + return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card_exp_month")) + } + yearStr := strconv.Itoa(int(year)) + if len(yearStr) < 2 || year == 0 { + return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card_exp_year")) + } + return nil +} + +func validateOperationIdentity(payoutID, operationRef string) error { + payoutID = strings.TrimSpace(payoutID) + operationRef = strings.TrimSpace(operationRef) + switch { + case payoutID == "" && operationRef == "": + return newPayoutError("missing_operation_ref", merrors.InvalidArgument("operation_ref or payout_id is required", "operation_ref")) + case payoutID != "" && operationRef != "": + return newPayoutError("ambiguous_operation_ref", merrors.InvalidArgument("provide either operation_ref or payout_id, not both")) + default: + return nil + } +} diff --git a/api/gateway/aurora/internal/service/gateway/card_payout_validation_test.go b/api/gateway/aurora/internal/service/gateway/card_payout_validation_test.go new file mode 100644 index 00000000..9a8f7c81 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_payout_validation_test.go @@ -0,0 +1,116 @@ +package gateway + +import ( + "testing" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func TestValidateCardPayoutRequest_Valid(t *testing.T) { + cfg := testProviderConfig() + req := validCardPayoutRequest() + if err := validateCardPayoutRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardPayoutRequest_Errors(t *testing.T) { + baseCfg := testProviderConfig() + cases := []struct { + name string + mutate func(*mntxv1.CardPayoutRequest) + config func(provider.Config) provider.Config + expected string + }{ + { + name: "missing_operation_identity", + mutate: func(r *mntxv1.CardPayoutRequest) { r.PayoutId = "" }, + expected: "missing_operation_ref", + }, + { + name: "missing_parent_payment_ref", + mutate: func(r *mntxv1.CardPayoutRequest) { r.ParentPaymentRef = "" }, + expected: "missing_parent_payment_ref", + }, + { + name: "both_operation_and_payout_identity", + mutate: func(r *mntxv1.CardPayoutRequest) { + r.PayoutId = "parent-1" + r.OperationRef = "parent-1:hop_1_card_payout_send" + }, + expected: "ambiguous_operation_ref", + }, + { + name: "missing_customer_id", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerId = "" }, + expected: "missing_customer_id", + }, + { + name: "missing_customer_ip", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerIp = "" }, + expected: "missing_customer_ip", + }, + { + name: "invalid_amount", + mutate: func(r *mntxv1.CardPayoutRequest) { r.AmountMinor = 0 }, + expected: "invalid_amount", + }, + { + name: "missing_currency", + mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "" }, + expected: "missing_currency", + }, + { + name: "unsupported_currency", + mutate: func(r *mntxv1.CardPayoutRequest) { r.Currency = "EUR" }, + config: func(cfg provider.Config) provider.Config { + cfg.AllowedCurrencies = []string{"USD"} + return cfg + }, + expected: "unsupported_currency", + }, + { + name: "missing_card_pan", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardPan = "" }, + expected: "missing_card_pan", + }, + { + name: "missing_card_holder", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardHolder = "" }, + expected: "missing_card_holder", + }, + { + name: "invalid_expiry_month", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpMonth = 13 }, + expected: "invalid_expiry_month", + }, + { + name: "invalid_expiry_year", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CardExpYear = 0 }, + expected: "invalid_expiry_year", + }, + { + name: "missing_customer_country_when_required", + mutate: func(r *mntxv1.CardPayoutRequest) { r.CustomerCountry = "" }, + config: func(cfg provider.Config) provider.Config { + cfg.RequireCustomerAddress = true + return cfg + }, + expected: "missing_customer_country", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := validCardPayoutRequest() + tc.mutate(req) + cfg := baseCfg + if tc.config != nil { + cfg = tc.config(cfg) + } + err := validateCardPayoutRequest(req, cfg) + requireReason(t, err, tc.expected) + }) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/card_processor.go b/api/gateway/aurora/internal/service/gateway/card_processor.go new file mode 100644 index 00000000..f79bcaf4 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_processor.go @@ -0,0 +1,1580 @@ +package gateway + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/gateway/aurora/storage" + "github.com/tech/sendico/gateway/aurora/storage/model" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" + "google.golang.org/protobuf/proto" +) + +const ( + defaultDispatchThrottleInterval = 150 * time.Millisecond + defaultMaxDispatchAttempts = uint32(5) +) + +type cardPayoutProcessor struct { + logger mlogger.Logger + config provider.Config + clock clockpkg.Clock + store storage.Repository + httpClient *http.Client + producer msg.Producer + msgCfg pmodel.SettingsT + outbox *gatewayoutbox.ReliableRuntime + + perTxMinAmountMinor int64 + perTxMinAmountMinorByCurrency map[string]int64 + dispatchThrottleInterval time.Duration + dispatchMaxAttempts uint32 + executionMode payoutExecutionMode + + dispatchMu sync.Mutex + nextDispatchAllowed time.Time + dispatchSerialGate chan struct{} + + retryPolicy payoutFailurePolicy + retryDelayFn func(attempt uint32) time.Duration + + retryMu sync.Mutex + retryTimers map[string]*time.Timer + retryCtx context.Context + retryStop context.CancelFunc + + retryReqMu sync.RWMutex + cardRetryRequests map[string]*mntxv1.CardPayoutRequest + cardTokenRetryRequest map[string]*mntxv1.CardTokenPayoutRequest + + attemptMu sync.Mutex + dispatchAttempts map[string]uint32 + + tokenMu sync.RWMutex + tokenPAN map[string]string + + simulator *payoutSimulator +} + +func mergePayoutStateWithExisting(state, existing *model.CardPayout) { + if state == nil || existing == nil { + return + } + + state.ID = existing.ID // preserve ID for upsert + if !existing.CreatedAt.IsZero() { + state.CreatedAt = existing.CreatedAt + } + if state.OperationRef == "" { + state.OperationRef = existing.OperationRef + } + if state.IdempotencyKey == "" { + state.IdempotencyKey = existing.IdempotencyKey + } + if state.IntentRef == "" { + state.IntentRef = existing.IntentRef + } + if existing.PaymentRef != "" { + state.PaymentRef = existing.PaymentRef + } +} + +func findOperationRef(operationRef, payoutID string) string { + ref := strings.TrimSpace(operationRef) + if ref != "" { + return ref + } + return strings.TrimSpace(payoutID) +} + +func (p *cardPayoutProcessor) findExistingPayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) { + if p == nil || state == nil { + return nil, nil + } + if opRef := strings.TrimSpace(state.OperationRef); opRef != "" { + existing, err := p.store.Payouts().FindByOperationRef(ctx, opRef) + if err == nil { + if existing != nil { + return existing, nil + } + } + if !errors.Is(err, merrors.ErrNoData) { + if err != nil { + return nil, err + } + } + } + return nil, nil +} + +func (p *cardPayoutProcessor) findAndMergePayoutState(ctx context.Context, state *model.CardPayout) (*model.CardPayout, error) { + if p == nil || state == nil { + return nil, nil + } + existing, err := p.findExistingPayoutState(ctx, state) + if err != nil { + return nil, err + } + mergePayoutStateWithExisting(state, existing) + return existing, nil +} + +func (p *cardPayoutProcessor) resolveProjectID(requestProjectID int64, logFieldKey, logFieldValue string) (int64, error) { + projectID := requestProjectID + if projectID == 0 { + projectID = p.config.ProjectID + } + if projectID == 0 { + p.logger.Warn("Aurora project_id is not configured", zap.String(logFieldKey, logFieldValue)) + return 0, merrors.Internal("aurora project_id is not configured") + } + return projectID, nil +} + +func applyCardPayoutSendResult(state *model.CardPayout, result *provider.CardPayoutSendResult) { + if state == nil || result == nil { + return + } + state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID) + if result.Accepted { + switch strings.ToLower(strings.TrimSpace(result.ProviderStatus)) { + case "success", "approved", "completed": + state.Status = model.PayoutStatusSuccess + case "cancelled", "canceled": + state.Status = model.PayoutStatusCancelled + default: + state.Status = model.PayoutStatusWaiting + } + state.ProviderCode = strings.TrimSpace(result.ErrorCode) + state.ProviderMessage = strings.TrimSpace(result.ErrorMessage) + return + } + state.Status = model.PayoutStatusFailed + state.ProviderCode = strings.TrimSpace(result.ErrorCode) + state.ProviderMessage = strings.TrimSpace(result.ErrorMessage) +} + +func payoutStateLogFields(state *model.CardPayout) []zap.Field { + if state == nil { + return nil + } + return []zap.Field{ + zap.String("payment_ref", state.PaymentRef), + zap.String("customer_id", state.CustomerID), + zap.String("operation_ref", state.OperationRef), + zap.String("idempotency_key", state.IdempotencyKey), + zap.String("intent_ref", state.IntentRef), + } +} + +func newCardPayoutProcessor( + logger mlogger.Logger, + cfg provider.Config, + clock clockpkg.Clock, + store storage.Repository, + client *http.Client, + producer msg.Producer, +) *cardPayoutProcessor { + retryCtx, retryStop := context.WithCancel(context.Background()) + return &cardPayoutProcessor{ + logger: logger.Named("card_payout_processor"), + config: cfg, + clock: clock, + store: store, + httpClient: client, + producer: producer, + dispatchThrottleInterval: defaultDispatchThrottleInterval, + dispatchMaxAttempts: defaultMaxDispatchAttempts, + executionMode: newDefaultPayoutExecutionMode(), + dispatchSerialGate: make(chan struct{}, 1), + retryPolicy: defaultPayoutFailurePolicy(), + retryDelayFn: retryDelayDuration, + retryTimers: map[string]*time.Timer{}, + retryCtx: retryCtx, + retryStop: retryStop, + cardRetryRequests: map[string]*mntxv1.CardPayoutRequest{}, + cardTokenRetryRequest: map[string]*mntxv1.CardTokenPayoutRequest{}, + dispatchAttempts: map[string]uint32{}, + tokenPAN: map[string]string{}, + simulator: newPayoutSimulator(), + } +} + +func (p *cardPayoutProcessor) rememberTokenPAN(token, pan string) { + if p == nil { + return + } + key := strings.TrimSpace(token) + value := normalizeCardNumber(pan) + if key == "" || value == "" { + return + } + p.tokenMu.Lock() + defer p.tokenMu.Unlock() + if p.tokenPAN == nil { + p.tokenPAN = map[string]string{} + } + p.tokenPAN[key] = value +} + +func (p *cardPayoutProcessor) panForToken(token string) string { + if p == nil { + return "" + } + key := strings.TrimSpace(token) + if key == "" { + return "" + } + p.tokenMu.RLock() + defer p.tokenMu.RUnlock() + return strings.TrimSpace(p.tokenPAN[key]) +} + +func (p *cardPayoutProcessor) applyGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) { + if p == nil { + return + } + minAmountMinor, perCurrency := perTxMinAmountPolicy(descriptor) + p.perTxMinAmountMinor = minAmountMinor + p.perTxMinAmountMinorByCurrency = perCurrency + p.dispatchThrottleInterval = dispatchThrottleIntervalFromDescriptor(descriptor, defaultDispatchThrottleInterval) + p.logger.Info("Configured payout dispatch throttle", + zap.Duration("dispatch_interval", p.dispatchThrottleInterval), + zap.Bool("sequential_dispatch", p.dispatchSerialGate != nil), + zap.String("execution_mode", payoutExecutionModeName(p.executionMode)), + ) +} + +func (p *cardPayoutProcessor) setExecutionMode(mode payoutExecutionMode) { + if p == nil { + return + } + p.executionMode = normalizePayoutExecutionMode(mode) +} + +func (p *cardPayoutProcessor) observeExecutionState(state *model.CardPayout) { + if p == nil || state == nil { + return + } + if p.executionMode == nil { + return + } + p.executionMode.OnPersistedState(state.OperationRef, state.Status) +} + +func perTxMinAmountPolicy(descriptor *gatewayv1.GatewayInstanceDescriptor) (int64, map[string]int64) { + if descriptor == nil || descriptor.GetLimits() == nil { + return 0, nil + } + limits := descriptor.GetLimits() + globalMin, _ := decimalAmountToMinor(firstNonEmpty(limits.GetPerTxMinAmount(), limits.GetMinAmount())) + perCurrency := map[string]int64{} + for currency, override := range limits.GetCurrencyLimits() { + if override == nil { + continue + } + minor, ok := decimalAmountToMinor(override.GetMinAmount()) + if !ok { + continue + } + code := strings.ToUpper(strings.TrimSpace(currency)) + if code == "" { + continue + } + perCurrency[code] = minor + } + if len(perCurrency) == 0 { + perCurrency = nil + } + return globalMin, perCurrency +} + +func decimalAmountToMinor(raw string) (int64, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return 0, false + } + value, err := decimal.NewFromString(raw) + if err != nil || !value.IsPositive() { + return 0, false + } + minor := value.Mul(decimal.NewFromInt(100)).Ceil().IntPart() + if minor <= 0 { + return 0, false + } + return minor, true +} + +func (p *cardPayoutProcessor) validatePerTxMinimum(amountMinor int64, currency string) error { + if p == nil { + return nil + } + minAmountMinor := p.perTxMinimum(currency) + if minAmountMinor <= 0 || amountMinor >= minAmountMinor { + return nil + } + return newPayoutError("amount_below_minimum", merrors.InvalidArgument( + fmt.Sprintf("amount_minor must be at least %d", minAmountMinor), + "amount_minor", + )) +} + +func (p *cardPayoutProcessor) perTxMinimum(currency string) int64 { + if p == nil { + return 0 + } + minAmountMinor := p.perTxMinAmountMinor + if len(p.perTxMinAmountMinorByCurrency) == 0 { + return minAmountMinor + } + code := strings.ToUpper(strings.TrimSpace(currency)) + if code == "" { + return minAmountMinor + } + if override, ok := p.perTxMinAmountMinorByCurrency[code]; ok && override > 0 { + return override + } + return minAmountMinor +} + +func dispatchThrottleIntervalFromDescriptor( + descriptor *gatewayv1.GatewayInstanceDescriptor, + fallback time.Duration, +) time.Duration { + if fallback < 0 { + fallback = 0 + } + if descriptor == nil || descriptor.GetLimits() == nil { + return fallback + } + velocity := descriptor.GetLimits().GetVelocityLimit() + if len(velocity) == 0 { + return fallback + } + + interval := time.Duration(0) + for bucket, maxOps := range velocity { + cleanBucket := strings.TrimSpace(bucket) + if cleanBucket == "" || maxOps <= 0 { + continue + } + window, err := time.ParseDuration(cleanBucket) + if err != nil || window <= 0 { + continue + } + candidate := window / time.Duration(maxOps) + if candidate <= 0 { + continue + } + if candidate > interval { + interval = candidate + } + } + if interval <= 0 { + return fallback + } + return interval +} + +func (p *cardPayoutProcessor) waitDispatchSlot(ctx context.Context) error { + if p == nil { + return merrors.Internal("card payout processor not initialised") + } + if ctx == nil { + ctx = context.Background() + } + if p.dispatchThrottleInterval <= 0 { + return nil + } + + for { + p.dispatchMu.Lock() + now := time.Now().UTC() + if p.nextDispatchAllowed.IsZero() || !p.nextDispatchAllowed.After(now) { + p.nextDispatchAllowed = now.Add(p.dispatchThrottleInterval) + p.dispatchMu.Unlock() + return nil + } + wait := p.nextDispatchAllowed.Sub(now) + p.dispatchMu.Unlock() + + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return ctx.Err() + case <-timer.C: + } + } +} + +func (p *cardPayoutProcessor) acquireDispatchExecution(ctx context.Context) (func(), error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + if ctx == nil { + ctx = context.Background() + } + if p.dispatchSerialGate == nil { + return func() {}, nil + } + select { + case p.dispatchSerialGate <- struct{}{}: + return func() { + <-p.dispatchSerialGate + }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +func (p *cardPayoutProcessor) stopRetries() { + if p == nil { + return + } + if p.executionMode != nil { + p.executionMode.Shutdown() + } + if p.retryStop != nil { + p.retryStop() + } + p.retryMu.Lock() + defer p.retryMu.Unlock() + for key, timer := range p.retryTimers { + if timer != nil { + timer.Stop() + } + delete(p.retryTimers, key) + } + p.retryReqMu.Lock() + p.cardRetryRequests = map[string]*mntxv1.CardPayoutRequest{} + p.cardTokenRetryRequest = map[string]*mntxv1.CardTokenPayoutRequest{} + p.retryReqMu.Unlock() + + p.attemptMu.Lock() + p.dispatchAttempts = map[string]uint32{} + p.attemptMu.Unlock() +} + +func (p *cardPayoutProcessor) clearRetryTimer(operationRef string) { + if p == nil { + return + } + key := strings.TrimSpace(operationRef) + if key == "" { + return + } + p.retryMu.Lock() + defer p.retryMu.Unlock() + timer := p.retryTimers[key] + if timer != nil { + timer.Stop() + } + delete(p.retryTimers, key) +} + +func (p *cardPayoutProcessor) maxDispatchAttempts() uint32 { + if p == nil { + return defaultMaxDispatchAttempts + } + return maxDispatchAttempts(p.dispatchMaxAttempts) +} + +func (p *cardPayoutProcessor) rememberCardRetryRequest(req *mntxv1.CardPayoutRequest) { + if p == nil || req == nil { + return + } + key := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + if key == "" { + return + } + cloned, ok := proto.Clone(req).(*mntxv1.CardPayoutRequest) + if !ok { + return + } + p.retryReqMu.Lock() + defer p.retryReqMu.Unlock() + p.cardRetryRequests[key] = cloned +} + +func (p *cardPayoutProcessor) rememberCardTokenRetryRequest(req *mntxv1.CardTokenPayoutRequest) { + if p == nil || req == nil { + return + } + key := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + if key == "" { + return + } + cloned, ok := proto.Clone(req).(*mntxv1.CardTokenPayoutRequest) + if !ok { + return + } + p.retryReqMu.Lock() + defer p.retryReqMu.Unlock() + p.cardTokenRetryRequest[key] = cloned +} + +func (p *cardPayoutProcessor) loadCardRetryRequest(operationRef string) *mntxv1.CardPayoutRequest { + if p == nil { + return nil + } + key := strings.TrimSpace(operationRef) + if key == "" { + return nil + } + p.retryReqMu.RLock() + defer p.retryReqMu.RUnlock() + req := p.cardRetryRequests[key] + if req == nil { + return nil + } + cloned, ok := proto.Clone(req).(*mntxv1.CardPayoutRequest) + if !ok { + return nil + } + return cloned +} + +func (p *cardPayoutProcessor) loadCardTokenRetryRequest(operationRef string) *mntxv1.CardTokenPayoutRequest { + if p == nil { + return nil + } + key := strings.TrimSpace(operationRef) + if key == "" { + return nil + } + p.retryReqMu.RLock() + defer p.retryReqMu.RUnlock() + req := p.cardTokenRetryRequest[key] + if req == nil { + return nil + } + cloned, ok := proto.Clone(req).(*mntxv1.CardTokenPayoutRequest) + if !ok { + return nil + } + return cloned +} + +func (p *cardPayoutProcessor) incrementDispatchAttempt(operationRef string) uint32 { + if p == nil { + return 0 + } + key := strings.TrimSpace(operationRef) + if key == "" { + return 0 + } + p.attemptMu.Lock() + defer p.attemptMu.Unlock() + next := p.dispatchAttempts[key] + 1 + p.dispatchAttempts[key] = next + return next +} + +func (p *cardPayoutProcessor) currentDispatchAttempt(operationRef string) uint32 { + if p == nil { + return 0 + } + key := strings.TrimSpace(operationRef) + if key == "" { + return 0 + } + p.attemptMu.Lock() + defer p.attemptMu.Unlock() + return p.dispatchAttempts[key] +} + +func (p *cardPayoutProcessor) clearDispatchAttempt(operationRef string) { + if p == nil { + return + } + key := strings.TrimSpace(operationRef) + if key == "" { + return + } + p.attemptMu.Lock() + defer p.attemptMu.Unlock() + delete(p.dispatchAttempts, key) +} + +func (p *cardPayoutProcessor) clearRetryState(operationRef string) { + if p == nil { + return + } + key := strings.TrimSpace(operationRef) + if key == "" { + return + } + p.clearRetryTimer(key) + p.clearDispatchAttempt(key) + + p.retryReqMu.Lock() + defer p.retryReqMu.Unlock() + delete(p.cardRetryRequests, key) + delete(p.cardTokenRetryRequest, key) +} + +func payoutAcceptedForState(state *model.CardPayout) bool { + if state == nil { + return false + } + switch state.Status { + case model.PayoutStatusFailed, model.PayoutStatusCancelled: + return false + default: + return true + } +} + +func cardPayoutResponseFromState( + state *model.CardPayout, + accepted bool, + errorCode string, + errorMessage string, +) *mntxv1.CardPayoutResponse { + return &mntxv1.CardPayoutResponse{ + Payout: StateToProto(state), + Accepted: accepted, + ProviderRequestId: strings.TrimSpace(firstNonEmpty(state.ProviderPaymentID)), + ErrorCode: strings.TrimSpace(errorCode), + ErrorMessage: strings.TrimSpace(errorMessage), + } +} + +func cardTokenPayoutResponseFromState( + state *model.CardPayout, + accepted bool, + errorCode string, + errorMessage string, +) *mntxv1.CardTokenPayoutResponse { + return &mntxv1.CardTokenPayoutResponse{ + Payout: StateToProto(state), + Accepted: accepted, + ProviderRequestId: strings.TrimSpace(firstNonEmpty(state.ProviderPaymentID)), + ErrorCode: strings.TrimSpace(errorCode), + ErrorMessage: strings.TrimSpace(errorMessage), + } +} + +func (p *cardPayoutProcessor) dispatchCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*provider.CardPayoutSendResult, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + if req == nil { + return nil, merrors.InvalidArgument("card payout request is required") + } + opRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + if mode := p.executionMode; mode != nil { + if err := mode.BeforeDispatch(ctx, opRef); err != nil { + return nil, err + } + } + release, err := p.acquireDispatchExecution(ctx) + if err != nil { + return nil, err + } + defer release() + if err := p.waitDispatchSlot(ctx); err != nil { + return nil, err + } + attempt := p.incrementDispatchAttempt(opRef) + scenario := p.simulator.resolveByPAN(req.GetCardPan()) + p.logger.Info("Dispatching simulated card payout attempt", + zap.String("operation_ref", opRef), + zap.Uint32("attempt", attempt), + zap.String("scenario", scenario.Name), + zap.String("pan", provider.MaskPAN(req.GetCardPan())), + ) + return p.simulator.buildPayoutResult(opRef, scenario) +} + +func (p *cardPayoutProcessor) dispatchCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*provider.CardPayoutSendResult, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + if req == nil { + return nil, merrors.InvalidArgument("card token payout request is required") + } + opRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + if mode := p.executionMode; mode != nil { + if err := mode.BeforeDispatch(ctx, opRef); err != nil { + return nil, err + } + } + release, err := p.acquireDispatchExecution(ctx) + if err != nil { + return nil, err + } + defer release() + if err := p.waitDispatchSlot(ctx); err != nil { + return nil, err + } + attempt := p.incrementDispatchAttempt(opRef) + cardPAN := p.panForToken(req.GetCardToken()) + scenario := p.simulator.resolveByPAN(cardPAN) + scenarioSource := "token" + if cardPAN == "" { + scenario = p.simulator.resolveByMaskedPAN(req.GetMaskedPan()) + scenarioSource = "masked_pan" + } + p.logger.Info("Dispatching simulated card token payout attempt", + zap.String("operation_ref", opRef), + zap.Uint32("attempt", attempt), + zap.String("scenario", scenario.Name), + zap.String("scenario_source", scenarioSource), + zap.String("masked_pan", strings.TrimSpace(req.GetMaskedPan())), + ) + return p.simulator.buildPayoutResult(opRef, scenario) +} + +func maxDispatchAttempts(v uint32) uint32 { + if v == 0 { + return defaultMaxDispatchAttempts + } + return v +} + +func (p *cardPayoutProcessor) scheduleRetryTimer(operationRef string, delay time.Duration, run func()) { + if p == nil || run == nil { + return + } + key := strings.TrimSpace(operationRef) + if key == "" { + return + } + if delay < 0 { + delay = 0 + } + p.retryMu.Lock() + defer p.retryMu.Unlock() + if old := p.retryTimers[key]; old != nil { + old.Stop() + } + + var timer *time.Timer + timer = time.AfterFunc(delay, func() { + select { + case <-p.retryCtx.Done(): + return + default: + } + p.retryMu.Lock() + if p.retryTimers[key] == timer { + delete(p.retryTimers, key) + } + p.retryMu.Unlock() + run() + }) + p.retryTimers[key] = timer +} + +func retryDelayDuration(attempt uint32) time.Duration { + return time.Duration(retryDelayForAttempt(attempt)) * time.Second +} + +func (p *cardPayoutProcessor) scheduleCardPayoutRetry(req *mntxv1.CardPayoutRequest, failedAttempt uint32, maxAttempts uint32) { + if p == nil || req == nil { + return + } + maxAttempts = maxDispatchAttempts(maxAttempts) + nextAttempt := failedAttempt + 1 + if nextAttempt > maxAttempts { + return + } + cloned, ok := proto.Clone(req).(*mntxv1.CardPayoutRequest) + if !ok { + return + } + operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId()) + delay := retryDelayDuration(failedAttempt) + if p.retryDelayFn != nil { + delay = p.retryDelayFn(failedAttempt) + } + p.logger.Info("Scheduling card payout retry", + zap.String("operation_ref", operationRef), + zap.Uint32("failed_attempt", failedAttempt), + zap.Uint32("next_attempt", nextAttempt), + zap.Uint32("max_attempts", maxAttempts), + zap.Duration("delay", delay), + ) + p.scheduleRetryTimer(operationRef, delay, func() { + p.runCardPayoutRetry(cloned, nextAttempt, maxAttempts) + }) +} + +func (p *cardPayoutProcessor) scheduleCardTokenPayoutRetry(req *mntxv1.CardTokenPayoutRequest, failedAttempt uint32, maxAttempts uint32) { + if p == nil || req == nil { + return + } + maxAttempts = maxDispatchAttempts(maxAttempts) + nextAttempt := failedAttempt + 1 + if nextAttempt > maxAttempts { + return + } + cloned, ok := proto.Clone(req).(*mntxv1.CardTokenPayoutRequest) + if !ok { + return + } + operationRef := findOperationRef(cloned.GetOperationRef(), cloned.GetPayoutId()) + delay := retryDelayDuration(failedAttempt) + if p.retryDelayFn != nil { + delay = p.retryDelayFn(failedAttempt) + } + p.logger.Info("Scheduling card token payout retry", + zap.String("operation_ref", operationRef), + zap.Uint32("failed_attempt", failedAttempt), + zap.Uint32("next_attempt", nextAttempt), + zap.Uint32("max_attempts", maxAttempts), + zap.Duration("delay", delay), + ) + p.scheduleRetryTimer(operationRef, delay, func() { + p.runCardTokenPayoutRetry(cloned, nextAttempt, maxAttempts) + }) +} + +func (p *cardPayoutProcessor) retryContext() (context.Context, context.CancelFunc) { + if p == nil { + return context.Background(), func() {} + } + ctx := p.retryCtx + if ctx == nil { + ctx = context.Background() + } + timeout := p.config.Timeout() + if timeout <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, timeout) +} + +func (p *cardPayoutProcessor) runCardPayoutRetry(req *mntxv1.CardPayoutRequest, attempt uint32, maxAttempts uint32) { + if p == nil || req == nil { + return + } + operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + if operationRef == "" { + return + } + p.logger.Info("Executing scheduled card payout retry", + zap.String("operation_ref", operationRef), + zap.Uint32("attempt", attempt), + zap.Uint32("max_attempts", maxAttempts), + ) + ctx, cancel := p.retryContext() + defer cancel() + + state, err := p.store.Payouts().FindByOperationRef(ctx, operationRef) + if err != nil || state == nil { + p.logger.Warn("Retry payout state lookup failed", + zap.String("operation_ref", operationRef), + zap.Uint32("attempt", attempt), + zap.Error(err), + ) + return + } + if isFinalStatus(state) { + p.clearRetryState(operationRef) + return + } + + result, err := p.dispatchCardPayout(ctx, req) + now := p.clock.Now() + maxAttempts = maxDispatchAttempts(maxAttempts) + if err != nil { + decision := p.retryPolicy.decideTransportFailure() + state.ProviderCode = "" + state.ProviderMessage = err.Error() + state.UpdatedAt = now + if decision.Action == payoutFailureActionRetry && attempt < maxAttempts { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist retryable payout transport failure", zap.Error(upErr)) + return + } + p.scheduleCardPayoutRetry(req, attempt, maxAttempts) + return + } + + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason("", err.Error()) + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist terminal payout transport failure", zap.Error(upErr)) + } + p.clearRetryState(operationRef) + return + } + + applyCardPayoutSendResult(state, result) + state.UpdatedAt = now + if result.Accepted { + state.FailureReason = "" + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist accepted payout retry result", zap.Error(upErr)) + } + p.clearRetryTimer(operationRef) + return + } + + decision := p.retryPolicy.decideProviderFailure(result.ErrorCode) + if decision.Action == payoutFailureActionRetry && attempt < maxAttempts { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist retryable payout provider failure", zap.Error(upErr)) + return + } + p.scheduleCardPayoutRetry(req, attempt, maxAttempts) + return + } + + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage) + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist terminal payout provider failure", zap.Error(upErr)) + } + p.clearRetryState(operationRef) +} + +func (p *cardPayoutProcessor) runCardTokenPayoutRetry(req *mntxv1.CardTokenPayoutRequest, attempt uint32, maxAttempts uint32) { + if p == nil || req == nil { + return + } + operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + if operationRef == "" { + return + } + p.logger.Info("Executing scheduled card token payout retry", + zap.String("operation_ref", operationRef), + zap.Uint32("attempt", attempt), + zap.Uint32("max_attempts", maxAttempts), + ) + ctx, cancel := p.retryContext() + defer cancel() + + state, err := p.store.Payouts().FindByOperationRef(ctx, operationRef) + if err != nil || state == nil { + p.logger.Warn("Retry token payout state lookup failed", + zap.String("operation_ref", operationRef), + zap.Uint32("attempt", attempt), + zap.Error(err), + ) + return + } + if isFinalStatus(state) { + p.clearRetryState(operationRef) + return + } + + result, err := p.dispatchCardTokenPayout(ctx, req) + now := p.clock.Now() + maxAttempts = maxDispatchAttempts(maxAttempts) + if err != nil { + decision := p.retryPolicy.decideTransportFailure() + state.ProviderCode = "" + state.ProviderMessage = err.Error() + state.UpdatedAt = now + if decision.Action == payoutFailureActionRetry && attempt < maxAttempts { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist retryable token payout transport failure", zap.Error(upErr)) + return + } + p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts) + return + } + + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason("", err.Error()) + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist terminal token payout transport failure", zap.Error(upErr)) + } + p.clearRetryState(operationRef) + return + } + + applyCardPayoutSendResult(state, result) + state.UpdatedAt = now + if result.Accepted { + state.FailureReason = "" + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist accepted token payout retry result", zap.Error(upErr)) + } + p.clearRetryTimer(operationRef) + return + } + + decision := p.retryPolicy.decideProviderFailure(result.ErrorCode) + if decision.Action == payoutFailureActionRetry && attempt < maxAttempts { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist retryable token payout provider failure", zap.Error(upErr)) + return + } + p.scheduleCardTokenPayoutRetry(req, attempt, maxAttempts) + return + } + + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage) + if upErr := p.updatePayoutStatus(ctx, state); upErr != nil { + p.logger.Warn("Failed to persist terminal token payout provider failure", zap.Error(upErr)) + } + p.clearRetryState(operationRef) +} + +func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + + req = sanitizeCardPayoutRequest(req) + operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + parentPaymentRef := strings.TrimSpace(req.GetParentPaymentRef()) + + p.logger.Info("Submitting card payout", + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + zap.String("operation_ref", operationRef), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + ) + + if err := validateCardPayoutRequest(req, p.config); err != nil { + p.logger.Warn("Card payout validation failed", + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) + return nil, err + } + if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil { + p.logger.Warn("Card payout amount below configured minimum", + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), + zap.String("customer_id", req.GetCustomerId()), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + zap.Int64("configured_min_amount_minor", p.perTxMinimum(req.GetCurrency())), + zap.Error(err), + ) + return nil, err + } + + projectID, err := p.resolveProjectID(req.GetProjectId(), "operation_ref", operationRef) + if err != nil { + return nil, err + } + req.ProjectId = projectID + + now := p.clock.Now() + + state := &model.CardPayout{ + Base: storable.Base{ + ID: bson.NilObjectID, + }, + PaymentRef: parentPaymentRef, + OperationRef: operationRef, + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + IntentRef: strings.TrimSpace(req.GetIntentRef()), + ProjectID: projectID, + CustomerID: strings.TrimSpace(req.GetCustomerId()), + AmountMinor: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + Status: model.PayoutStatusProcessing, + CreatedAt: now, + UpdatedAt: now, + } + + // Keep CreatedAt/refs if record already exists. + existing, err := p.findAndMergePayoutState(ctx, state) + if err != nil { + return nil, err + } + if existing != nil { + switch existing.Status { + case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled: + p.observeExecutionState(existing) + return cardPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil + } + } + p.rememberCardRetryRequest(req) + + result, err := p.dispatchCardPayout(ctx, req) + if err != nil { + decision := p.retryPolicy.decideTransportFailure() + state.ProviderMessage = err.Error() + state.UpdatedAt = p.clock.Now() + maxAttempts := p.maxDispatchAttempts() + if decision.Action == payoutFailureActionRetry && maxAttempts > 1 { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + if e := p.updatePayoutStatus(ctx, state); e != nil { + fields := append([]zap.Field{zap.Error(e)}, payoutStateLogFields(state)...) + p.logger.Warn("Failed to update payout status", fields...) + return nil, e + } + p.scheduleCardPayoutRetry(req, 1, maxAttempts) + return cardPayoutResponseFromState(state, true, "", ""), nil + } + + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason("", err.Error()) + if e := p.updatePayoutStatus(ctx, state); e != nil { + fields := append([]zap.Field{zap.Error(e)}, payoutStateLogFields(state)...) + p.logger.Warn("Failed to update payout status", fields...) + return nil, e + } + fields := append([]zap.Field{zap.Error(err)}, payoutStateLogFields(state)...) + p.logger.Warn("Aurora payout submission failed", fields...) + p.clearRetryState(state.OperationRef) + return nil, err + } + + applyCardPayoutSendResult(state, result) + state.UpdatedAt = p.clock.Now() + accepted := result.Accepted + errorCode := strings.TrimSpace(result.ErrorCode) + errorMessage := strings.TrimSpace(result.ErrorMessage) + scheduleRetry := false + retryMaxAttempts := uint32(0) + + if !result.Accepted { + decision := p.retryPolicy.decideProviderFailure(result.ErrorCode) + maxAttempts := p.maxDispatchAttempts() + if decision.Action == payoutFailureActionRetry && maxAttempts > 1 { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + accepted = true + errorCode = "" + errorMessage = "" + scheduleRetry = true + retryMaxAttempts = maxAttempts + } else { + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage) + p.clearRetryState(state.OperationRef) + } + } else { + p.clearRetryTimer(state.OperationRef) + } + + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to store payout", + zap.Error(err), + zap.String("payment_ref", state.PaymentRef), + zap.String("customer_id", state.CustomerID), + zap.String("operation_ref", state.OperationRef), + zap.String("idempotency_key", state.IdempotencyKey), + ) + return nil, err + } + if scheduleRetry { + p.scheduleCardPayoutRetry(req, 1, retryMaxAttempts) + } + + resp := cardPayoutResponseFromState(state, accepted, errorCode, errorMessage) + + p.logger.Info("Card payout submission stored", + zap.String("payment_ref", state.PaymentRef), + zap.String("status", string(state.Status)), + zap.Bool("accepted", accepted), + zap.String("provider_request_id", resp.GetProviderRequestId()), + ) + + return resp, nil +} + +func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + + req = sanitizeCardTokenPayoutRequest(req) + operationRef := findOperationRef(req.GetOperationRef(), req.GetPayoutId()) + parentPaymentRef := strings.TrimSpace(req.GetParentPaymentRef()) + + p.logger.Info("Submitting card token payout", + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + zap.String("operation_ref", operationRef), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), + ) + + if err := validateCardTokenPayoutRequest(req, p.config); err != nil { + p.logger.Warn("Card token payout validation failed", + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) + return nil, err + } + if err := p.validatePerTxMinimum(req.GetAmountMinor(), req.GetCurrency()); err != nil { + p.logger.Warn("Card token payout amount below configured minimum", + zap.String("parent_payment_ref", parentPaymentRef), + zap.String("operation_ref", operationRef), + zap.String("customer_id", req.GetCustomerId()), + zap.Int64("amount_minor", req.GetAmountMinor()), + zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + zap.Int64("configured_min_amount_minor", p.perTxMinimum(req.GetCurrency())), + zap.Error(err), + ) + return nil, err + } + + projectID, err := p.resolveProjectID(req.GetProjectId(), "operation_ref", operationRef) + if err != nil { + return nil, err + } + req.ProjectId = projectID + + now := p.clock.Now() + state := &model.CardPayout{ + PaymentRef: parentPaymentRef, + OperationRef: operationRef, + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + IntentRef: strings.TrimSpace(req.GetIntentRef()), + ProjectID: projectID, + CustomerID: strings.TrimSpace(req.GetCustomerId()), + AmountMinor: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + Status: model.PayoutStatusProcessing, + CreatedAt: now, + UpdatedAt: now, + } + + existing, err := p.findAndMergePayoutState(ctx, state) + if err != nil { + return nil, err + } + if existing != nil { + switch existing.Status { + case model.PayoutStatusProcessing, model.PayoutStatusWaiting, model.PayoutStatusSuccess, model.PayoutStatusFailed, model.PayoutStatusCancelled: + p.observeExecutionState(existing) + return cardTokenPayoutResponseFromState(existing, payoutAcceptedForState(existing), "", ""), nil + } + } + p.rememberCardTokenRetryRequest(req) + + result, err := p.dispatchCardTokenPayout(ctx, req) + if err != nil { + decision := p.retryPolicy.decideTransportFailure() + state.ProviderMessage = err.Error() + state.UpdatedAt = p.clock.Now() + maxAttempts := p.maxDispatchAttempts() + if decision.Action == payoutFailureActionRetry && maxAttempts > 1 { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + if e := p.updatePayoutStatus(ctx, state); e != nil { + return nil, e + } + p.scheduleCardTokenPayoutRetry(req, 1, maxAttempts) + return cardTokenPayoutResponseFromState(state, true, "", ""), nil + } + + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason("", err.Error()) + if e := p.updatePayoutStatus(ctx, state); e != nil { + return nil, e + } + p.clearRetryState(state.OperationRef) + p.logger.Warn("Aurora token payout submission failed", + zap.String("payment_ref", state.PaymentRef), + zap.String("customer_id", state.CustomerID), + zap.Error(err), + ) + return nil, err + } + + applyCardPayoutSendResult(state, result) + accepted := result.Accepted + errorCode := strings.TrimSpace(result.ErrorCode) + errorMessage := strings.TrimSpace(result.ErrorMessage) + scheduleRetry := false + retryMaxAttempts := uint32(0) + + if !result.Accepted { + decision := p.retryPolicy.decideProviderFailure(result.ErrorCode) + maxAttempts := p.maxDispatchAttempts() + if decision.Action == payoutFailureActionRetry && maxAttempts > 1 { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + accepted = true + errorCode = "" + errorMessage = "" + scheduleRetry = true + retryMaxAttempts = maxAttempts + } else { + state.Status = model.PayoutStatusFailed + state.FailureReason = payoutFailureReason(result.ErrorCode, result.ErrorMessage) + p.clearRetryState(state.OperationRef) + } + } else { + p.clearRetryTimer(state.OperationRef) + } + + state.UpdatedAt = p.clock.Now() + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to update payout status", zap.Error(err)) + return nil, err + } + if scheduleRetry { + p.scheduleCardTokenPayoutRetry(req, 1, retryMaxAttempts) + } + + resp := cardTokenPayoutResponseFromState(state, accepted, errorCode, errorMessage) + + p.logger.Info("Card token payout submission stored", + zap.String("payment_ref", state.PaymentRef), + zap.String("status", string(state.Status)), + zap.Bool("accepted", accepted), + zap.String("provider_request_id", resp.GetProviderRequestId()), + ) + + return resp, nil +} + +func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardTokenizeRequest) (*mntxv1.CardTokenizeResponse, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + + p.logger.Info("Submitting card tokenization", + zap.String("request_id", strings.TrimSpace(req.GetRequestId())), + zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), + ) + + cardInput, err := validateCardTokenizeRequest(req, p.config) + if err != nil { + p.logger.Warn("Card tokenization validation failed", + zap.String("request_id", req.GetRequestId()), + zap.String("customer_id", req.GetCustomerId()), + zap.Error(err), + ) + return nil, err + } + + projectID, err := p.resolveProjectID(req.GetProjectId(), "request_id", req.GetRequestId()) + if err != nil { + return nil, err + } + + req = sanitizeCardTokenizeRequest(req) + cardInput = extractTokenizeCard(req) + + token := buildSimulatedCardToken(req.GetRequestId(), cardInput.pan) + maskedPAN := provider.MaskPAN(cardInput.pan) + p.rememberTokenPAN(token, cardInput.pan) + scenario := p.simulator.resolveByPAN(cardInput.pan) + resp := &mntxv1.CardTokenizeResponse{ + RequestId: req.GetRequestId(), + Success: true, + ErrorCode: "", + ErrorMessage: "", + Token: token, + MaskedPan: maskedPAN, + ExpiryMonth: fmt.Sprintf("%02d", cardInput.month), + ExpiryYear: normalizeExpiryYear(cardInput.year), + CardBrand: "MIR", + } + + p.logger.Info("Card tokenization completed (simulated)", + zap.String("request_id", resp.GetRequestId()), + zap.Bool("success", resp.GetSuccess()), + zap.Int64("project_id", projectID), + zap.String("scenario", scenario.Name), + zap.String("masked_pan", maskedPAN), + ) + + return resp, nil +} + +func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mntxv1.CardPayoutState, error) { + if p == nil { + return nil, merrors.Internal("card payout processor not initialised") + } + + id := strings.TrimSpace(payoutID) + p.logger.Info("Card payout status requested", zap.String("operation_ref", id)) + + if id == "" { + p.logger.Warn("Payout status requested with empty payout_id") + return nil, merrors.InvalidArgument("payout_id is required", "payout_id") + } + + state, err := p.store.Payouts().FindByOperationRef(ctx, id) + if err != nil && !errors.Is(err, merrors.ErrNoData) { + p.logger.Warn("Payout status lookup by operation ref failed", zap.String("operation_ref", id), zap.Error(err)) + return nil, err + } + if state == nil || errors.Is(err, merrors.ErrNoData) { + p.logger.Warn("Payout status not found", zap.String("operation_ref", id)) + return nil, merrors.NoData("payout not found") + } + + p.logger.Info("Card payout status resolved", + zap.String("payment_ref", state.PaymentRef), + zap.String("operation_ref", state.OperationRef), + zap.String("status", string(state.Status)), + ) + + return StateToProto(state), nil +} + +func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byte) (int, error) { + if p == nil { + return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") + } + + p.logger.Debug("Processing provider callback", zap.Int("payload_bytes", len(payload))) + + if len(payload) == 0 { + p.logger.Warn("Received empty callback payload") + return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty") + } + + if strings.TrimSpace(p.config.SecretKey) == "" { + p.logger.Warn("Provider secret key is not configured; cannot verify callback") + return http.StatusInternalServerError, merrors.Internal("provider secret key is not configured") + } + + var cb providerCallback + if err := json.Unmarshal(payload, &cb); err != nil { + p.logger.Warn("Failed to unmarshal callback payload", zap.Error(err)) + return http.StatusBadRequest, err + } + + signature, err := verifyCallbackSignature(payload, p.config.SecretKey) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, merrors.ErrDataConflict) { + status = http.StatusForbidden + } + p.logger.Warn("Callback signature check failed", + zap.String("payout_id", cb.Payment.ID), + zap.String("signature", signature), + zap.String("payload", string(payload)), + zap.Error(err), + ) + return status, err + } + + // mapCallbackToState currently returns proto-state in your code. + // Convert it to mongo model and preserve internal refs if record exists. + pbState, statusLabel := mapCallbackToState(p.clock, p.config, cb) + + // Convert proto -> mongo (operationRef/idempotencyKey are internal; keep empty for now) + state := CardPayoutStateFromProto(p.clock, pbState) + + // Preserve CreatedAt + internal keys from existing record if present. + existing, err := p.findAndMergePayoutState(ctx, state) + if err != nil { + p.logger.Warn("Failed to fetch payout state while processing callback", + zap.Error(err), + zap.String("payment_ref", state.PaymentRef), + ) + return http.StatusInternalServerError, err + } + operationRef := strings.TrimSpace(state.OperationRef) + if existing != nil && strings.TrimSpace(state.FailureReason) == "" { + state.FailureReason = strings.TrimSpace(existing.FailureReason) + } + + retryScheduled := false + if state.Status == model.PayoutStatusFailed || state.Status == model.PayoutStatusCancelled { + decision := p.retryPolicy.decideProviderFailure(state.ProviderCode) + attemptsUsed := p.currentDispatchAttempt(operationRef) + maxAttempts := p.maxDispatchAttempts() + if decision.Action == payoutFailureActionRetry && attemptsUsed > 0 && attemptsUsed < maxAttempts { + if req := p.loadCardRetryRequest(operationRef); req != nil { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + p.logger.Info("Callback decline is retryable; scheduling card payout retry", + zap.String("operation_ref", operationRef), + zap.String("provider_code", strings.TrimSpace(state.ProviderCode)), + zap.Uint32("attempts_used", attemptsUsed), + zap.Uint32("max_attempts", maxAttempts), + ) + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to persist callback retry scheduling state", zap.Error(err)) + return http.StatusInternalServerError, err + } + p.scheduleCardPayoutRetry(req, attemptsUsed, maxAttempts) + retryScheduled = true + } else if req := p.loadCardTokenRetryRequest(operationRef); req != nil { + state.Status = model.PayoutStatusProcessing + state.FailureReason = "" + p.logger.Info("Callback decline is retryable; scheduling card token payout retry", + zap.String("operation_ref", operationRef), + zap.String("provider_code", strings.TrimSpace(state.ProviderCode)), + zap.Uint32("attempts_used", attemptsUsed), + zap.Uint32("max_attempts", maxAttempts), + ) + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to persist callback token retry scheduling state", zap.Error(err)) + return http.StatusInternalServerError, err + } + p.scheduleCardTokenPayoutRetry(req, attemptsUsed, maxAttempts) + retryScheduled = true + } else { + p.logger.Warn("Retryable callback decline received but no retry request snapshot found", + zap.String("operation_ref", operationRef), + zap.String("provider_code", strings.TrimSpace(state.ProviderCode)), + zap.Uint32("attempts_used", attemptsUsed), + zap.Uint32("max_attempts", maxAttempts), + ) + } + } + if !retryScheduled && strings.TrimSpace(state.FailureReason) == "" { + state.FailureReason = payoutFailureReason(state.ProviderCode, state.ProviderMessage) + } + } else if state.Status == model.PayoutStatusSuccess { + state.FailureReason = "" + } + + if !retryScheduled { + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to update payout state while processing callback", zap.Error(err)) + } + if isFinalStatus(state) { + p.clearRetryState(operationRef) + } + } + provider.ObserveCallback(statusLabel) + + p.logger.Info("Aurora payout callback processed", + zap.String("payment_ref", state.PaymentRef), + zap.String("status", statusLabel), + zap.String("provider_code", state.ProviderCode), + zap.String("provider_message", state.ProviderMessage), + zap.Bool("retry_scheduled", retryScheduled), + zap.String("masked_account", cb.Account.Number), + ) + + return http.StatusOK, nil +} diff --git a/api/gateway/aurora/internal/service/gateway/card_processor_test.go b/api/gateway/aurora/internal/service/gateway/card_processor_test.go new file mode 100644 index 00000000..91ff941c --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_processor_test.go @@ -0,0 +1,763 @@ +package gateway + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/gateway/aurora/storage/model" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/merrors" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" +) + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +type staticClock struct { + now time.Time +} + +func (s staticClock) Now() time.Time { + return s.now +} + +type apiResponse struct { + RequestID string `json:"request_id"` + Status string `json:"status"` + Message string `json:"message"` + Code string `json:"code"` + Operation struct { + RequestID string `json:"request_id"` + Status string `json:"status"` + Code string `json:"code"` + Message string `json:"message"` + } `json:"operation"` +} + +func TestCardPayoutProcessor_Submit_Success(t *testing.T) { + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + ProjectID: 99, + AllowedCurrencies: []string{"RUB"}, + } + + existingCreated := time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC) + + repo := newMockRepository() + repo.payouts.Save(&model.CardPayout{ + PaymentRef: "payment-parent-1", + OperationRef: "payout-1", + CreatedAt: existingCreated, + }) + + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + resp := apiResponse{} + resp.Operation.RequestID = "req-123" + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil) + + req := validCardPayoutRequest() + req.ProjectId = 0 + + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !resp.GetAccepted() { + t.Fatalf("expected accepted payout response") + } + if resp.GetPayout().GetProjectId() != cfg.ProjectID { + t.Fatalf("expected project id %d, got %d", cfg.ProjectID, resp.GetPayout().GetProjectId()) + } + if resp.GetPayout().GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING { + t.Fatalf("expected waiting status, got %v", resp.GetPayout().GetStatus()) + } + if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated) { + t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime()) + } + + stored, ok := repo.payouts.Get(req.GetPayoutId()) + if !ok || stored == nil { + t.Fatalf("expected payout state stored") + } + if stored.ProviderPaymentID == "" { + t.Fatalf("expected provider payment id") + } + if !stored.CreatedAt.Equal(existingCreated) { + t.Fatalf("expected created_at preserved in model, got %v", stored.CreatedAt) + } +} + +func TestCardPayoutProcessor_Submit_MissingConfig(t *testing.T) { + cfg := provider.Config{ + AllowedCurrencies: []string{"RUB"}, + } + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + clockpkg.NewSystem(), + newMockRepository(), + &http.Client{}, + nil, + ) + + _, err := processor.Submit(context.Background(), validCardPayoutRequest()) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got %v", err) + } +} + +func TestCardPayoutProcessor_Submit_RejectsAmountBelowConfiguredMinimum(t *testing.T) { + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)}, + repo, + &http.Client{}, + nil, + ) + processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{ + Limits: &gatewayv1.Limits{ + PerTxMinAmount: "20.00", + }, + }) + + req := validCardPayoutRequest() // 15.00 RUB + _, err := processor.Submit(context.Background(), req) + requireReason(t, err, "amount_below_minimum") +} + +func TestCardPayoutProcessor_SubmitToken_RejectsAmountBelowCurrencyMinimum(t *testing.T) { + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + AllowedCurrencies: []string{"USD"}, + } + + repo := newMockRepository() + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)}, + repo, + &http.Client{}, + nil, + ) + processor.applyGatewayDescriptor(&gatewayv1.GatewayInstanceDescriptor{ + Limits: &gatewayv1.Limits{ + PerTxMinAmount: "20.00", + CurrencyLimits: map[string]*gatewayv1.LimitsOverride{ + "USD": {MinAmount: "30.00"}, + }, + }, + }) + + req := validCardTokenPayoutRequest() // 25.00 USD + _, err := processor.SubmitToken(context.Background(), req) + requireReason(t, err, "amount_below_minimum") +} + +func TestCardPayoutProcessor_ProcessCallback(t *testing.T) { + cfg := provider.Config{ + SecretKey: "secret", + StatusSuccess: "success", + StatusProcessing: "processing", + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}, + repo, + &http.Client{}, + nil, + ) + + cb := baseCallback() + cb.Payment.Sum.Currency = "RUB" + + sig, err := provider.SignPayload(cb, cfg.SecretKey) + if err != nil { + t.Fatalf("failed to sign callback: %v", err) + } + cb.Signature = sig + + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + + status, err := processor.ProcessCallback(context.Background(), payload) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if status != http.StatusOK { + t.Fatalf("expected status ok, got %d", status) + } + + state, ok := repo.payouts.Get(cb.Payment.ID) + if !ok || state == nil { + t.Fatalf("expected payout state stored") + } + + if state.Status != model.PayoutStatusSuccess { + t.Fatalf("expected success status in model, got %v", state.Status) + } +} + +func TestCardPayoutProcessor_Submit_SameParentDifferentOperationsStoredSeparately(t *testing.T) { + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + ProjectID: 99, + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + var callN int + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + callN++ + resp := apiResponse{} + resp.Operation.RequestID = fmt.Sprintf("req-%d", callN) + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)}, + repo, + httpClient, + nil, + ) + + parentPaymentRef := "payment-parent-1" + op1 := parentPaymentRef + ":hop_4_card_payout_send" + op2 := parentPaymentRef + ":hop_4_card_payout_send_2" + + req1 := validCardPayoutRequest() + req1.PayoutId = "" + req1.OperationRef = op1 + req1.IdempotencyKey = "idem-1" + req1.ParentPaymentRef = parentPaymentRef + req1.CardPan = "2204310000002456" + + req2 := validCardPayoutRequest() + req2.PayoutId = "" + req2.OperationRef = op2 + req2.IdempotencyKey = "idem-2" + req2.ParentPaymentRef = parentPaymentRef + req2.CardPan = "2204320000009754" + + if _, err := processor.Submit(context.Background(), req1); err != nil { + t.Fatalf("first submit failed: %v", err) + } + if _, err := processor.Submit(context.Background(), req2); err != nil { + t.Fatalf("second submit failed: %v", err) + } + + first, err := repo.payouts.FindByOperationRef(context.Background(), op1) + if err != nil || first == nil { + t.Fatalf("expected first operation stored, err=%v", err) + } + second, err := repo.payouts.FindByOperationRef(context.Background(), op2) + if err != nil || second == nil { + t.Fatalf("expected second operation stored, err=%v", err) + } + + if got, want := first.PaymentRef, parentPaymentRef; got != want { + t.Fatalf("first parent payment ref mismatch: got=%q want=%q", got, want) + } + if got, want := second.PaymentRef, parentPaymentRef; got != want { + t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want) + } + if got, want := first.OperationRef, op1; got != want { + t.Fatalf("first operation ref mismatch: got=%q want=%q", got, want) + } + if got, want := second.OperationRef, op2; got != want { + t.Fatalf("second operation ref mismatch: got=%q want=%q", got, want) + } + if first.ProviderPaymentID == "" || second.ProviderPaymentID == "" { + t.Fatalf("expected provider payment ids for both operations") + } + if first.ProviderPaymentID == second.ProviderPaymentID { + t.Fatalf("expected different provider payment ids, got=%q", first.ProviderPaymentID) + } +} + +func TestCardPayoutProcessor_StrictMode_BlocksSecondOperationUntilFirstFinalCallback(t *testing.T) { + t.Skip("aurora simulator has no external provider transport call counting") + + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + ProjectID: 99, + StatusSuccess: "success", + StatusProcessing: "processing", + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + var callN atomic.Int32 + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + n := callN.Add(1) + resp := apiResponse{} + resp.Operation.RequestID = fmt.Sprintf("req-%d", n) + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC)}, + repo, + httpClient, + nil, + ) + defer processor.stopRetries() + processor.dispatchThrottleInterval = 0 + processor.setExecutionMode(newStrictIsolatedPayoutExecutionMode()) + + req1 := validCardPayoutRequest() + req1.PayoutId = "" + req1.OperationRef = "op-strict-1" + req1.ParentPaymentRef = "payment-strict-1" + req1.IdempotencyKey = "idem-strict-1" + req1.CardPan = "2204310000002456" + + req2 := validCardPayoutRequest() + req2.PayoutId = "" + req2.OperationRef = "op-strict-2" + req2.ParentPaymentRef = "payment-strict-2" + req2.IdempotencyKey = "idem-strict-2" + req2.CardPan = "2204320000009754" + + if _, err := processor.Submit(context.Background(), req1); err != nil { + t.Fatalf("first submit failed: %v", err) + } + + secondDone := make(chan error, 1) + go func() { + _, err := processor.Submit(context.Background(), req2) + secondDone <- err + }() + + select { + case err := <-secondDone: + t.Fatalf("second submit should block before first operation is final, err=%v", err) + case <-time.After(120 * time.Millisecond): + } + + cb := baseCallback() + cb.Payment.ID = req1.GetOperationRef() + cb.Payment.Status = "success" + cb.Operation.Status = "success" + cb.Operation.Code = "0" + cb.Operation.Message = "Success" + cb.Payment.Sum.Currency = "RUB" + + sig, err := provider.SignPayload(cb, cfg.SecretKey) + if err != nil { + t.Fatalf("failed to sign callback: %v", err) + } + cb.Signature = sig + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + + status, err := processor.ProcessCallback(context.Background(), payload) + if err != nil { + t.Fatalf("callback failed: %v", err) + } + if status != http.StatusOK { + t.Fatalf("unexpected callback status: %d", status) + } + + select { + case err := <-secondDone: + if err != nil { + t.Fatalf("second submit returned error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatalf("timeout waiting for second submit to unblock") + } + + if got, want := callN.Load(), int32(2); got != want { + t.Fatalf("unexpected provider call count: got=%d want=%d", got, want) + } +} + +func TestCardPayoutProcessor_ProcessCallback_UpdatesMatchingOperationWithinSameParent(t *testing.T) { + cfg := provider.Config{ + SecretKey: "secret", + StatusSuccess: "success", + StatusProcessing: "processing", + AllowedCurrencies: []string{"RUB"}, + } + + parentPaymentRef := "payment-parent-1" + op1 := parentPaymentRef + ":hop_4_card_payout_send" + op2 := parentPaymentRef + ":hop_4_card_payout_send_2" + now := time.Date(2026, 3, 4, 2, 3, 4, 0, time.UTC) + + repo := newMockRepository() + repo.payouts.Save(&model.CardPayout{ + PaymentRef: parentPaymentRef, + OperationRef: op1, + Status: model.PayoutStatusWaiting, + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + }) + repo.payouts.Save(&model.CardPayout{ + PaymentRef: parentPaymentRef, + OperationRef: op2, + Status: model.PayoutStatusWaiting, + CreatedAt: now.Add(-time.Minute), + UpdatedAt: now.Add(-time.Minute), + }) + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: now}, + repo, + &http.Client{}, + nil, + ) + + cb := baseCallback() + cb.Payment.ID = op2 + cb.Payment.Status = "success" + cb.Operation.Status = "success" + cb.Operation.Code = "0" + cb.Operation.Provider.PaymentID = "provider-op-2" + cb.Payment.Sum.Currency = "RUB" + + sig, err := provider.SignPayload(cb, cfg.SecretKey) + if err != nil { + t.Fatalf("failed to sign callback: %v", err) + } + cb.Signature = sig + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + + status, err := processor.ProcessCallback(context.Background(), payload) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if status != http.StatusOK { + t.Fatalf("expected status ok, got %d", status) + } + + first, err := repo.payouts.FindByOperationRef(context.Background(), op1) + if err != nil || first == nil { + t.Fatalf("expected first operation present, err=%v", err) + } + second, err := repo.payouts.FindByOperationRef(context.Background(), op2) + if err != nil || second == nil { + t.Fatalf("expected second operation present, err=%v", err) + } + + if got, want := first.Status, model.PayoutStatusWaiting; got != want { + t.Fatalf("first operation status mismatch: got=%v want=%v", got, want) + } + if got, want := second.Status, model.PayoutStatusSuccess; got != want { + t.Fatalf("second operation status mismatch: got=%v want=%v", got, want) + } + if got, want := second.PaymentRef, parentPaymentRef; got != want { + t.Fatalf("second parent payment ref mismatch: got=%q want=%q", got, want) + } +} + +func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineUntilSuccess(t *testing.T) { + t.Skip("aurora simulator uses deterministic scenario dispatch instead of mocked provider HTTP") + + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + ProjectID: 99, + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + var calls atomic.Int32 + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + n := calls.Add(1) + resp := apiResponse{} + if n == 1 { + resp.Code = providerCodeDeclineAmountOrFrequencyLimit + resp.Message = "Decline due to amount or frequency limit" + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + } + resp.Operation.RequestID = "req-retry-success" + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)}, + repo, + httpClient, + nil, + ) + defer processor.stopRetries() + processor.dispatchThrottleInterval = 0 + processor.retryDelayFn = func(uint32) time.Duration { return 10 * time.Millisecond } + + req := validCardPayoutRequest() + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("submit returned error: %v", err) + } + if !resp.GetAccepted() { + t.Fatalf("expected accepted response when retry is scheduled") + } + + deadline := time.Now().Add(2 * time.Second) + for { + state, ok := repo.payouts.Get(req.GetPayoutId()) + if ok && state != nil && state.Status == model.PayoutStatusWaiting && state.ProviderPaymentID == "req-retry-success" { + break + } + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for successful retry result") + } + time.Sleep(20 * time.Millisecond) + } + if got, want := calls.Load(), int32(2); got != want { + t.Fatalf("unexpected provider call count: got=%d want=%d", got, want) + } +} + +func TestCardPayoutProcessor_Submit_RetriesProviderLimitDeclineThenFails(t *testing.T) { + t.Skip("aurora simulator uses deterministic scenario dispatch instead of mocked provider HTTP") + + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + ProjectID: 99, + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + var calls atomic.Int32 + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + _ = calls.Add(1) + resp := apiResponse{ + Code: providerCodeDeclineAmountOrFrequencyLimit, + Message: "Decline due to amount or frequency limit", + } + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusTooManyRequests, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 4, 1, 2, 3, 0, time.UTC)}, + repo, + httpClient, + nil, + ) + defer processor.stopRetries() + processor.dispatchThrottleInterval = 0 + processor.retryDelayFn = func(uint32) time.Duration { return time.Millisecond } + + req := validCardPayoutRequest() + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("submit returned error: %v", err) + } + if !resp.GetAccepted() { + t.Fatalf("expected accepted response when retry is scheduled") + } + + deadline := time.Now().Add(2 * time.Second) + for { + state, ok := repo.payouts.Get(req.GetPayoutId()) + if ok && state != nil && state.Status == model.PayoutStatusFailed { + if !strings.Contains(state.FailureReason, providerCodeDeclineAmountOrFrequencyLimit) { + t.Fatalf("expected failure reason to include provider code, got=%q", state.FailureReason) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for terminal failed status") + } + time.Sleep(10 * time.Millisecond) + } + if got, want := calls.Load(), int32(defaultMaxDispatchAttempts); got != want { + t.Fatalf("unexpected provider call count: got=%d want=%d", got, want) + } +} + +func TestCardPayoutProcessor_ProcessCallback_RetryableDeclineSchedulesRetry(t *testing.T) { + t.Skip("aurora simulator does not run provider HTTP retry flow used by legacy transport tests") + + cfg := provider.Config{ + BaseURL: "https://provider.test", + SecretKey: "secret", + ProjectID: 99, + StatusSuccess: "success", + StatusProcessing: "processing", + AllowedCurrencies: []string{"RUB"}, + } + + repo := newMockRepository() + var calls atomic.Int32 + httpClient := &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + n := calls.Add(1) + resp := apiResponse{} + if n == 1 { + resp.Operation.RequestID = "req-initial" + } else { + resp.Operation.RequestID = "req-after-callback-retry" + } + body, _ := json.Marshal(resp) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + }, nil + }), + } + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + staticClock{now: time.Date(2026, 3, 4, 2, 0, 0, 0, time.UTC)}, + repo, + httpClient, + nil, + ) + defer processor.stopRetries() + processor.dispatchThrottleInterval = 0 + processor.retryDelayFn = func(uint32) time.Duration { return 5 * time.Millisecond } + + req := validCardPayoutRequest() + resp, err := processor.Submit(context.Background(), req) + if err != nil { + t.Fatalf("submit returned error: %v", err) + } + if !resp.GetAccepted() { + t.Fatalf("expected accepted submit response") + } + + cb := baseCallback() + cb.Payment.ID = req.GetPayoutId() + cb.Payment.Status = "failed" + cb.Operation.Status = "failed" + cb.Operation.Code = providerCodeDeclineAmountOrFrequencyLimit + cb.Operation.Message = "Decline due to amount or frequency limit" + cb.Payment.Sum.Currency = "RUB" + + sig, err := provider.SignPayload(cb, cfg.SecretKey) + if err != nil { + t.Fatalf("failed to sign callback: %v", err) + } + cb.Signature = sig + payload, err := json.Marshal(cb) + if err != nil { + t.Fatalf("failed to marshal callback: %v", err) + } + + status, err := processor.ProcessCallback(context.Background(), payload) + if err != nil { + t.Fatalf("process callback returned error: %v", err) + } + if status != http.StatusOK { + t.Fatalf("unexpected callback status: %d", status) + } + + deadline := time.Now().Add(2 * time.Second) + for { + state, ok := repo.payouts.Get(req.GetPayoutId()) + if ok && state != nil && state.Status == model.PayoutStatusWaiting && state.ProviderPaymentID == "req-after-callback-retry" { + break + } + if time.Now().After(deadline) { + t.Fatalf("timeout waiting for callback-scheduled retry result") + } + time.Sleep(10 * time.Millisecond) + } + + if got, want := calls.Load(), int32(2); got != want { + t.Fatalf("unexpected provider call count: got=%d want=%d", got, want) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/card_token_validation.go b/api/gateway/aurora/internal/service/gateway/card_token_validation.go new file mode 100644 index 00000000..08ed1a33 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_token_validation.go @@ -0,0 +1,66 @@ +package gateway + +import ( + "strings" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func validateCardTokenPayoutRequest(req *mntxv1.CardTokenPayoutRequest, cfg provider.Config) error { + if req == nil { + return newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) + } + + if err := validateOperationIdentity(strings.TrimSpace(req.GetPayoutId()), strings.TrimSpace(req.GetOperationRef())); err != nil { + return err + } + if strings.TrimSpace(req.GetParentPaymentRef()) == "" { + return newPayoutError("missing_parent_payment_ref", merrors.InvalidArgument("parent_payment_ref is required", "parent_payment_ref")) + } + if strings.TrimSpace(req.GetCustomerId()) == "" { + return newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) + } + if strings.TrimSpace(req.GetCustomerFirstName()) == "" { + return newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name")) + } + if strings.TrimSpace(req.GetCustomerLastName()) == "" { + return newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name")) + } + if strings.TrimSpace(req.GetCustomerIp()) == "" { + return newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip")) + } + if req.GetAmountMinor() <= 0 { + return newPayoutError("invalid_amount", merrors.InvalidArgument("amount_minor must be positive", "amount_minor")) + } + + currency := strings.ToUpper(strings.TrimSpace(req.GetCurrency())) + if currency == "" { + return newPayoutError("missing_currency", merrors.InvalidArgument("currency is required", "currency")) + } + if !cfg.CurrencyAllowed(currency) { + return newPayoutError("unsupported_currency", merrors.InvalidArgument("currency is not allowed for this project", "currency")) + } + + if strings.TrimSpace(req.GetCardToken()) == "" { + return newPayoutError("missing_card_token", merrors.InvalidArgument("card_token is required", "card_token")) + } + + if cfg.RequireCustomerAddress { + if strings.TrimSpace(req.GetCustomerCountry()) == "" { + return newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country")) + } + if strings.TrimSpace(req.GetCustomerCity()) == "" { + return newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city")) + } + if strings.TrimSpace(req.GetCustomerAddress()) == "" { + return newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address")) + } + if strings.TrimSpace(req.GetCustomerZip()) == "" { + return newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip")) + } + } + + return nil +} diff --git a/api/gateway/aurora/internal/service/gateway/card_token_validation_test.go b/api/gateway/aurora/internal/service/gateway/card_token_validation_test.go new file mode 100644 index 00000000..6dabcabe --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_token_validation_test.go @@ -0,0 +1,106 @@ +package gateway + +import ( + "testing" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func TestValidateCardTokenPayoutRequest_Valid(t *testing.T) { + cfg := testProviderConfig() + req := validCardTokenPayoutRequest() + if err := validateCardTokenPayoutRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardTokenPayoutRequest_Errors(t *testing.T) { + baseCfg := testProviderConfig() + cases := []struct { + name string + mutate func(*mntxv1.CardTokenPayoutRequest) + config func(provider.Config) provider.Config + expected string + }{ + { + name: "missing_operation_identity", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.PayoutId = "" }, + expected: "missing_operation_ref", + }, + { + name: "missing_parent_payment_ref", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.ParentPaymentRef = "" }, + expected: "missing_parent_payment_ref", + }, + { + name: "both_operation_and_payout_identity", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { + r.PayoutId = "parent-1" + r.OperationRef = "parent-1:hop_1_card_payout_send" + }, + expected: "ambiguous_operation_ref", + }, + { + name: "missing_customer_id", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerId = "" }, + expected: "missing_customer_id", + }, + { + name: "missing_customer_ip", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CustomerIp = "" }, + expected: "missing_customer_ip", + }, + { + name: "invalid_amount", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.AmountMinor = 0 }, + expected: "invalid_amount", + }, + { + name: "missing_currency", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "" }, + expected: "missing_currency", + }, + { + name: "unsupported_currency", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.Currency = "EUR" }, + config: func(cfg provider.Config) provider.Config { + cfg.AllowedCurrencies = []string{"USD"} + return cfg + }, + expected: "unsupported_currency", + }, + { + name: "missing_card_token", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { r.CardToken = "" }, + expected: "missing_card_token", + }, + { + name: "missing_customer_city_when_required", + mutate: func(r *mntxv1.CardTokenPayoutRequest) { + r.CustomerCountry = "US" + r.CustomerCity = "" + r.CustomerAddress = "Main St" + r.CustomerZip = "12345" + }, + config: func(cfg provider.Config) provider.Config { + cfg.RequireCustomerAddress = true + return cfg + }, + expected: "missing_customer_city", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := validCardTokenPayoutRequest() + tc.mutate(req) + cfg := baseCfg + if tc.config != nil { + cfg = tc.config(cfg) + } + err := validateCardTokenPayoutRequest(req, cfg) + requireReason(t, err, tc.expected) + }) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/card_tokenize_validation.go b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation.go new file mode 100644 index 00000000..31c75aa8 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation.go @@ -0,0 +1,108 @@ +package gateway + +import ( + "strings" + "time" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +type tokenizeCardInput struct { + pan string + month uint32 + year uint32 + holder string + cvv string +} + +func validateCardTokenizeRequest(req *mntxv1.CardTokenizeRequest, cfg provider.Config) (*tokenizeCardInput, error) { + if req == nil { + return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) + } + if strings.TrimSpace(req.GetRequestId()) == "" { + return nil, newPayoutError("missing_request_id", merrors.InvalidArgument("request_id is required", "request_id")) + } + if strings.TrimSpace(req.GetCustomerId()) == "" { + return nil, newPayoutError("missing_customer_id", merrors.InvalidArgument("customer_id is required", "customer_id")) + } + if strings.TrimSpace(req.GetCustomerFirstName()) == "" { + return nil, newPayoutError("missing_customer_first_name", merrors.InvalidArgument("customer_first_name is required", "customer_first_name")) + } + if strings.TrimSpace(req.GetCustomerLastName()) == "" { + return nil, newPayoutError("missing_customer_last_name", merrors.InvalidArgument("customer_last_name is required", "customer_last_name")) + } + if strings.TrimSpace(req.GetCustomerIp()) == "" { + return nil, newPayoutError("missing_customer_ip", merrors.InvalidArgument("customer_ip is required", "customer_ip")) + } + + card := extractTokenizeCard(req) + if card.pan == "" { + return nil, newPayoutError("missing_card_pan", merrors.InvalidArgument("card_pan is required", "card.pan")) + } + if card.holder == "" { + return nil, newPayoutError("missing_card_holder", merrors.InvalidArgument("card_holder is required", "card.holder")) + } + if card.month == 0 || card.month > 12 { + return nil, newPayoutError("invalid_expiry_month", merrors.InvalidArgument("card_exp_month must be between 1 and 12", "card.exp_month")) + } + if card.year == 0 { + return nil, newPayoutError("invalid_expiry_year", merrors.InvalidArgument("card_exp_year must be provided", "card.exp_year")) + } + if card.cvv == "" { + return nil, newPayoutError("missing_cvv", merrors.InvalidArgument("card_cvv is required", "card.cvv")) + } + if expired(card.month, card.year) { + return nil, newPayoutError("expired_card", merrors.InvalidArgument("card expiry is in the past", "card.expiry")) + } + + if cfg.RequireCustomerAddress { + if strings.TrimSpace(req.GetCustomerCountry()) == "" { + return nil, newPayoutError("missing_customer_country", merrors.InvalidArgument("customer_country is required", "customer_country")) + } + if strings.TrimSpace(req.GetCustomerCity()) == "" { + return nil, newPayoutError("missing_customer_city", merrors.InvalidArgument("customer_city is required", "customer_city")) + } + if strings.TrimSpace(req.GetCustomerAddress()) == "" { + return nil, newPayoutError("missing_customer_address", merrors.InvalidArgument("customer_address is required", "customer_address")) + } + if strings.TrimSpace(req.GetCustomerZip()) == "" { + return nil, newPayoutError("missing_customer_zip", merrors.InvalidArgument("customer_zip is required", "customer_zip")) + } + } + + return card, nil +} + +func extractTokenizeCard(req *mntxv1.CardTokenizeRequest) *tokenizeCardInput { + card := req.GetCard() + if card != nil { + return &tokenizeCardInput{ + pan: strings.TrimSpace(card.GetPan()), + month: card.GetExpMonth(), + year: card.GetExpYear(), + holder: strings.TrimSpace(card.GetCardHolder()), + cvv: strings.TrimSpace(card.GetCvv()), + } + } + return &tokenizeCardInput{ + pan: strings.TrimSpace(req.GetCardPan()), + month: req.GetCardExpMonth(), + year: req.GetCardExpYear(), + holder: strings.TrimSpace(req.GetCardHolder()), + cvv: strings.TrimSpace(req.GetCardCvv()), + } +} + +func expired(month uint32, year uint32) bool { + now := time.Now() + y := int(year) + m := time.Month(month) + // Normalize 2-digit years: assume 2000-2099. + if y < 100 { + y += 2000 + } + expiry := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, -1) + return now.After(expiry) +} diff --git a/api/gateway/aurora/internal/service/gateway/card_tokenize_validation_test.go b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation_test.go new file mode 100644 index 00000000..e0a51783 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/card_tokenize_validation_test.go @@ -0,0 +1,76 @@ +package gateway + +import ( + "testing" + "time" + + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func TestValidateCardTokenizeRequest_ValidTopLevel(t *testing.T) { + cfg := testProviderConfig() + req := validCardTokenizeRequest() + if _, err := validateCardTokenizeRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardTokenizeRequest_ValidNestedCard(t *testing.T) { + cfg := testProviderConfig() + req := validCardTokenizeRequest() + req.Card = &mntxv1.CardDetails{ + Pan: "4111111111111111", + ExpMonth: req.CardExpMonth, + ExpYear: req.CardExpYear, + CardHolder: req.CardHolder, + Cvv: req.CardCvv, + } + req.CardPan = "" + req.CardExpMonth = 0 + req.CardExpYear = 0 + req.CardHolder = "" + req.CardCvv = "" + + if _, err := validateCardTokenizeRequest(req, cfg); err != nil { + t.Fatalf("expected no error, got %v", err) + } +} + +func TestValidateCardTokenizeRequest_Expired(t *testing.T) { + cfg := testProviderConfig() + req := validCardTokenizeRequest() + now := time.Now().UTC() + req.CardExpMonth = uint32(now.Month()) + req.CardExpYear = uint32(now.Year() - 1) + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "expired_card") +} + +func TestValidateCardTokenizeRequest_MissingCvv(t *testing.T) { + cfg := testProviderConfig() + req := validCardTokenizeRequest() + req.CardCvv = "" + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "missing_cvv") +} + +func TestValidateCardTokenizeRequest_MissingCardPan(t *testing.T) { + cfg := testProviderConfig() + req := validCardTokenizeRequest() + req.CardPan = "" + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "missing_card_pan") +} + +func TestValidateCardTokenizeRequest_AddressRequired(t *testing.T) { + cfg := testProviderConfig() + cfg.RequireCustomerAddress = true + req := validCardTokenizeRequest() + req.CustomerCountry = "" + + _, err := validateCardTokenizeRequest(req, cfg) + requireReason(t, err, "missing_customer_country") +} diff --git a/api/gateway/aurora/internal/service/gateway/connector.go b/api/gateway/aurora/internal/service/gateway/connector.go new file mode 100644 index 00000000..58df3891 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/connector.go @@ -0,0 +1,388 @@ +package gateway + +import ( + "context" + "errors" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/aurora/internal/appversion" + "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/types/known/structpb" +) + +const connectorTypeID = "mntx" + +func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { + return &connectorv1.GetCapabilitiesResponse{ + Capabilities: &connectorv1.ConnectorCapabilities{ + ConnectorType: connectorTypeID, + Version: appversion.Create().Short(), + SupportedAccountKinds: nil, + SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT}, + OperationParams: connectorOperationParams(), + }, + }, nil +} + +func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil +} + +func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) { + return nil, merrors.NotImplemented("get_account: unsupported") +} + +func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { + return nil, merrors.NotImplemented("list_accounts: unsupported") +} + +func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) { + return nil, merrors.NotImplemented("get_balance: unsupported") +} + +func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + if req == nil || req.GetOperation() == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil + } + op := req.GetOperation() + idempotencyKey := strings.TrimSpace(op.GetIdempotencyKey()) + if idempotencyKey == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil + } + operationRef := strings.TrimSpace(op.GetOperationRef()) + if operationRef == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation_ref is required", op, "")}}, nil + } + intentRef := strings.TrimSpace(op.GetIntentRef()) + if intentRef == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: intent_ref is required", op, "")}}, nil + } + if op.GetType() != connectorv1.OperationType_PAYOUT { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil + } + reader := params.New(op.GetParams()) + amountMinor, currency, err := payoutAmount(op, reader) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + parentPaymentRef := strings.TrimSpace(reader.String("parent_payment_ref")) + + payoutID := operationIDForRequest(operationRef) + + if strings.TrimSpace(reader.String("card_token")) != "" { + resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency)) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil + } + cr := buildCardPayoutRequestFromParams(reader, payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef, amountMinor, currency) + resp, err := s.CreateCardPayout(ctx, cr) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil +} + +func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) { + if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { + return nil, merrors.InvalidArgument("get_operation: operation_id is required") + } + + operationRef := strings.TrimSpace(req.GetOperationId()) + if s.storage == nil || s.storage.Payouts() == nil { + return nil, merrors.Internal("get_operation: storage is not configured") + } + + payout, err := s.storage.Payouts().FindByOperationRef(ctx, operationRef) + if err != nil { + return nil, err + } + if payout == nil { + return nil, merrors.NoData("payout not found") + } + + return &connectorv1.GetOperationResponse{Operation: payoutToOperation(StateToProto(payout))}, nil +} + +func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { + return nil, merrors.NotImplemented("list_operations: unsupported") +} + +func connectorOperationParams() []*connectorv1.OperationParamSpec { + return []*connectorv1.OperationParamSpec{ + {OperationType: connectorv1.OperationType_PAYOUT, Params: []*connectorv1.ParamSpec{ + {Key: "customer_id", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "customer_first_name", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "customer_last_name", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "customer_ip", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "card_token", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "card_pan", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "card_exp_year", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "card_exp_month", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "project_id", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "parent_payment_ref", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_city", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_address", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_zip", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "masked_pan", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false}, + }}, + } +} + +func payoutAmount(op *connectorv1.Operation, reader params.Reader) (int64, string, error) { + if op == nil { + return 0, "", merrors.InvalidArgument("payout: operation is required") + } + currency := currencyFromOperation(op) + if currency == "" { + return 0, "", merrors.InvalidArgument("payout: currency is required") + } + if minor, ok := reader.Int64("amount_minor"); ok && minor > 0 { + return minor, currency, nil + } + money := op.GetMoney() + if money == nil { + return 0, "", merrors.InvalidArgument("payout: money is required") + } + amount := strings.TrimSpace(money.GetAmount()) + if amount == "" { + return 0, "", merrors.InvalidArgument("payout: amount is required") + } + dec, err := decimal.NewFromString(amount) + if err != nil { + return 0, "", merrors.InvalidArgument("payout: invalid amount") + } + minor := dec.Mul(decimal.NewFromInt(100)).IntPart() + return minor, currency, nil +} + +func currencyFromOperation(op *connectorv1.Operation) string { + if op == nil || op.GetMoney() == nil { + return "" + } + currency := strings.TrimSpace(op.GetMoney().GetCurrency()) + if idx := strings.Index(currency, "-"); idx > 0 { + currency = currency[:idx] + } + return strings.ToUpper(currency) +} + +func operationIDForRequest(operationRef string) string { + return strings.TrimSpace(operationRef) +} + +func metadataFromReader(reader params.Reader) map[string]string { + metadata := reader.StringMap("metadata") + if len(metadata) == 0 { + return nil + } + return metadata +} + +func buildCardTokenPayoutRequestFromParams(reader params.Reader, + payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string, + amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { + operationRef = strings.TrimSpace(operationRef) + payoutID = strings.TrimSpace(payoutID) + if operationRef != "" { + payoutID = "" + } + req := &mntxv1.CardTokenPayoutRequest{ + PayoutId: payoutID, + ParentPaymentRef: strings.TrimSpace(parentPaymentRef), + ProjectId: readerInt64(reader, "project_id"), + CustomerId: strings.TrimSpace(reader.String("customer_id")), + CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), + CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")), + CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")), + CustomerIp: strings.TrimSpace(reader.String("customer_ip")), + CustomerZip: strings.TrimSpace(reader.String("customer_zip")), + CustomerCountry: strings.TrimSpace(reader.String("customer_country")), + CustomerState: strings.TrimSpace(reader.String("customer_state")), + CustomerCity: strings.TrimSpace(reader.String("customer_city")), + CustomerAddress: strings.TrimSpace(reader.String("customer_address")), + AmountMinor: amountMinor, + Currency: currency, + CardToken: strings.TrimSpace(reader.String("card_token")), + CardHolder: strings.TrimSpace(reader.String("card_holder")), + MaskedPan: strings.TrimSpace(reader.String("masked_pan")), + Metadata: metadataFromReader(reader), + OperationRef: operationRef, + IdempotencyKey: strings.TrimSpace(idempotencyKey), + IntentRef: strings.TrimSpace(intentRef), + } + return req +} + +func buildCardPayoutRequestFromParams(reader params.Reader, + payoutID, parentPaymentRef, idempotencyKey, operationRef, intentRef string, + amountMinor int64, currency string) *mntxv1.CardPayoutRequest { + operationRef = strings.TrimSpace(operationRef) + payoutID = strings.TrimSpace(payoutID) + if operationRef != "" { + payoutID = "" + } + return &mntxv1.CardPayoutRequest{ + PayoutId: payoutID, + ParentPaymentRef: strings.TrimSpace(parentPaymentRef), + ProjectId: readerInt64(reader, "project_id"), + CustomerId: strings.TrimSpace(reader.String("customer_id")), + CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), + CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")), + CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")), + CustomerIp: strings.TrimSpace(reader.String("customer_ip")), + CustomerZip: strings.TrimSpace(reader.String("customer_zip")), + CustomerCountry: strings.TrimSpace(reader.String("customer_country")), + CustomerState: strings.TrimSpace(reader.String("customer_state")), + CustomerCity: strings.TrimSpace(reader.String("customer_city")), + CustomerAddress: strings.TrimSpace(reader.String("customer_address")), + AmountMinor: amountMinor, + Currency: currency, + CardPan: strings.TrimSpace(reader.String("card_pan")), + CardExpYear: uint32(readerInt64(reader, "card_exp_year")), + CardExpMonth: uint32(readerInt64(reader, "card_exp_month")), + CardHolder: strings.TrimSpace(reader.String("card_holder")), + Metadata: metadataFromReader(reader), + OperationRef: operationRef, + IdempotencyKey: strings.TrimSpace(idempotencyKey), + IntentRef: strings.TrimSpace(intentRef), + } +} + +func readerInt64(reader params.Reader, key string) int64 { + if v, ok := reader.Int64(key); ok { + return v + } + return 0 +} + +func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt { + if state == nil { + return &connectorv1.OperationReceipt{ + Status: connectorv1.OperationStatus_OPERATION_PROCESSING, + } + } + return &connectorv1.OperationReceipt{ + OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), + Status: payoutStatusToOperation(state.GetStatus()), + ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), + } +} + +func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation { + if state == nil { + return nil + } + op := &connectorv1.Operation{ + OperationId: firstNonEmpty(strings.TrimSpace(state.GetOperationRef()), strings.TrimSpace(state.GetPayoutId())), + Type: connectorv1.OperationType_PAYOUT, + Status: payoutStatusToOperation(state.GetStatus()), + Money: &moneyv1.Money{ + Amount: minorToDecimal(state.GetAmountMinor()), + Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())), + }, + ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), + IntentRef: strings.TrimSpace(state.GetIntentRef()), + OperationRef: strings.TrimSpace(state.GetOperationRef()), + CreatedAt: state.GetCreatedAt(), + UpdatedAt: state.GetUpdatedAt(), + } + + params := map[string]interface{}{} + if paymentRef := strings.TrimSpace(state.GetParentPaymentRef()); paymentRef != "" { + params["payment_ref"] = paymentRef + params["parent_payment_ref"] = paymentRef + } + if providerCode := strings.TrimSpace(state.GetProviderCode()); providerCode != "" { + params["provider_code"] = providerCode + } + if providerMessage := strings.TrimSpace(state.GetProviderMessage()); providerMessage != "" { + params["provider_message"] = providerMessage + params["failure_reason"] = providerMessage + } + if len(params) > 0 { + op.Params = structFromMap(params) + } + + return op +} + +func minorToDecimal(amount int64) string { + dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100)) + return dec.StringFixed(2) +} + +func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus { + switch status { + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + return connectorv1.OperationStatus_OPERATION_FAILED + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + return connectorv1.OperationStatus_OPERATION_CANCELLED + + default: + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED + } +} + +func structFromMap(values map[string]interface{}) *structpb.Struct { + if len(values) == 0 { + return nil + } + result, err := structpb.NewStruct(values) + if err != nil { + return nil + } + return result +} + +func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { + err := &connectorv1.ConnectorError{ + Code: code, + Message: strings.TrimSpace(message), + AccountId: strings.TrimSpace(accountID), + } + if op != nil { + err.CorrelationId = strings.TrimSpace(op.GetCorrelationId()) + err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId()) + err.OperationId = strings.TrimSpace(op.GetOperationId()) + } + return err +} + +func mapErrorCode(err error) connectorv1.ErrorCode { + switch { + case errors.Is(err, merrors.ErrInvalidArg): + return connectorv1.ErrorCode_INVALID_PARAMS + case errors.Is(err, merrors.ErrNoData): + return connectorv1.ErrorCode_NOT_FOUND + case errors.Is(err, merrors.ErrNotImplemented): + return connectorv1.ErrorCode_UNSUPPORTED_OPERATION + case errors.Is(err, merrors.ErrInternal): + return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE + default: + return connectorv1.ErrorCode_PROVIDER_ERROR + } +} diff --git a/api/gateway/aurora/internal/service/gateway/helpers.go b/api/gateway/aurora/internal/service/gateway/helpers.go new file mode 100644 index 00000000..0d7986f3 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/helpers.go @@ -0,0 +1,99 @@ +package gateway + +import ( + "strings" + "time" + + "github.com/tech/sendico/gateway/aurora/storage/model" + clockpkg "github.com/tech/sendico/pkg/clock" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func tsOrNow(clock clockpkg.Clock, ts *timestamppb.Timestamp) time.Time { + if ts == nil { + return clock.Now() + } + return ts.AsTime() +} + +func CardPayoutStateFromProto(clock clockpkg.Clock, p *mntxv1.CardPayoutState) *model.CardPayout { + if p == nil { + return nil + } + + return &model.CardPayout{ + PaymentRef: strings.TrimSpace(p.GetParentPaymentRef()), + OperationRef: p.GetOperationRef(), + IntentRef: p.GetIntentRef(), + IdempotencyKey: p.GetIdempotencyKey(), + ProjectID: p.ProjectId, + CustomerID: p.CustomerId, + AmountMinor: p.AmountMinor, + Currency: p.Currency, + Status: payoutStatusFromProto(p.Status), + ProviderCode: p.ProviderCode, + ProviderMessage: p.ProviderMessage, + ProviderPaymentID: p.ProviderPaymentId, + CreatedAt: tsOrNow(clock, p.CreatedAt), + UpdatedAt: tsOrNow(clock, p.UpdatedAt), + } +} + +func StateToProto(m *model.CardPayout) *mntxv1.CardPayoutState { + return &mntxv1.CardPayoutState{ + PayoutId: firstNonEmpty(m.OperationRef, m.PaymentRef), + ParentPaymentRef: m.PaymentRef, + ProjectId: m.ProjectID, + CustomerId: m.CustomerID, + AmountMinor: m.AmountMinor, + Currency: m.Currency, + Status: payoutStatusToProto(m.Status), + ProviderCode: m.ProviderCode, + ProviderMessage: m.ProviderMessage, + ProviderPaymentId: m.ProviderPaymentID, + CreatedAt: timestamppb.New(m.CreatedAt), + UpdatedAt: timestamppb.New(m.UpdatedAt), + } +} + +func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus { + switch s { + case model.PayoutStatusCreated: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED + case model.PayoutStatusProcessing: + return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING + case model.PayoutStatusWaiting: + return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING + case model.PayoutStatusSuccess: + return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS + case model.PayoutStatusFailed: + return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + case model.PayoutStatusCancelled: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED + default: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED + } +} + +func payoutStatusFromProto(s mntxv1.PayoutStatus) model.PayoutStatus { + switch s { + case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: + return model.PayoutStatusCreated + + case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: + return model.PayoutStatusWaiting + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: + return model.PayoutStatusSuccess + + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + return model.PayoutStatusFailed + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + return model.PayoutStatusCancelled + + default: + return model.PayoutStatusCreated + } +} diff --git a/api/gateway/aurora/internal/service/gateway/instances.go b/api/gateway/aurora/internal/service/gateway/instances.go new file mode 100644 index 00000000..4d522722 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/instances.go @@ -0,0 +1,62 @@ +package gateway + +import ( + "context" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "google.golang.org/protobuf/proto" +) + +// ListGatewayInstances exposes the Aurora gateway instance descriptors. +func (s *Service) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { + return executeUnary(ctx, s, "ListGatewayInstances", s.handleListGatewayInstances, req) +} + +func (s *Service) handleListGatewayInstances(_ context.Context, _ *mntxv1.ListGatewayInstancesRequest) gsresponse.Responder[mntxv1.ListGatewayInstancesResponse] { + items := make([]*gatewayv1.GatewayInstanceDescriptor, 0, 1) + if s.gatewayDescriptor != nil { + items = append(items, cloneGatewayDescriptor(s.gatewayDescriptor)) + } + return gsresponse.Success(&mntxv1.ListGatewayInstancesResponse{Items: items}) +} + +func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1.GatewayInstanceDescriptor { + if src == nil { + return nil + } + cp := proto.Clone(src).(*gatewayv1.GatewayInstanceDescriptor) + if src.Currencies != nil { + cp.Currencies = append([]string(nil), src.Currencies...) + } + if src.Capabilities != nil { + cp.Capabilities = proto.Clone(src.Capabilities).(*gatewayv1.RailCapabilities) + } + if src.Limits != nil { + limits := &gatewayv1.Limits{} + if src.Limits.VolumeLimit != nil { + limits.VolumeLimit = map[string]string{} + for key, value := range src.Limits.VolumeLimit { + limits.VolumeLimit[key] = value + } + } + if src.Limits.VelocityLimit != nil { + limits.VelocityLimit = map[string]int32{} + for key, value := range src.Limits.VelocityLimit { + limits.VelocityLimit[key] = value + } + } + if src.Limits.CurrencyLimits != nil { + limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{} + for key, value := range src.Limits.CurrencyLimits { + if value == nil { + continue + } + limits.CurrencyLimits[key] = proto.Clone(value).(*gatewayv1.LimitsOverride) + } + } + cp.Limits = limits + } + return cp +} diff --git a/api/gateway/aurora/internal/service/gateway/metrics.go b/api/gateway/aurora/internal/service/gateway/metrics.go new file mode 100644 index 00000000..2c767935 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/metrics.go @@ -0,0 +1,66 @@ +package gateway + +import ( + "errors" + "sync" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/tech/sendico/pkg/merrors" +) + +var ( + metricsOnce sync.Once + + rpcLatency *prometheus.HistogramVec + rpcStatus *prometheus.CounterVec +) + +func initMetrics() { + metricsOnce.Do(func() { + rpcLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "sendico", + Subsystem: "aurora_gateway", + Name: "rpc_latency_seconds", + Help: "Latency distribution for Aurora gateway RPC handlers.", + Buckets: prometheus.DefBuckets, + }, []string{"method"}) + + rpcStatus = promauto.NewCounterVec(prometheus.CounterOpts{ + Namespace: "sendico", + Subsystem: "aurora_gateway", + Name: "rpc_requests_total", + Help: "Total number of RPC invocations grouped by method and status.", + }, []string{"method", "status"}) + + }) +} + +func observeRPC(method string, err error, duration time.Duration) { + if rpcLatency != nil { + rpcLatency.WithLabelValues(method).Observe(duration.Seconds()) + } + if rpcStatus != nil { + rpcStatus.WithLabelValues(method, statusLabel(err)).Inc() + } +} + +func statusLabel(err error) string { + switch { + case err == nil: + return "ok" + case errors.Is(err, merrors.ErrInvalidArg): + return "invalid_argument" + case errors.Is(err, merrors.ErrNoData): + return "not_found" + case errors.Is(err, merrors.ErrDataConflict): + return "conflict" + case errors.Is(err, merrors.ErrAccessDenied): + return "denied" + case errors.Is(err, merrors.ErrInternal): + return "internal" + default: + return "error" + } +} diff --git a/api/gateway/aurora/internal/service/gateway/options.go b/api/gateway/aurora/internal/service/gateway/options.go new file mode 100644 index 00000000..9aebba37 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/options.go @@ -0,0 +1,86 @@ +package gateway + +import ( + "net/http" + "strings" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/gateway/aurora/storage" + "github.com/tech/sendico/pkg/clock" + msg "github.com/tech/sendico/pkg/messaging" + pmodel "github.com/tech/sendico/pkg/model" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" +) + +// Option configures optional service dependencies. +type Option func(*Service) + +// WithClock injects a custom clock (useful for tests). +func WithClock(c clock.Clock) Option { + return func(s *Service) { + if c != nil { + s.clock = c + } + } +} + +// WithProducer attaches a messaging producer to the service. +func WithProducer(p msg.Producer) Option { + return func(s *Service) { + s.producer = p + } +} + +func WithStorage(storage storage.Repository) Option { + return func(s *Service) { + s.storage = storage + } +} + +// WithHTTPClient injects a custom HTTP client (useful for tests). +func WithHTTPClient(client *http.Client) Option { + return func(s *Service) { + if client != nil { + s.httpClient = client + } + } +} + +// WithProviderConfig sets provider integration options. +func WithProviderConfig(cfg provider.Config) Option { + return func(s *Service) { + s.config = cfg + } +} + +// WithGatewayDescriptor sets the self-declared gateway instance descriptor. +func WithGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) Option { + return func(s *Service) { + if descriptor != nil { + s.gatewayDescriptor = descriptor + } + } +} + +// WithDiscoveryInvokeURI sets the invoke URI used when announcing the gateway. +func WithDiscoveryInvokeURI(invokeURI string) Option { + return func(s *Service) { + s.invokeURI = strings.TrimSpace(invokeURI) + } +} + +// WithMessagingSettings applies messaging driver settings. +func WithMessagingSettings(settings pmodel.SettingsT) Option { + return func(s *Service) { + if settings != nil { + s.msgCfg = settings + } + } +} + +// WithStrictOperationIsolation serialises payout processing to one unresolved operation at a time. +func WithStrictOperationIsolation(enabled bool) Option { + return func(s *Service) { + s.strictIsolation = enabled + } +} diff --git a/api/gateway/aurora/internal/service/gateway/outbox_reliable.go b/api/gateway/aurora/internal/service/gateway/outbox_reliable.go new file mode 100644 index 00000000..64eeb663 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/outbox_reliable.go @@ -0,0 +1,50 @@ +package gateway + +import ( + "context" + + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db/transaction" + me "github.com/tech/sendico/pkg/messaging/envelope" +) + +type outboxProvider interface { + Outbox() gatewayoutbox.Store +} + +type transactionProvider interface { + TransactionFactory() transaction.Factory +} + +func (p *cardPayoutProcessor) outboxStore() gatewayoutbox.Store { + provider, ok := p.store.(outboxProvider) + if !ok || provider == nil { + return nil + } + return provider.Outbox() +} + +func (p *cardPayoutProcessor) startOutboxReliableProducer() error { + if p == nil || p.outbox == nil { + return nil + } + return p.outbox.Start(p.logger, p.producer, p.outboxStore(), p.msgCfg) +} + +func (p *cardPayoutProcessor) sendWithOutbox(ctx context.Context, env me.Envelope) error { + if err := p.startOutboxReliableProducer(); err != nil { + return err + } + if p.outbox == nil { + return nil + } + return p.outbox.Send(ctx, env) +} + +func (p *cardPayoutProcessor) executeTransaction(ctx context.Context, cb transaction.Callback) (any, error) { + provider, ok := p.store.(transactionProvider) + if !ok || provider == nil || provider.TransactionFactory() == nil { + return cb(ctx) + } + return provider.TransactionFactory().CreateTransaction().Execute(ctx, cb) +} diff --git a/api/gateway/aurora/internal/service/gateway/payout_execution_mode.go b/api/gateway/aurora/internal/service/gateway/payout_execution_mode.go new file mode 100644 index 00000000..b703aff8 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/payout_execution_mode.go @@ -0,0 +1,168 @@ +package gateway + +import ( + "context" + "errors" + "strings" + "sync" + + "github.com/tech/sendico/gateway/aurora/storage/model" +) + +const ( + payoutExecutionModeDefaultName = "default" + payoutExecutionModeStrictIsolatedName = "strict_isolated" +) + +var errPayoutExecutionModeStopped = errors.New("payout execution mode stopped") + +type payoutExecutionMode interface { + Name() string + BeforeDispatch(ctx context.Context, operationRef string) error + OnPersistedState(operationRef string, status model.PayoutStatus) + Shutdown() +} + +type defaultPayoutExecutionMode struct{} + +func newDefaultPayoutExecutionMode() payoutExecutionMode { + return &defaultPayoutExecutionMode{} +} + +func (m *defaultPayoutExecutionMode) Name() string { + return payoutExecutionModeDefaultName +} + +func (m *defaultPayoutExecutionMode) BeforeDispatch(_ context.Context, _ string) error { + return nil +} + +func (m *defaultPayoutExecutionMode) OnPersistedState(_ string, _ model.PayoutStatus) {} + +func (m *defaultPayoutExecutionMode) Shutdown() {} + +type strictIsolatedPayoutExecutionMode struct { + mu sync.Mutex + activeOperation string + waitCh chan struct{} + stopped bool +} + +func newStrictIsolatedPayoutExecutionMode() payoutExecutionMode { + return &strictIsolatedPayoutExecutionMode{ + waitCh: make(chan struct{}), + } +} + +func (m *strictIsolatedPayoutExecutionMode) Name() string { + return payoutExecutionModeStrictIsolatedName +} + +func (m *strictIsolatedPayoutExecutionMode) BeforeDispatch(ctx context.Context, operationRef string) error { + opRef := strings.TrimSpace(operationRef) + if opRef == "" { + return nil + } + if ctx == nil { + ctx = context.Background() + } + + for { + waitCh, allowed, err := m.tryAcquire(opRef) + if allowed { + return nil + } + if err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-waitCh: + } + } +} + +func (m *strictIsolatedPayoutExecutionMode) OnPersistedState(operationRef string, status model.PayoutStatus) { + opRef := strings.TrimSpace(operationRef) + if opRef == "" { + return + } + + m.mu.Lock() + defer m.mu.Unlock() + if m.stopped { + return + } + + if isFinalPayoutStatus(status) { + if m.activeOperation == opRef { + m.activeOperation = "" + m.signalLocked() + } + return + } + + if m.activeOperation == "" { + m.activeOperation = opRef + m.signalLocked() + } +} + +func (m *strictIsolatedPayoutExecutionMode) Shutdown() { + m.mu.Lock() + defer m.mu.Unlock() + if m.stopped { + return + } + m.stopped = true + m.activeOperation = "" + m.signalLocked() +} + +func (m *strictIsolatedPayoutExecutionMode) tryAcquire(operationRef string) (<-chan struct{}, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.stopped { + return nil, false, errPayoutExecutionModeStopped + } + + switch owner := strings.TrimSpace(m.activeOperation); { + case owner == "": + m.activeOperation = operationRef + m.signalLocked() + return nil, true, nil + case owner == operationRef: + return nil, true, nil + default: + return m.waitCh, false, nil + } +} + +func (m *strictIsolatedPayoutExecutionMode) signalLocked() { + if m.waitCh == nil { + m.waitCh = make(chan struct{}) + return + } + close(m.waitCh) + m.waitCh = make(chan struct{}) +} + +func normalizePayoutExecutionMode(mode payoutExecutionMode) payoutExecutionMode { + if mode == nil { + return newDefaultPayoutExecutionMode() + } + return mode +} + +func payoutExecutionModeName(mode payoutExecutionMode) string { + if mode == nil { + return payoutExecutionModeDefaultName + } + name := strings.TrimSpace(mode.Name()) + if name == "" { + return payoutExecutionModeDefaultName + } + return name +} diff --git a/api/gateway/aurora/internal/service/gateway/payout_execution_mode_test.go b/api/gateway/aurora/internal/service/gateway/payout_execution_mode_test.go new file mode 100644 index 00000000..adcbc561 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/payout_execution_mode_test.go @@ -0,0 +1,58 @@ +package gateway + +import ( + "context" + "testing" + "time" + + "github.com/tech/sendico/gateway/aurora/storage/model" +) + +func TestStrictIsolatedPayoutExecutionMode_BlocksOtherOperationUntilFinalStatus(t *testing.T) { + mode := newStrictIsolatedPayoutExecutionMode() + if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil { + t.Fatalf("first acquire failed: %v", err) + } + + waitCtx, waitCancel := context.WithTimeout(context.Background(), time.Second) + defer waitCancel() + secondDone := make(chan error, 1) + go func() { + secondDone <- mode.BeforeDispatch(waitCtx, "op-2") + }() + + select { + case err := <-secondDone: + t.Fatalf("second operation should be blocked before final status, got err=%v", err) + case <-time.After(80 * time.Millisecond): + } + + mode.OnPersistedState("op-1", model.PayoutStatusWaiting) + + select { + case err := <-secondDone: + t.Fatalf("second operation should remain blocked on non-final status, got err=%v", err) + case <-time.After(80 * time.Millisecond): + } + + mode.OnPersistedState("op-1", model.PayoutStatusSuccess) + + select { + case err := <-secondDone: + if err != nil { + t.Fatalf("second operation should proceed after final status, got err=%v", err) + } + case <-time.After(time.Second): + t.Fatalf("timeout waiting for second operation to proceed") + } +} + +func TestStrictIsolatedPayoutExecutionMode_AllowsSameOperationReentry(t *testing.T) { + mode := newStrictIsolatedPayoutExecutionMode() + if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil { + t.Fatalf("first acquire failed: %v", err) + } + if err := mode.BeforeDispatch(context.Background(), "op-1"); err != nil { + t.Fatalf("same operation should be re-entrant, got err=%v", err) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/payout_failure_policy.go b/api/gateway/aurora/internal/service/gateway/payout_failure_policy.go new file mode 100644 index 00000000..83b84e9d --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/payout_failure_policy.go @@ -0,0 +1,87 @@ +package gateway + +import ( + "strings" +) + +const ( + providerCodeDeclineAmountOrFrequencyLimit = "10101" +) + +type payoutFailureAction int + +const ( + payoutFailureActionFail payoutFailureAction = iota + 1 + payoutFailureActionRetry +) + +type payoutFailureDecision struct { + Action payoutFailureAction + Reason string +} + +type payoutFailurePolicy struct { + providerCodeActions map[string]payoutFailureAction +} + +func defaultPayoutFailurePolicy() payoutFailurePolicy { + return payoutFailurePolicy{ + providerCodeActions: map[string]payoutFailureAction{ + providerCodeDeclineAmountOrFrequencyLimit: payoutFailureActionRetry, + }, + } +} + +func (p payoutFailurePolicy) decideProviderFailure(code string) payoutFailureDecision { + normalized := strings.TrimSpace(code) + if normalized == "" { + return payoutFailureDecision{ + Action: payoutFailureActionFail, + Reason: "provider_failure", + } + } + if action, ok := p.providerCodeActions[normalized]; ok { + return payoutFailureDecision{ + Action: action, + Reason: "provider_code_" + normalized, + } + } + return payoutFailureDecision{ + Action: payoutFailureActionFail, + Reason: "provider_code_" + normalized, + } +} + +func (p payoutFailurePolicy) decideTransportFailure() payoutFailureDecision { + return payoutFailureDecision{ + Action: payoutFailureActionRetry, + Reason: "transport_failure", + } +} + +func payoutFailureReason(code, message string) string { + cleanCode := strings.TrimSpace(code) + cleanMessage := strings.TrimSpace(message) + switch { + case cleanCode != "" && cleanMessage != "": + return cleanCode + ": " + cleanMessage + case cleanCode != "": + return cleanCode + default: + return cleanMessage + } +} + +func retryDelayForAttempt(attempt uint32) int { + // Backoff in seconds by attempt number (attempt starts at 1). + switch { + case attempt <= 1: + return 5 + case attempt == 2: + return 15 + case attempt == 3: + return 30 + default: + return 60 + } +} diff --git a/api/gateway/aurora/internal/service/gateway/payout_failure_policy_test.go b/api/gateway/aurora/internal/service/gateway/payout_failure_policy_test.go new file mode 100644 index 00000000..d5566f57 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/payout_failure_policy_test.go @@ -0,0 +1,52 @@ +package gateway + +import "testing" + +func TestPayoutFailurePolicy_DecideProviderFailure(t *testing.T) { + policy := defaultPayoutFailurePolicy() + + cases := []struct { + name string + code string + action payoutFailureAction + }{ + { + name: "retryable provider limit code", + code: providerCodeDeclineAmountOrFrequencyLimit, + action: payoutFailureActionRetry, + }, + { + name: "unknown provider code", + code: "99999", + action: payoutFailureActionFail, + }, + { + name: "empty provider code", + code: "", + action: payoutFailureActionFail, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Helper() + got := policy.decideProviderFailure(tc.code) + if got.Action != tc.action { + t.Fatalf("action mismatch: got=%v want=%v", got.Action, tc.action) + } + }) + } +} + +func TestPayoutFailureReason(t *testing.T) { + if got, want := payoutFailureReason("10101", "Decline due to amount or frequency limit"), "10101: Decline due to amount or frequency limit"; got != want { + t.Fatalf("failure reason mismatch: got=%q want=%q", got, want) + } + if got, want := payoutFailureReason("", "network error"), "network error"; got != want { + t.Fatalf("failure reason mismatch: got=%q want=%q", got, want) + } + if got, want := payoutFailureReason("10101", ""), "10101"; got != want { + t.Fatalf("failure reason mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/scenario_simulator.go b/api/gateway/aurora/internal/service/gateway/scenario_simulator.go new file mode 100644 index 00000000..39e064d8 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/scenario_simulator.go @@ -0,0 +1,243 @@ +package gateway + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "strings" + "sync/atomic" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/pkg/merrors" +) + +type simulatedCardScenario struct { + Name string + CardNumbers []string + CardLast4 []string + Accepted bool + ProviderStatus string + ErrorCode string + ErrorMessage string + DispatchError string +} + +type payoutSimulator struct { + scenarios []simulatedCardScenario + defaultScenario simulatedCardScenario + seq atomic.Uint64 +} + +func newPayoutSimulator() *payoutSimulator { + return &payoutSimulator{ + scenarios: []simulatedCardScenario{ + { + Name: "approved_instant", + CardNumbers: []string{"2200001111111111"}, + Accepted: true, + ProviderStatus: "success", + ErrorCode: "00", + ErrorMessage: "Approved by issuer", + }, + { + Name: "pending_issuer_review", + CardNumbers: []string{"2200002222222222"}, + Accepted: true, + ProviderStatus: "processing", + ErrorCode: "P01", + ErrorMessage: "Pending issuer review", + }, + { + Name: "insufficient_funds", + CardNumbers: []string{"2200003333333333"}, + CardLast4: []string{"3333"}, + Accepted: false, + ErrorCode: "51", + ErrorMessage: "Insufficient funds", + }, + { + Name: "issuer_unavailable_retryable", + CardNumbers: []string{"2200004444444444"}, + CardLast4: []string{"4444"}, + Accepted: false, + ErrorCode: "10101", + ErrorMessage: "Issuer temporary unavailable, retry later", + }, + { + Name: "stolen_card", + CardNumbers: []string{"2200005555555555"}, + CardLast4: []string{"5555"}, + Accepted: false, + ErrorCode: "43", + ErrorMessage: "Stolen card, pickup", + }, + { + Name: "do_not_honor", + CardNumbers: []string{"2200006666666666"}, + CardLast4: []string{"6666"}, + Accepted: false, + ErrorCode: "05", + ErrorMessage: "Do not honor", + }, + { + Name: "expired_card", + CardNumbers: []string{"2200007777777777"}, + CardLast4: []string{"7777"}, + Accepted: false, + ErrorCode: "54", + ErrorMessage: "Expired card", + }, + { + Name: "provider_timeout_transport", + CardNumbers: []string{"2200008888888888"}, + CardLast4: []string{"8888"}, + DispatchError: "provider timeout while calling payout endpoint", + }, + { + Name: "provider_unreachable_transport", + CardNumbers: []string{"2200009999999998"}, + CardLast4: []string{"9998"}, + DispatchError: "provider host unreachable", + }, + { + Name: "provider_maintenance", + CardNumbers: []string{"2200009999999997"}, + CardLast4: []string{"9997"}, + Accepted: false, + ErrorCode: "91", + ErrorMessage: "Issuer or switch is inoperative", + }, + { + Name: "provider_system_malfunction", + CardNumbers: []string{"2200009999999996"}, + CardLast4: []string{"9996"}, + Accepted: false, + ErrorCode: "96", + ErrorMessage: "System malfunction", + }, + }, + defaultScenario: simulatedCardScenario{ + Name: "default_processing", + Accepted: true, + ProviderStatus: "processing", + ErrorCode: "P00", + ErrorMessage: "Queued for provider processing", + }, + } +} + +func (s *payoutSimulator) resolveByPAN(pan string) simulatedCardScenario { + return s.resolve(normalizeCardNumber(pan), "") +} + +func (s *payoutSimulator) resolveByMaskedPAN(masked string) simulatedCardScenario { + digits := normalizeCardNumber(masked) + last4 := "" + if len(digits) >= 4 { + last4 = digits[len(digits)-4:] + } + return s.resolve("", last4) +} + +func (s *payoutSimulator) resolve(pan, last4 string) simulatedCardScenario { + if s == nil { + return simulatedCardScenario{} + } + for _, scenario := range s.scenarios { + for _, value := range scenario.CardNumbers { + if pan != "" && normalizeCardNumber(value) == pan { + return scenario + } + } + } + if strings.TrimSpace(last4) != "" { + for _, scenario := range s.scenarios { + if scenarioMatchesLast4(scenario, last4) { + return scenario + } + } + } + return s.defaultScenario +} + +func (s *payoutSimulator) buildPayoutResult(operationRef string, scenario simulatedCardScenario) (*provider.CardPayoutSendResult, error) { + if s == nil { + return &provider.CardPayoutSendResult{ + Accepted: true, + StatusCode: 200, + ErrorCode: "P00", + ErrorMessage: "Queued for provider processing", + }, nil + } + if msg := strings.TrimSpace(scenario.DispatchError); msg != "" { + return nil, merrors.Internal("aurora simulated transport error: " + msg) + } + id := s.seq.Add(1) + ref := strings.TrimSpace(operationRef) + if ref == "" { + ref = "card-op" + } + statusCode := 200 + if !scenario.Accepted { + statusCode = 422 + } + return &provider.CardPayoutSendResult{ + Accepted: scenario.Accepted, + ProviderRequestID: fmt.Sprintf("aurora-%s-%06d", ref, id), + ProviderStatus: strings.TrimSpace(scenario.ProviderStatus), + StatusCode: statusCode, + ErrorCode: strings.TrimSpace(scenario.ErrorCode), + ErrorMessage: strings.TrimSpace(scenario.ErrorMessage), + }, nil +} + +func scenarioMatchesLast4(scenario simulatedCardScenario, last4 string) bool { + candidate := strings.TrimSpace(last4) + if candidate == "" { + return false + } + for _, value := range scenario.CardLast4 { + if normalizeCardNumber(value) == candidate { + return true + } + } + for _, value := range scenario.CardNumbers { + normalized := normalizeCardNumber(value) + if len(normalized) >= 4 && normalized[len(normalized)-4:] == candidate { + return true + } + } + return false +} + +func normalizeCardNumber(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + var b strings.Builder + b.Grow(len(value)) + for _, r := range value { + if r >= '0' && r <= '9' { + b.WriteRune(r) + } + } + return b.String() +} + +func normalizeExpiryYear(year uint32) string { + if year == 0 { + return "" + } + v := int(year) + if v < 100 { + v += 2000 + } + return fmt.Sprintf("%04d", v) +} + +func buildSimulatedCardToken(requestID, pan string) string { + input := strings.TrimSpace(requestID) + "|" + normalizeCardNumber(pan) + sum := sha1.Sum([]byte(input)) + return "aur_tok_" + hex.EncodeToString(sum[:8]) +} diff --git a/api/gateway/aurora/internal/service/gateway/scenario_simulator_test.go b/api/gateway/aurora/internal/service/gateway/scenario_simulator_test.go new file mode 100644 index 00000000..b82ee5ac --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/scenario_simulator_test.go @@ -0,0 +1,51 @@ +package gateway + +import ( + "testing" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/gateway/aurora/storage/model" +) + +func TestPayoutSimulatorResolveByPAN_KnownCard(t *testing.T) { + sim := newPayoutSimulator() + + scenario := sim.resolveByPAN("2200003333333333") + if scenario.Name != "insufficient_funds" { + t.Fatalf("unexpected scenario: got=%q", scenario.Name) + } + if scenario.ErrorCode != "51" { + t.Fatalf("unexpected error code: got=%q", scenario.ErrorCode) + } +} + +func TestPayoutSimulatorResolveByPAN_Default(t *testing.T) { + sim := newPayoutSimulator() + + scenario := sim.resolveByPAN("2200009999999999") + if scenario.Name != "default_processing" { + t.Fatalf("unexpected default scenario: got=%q", scenario.Name) + } + if !scenario.Accepted { + t.Fatalf("default scenario should be accepted") + } +} + +func TestApplyCardPayoutSendResult_AcceptedSuccessStatus(t *testing.T) { + state := &model.CardPayout{} + result := &provider.CardPayoutSendResult{ + Accepted: true, + ProviderStatus: "success", + ErrorCode: "00", + ErrorMessage: "Approved", + } + + applyCardPayoutSendResult(state, result) + + if state.Status != model.PayoutStatusSuccess { + t.Fatalf("unexpected status: got=%q", state.Status) + } + if state.ProviderCode != "00" { + t.Fatalf("unexpected provider code: got=%q", state.ProviderCode) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/service.go b/api/gateway/aurora/internal/service/gateway/service.go new file mode 100644 index 00000000..3cdf2003 --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/service.go @@ -0,0 +1,303 @@ +package gateway + +import ( + "context" + "net/http" + "strings" + + "github.com/tech/sendico/gateway/aurora/internal/appversion" + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/gateway/aurora/storage" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/api/routers/gsresponse" + clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/discovery" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + "go.uber.org/zap" + "google.golang.org/grpc" +) + +type Service struct { + logger mlogger.Logger + clock clockpkg.Clock + producer msg.Producer + msgCfg pmodel.SettingsT + storage storage.Repository + config provider.Config + httpClient *http.Client + card *cardPayoutProcessor + outbox gatewayoutbox.ReliableRuntime + gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor + announcer *discovery.Announcer + invokeURI string + strictIsolation bool + + connectorv1.UnimplementedConnectorServiceServer +} + +type payoutFailure interface { + error + Reason() string +} + +type reasonedError struct { + reason string + err error +} + +func (r reasonedError) Error() string { + return r.err.Error() +} + +func (r reasonedError) Unwrap() error { + return r.err +} + +func (r reasonedError) Reason() string { + return r.reason +} + +// NewService constructs the Aurora gateway service skeleton. +func NewService(logger mlogger.Logger, opts ...Option) *Service { + svc := &Service{ + logger: logger.Named("service"), + clock: clockpkg.NewSystem(), + config: provider.DefaultConfig(), + msgCfg: map[string]any{}, + } + + initMetrics() + + for _, opt := range opts { + if opt != nil { + opt(svc) + } + } + + if svc.clock == nil { + svc.clock = clockpkg.NewSystem() + } + + if svc.httpClient == nil { + svc.httpClient = &http.Client{Timeout: svc.config.Timeout()} + } else if svc.httpClient.Timeout <= 0 { + svc.httpClient.Timeout = svc.config.Timeout() + } + + svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer) + if svc.strictIsolation { + svc.card.setExecutionMode(newStrictIsolatedPayoutExecutionMode()) + } + svc.card.outbox = &svc.outbox + svc.card.msgCfg = svc.msgCfg + if err := svc.card.startOutboxReliableProducer(); err != nil { + svc.logger.Warn("Failed to initialise outbox reliable producer", zap.Error(err)) + } + svc.card.applyGatewayDescriptor(svc.gatewayDescriptor) + svc.startDiscoveryAnnouncer() + + return svc +} + +// Register wires the service onto the provided gRPC router. +func (s *Service) Register(router routers.GRPC) error { + return router.Register(func(reg grpc.ServiceRegistrar) { + connectorv1.RegisterConnectorServiceServer(reg, s) + }) +} + +func (s *Service) Shutdown() { + if s == nil { + return + } + if s.card != nil { + s.card.stopRetries() + } + s.outbox.Stop() + if s.announcer != nil { + s.announcer.Stop() + } +} + +func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { + log := svc.logger.Named("rpc") + log.Info("RPC request started", zap.String("method", method)) + + start := svc.clock.Now() + resp, err := gsresponse.Unary(svc.logger, mservice.MntxGateway, handler)(ctx, req) + duration := svc.clock.Now().Sub(start) + observeRPC(method, err, duration) + + if err != nil { + log.Warn("RPC request failed", zap.String("method", method), zap.Duration("duration", duration), zap.Error(err)) + } else { + log.Info("RPC request completed", zap.String("method", method), zap.Duration("duration", duration)) + } + return resp, err +} + +func normalizeReason(reason string) string { + return strings.ToLower(strings.TrimSpace(reason)) +} + +func newPayoutError(reason string, err error) error { + return reasonedError{ + reason: normalizeReason(reason), + err: err, + } +} + +func (s *Service) startDiscoveryAnnouncer() { + if s == nil || s.producer == nil { + return + } + announce := discovery.Announcement{ + Service: mservice.MntxGateway, + Rail: discovery.RailCardPayout, + Operations: discovery.CardPayoutRailGatewayOperations(), + InvokeURI: s.invokeURI, + Version: appversion.Create().Short(), + InstanceID: discovery.InstanceID(), + } + if s.gatewayDescriptor != nil { + if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" { + announce.ID = id + } + announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor) + } + if strings.TrimSpace(announce.ID) == "" { + announce.ID = discovery.StablePaymentGatewayID(discovery.RailCardPayout) + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce) + s.announcer.Start() +} + +func currenciesFromDescriptor(src *gatewayv1.GatewayInstanceDescriptor) []discovery.CurrencyAnnouncement { + if src == nil { + return nil + } + network := strings.TrimSpace(src.GetNetwork()) + limitsCfg := src.GetLimits() + values := src.GetCurrencies() + if len(values) == 0 { + return nil + } + seen := map[string]bool{} + result := make([]discovery.CurrencyAnnouncement, 0, len(values)) + for _, value := range values { + currency := strings.ToUpper(strings.TrimSpace(value)) + if currency == "" || seen[currency] { + continue + } + seen[currency] = true + result = append(result, discovery.CurrencyAnnouncement{ + Currency: currency, + Network: network, + Limits: currencyLimitsFromDescriptor(limitsCfg, currency), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func currencyLimitsFromDescriptor(src *gatewayv1.Limits, currency string) *discovery.CurrencyLimits { + if src == nil { + return nil + } + amountMin := firstNonEmpty(src.GetPerTxMinAmount(), src.GetMinAmount()) + amountMax := firstNonEmpty(src.GetPerTxMaxAmount(), src.GetMaxAmount()) + + limits := &discovery.CurrencyLimits{} + if amountMin != "" || amountMax != "" { + limits.Amount = &discovery.CurrencyAmount{ + Min: amountMin, + Max: amountMax, + } + } + + running := &discovery.CurrencyRunningLimits{} + for bucket, max := range src.GetVolumeLimit() { + bucket = strings.TrimSpace(bucket) + max = strings.TrimSpace(max) + if bucket == "" || max == "" { + continue + } + running.Volume = append(running.Volume, discovery.VolumeLimit{ + Window: discovery.Window{ + Raw: bucket, + Named: bucket, + }, + Max: max, + }) + } + for bucket, max := range src.GetVelocityLimit() { + bucket = strings.TrimSpace(bucket) + if bucket == "" || max <= 0 { + continue + } + running.Velocity = append(running.Velocity, discovery.VelocityLimit{ + Window: discovery.Window{ + Raw: bucket, + Named: bucket, + }, + Max: int(max), + }) + } + if override := src.GetCurrencyLimits()[strings.ToUpper(strings.TrimSpace(currency))]; override != nil { + if min := strings.TrimSpace(override.GetMinAmount()); min != "" { + if limits.Amount == nil { + limits.Amount = &discovery.CurrencyAmount{} + } + limits.Amount.Min = min + } + if max := strings.TrimSpace(override.GetMaxAmount()); max != "" { + if limits.Amount == nil { + limits.Amount = &discovery.CurrencyAmount{} + } + limits.Amount.Max = max + } + if maxVolume := strings.TrimSpace(override.GetMaxVolume()); maxVolume != "" { + running.Volume = append(running.Volume, discovery.VolumeLimit{ + Window: discovery.Window{ + Raw: "default", + Named: "default", + }, + Max: maxVolume, + }) + } + if maxOps := int(override.GetMaxOps()); maxOps > 0 { + running.Velocity = append(running.Velocity, discovery.VelocityLimit{ + Window: discovery.Window{ + Raw: "default", + Named: "default", + }, + Max: maxOps, + }) + } + } + if len(running.Volume) > 0 || len(running.Velocity) > 0 { + limits.Running = running + } + if limits.Amount == nil && limits.Running == nil { + return nil + } + return limits +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + clean := strings.TrimSpace(value) + if clean != "" { + return clean + } + } + return "" +} diff --git a/api/gateway/aurora/internal/service/gateway/service_test.go b/api/gateway/aurora/internal/service/gateway/service_test.go new file mode 100644 index 00000000..f470dc1e --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/service_test.go @@ -0,0 +1,19 @@ +package gateway + +import ( + "testing" + + "go.uber.org/zap" +) + +func TestNewService_StrictOperationIsolationOption(t *testing.T) { + svc := NewService(zap.NewNop(), WithStrictOperationIsolation(true)) + t.Cleanup(svc.Shutdown) + + if svc.card == nil { + t.Fatalf("expected card processor to be initialised") + } + if got, want := payoutExecutionModeName(svc.card.executionMode), payoutExecutionModeStrictIsolatedName; got != want { + t.Fatalf("execution mode mismatch: got=%q want=%q", got, want) + } +} diff --git a/api/gateway/aurora/internal/service/gateway/testhelpers_test.go b/api/gateway/aurora/internal/service/gateway/testhelpers_test.go new file mode 100644 index 00000000..4445342f --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/testhelpers_test.go @@ -0,0 +1,86 @@ +package gateway + +import ( + "errors" + "testing" + "time" + + "github.com/tech/sendico/gateway/aurora/internal/service/provider" + "github.com/tech/sendico/pkg/merrors" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func requireReason(t *testing.T, err error, reason string) { + t.Helper() + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInvalidArg) { + t.Fatalf("expected invalid argument error, got %v", err) + } + reasoned, ok := err.(payoutFailure) + if !ok { + t.Fatalf("expected payout failure reason, got %T", err) + } + if reasoned.Reason() != reason { + t.Fatalf("expected reason %q, got %q", reason, reasoned.Reason()) + } +} + +func testProviderConfig() provider.Config { + return provider.Config{ + AllowedCurrencies: []string{"RUB", "USD"}, + } +} + +func validCardPayoutRequest() *mntxv1.CardPayoutRequest { + return &mntxv1.CardPayoutRequest{ + PayoutId: "payout-1", + ParentPaymentRef: "payment-parent-1", + CustomerId: "cust-1", + CustomerFirstName: "Jane", + CustomerLastName: "Doe", + CustomerIp: "203.0.113.10", + AmountMinor: 1500, + Currency: "RUB", + CardPan: "4111111111111111", + CardHolder: "JANE DOE", + CardExpMonth: 12, + CardExpYear: 2035, + } +} + +func validCardTokenPayoutRequest() *mntxv1.CardTokenPayoutRequest { + return &mntxv1.CardTokenPayoutRequest{ + PayoutId: "payout-1", + ParentPaymentRef: "payment-parent-1", + CustomerId: "cust-1", + CustomerFirstName: "Jane", + CustomerLastName: "Doe", + CustomerIp: "203.0.113.11", + AmountMinor: 2500, + Currency: "USD", + CardToken: "tok_123", + } +} + +func validCardTokenizeRequest() *mntxv1.CardTokenizeRequest { + month, year := futureExpiry() + return &mntxv1.CardTokenizeRequest{ + RequestId: "req-1", + CustomerId: "cust-1", + CustomerFirstName: "Jane", + CustomerLastName: "Doe", + CustomerIp: "203.0.113.12", + CardPan: "4111111111111111", + CardHolder: "JANE DOE", + CardCvv: "123", + CardExpMonth: month, + CardExpYear: year, + } +} + +func futureExpiry() (uint32, uint32) { + now := time.Now().UTC() + return uint32(now.Month()), uint32(now.Year() + 1) +} diff --git a/api/gateway/aurora/internal/service/gateway/transfer_notifications.go b/api/gateway/aurora/internal/service/gateway/transfer_notifications.go new file mode 100644 index 00000000..b30a8c5d --- /dev/null +++ b/api/gateway/aurora/internal/service/gateway/transfer_notifications.go @@ -0,0 +1,114 @@ +package gateway + +import ( + "context" + "fmt" + + "github.com/tech/sendico/gateway/aurora/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" + pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/mutil/mzap" + "github.com/tech/sendico/pkg/payments/rail" + paytypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" +) + +func isFinalStatus(t *model.CardPayout) bool { + if t == nil { + return false + } + return isFinalPayoutStatus(t.Status) +} + +func isFinalPayoutStatus(status model.PayoutStatus) bool { + switch status { + case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.CardPayout) (rail.OperationResult, error) { + switch t.Status { + case model.PayoutStatusFailed: + return rail.OperationResultFailed, nil + case model.PayoutStatusSuccess: + return rail.OperationResultSuccess, nil + case model.PayoutStatusCancelled: + return rail.OperationResultCancelled, nil + default: + return rail.OperationResultFailed, merrors.InvalidArgument(fmt.Sprintf("unexpected transfer status, %s", t.Status), "t.Status") + } +} + +func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *model.CardPayout) error { + if !isFinalStatus(state) { + if err := p.store.Payouts().Upsert(ctx, state); err != nil { + p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID), + zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)), + ) + return err + } + p.observeExecutionState(state) + return nil + } + + _, err := p.executeTransaction(ctx, func(txCtx context.Context) (any, error) { + if upsertErr := p.store.Payouts().Upsert(txCtx, state); upsertErr != nil { + return nil, upsertErr + } + if isFinalStatus(state) { + if emitErr := p.emitTransferStatusEvent(txCtx, state); emitErr != nil { + return nil, emitErr + } + } + return nil, nil + }) + if err != nil { + p.logger.Warn("Failed to update transfer status", zap.Error(err), mzap.ObjRef("payout_ref", state.ID), + zap.String("payment_ref", state.PaymentRef), zap.String("status", string(state.Status)), + ) + return err + } + p.observeExecutionState(state) + return nil +} + +func (p *cardPayoutProcessor) emitTransferStatusEvent(ctx context.Context, payout *model.CardPayout) error { + if p == nil || payout == nil { + return nil + } + if p.producer == nil || p.outboxStore() == nil { + return nil + } + + status, err := toOpStatus(payout) + if err != nil { + p.logger.Warn("Failed to convert payout status to operation status for transfer status event", zap.Error(err), + mzap.ObjRef("payout_ref", payout.ID), zap.String("payment_ref", payout.PaymentRef), zap.String("status", string(payout.Status))) + return err + } + + exec := pmodel.PaymentGatewayExecution{ + PaymentIntentID: payout.IntentRef, + IdempotencyKey: payout.IdempotencyKey, + ExecutedMoney: &paytypes.Money{ + Amount: fmt.Sprintf("%d", payout.AmountMinor), + Currency: payout.Currency, + }, + PaymentRef: payout.PaymentRef, + Status: status, + OperationRef: payout.OperationRef, + Error: payout.FailureReason, + TransferRef: payout.GetID().Hex(), + } + env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec) + if err := p.sendWithOutbox(ctx, env); err != nil { + p.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", payout.ID)) + return err + } + return nil +} diff --git a/api/gateway/aurora/internal/service/provider/config.go b/api/gateway/aurora/internal/service/provider/config.go new file mode 100644 index 00000000..01ee7310 --- /dev/null +++ b/api/gateway/aurora/internal/service/provider/config.go @@ -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)) +} diff --git a/api/gateway/aurora/internal/service/provider/mask.go b/api/gateway/aurora/internal/service/provider/mask.go new file mode 100644 index 00000000..4fdbfa1b --- /dev/null +++ b/api/gateway/aurora/internal/service/provider/mask.go @@ -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:] +} diff --git a/api/gateway/aurora/internal/service/provider/mask_test.go b/api/gateway/aurora/internal/service/provider/mask_test.go new file mode 100644 index 00000000..217b1a9b --- /dev/null +++ b/api/gateway/aurora/internal/service/provider/mask_test.go @@ -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) + } + }) + } +} diff --git a/api/gateway/aurora/internal/service/provider/metrics.go b/api/gateway/aurora/internal/service/provider/metrics.go new file mode 100644 index 00000000..4586dac0 --- /dev/null +++ b/api/gateway/aurora/internal/service/provider/metrics.go @@ -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() + } +} diff --git a/api/gateway/aurora/internal/service/provider/payloads.go b/api/gateway/aurora/internal/service/provider/payloads.go new file mode 100644 index 00000000..d83666b8 --- /dev/null +++ b/api/gateway/aurora/internal/service/provider/payloads.go @@ -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 +} diff --git a/api/gateway/aurora/internal/service/provider/signature.go b/api/gateway/aurora/internal/service/provider/signature.go new file mode 100644 index 00000000..1b59c0d3 --- /dev/null +++ b/api/gateway/aurora/internal/service/provider/signature.go @@ -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) + } +} diff --git a/api/gateway/aurora/internal/service/provider/signature_test.go b/api/gateway/aurora/internal/service/provider/signature_test.go new file mode 100644 index 00000000..71a128c5 --- /dev/null +++ b/api/gateway/aurora/internal/service/provider/signature_test.go @@ -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": "", + }, + "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": "", + }, + "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) + } +} diff --git a/api/gateway/aurora/main.go b/api/gateway/aurora/main.go new file mode 100644 index 00000000..f0234915 --- /dev/null +++ b/api/gateway/aurora/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "github.com/tech/sendico/gateway/aurora/internal/appversion" + si "github.com/tech/sendico/gateway/aurora/internal/server" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/server" + smain "github.com/tech/sendico/pkg/server/main" +) + +func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) { + return si.Create(logger, file, debug) +} + +func main() { + smain.RunServer("gateway", appversion.Create(), factory) +} diff --git a/api/gateway/aurora/storage/model/state.go b/api/gateway/aurora/storage/model/state.go new file mode 100644 index 00000000..be91e760 --- /dev/null +++ b/api/gateway/aurora/storage/model/state.go @@ -0,0 +1,28 @@ +package model + +import ( + "time" + + "github.com/tech/sendico/pkg/db/storable" +) + +// CardPayout is a Mongo/JSON representation of proto CardPayout +type CardPayout struct { + storable.Base `bson:",inline" json:",inline"` + PaymentRef string `bson:"paymentRef" json:"payment_ref"` + OperationRef string `bson:"operationRef" json:"operation_ref"` + IdempotencyKey string `bson:"idempotencyKey" json:"idempotency_key"` + IntentRef string `bson:"intentRef" json:"intentRef"` + ProjectID int64 `bson:"projectId" json:"project_id"` + CustomerID string `bson:"customerId" json:"customer_id"` + AmountMinor int64 `bson:"amountMinor" json:"amount_minor"` + Currency string `bson:"currency" json:"currency"` + Status PayoutStatus `bson:"status" json:"status"` + ProviderCode string `bson:"providerCode,omitempty" json:"provider_code,omitempty"` + ProviderMessage string `bson:"providerMessage,omitempty" json:"provider_message,omitempty"` + ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"provider_payment_id,omitempty"` + FailureReason string `bson:"failureReason,omitempty" json:"failure_reason,omitempty"` + + CreatedAt time.Time `bson:"createdAt" json:"created_at"` + UpdatedAt time.Time `bson:"updatedAt" json:"updated_at"` +} diff --git a/api/gateway/aurora/storage/model/status.go b/api/gateway/aurora/storage/model/status.go new file mode 100644 index 00000000..1fc14d70 --- /dev/null +++ b/api/gateway/aurora/storage/model/status.go @@ -0,0 +1,13 @@ +package model + +type PayoutStatus string + +const ( + PayoutStatusCreated PayoutStatus = "created" // record exists, not started + PayoutStatusProcessing PayoutStatus = "processing" // we are working on it + PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world + + PayoutStatusSuccess PayoutStatus = "success" // final success + PayoutStatusFailed PayoutStatus = "failed" // final failure + PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled +) diff --git a/api/gateway/aurora/storage/mongo/repository.go b/api/gateway/aurora/storage/mongo/repository.go new file mode 100644 index 00000000..a2bdf6f3 --- /dev/null +++ b/api/gateway/aurora/storage/mongo/repository.go @@ -0,0 +1,88 @@ +package mongo + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/aurora/storage" + "github.com/tech/sendico/gateway/aurora/storage/mongo/store" + gatewayoutbox "github.com/tech/sendico/gateway/common/outbox" + "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/db/transaction" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +type Repository struct { + logger mlogger.Logger + conn *db.MongoConnection + db *mongo.Database + txFactory transaction.Factory + + payouts storage.PayoutsStore + outbox gatewayoutbox.Store +} + +func New(logger mlogger.Logger, conn *db.MongoConnection) (*Repository, error) { + if logger == nil { + logger = zap.NewNop() + } + if conn == nil { + return nil, merrors.InvalidArgument("mongo connection is nil") + } + client := conn.Client() + if client == nil { + return nil, merrors.Internal("mongo client is not initialised") + } + db := conn.Database() + if db == nil { + return nil, merrors.Internal("mongo database is not initialised") + } + dbName := db.Name() + logger = logger.Named("storage").Named("mongo") + if dbName != "" { + logger = logger.With(zap.String("database", dbName)) + } + result := &Repository{ + logger: logger, + conn: conn, + db: db, + txFactory: newMongoTransactionFactory(client), + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := result.conn.Ping(ctx); err != nil { + result.logger.Error("Mongo ping failed during repository initialisation", zap.Error(err)) + return nil, err + } + payoutsStore, err := store.NewPayouts(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise payouts store", zap.Error(err), zap.String("store", "payments")) + return nil, err + } + outboxStore, err := gatewayoutbox.NewMongoStore(result.logger, result.db) + if err != nil { + result.logger.Error("Failed to initialise outbox store", zap.Error(err), zap.String("store", "outbox")) + return nil, err + } + result.payouts = payoutsStore + result.outbox = outboxStore + result.logger.Info("Payouts gateway MongoDB storage initialised") + return result, nil +} + +func (r *Repository) Payouts() storage.PayoutsStore { + return r.payouts +} + +func (r *Repository) Outbox() gatewayoutbox.Store { + return r.outbox +} + +func (r *Repository) TransactionFactory() transaction.Factory { + return r.txFactory +} + +var _ storage.Repository = (*Repository)(nil) diff --git a/api/gateway/aurora/storage/mongo/store/payouts.go b/api/gateway/aurora/storage/mongo/store/payouts.go new file mode 100644 index 00000000..c1a4de3f --- /dev/null +++ b/api/gateway/aurora/storage/mongo/store/payouts.go @@ -0,0 +1,108 @@ +package store + +import ( + "context" + "strings" + + storage "github.com/tech/sendico/gateway/aurora/storage" + "github.com/tech/sendico/gateway/aurora/storage/model" + "github.com/tech/sendico/pkg/db/repository" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mutil/mzap" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.uber.org/zap" +) + +const ( + payoutsCollection = "card_payouts" + payoutIdemField = "idempotencyKey" + payoutIdField = "paymentRef" + payoutOpField = "operationRef" +) + +type Payouts struct { + logger mlogger.Logger + repository repository.Repository +} + +func NewPayouts(logger mlogger.Logger, db *mongo.Database) (*Payouts, error) { + if db == nil { + return nil, merrors.InvalidArgument("mongo database is nil") + } + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("payouts").With(zap.String("collection", payoutsCollection)) + + repo := repository.CreateMongoRepository(db, payoutsCollection) + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: payoutOpField, Sort: ri.Asc}}, + Unique: true, + Sparse: true, + }); err != nil { + logger.Error("Failed to create payouts operation index", + zap.Error(err), zap.String("index_field", payoutOpField)) + return nil, err + } + if err := repo.CreateIndex(&ri.Definition{ + Keys: []ri.Key{{Field: payoutIdemField, Sort: ri.Asc}}, + Unique: true, + }); err != nil { + logger.Error("Failed to create payouts idempotency index", + zap.Error(err), zap.String("index_field", payoutIdemField)) + return nil, err + } + + p := &Payouts{ + logger: logger, + repository: repo, + } + p.logger.Debug("Payouts store initialised") + return p, nil +} + +func (p *Payouts) findOneByField(ctx context.Context, field, value string) (*model.CardPayout, error) { + var res model.CardPayout + return &res, p.repository.FindOneByFilter(ctx, repository.Filter(field, value), &res) +} + +func (p *Payouts) FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error) { + return p.findOneByField(ctx, payoutIdemField, key) +} + +func (p *Payouts) FindByOperationRef(ctx context.Context, operationRef string) (*model.CardPayout, error) { + return p.findOneByField(ctx, payoutOpField, operationRef) +} + +func (p *Payouts) FindByPaymentID(ctx context.Context, paymentID string) (*model.CardPayout, error) { + return p.findOneByField(ctx, payoutIdField, paymentID) +} + +func (p *Payouts) Upsert(ctx context.Context, record *model.CardPayout) error { + if record == nil { + p.logger.Warn("Invalid argument provided: nil record") + return merrors.InvalidArgument("payout record is nil", "record") + } + + record.OperationRef = strings.TrimSpace(record.OperationRef) + record.PaymentRef = strings.TrimSpace(record.PaymentRef) + record.CustomerID = strings.TrimSpace(record.CustomerID) + record.ProviderCode = strings.TrimSpace(record.ProviderCode) + record.ProviderPaymentID = strings.TrimSpace(record.ProviderPaymentID) + + if record.OperationRef == "" { + p.logger.Warn("Invalid argument provided: operation reference missing") + return merrors.InvalidArgument("operation ref is required", "operation_ref") + } + + if err := p.repository.Upsert(ctx, record); err != nil { + p.logger.Warn("Failed to upsert payout record", zap.Error(err), mzap.ObjRef("payout_ref", record.ID), + zap.String("operation_ref", record.OperationRef), zap.String("payment_ref", record.PaymentRef)) + return err + } + return nil +} + +var _ storage.PayoutsStore = (*Payouts)(nil) diff --git a/api/gateway/aurora/storage/mongo/transaction.go b/api/gateway/aurora/storage/mongo/transaction.go new file mode 100644 index 00000000..52bad57e --- /dev/null +++ b/api/gateway/aurora/storage/mongo/transaction.go @@ -0,0 +1,38 @@ +package mongo + +import ( + "context" + + "github.com/tech/sendico/pkg/db/transaction" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +type mongoTransactionFactory struct { + client *mongo.Client +} + +func (f *mongoTransactionFactory) CreateTransaction() transaction.Transaction { + return &mongoTransaction{client: f.client} +} + +type mongoTransaction struct { + client *mongo.Client +} + +func (t *mongoTransaction) Execute(ctx context.Context, cb transaction.Callback) (any, error) { + session, err := t.client.StartSession() + if err != nil { + return nil, err + } + defer session.EndSession(ctx) + + run := func(sessCtx context.Context) (any, error) { + return cb(sessCtx) + } + + return session.WithTransaction(ctx, run) +} + +func newMongoTransactionFactory(client *mongo.Client) transaction.Factory { + return &mongoTransactionFactory{client: client} +} diff --git a/api/gateway/aurora/storage/storage.go b/api/gateway/aurora/storage/storage.go new file mode 100644 index 00000000..cfd79fb6 --- /dev/null +++ b/api/gateway/aurora/storage/storage.go @@ -0,0 +1,21 @@ +package storage + +import ( + "context" + + "github.com/tech/sendico/gateway/aurora/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +var ErrDuplicate = merrors.DataConflict("payment gateway storage: duplicate record") + +type Repository interface { + Payouts() PayoutsStore +} + +type PayoutsStore interface { + FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error) + FindByOperationRef(ctx context.Context, key string) (*model.CardPayout, error) + FindByPaymentID(ctx context.Context, key string) (*model.CardPayout, error) + Upsert(ctx context.Context, record *model.CardPayout) error +} diff --git a/api/gateway/tgsettle/internal/service/treasury/bot/commands.go b/api/gateway/tgsettle/internal/service/treasury/bot/commands.go index bc8ea944..d506616c 100644 --- a/api/gateway/tgsettle/internal/service/treasury/bot/commands.go +++ b/api/gateway/tgsettle/internal/service/treasury/bot/commands.go @@ -47,16 +47,22 @@ func parseCommand(text string) Command { } func supportedCommandsMessage() string { - lines := make([]string, 0, len(supportedCommands)+1) - lines = append(lines, "Supported commands:") + lines := make([]string, 0, len(supportedCommands)+2) + lines = append(lines, "*Supported Commands*") + lines = append(lines, "") for _, cmd := range supportedCommands { - lines = append(lines, cmd.Slash()) + lines = append(lines, markdownCommand(cmd)) } return strings.Join(lines, "\n") } func confirmationCommandsMessage() string { - return "Confirm operation?\n\n" + CommandConfirm.Slash() + "\n" + CommandCancel.Slash() + return strings.Join([]string{ + "*Confirm Operation*", + "", + "Use " + markdownCommand(CommandConfirm) + " to execute.", + "Use " + markdownCommand(CommandCancel) + " to abort.", + }, "\n") } func helpMessage(accountCode string, currency string) string { @@ -70,16 +76,18 @@ func helpMessage(accountCode string, currency string) string { } lines := []string{ - "Treasury bot help", + "*Treasury Bot Help*", "", - "Attached account: " + accountCode + " (" + currency + ")", + "*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")", "", - "How to use:", - "1) Start funding with " + CommandFund.Slash() + " or withdrawal with " + CommandWithdraw.Slash(), - "2) Enter amount as decimal, dot separator, no currency (example: 1250.75)", - "3) Confirm with " + CommandConfirm.Slash() + " or abort with " + CommandCancel.Slash(), + "*How to use*", + "1. Start funding with " + markdownCommand(CommandFund) + " or withdrawal with " + markdownCommand(CommandWithdraw) + ".", + "2. Enter amount as decimal with dot separator and no currency.", + " Example: " + markdownCode("1250.75"), + "3. Confirm with " + markdownCommand(CommandConfirm) + " or abort with " + markdownCommand(CommandCancel) + ".", "", - "After confirmation there is a cooldown window. You can cancel during it with " + CommandCancel.Slash() + ".", + "*Cooldown*", + "After confirmation there is a cooldown window. You can cancel during it with " + markdownCommand(CommandCancel) + ".", "You will receive a follow-up message with execution success or failure.", } return strings.Join(lines, "\n") diff --git a/api/gateway/tgsettle/internal/service/treasury/bot/markup.go b/api/gateway/tgsettle/internal/service/treasury/bot/markup.go new file mode 100644 index 00000000..a51b3d6e --- /dev/null +++ b/api/gateway/tgsettle/internal/service/treasury/bot/markup.go @@ -0,0 +1,18 @@ +package bot + +import ( + "strings" +) + +func markdownCode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + value = "N/A" + } + value = strings.ReplaceAll(value, "`", "'") + return "`" + value + "`" +} + +func markdownCommand(command Command) string { + return markdownCode(command.Slash()) +} diff --git a/api/gateway/tgsettle/internal/service/treasury/bot/router.go b/api/gateway/tgsettle/internal/service/treasury/bot/router.go index c6adf427..23d8186f 100644 --- a/api/gateway/tgsettle/internal/service/treasury/bot/router.go +++ b/api/gateway/tgsettle/internal/service/treasury/bot/router.go @@ -14,10 +14,10 @@ import ( "go.uber.org/zap" ) -const unauthorizedMessage = "Sorry, your Telegram account is not authorized to perform treasury operations." -const unauthorizedChatMessage = "Sorry, this Telegram chat is not authorized to perform treasury operations." +const unauthorizedMessage = "*Unauthorized*\nYour Telegram account is not allowed to perform treasury operations." +const unauthorizedChatMessage = "*Unauthorized Chat*\nThis Telegram chat is not allowed to perform treasury operations." -const amountInputHint = "Enter amount as a decimal number using a dot separator and without currency.\nExample: 1250.75" +const amountInputHint = "*Amount format*\nEnter amount as a decimal number using a dot separator and without currency.\nExample: `1250.75`" type SendTextFunc func(ctx context.Context, chatID string, text string) error @@ -232,7 +232,7 @@ func (r *Router) startAmountDialog(ctx context.Context, userID, accountID, chatI if r.logger != nil { r.logger.Warn("Failed to check active treasury request", zap.Error(err), zap.String("telegram_user_id", userID), zap.String("ledger_account_id", accountID)) } - _ = r.sendText(ctx, chatID, "Unable to check pending treasury operations right now. Please try again.") + _ = r.sendText(ctx, chatID, "*Temporary issue*\nUnable to check pending treasury operations right now. Please try again.") return } if active != nil { @@ -274,22 +274,22 @@ func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID st if typed, ok := err.(limitError); ok { switch typed.LimitKind() { case "per_operation": - _ = r.sendText(ctx, chatID, "Amount exceeds allowed limit.\n\nMax per operation: "+typed.LimitMax()+"\n\nEnter another amount or "+CommandCancel.Slash()) + _ = r.sendText(ctx, chatID, "*Amount exceeds allowed limit*\n\n*Max per operation:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return case "daily": - _ = r.sendText(ctx, chatID, "Daily amount limit exceeded.\n\nMax per day: "+typed.LimitMax()+"\n\nEnter another amount or "+CommandCancel.Slash()) + _ = r.sendText(ctx, chatID, "*Daily amount limit exceeded*\n\n*Max per day:* "+markdownCode(typed.LimitMax())+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } } if errors.Is(err, merrors.ErrInvalidArg) { - _ = r.sendText(ctx, chatID, "Invalid amount.\n\n"+amountInputHint+"\n\nEnter another amount or "+CommandCancel.Slash()) + _ = r.sendText(ctx, chatID, "*Invalid amount*\n\n"+amountInputHint+"\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } - _ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash()) + _ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } if record == nil { - _ = r.sendText(ctx, chatID, "Failed to create treasury request.\n\nEnter another amount or "+CommandCancel.Slash()) + _ = r.sendText(ctx, chatID, "*Failed to create treasury request*\n\nEnter another amount or "+markdownCommand(CommandCancel)+".") return } r.dialogs.Set(userID, DialogSession{ @@ -311,12 +311,12 @@ func (r *Router) confirm(ctx context.Context, userID string, accountID string, c } } if requestID == "" { - _ = r.sendText(ctx, chatID, "No pending treasury operation.") + _ = r.sendText(ctx, chatID, "*No pending treasury operation.*") return } record, err := r.service.ConfirmRequest(ctx, requestID, userID) if err != nil { - _ = r.sendText(ctx, chatID, "Unable to confirm treasury request.\n\nUse "+CommandCancel.Slash()+" or create a new request with "+CommandFund.Slash()+" or "+CommandWithdraw.Slash()+".") + _ = r.sendText(ctx, chatID, "*Unable to confirm treasury request.*\n\nUse "+markdownCommand(CommandCancel)+" or create a new request with "+markdownCommand(CommandFund)+" or "+markdownCommand(CommandWithdraw)+".") return } if r.tracker != nil { @@ -327,7 +327,12 @@ func (r *Router) confirm(ctx context.Context, userID string, accountID string, c if delay < 0 { delay = 0 } - _ = r.sendText(ctx, chatID, "Operation confirmed.\n\nExecution scheduled in "+formatSeconds(delay)+".\nYou can cancel during this cooldown with "+CommandCancel.Slash()+".\n\nYou will receive a follow-up message with execution success or failure.\n\nRequest ID: "+strings.TrimSpace(record.RequestID)) + _ = r.sendText(ctx, chatID, + "*Operation confirmed*\n\n"+ + "*Execution:* scheduled in "+markdownCode(formatSeconds(delay))+".\n"+ + "You can cancel during this cooldown with "+markdownCommand(CommandCancel)+".\n\n"+ + "You will receive a follow-up message with execution success or failure.\n\n"+ + "*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID))) } func (r *Router) cancel(ctx context.Context, userID string, accountID string, chatID string) { @@ -342,19 +347,19 @@ func (r *Router) cancel(ctx context.Context, userID string, accountID string, ch } if requestID == "" { r.dialogs.Clear(userID) - _ = r.sendText(ctx, chatID, "No pending treasury operation.") + _ = r.sendText(ctx, chatID, "*No pending treasury operation.*") return } record, err := r.service.CancelRequest(ctx, requestID, userID) if err != nil { - _ = r.sendText(ctx, chatID, "Unable to cancel treasury request.") + _ = r.sendText(ctx, chatID, "*Unable to cancel treasury request.*") return } if r.tracker != nil { r.tracker.Untrack(record.RequestID) } r.dialogs.Clear(userID) - _ = r.sendText(ctx, chatID, "Operation cancelled.\n\nRequest ID: "+strings.TrimSpace(record.RequestID)) + _ = r.sendText(ctx, chatID, "*Operation cancelled*\n\n*Request ID:* "+markdownCode(strings.TrimSpace(record.RequestID))) } func (r *Router) sendText(ctx context.Context, chatID string, text string) error { @@ -394,27 +399,27 @@ func (r *Router) logUnauthorized(update *model.TelegramWebhookUpdate) { func pendingRequestMessage(record *storagemodel.TreasuryRequest) string { if record == nil { - return "You already have a pending treasury operation.\n\n" + CommandCancel.Slash() + return "*Pending treasury operation already exists.*\n\nUse " + markdownCommand(CommandCancel) + "." } - return "You already have a pending treasury operation.\n\n" + - "Account: " + requestAccountDisplay(record) + "\n" + - "Request ID: " + strings.TrimSpace(record.RequestID) + "\n" + - "Status: " + strings.TrimSpace(string(record.Status)) + "\n" + - "Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" + - "Wait for execution or cancel it.\n\n" + CommandCancel.Slash() + return "*Pending Treasury Operation*\n\n" + + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" + + "*Request ID:* " + markdownCode(strings.TrimSpace(record.RequestID)) + "\n" + + "*Status:* " + markdownCode(strings.TrimSpace(string(record.Status))) + "\n" + + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" + + "Wait for execution or cancel with " + markdownCommand(CommandCancel) + "." } func confirmationPrompt(record *storagemodel.TreasuryRequest) string { if record == nil { - return "Request created.\n\n" + CommandConfirm.Slash() + "\n" + CommandCancel.Slash() + return "*Request created.*\n\nUse " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + "." } - title := "Funding request created." + title := "*Funding request created.*" if record.OperationType == storagemodel.TreasuryOperationWithdraw { - title = "Withdrawal request created." + title = "*Withdrawal request created.*" } return title + "\n\n" + - "Account: " + requestAccountDisplay(record) + "\n" + - "Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" + + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" + + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" + confirmationCommandsMessage() } @@ -430,13 +435,17 @@ func welcomeMessage(profile *AccountProfile) string { if currency == "" { currency = "N/A" } - return "Welcome to Sendico treasury bot.\n\nAttached account: " + accountCode + " (" + currency + ").\nUse " + CommandFund.Slash() + " to credit your account and " + CommandWithdraw.Slash() + " to debit it.\nAfter entering an amount, use " + CommandConfirm.Slash() + " or " + CommandCancel.Slash() + ".\nUse " + CommandHelp.Slash() + " for detailed usage." + return "*Sendico Treasury Bot*\n\n" + + "*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n" + + "Use " + markdownCommand(CommandFund) + " to credit your account and " + markdownCommand(CommandWithdraw) + " to debit it.\n" + + "After entering an amount, use " + markdownCommand(CommandConfirm) + " or " + markdownCommand(CommandCancel) + ".\n" + + "Use " + markdownCommand(CommandHelp) + " for detailed usage." } func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string { - action := "fund" + title := "*Funding request*" if operation == storagemodel.TreasuryOperationWithdraw { - action = "withdraw" + title = "*Withdrawal request*" } accountCode := displayAccountCode(profile, fallbackAccountID) currency := "" @@ -449,7 +458,9 @@ func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile * if currency == "" { currency = "N/A" } - return "Preparing to " + action + " account " + accountCode + " (" + currency + ").\n\n" + amountInputHint + return title + "\n\n" + + "*Account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")\n\n" + + amountInputHint } func requestAccountDisplay(record *storagemodel.TreasuryRequest) string { diff --git a/api/gateway/tgsettle/internal/service/treasury/scheduler.go b/api/gateway/tgsettle/internal/service/treasury/scheduler.go index 48f28d5b..30cd4177 100644 --- a/api/gateway/tgsettle/internal/service/treasury/scheduler.go +++ b/api/gateway/tgsettle/internal/service/treasury/scheduler.go @@ -272,11 +272,11 @@ func executionMessage(result *ExecutionResult) string { balanceCurrency = strings.TrimSpace(result.NewBalance.Currency) } } - return op + " completed.\n\n" + - "Account: " + requestAccountCode(request) + "\n" + - "Amount: " + sign + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" + - "New balance: " + balanceAmount + " " + balanceCurrency + "\n\n" + - "Reference: " + strings.TrimSpace(request.RequestID) + return "*" + op + " completed*\n\n" + + "*Account:* " + markdownCode(requestAccountCode(request)) + "\n" + + "*Amount:* " + markdownCode(sign+strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" + + "*New balance:* " + markdownCode(balanceAmount+" "+balanceCurrency) + "\n\n" + + "*Reference:* " + markdownCode(strings.TrimSpace(request.RequestID)) case storagemodel.TreasuryRequestStatusFailed: reason := strings.TrimSpace(request.ErrorMessage) if reason == "" && result.ExecutionError != nil { @@ -285,12 +285,12 @@ func executionMessage(result *ExecutionResult) string { if reason == "" { reason = "Unknown error." } - return "Execution failed.\n\n" + - "Account: " + requestAccountCode(request) + "\n" + - "Amount: " + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" + - "Status: FAILED\n\n" + - "Reason:\n" + reason + "\n\n" + - "Request ID: " + strings.TrimSpace(request.RequestID) + return "*Execution failed*\n\n" + + "*Account:* " + markdownCode(requestAccountCode(request)) + "\n" + + "*Amount:* " + markdownCode(strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" + + "*Status:* " + markdownCode("FAILED") + "\n" + + "*Reason:* " + markdownCode(compactForMarkdown(reason)) + "\n\n" + + "*Request ID:* " + markdownCode(strings.TrimSpace(request.RequestID)) default: return "" } @@ -305,3 +305,23 @@ func requestAccountCode(request *storagemodel.TreasuryRequest) string { } return strings.TrimSpace(request.LedgerAccountID) } + +func markdownCode(value string) string { + value = strings.TrimSpace(value) + if value == "" { + value = "N/A" + } + value = strings.ReplaceAll(value, "`", "'") + return "`" + value + "`" +} + +func compactForMarkdown(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "Unknown error." + } + value = strings.ReplaceAll(value, "\r\n", " ") + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + return strings.Join(strings.Fields(value), " ") +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go index 33fc1423..9282f65d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor.go @@ -47,7 +47,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste if err != nil { return nil, err } - destination, err := e.resolveDestination(req.Payment, action) + destination, err := e.resolveDestination(ctx, client, req.Payment, action) if err != nil { return nil, err } @@ -223,7 +223,7 @@ func (e *gatewayCryptoExecutor) submitWalletFeeTransfer( return nil } - destination, err := e.resolveDestination(req.Payment, discovery.RailOperationFee) + destination, err := e.resolveDestination(ctx, client, req.Payment, discovery.RailOperationFee) if err != nil { return err } @@ -341,7 +341,12 @@ func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool { return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet") } -func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action model.RailOperation) (*chainv1.TransferDestination, error) { +func (e *gatewayCryptoExecutor) resolveDestination( + ctx context.Context, + client chainclient.Client, + payment *agg.Payment, + action model.RailOperation, +) (*chainv1.TransferDestination, error) { if payment == nil { return nil, merrors.InvalidArgument("crypto send: payment is required") } @@ -367,7 +372,7 @@ func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action Memo: strings.TrimSpace(destination.ExternalChain.Memo), }, nil case model.EndpointTypeCard: - address, err := e.resolveCardFundingAddress(payment, action) + address, err := e.resolveCardFundingAddress(ctx, client, payment, action) if err != nil { return nil, err } @@ -381,7 +386,12 @@ func (e *gatewayCryptoExecutor) resolveDestination(payment *agg.Payment, action } } -func (e *gatewayCryptoExecutor) resolveCardFundingAddress(payment *agg.Payment, action model.RailOperation) (string, error) { +func (e *gatewayCryptoExecutor) resolveCardFundingAddress( + ctx context.Context, + client chainclient.Client, + payment *agg.Payment, + action model.RailOperation, +) (string, error) { if payment == nil { return "", merrors.InvalidArgument("crypto send: payment is required") } @@ -395,6 +405,13 @@ func (e *gatewayCryptoExecutor) resolveCardFundingAddress(payment *agg.Payment, } switch action { case discovery.RailOperationFee: + if feeWalletRef := strings.TrimSpace(route.FeeWalletRef); feeWalletRef != "" { + address, err := resolveManagedWalletDepositAddress(ctx, client, feeWalletRef) + if err != nil { + return "", err + } + return address, nil + } if feeAddress := strings.TrimSpace(route.FeeAddress); feeAddress != "" { return feeAddress, nil } @@ -406,6 +423,28 @@ func (e *gatewayCryptoExecutor) resolveCardFundingAddress(payment *agg.Payment, return address, nil } +func resolveManagedWalletDepositAddress(ctx context.Context, client chainclient.Client, walletRef string) (string, error) { + if client == nil { + return "", merrors.InvalidArgument("crypto send: gateway client is required to resolve fee wallet") + } + ref := strings.TrimSpace(walletRef) + if ref == "" { + return "", merrors.InvalidArgument("crypto send: fee wallet ref is required") + } + resp, err := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: ref}) + if err != nil { + return "", err + } + if resp == nil || resp.GetWallet() == nil { + return "", merrors.Internal("crypto send: fee wallet response is missing") + } + address := strings.TrimSpace(resp.GetWallet().GetDepositAddress()) + if address == "" { + return "", merrors.InvalidArgument("crypto send: fee wallet deposit address is required") + } + return address, nil +} + func destinationCardGatewayKey(payment *agg.Payment) string { if payment == nil || payment.QuoteSnapshot == nil || payment.QuoteSnapshot.Route == nil { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go index 08f61982..759ac482 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/crypto_executor_test.go @@ -332,6 +332,135 @@ func TestGatewayCryptoExecutor_ExecuteCrypto_SubmitsWalletFeeTransferOnSend(t *t } } +func TestGatewayCryptoExecutor_ExecuteCrypto_ResolvesFeeAddressFromFeeWalletRef(t *testing.T) { + orgID := bson.NewObjectID() + + submitRequests := make([]*chainv1.SubmitTransferRequest, 0, 2) + var managedWalletReq *chainv1.GetManagedWalletRequest + client := &chainclient.Fake{ + GetManagedWalletFn: func(_ context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { + managedWalletReq = req + return &chainv1.GetManagedWalletResponse{ + Wallet: &chainv1.ManagedWallet{ + WalletRef: "fee-wallet-ref", + DepositAddress: "TUA_FEE_FROM_WALLET", + }, + }, nil + }, + SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + submitRequests = append(submitRequests, req) + switch len(submitRequests) { + case 1: + return &chainv1.SubmitTransferResponse{ + Transfer: &chainv1.Transfer{ + TransferRef: "trf-principal", + OperationRef: "op-principal", + }, + }, nil + case 2: + return &chainv1.SubmitTransferResponse{ + Transfer: &chainv1.Transfer{ + TransferRef: "trf-fee", + OperationRef: "op-fee", + }, + }, nil + default: + t.Fatalf("unexpected transfer submission call %d", len(submitRequests)) + return nil, nil + } + }, + } + resolver := &fakeGatewayInvokeResolver{client: client} + registry := &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto_rail_gateway_arbitrum_sepolia", + InstanceID: "crypto_rail_gateway_arbitrum_sepolia", + Rail: discovery.RailCrypto, + InvokeURI: "grpc://crypto-gateway", + IsEnabled: true, + }, + }, + } + executor := &gatewayCryptoExecutor{ + gatewayInvokeResolver: resolver, + gatewayRegistry: registry, + cardGatewayRoutes: map[string]CardGatewayRoute{ + paymenttypes.DefaultCardsGatewayID: {FundingAddress: "TUA_DEST", FeeWalletRef: "fee-wallet-ref"}, + }, + } + + req := sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-1", + IdempotencyKey: "idem-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-1", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{Pan: "4111111111111111"}, + }, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USDT"}, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{Amount: "10.000000", Currency: "USDT"}, + FeeLines: []*paymenttypes.FeeLine{ + { + Money: &paymenttypes.Money{Amount: "0.70", Currency: "USDT"}, + LineType: paymenttypes.PostingLineTypeFee, + Side: paymenttypes.EntrySideDebit, + Meta: map[string]string{"fee_target": "wallet"}, + }, + }, + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + {Index: 1, Rail: "CRYPTO", Gateway: "crypto_rail_gateway_arbitrum_sepolia", InstanceID: "crypto_rail_gateway_arbitrum_sepolia", Role: paymenttypes.QuoteRouteHopRoleSource}, + {Index: 4, Rail: "CARD", Gateway: paymenttypes.DefaultCardsGatewayID, InstanceID: paymenttypes.DefaultCardsGatewayID, Role: paymenttypes.QuoteRouteHopRoleDestination}, + }, + }, + }, + }, + Step: xplan.Step{ + StepRef: "hop_1_crypto_send", + StepCode: "hop.1.crypto.send", + Action: discovery.RailOperationSend, + Rail: discovery.RailCrypto, + Gateway: "crypto_rail_gateway_arbitrum_sepolia", + InstanceID: "crypto_rail_gateway_arbitrum_sepolia", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_1_crypto_send", + StepCode: "hop.1.crypto.send", + Attempt: 1, + }, + } + + _, err := executor.ExecuteCrypto(context.Background(), req) + if err != nil { + t.Fatalf("ExecuteCrypto returned error: %v", err) + } + if managedWalletReq == nil { + t.Fatal("expected managed wallet lookup request") + } + if got, want := managedWalletReq.GetWalletRef(), "fee-wallet-ref"; got != want { + t.Fatalf("fee wallet ref lookup mismatch: got=%q want=%q", got, want) + } + if got, want := len(submitRequests), 2; got != want { + t.Fatalf("submit transfer calls mismatch: got=%d want=%d", got, want) + } + feeReq := submitRequests[1] + if got, want := feeReq.GetDestination().GetExternalAddress(), "TUA_FEE_FROM_WALLET"; got != want { + t.Fatalf("fee destination mismatch: got=%q want=%q", got, want) + } +} + func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *testing.T) { orgID := bson.NewObjectID() diff --git a/ci/dev/Caddyfile.dev b/ci/dev/Caddyfile.dev index badf1f47..3f1a64ff 100644 --- a/ci/dev/Caddyfile.dev +++ b/ci/dev/Caddyfile.dev @@ -31,10 +31,10 @@ reverse_proxy dev-notification:8081 } - # Monetix callbacks -> mntx gateway + # Aurora callbacks -> aurora gateway handle /gateway/m/* { - rewrite * /monetix/callback - reverse_proxy dev-mntx-gateway:8084 + rewrite * /aurora/callback + reverse_proxy dev-aurora-gateway:8084 header Cache-Control "no-cache, no-store, must-revalidate" } diff --git a/ci/dev/aurora-gateway.dockerfile b/ci/dev/aurora-gateway.dockerfile new file mode 100644 index 00000000..03ac0fcc --- /dev/null +++ b/ci/dev/aurora-gateway.dockerfile @@ -0,0 +1,36 @@ +# Development Dockerfile for aurora-gateway Service with Air hot reload + +FROM golang:alpine AS builder + +RUN apk add --no-cache bash git build-base protoc protobuf-dev && \ + go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && \ + go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && \ + go install github.com/air-verse/air@latest + +WORKDIR /src + +COPY api/proto ./api/proto +COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common +COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ +RUN bash ci/scripts/proto/generate.sh + +# Runtime stage for development with Air +FROM golang:alpine + +RUN apk add --no-cache bash git build-base && \ + go install github.com/air-verse/air@latest + +WORKDIR /src + +# Copy generated proto and pkg from builder +COPY --from=builder /src/api/proto ./api/proto +COPY --from=builder /src/api/pkg ./api/pkg +COPY --from=builder /src/api/gateway/common ./api/gateway/common + +# Source code will be mounted at runtime +WORKDIR /src/api/gateway/aurora + +EXPOSE 50075 9405 8084 + +CMD ["air", "-c", ".air.toml", "--", "-config.file", "/app/config.yml", "-debug"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 1223df50..5da9d06f 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -736,24 +736,24 @@ services: TRON_GATEWAY_GRPC_TOKEN: ${TRON_GATEWAY_GRPC_TOKEN:-} # -------------------------------------------------------------------------- - # MNTX Gateway Service (card payouts) + # Aurora Gateway Service (simulated card payouts) # -------------------------------------------------------------------------- - dev-mntx-gateway: + dev-aurora-gateway: <<: *common-env build: context: . - dockerfile: ci/dev/mntx-gateway.dockerfile - image: sendico-dev/mntx-gateway:latest - container_name: dev-mntx-gateway + dockerfile: ci/dev/aurora-gateway.dockerfile + image: sendico-dev/aurora-gateway:latest + container_name: dev-aurora-gateway restart: unless-stopped depends_on: dev-nats: { condition: service_started } dev-discovery: { condition: service_started } dev-vault: { condition: service_healthy } volumes: - - ./api/gateway/mntx:/src/api/gateway/mntx + - ./api/gateway/aurora:/src/api/gateway/aurora - ./api/gateway/common:/src/api/gateway/common - - ./api/gateway/mntx/config.dev.yml:/app/config.yml:ro + - ./api/gateway/aurora/config.dev.yml:/app/config.yml:ro ports: - "50075:50075" - "9405:9405" @@ -761,21 +761,21 @@ services: networks: - sendico-dev environment: - MNTX_GATEWAY_MONGO_HOST: dev-mongo-1 - MNTX_GATEWAY_MONGO_PORT: 27017 - MNTX_GATEWAY_MONGO_DATABASE: mntx_gateway - MNTX_GATEWAY_MONGO_USER: ${MONGO_USER} - MNTX_GATEWAY_MONGO_PASSWORD: ${MONGO_PASSWORD} - MNTX_GATEWAY_MONGO_AUTH_SOURCE: admin - MNTX_GATEWAY_MONGO_REPLICA_SET: dev-rs + AURORA_GATEWAY_MONGO_HOST: dev-mongo-1 + AURORA_GATEWAY_MONGO_PORT: 27017 + AURORA_GATEWAY_MONGO_DATABASE: aurora_gateway + AURORA_GATEWAY_MONGO_USER: ${MONGO_USER} + AURORA_GATEWAY_MONGO_PASSWORD: ${MONGO_PASSWORD} + AURORA_GATEWAY_MONGO_AUTH_SOURCE: admin + AURORA_GATEWAY_MONGO_REPLICA_SET: dev-rs NATS_HOST: dev-nats NATS_PORT: 4222 NATS_USER: ${NATS_USER} NATS_PASSWORD: ${NATS_PASSWORD} NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222 - MNTX_GATEWAY_GRPC_PORT: 50075 - MNTX_GATEWAY_METRICS_PORT: 9405 - MNTX_GATEWAY_HTTP_PORT: 8084 + AURORA_GATEWAY_GRPC_PORT: 50075 + AURORA_GATEWAY_METRICS_PORT: 9405 + AURORA_GATEWAY_HTTP_PORT: 8084 VAULT_ADDR: ${VAULT_ADDR} # --------------------------------------------------------------------------