Merge pull request 'fixed fee direction' (#665) from po-664 into main
All checks were successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful

Reviewed-on: #665
This commit was merged in pull request #665.
This commit is contained in:
2026-03-05 12:27:19 +00:00
69 changed files with 8677 additions and 82 deletions

View File

@@ -37,7 +37,7 @@ help:
@echo " make build-core Build core services (discovery, ledger, fees, documents)" @echo " make build-core Build core services (discovery, ledger, fees, documents)"
@echo " make build-fx Build FX services (oracle, ingestor)" @echo " make build-fx Build FX services (oracle, ingestor)"
@echo " make build-payments Build payment orchestrator" @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-api Build API services (notification, callbacks, bff)"
@echo " make build-frontend Build Flutter web frontend" @echo " make build-frontend Build Flutter web frontend"
@echo "" @echo ""
@@ -222,7 +222,7 @@ services-up:
dev-payments-methods \ dev-payments-methods \
dev-chain-gateway \ dev-chain-gateway \
dev-tron-gateway \ dev-tron-gateway \
dev-mntx-gateway \ dev-aurora-gateway \
dev-tgsettle-gateway \ dev-tgsettle-gateway \
dev-notification \ dev-notification \
dev-callbacks \ dev-callbacks \
@@ -252,7 +252,7 @@ list-services:
@echo " - dev-payments-methods :50066, :9416 (Payment Methods)" @echo " - dev-payments-methods :50066, :9416 (Payment Methods)"
@echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)" @echo " - dev-chain-gateway :50070, :9404 (EVM Blockchain Gateway)"
@echo " - dev-tron-gateway :50071, :9408 (TRON 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-tgsettle-gateway :50080, :9406 (Telegram Settlements)"
@echo " - dev-notification :8081 (Notifications)" @echo " - dev-notification :8081 (Notifications)"
@echo " - dev-callbacks :9420 (Webhook Callbacks)" @echo " - dev-callbacks :9420 (Webhook Callbacks)"
@@ -283,7 +283,7 @@ build-payments:
build-gateways: build-gateways:
@echo "$(GREEN)Building gateway services...$(NC)" @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: build-api:
@echo "$(GREEN)Building API services...$(NC)" @echo "$(GREEN)Building API services...$(NC)"

View File

@@ -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

5
api/gateway/aurora/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/aurora
internal/generated
.gocache
tmp
app

View File

@@ -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.

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
exec /app/aurora-gateway "$@"

55
api/gateway/aurora/go.mod Normal file
View File

@@ -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
)

223
api/gateway/aurora/go.sum Normal file
View File

@@ -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=

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)))
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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)
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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])
}

View File

@@ -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)
}
}

View File

@@ -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 ""
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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}
}

View File

@@ -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
}

View File

@@ -47,16 +47,22 @@ func parseCommand(text string) Command {
} }
func supportedCommandsMessage() string { func supportedCommandsMessage() string {
lines := make([]string, 0, len(supportedCommands)+1) lines := make([]string, 0, len(supportedCommands)+2)
lines = append(lines, "Supported commands:") lines = append(lines, "*Supported Commands*")
lines = append(lines, "")
for _, cmd := range supportedCommands { for _, cmd := range supportedCommands {
lines = append(lines, cmd.Slash()) lines = append(lines, markdownCommand(cmd))
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")
} }
func confirmationCommandsMessage() string { 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 { func helpMessage(accountCode string, currency string) string {
@@ -70,16 +76,18 @@ func helpMessage(accountCode string, currency string) string {
} }
lines := []string{ lines := []string{
"Treasury bot help", "*Treasury Bot Help*",
"", "",
"Attached account: " + accountCode + " (" + currency + ")", "*Attached account:* " + markdownCode(accountCode) + " (" + markdownCode(currency) + ")",
"", "",
"How to use:", "*How to use*",
"1) Start funding with " + CommandFund.Slash() + " or withdrawal with " + CommandWithdraw.Slash(), "1. Start funding with " + markdownCommand(CommandFund) + " or withdrawal with " + markdownCommand(CommandWithdraw) + ".",
"2) Enter amount as decimal, dot separator, no currency (example: 1250.75)", "2. Enter amount as decimal with dot separator and no currency.",
"3) Confirm with " + CommandConfirm.Slash() + " or abort with " + CommandCancel.Slash(), " 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.", "You will receive a follow-up message with execution success or failure.",
} }
return strings.Join(lines, "\n") return strings.Join(lines, "\n")

View File

@@ -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())
}

View File

@@ -14,10 +14,10 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
) )
const unauthorizedMessage = "Sorry, your Telegram account is not authorized to perform treasury operations." const unauthorizedMessage = "*Unauthorized*\nYour Telegram account is not allowed to perform treasury operations."
const unauthorizedChatMessage = "Sorry, this Telegram chat is not authorized 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 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 { 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.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 return
} }
if active != nil { if active != nil {
@@ -274,22 +274,22 @@ func (r *Router) captureAmount(ctx context.Context, userID, accountID, chatID st
if typed, ok := err.(limitError); ok { if typed, ok := err.(limitError); ok {
switch typed.LimitKind() { switch typed.LimitKind() {
case "per_operation": 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 return
case "daily": 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 return
} }
} }
if errors.Is(err, merrors.ErrInvalidArg) { 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 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 return
} }
if record == nil { 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 return
} }
r.dialogs.Set(userID, DialogSession{ r.dialogs.Set(userID, DialogSession{
@@ -311,12 +311,12 @@ func (r *Router) confirm(ctx context.Context, userID string, accountID string, c
} }
} }
if requestID == "" { if requestID == "" {
_ = r.sendText(ctx, chatID, "No pending treasury operation.") _ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
return return
} }
record, err := r.service.ConfirmRequest(ctx, requestID, userID) record, err := r.service.ConfirmRequest(ctx, requestID, userID)
if err != nil { 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 return
} }
if r.tracker != nil { if r.tracker != nil {
@@ -327,7 +327,12 @@ func (r *Router) confirm(ctx context.Context, userID string, accountID string, c
if delay < 0 { if delay < 0 {
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) { 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 == "" { if requestID == "" {
r.dialogs.Clear(userID) r.dialogs.Clear(userID)
_ = r.sendText(ctx, chatID, "No pending treasury operation.") _ = r.sendText(ctx, chatID, "*No pending treasury operation.*")
return return
} }
record, err := r.service.CancelRequest(ctx, requestID, userID) record, err := r.service.CancelRequest(ctx, requestID, userID)
if err != nil { if err != nil {
_ = r.sendText(ctx, chatID, "Unable to cancel treasury request.") _ = r.sendText(ctx, chatID, "*Unable to cancel treasury request.*")
return return
} }
if r.tracker != nil { if r.tracker != nil {
r.tracker.Untrack(record.RequestID) r.tracker.Untrack(record.RequestID)
} }
r.dialogs.Clear(userID) 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 { 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 { func pendingRequestMessage(record *storagemodel.TreasuryRequest) string {
if record == nil { 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" + return "*Pending Treasury Operation*\n\n" +
"Account: " + requestAccountDisplay(record) + "\n" + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
"Request ID: " + strings.TrimSpace(record.RequestID) + "\n" + "*Request ID:* " + markdownCode(strings.TrimSpace(record.RequestID)) + "\n" +
"Status: " + strings.TrimSpace(string(record.Status)) + "\n" + "*Status:* " + markdownCode(strings.TrimSpace(string(record.Status))) + "\n" +
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
"Wait for execution or cancel it.\n\n" + CommandCancel.Slash() "Wait for execution or cancel with " + markdownCommand(CommandCancel) + "."
} }
func confirmationPrompt(record *storagemodel.TreasuryRequest) string { func confirmationPrompt(record *storagemodel.TreasuryRequest) string {
if record == nil { 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 { if record.OperationType == storagemodel.TreasuryOperationWithdraw {
title = "Withdrawal request created." title = "*Withdrawal request created.*"
} }
return title + "\n\n" + return title + "\n\n" +
"Account: " + requestAccountDisplay(record) + "\n" + "*Account:* " + markdownCode(requestAccountDisplay(record)) + "\n" +
"Amount: " + strings.TrimSpace(record.Amount) + " " + strings.TrimSpace(record.Currency) + "\n\n" + "*Amount:* " + markdownCode(strings.TrimSpace(record.Amount)+" "+strings.TrimSpace(record.Currency)) + "\n\n" +
confirmationCommandsMessage() confirmationCommandsMessage()
} }
@@ -430,13 +435,17 @@ func welcomeMessage(profile *AccountProfile) string {
if currency == "" { if currency == "" {
currency = "N/A" 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 { func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *AccountProfile, fallbackAccountID string) string {
action := "fund" title := "*Funding request*"
if operation == storagemodel.TreasuryOperationWithdraw { if operation == storagemodel.TreasuryOperationWithdraw {
action = "withdraw" title = "*Withdrawal request*"
} }
accountCode := displayAccountCode(profile, fallbackAccountID) accountCode := displayAccountCode(profile, fallbackAccountID)
currency := "" currency := ""
@@ -449,7 +458,9 @@ func amountPromptMessage(operation storagemodel.TreasuryOperationType, profile *
if currency == "" { if currency == "" {
currency = "N/A" 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 { func requestAccountDisplay(record *storagemodel.TreasuryRequest) string {

View File

@@ -272,11 +272,11 @@ func executionMessage(result *ExecutionResult) string {
balanceCurrency = strings.TrimSpace(result.NewBalance.Currency) balanceCurrency = strings.TrimSpace(result.NewBalance.Currency)
} }
} }
return op + " completed.\n\n" + return "*" + op + " completed*\n\n" +
"Account: " + requestAccountCode(request) + "\n" + "*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
"Amount: " + sign + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" + "*Amount:* " + markdownCode(sign+strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
"New balance: " + balanceAmount + " " + balanceCurrency + "\n\n" + "*New balance:* " + markdownCode(balanceAmount+" "+balanceCurrency) + "\n\n" +
"Reference: " + strings.TrimSpace(request.RequestID) "*Reference:* " + markdownCode(strings.TrimSpace(request.RequestID))
case storagemodel.TreasuryRequestStatusFailed: case storagemodel.TreasuryRequestStatusFailed:
reason := strings.TrimSpace(request.ErrorMessage) reason := strings.TrimSpace(request.ErrorMessage)
if reason == "" && result.ExecutionError != nil { if reason == "" && result.ExecutionError != nil {
@@ -285,12 +285,12 @@ func executionMessage(result *ExecutionResult) string {
if reason == "" { if reason == "" {
reason = "Unknown error." reason = "Unknown error."
} }
return "Execution failed.\n\n" + return "*Execution failed*\n\n" +
"Account: " + requestAccountCode(request) + "\n" + "*Account:* " + markdownCode(requestAccountCode(request)) + "\n" +
"Amount: " + strings.TrimSpace(request.Amount) + " " + strings.TrimSpace(request.Currency) + "\n" + "*Amount:* " + markdownCode(strings.TrimSpace(request.Amount)+" "+strings.TrimSpace(request.Currency)) + "\n" +
"Status: FAILED\n\n" + "*Status:* " + markdownCode("FAILED") + "\n" +
"Reason:\n" + reason + "\n\n" + "*Reason:* " + markdownCode(compactForMarkdown(reason)) + "\n\n" +
"Request ID: " + strings.TrimSpace(request.RequestID) "*Request ID:* " + markdownCode(strings.TrimSpace(request.RequestID))
default: default:
return "" return ""
} }
@@ -305,3 +305,23 @@ func requestAccountCode(request *storagemodel.TreasuryRequest) string {
} }
return strings.TrimSpace(request.LedgerAccountID) 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), " ")
}

View File

@@ -47,7 +47,7 @@ func (e *gatewayCryptoExecutor) ExecuteCrypto(ctx context.Context, req sexec.Ste
if err != nil { if err != nil {
return nil, err return nil, err
} }
destination, err := e.resolveDestination(req.Payment, action) destination, err := e.resolveDestination(ctx, client, req.Payment, action)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -223,7 +223,7 @@ func (e *gatewayCryptoExecutor) submitWalletFeeTransfer(
return nil return nil
} }
destination, err := e.resolveDestination(req.Payment, discovery.RailOperationFee) destination, err := e.resolveDestination(ctx, client, req.Payment, discovery.RailOperationFee)
if err != nil { if err != nil {
return err return err
} }
@@ -341,7 +341,12 @@ func isWalletDebitFeeLine(line *paymenttypes.FeeLine) bool {
return strings.EqualFold(strings.TrimSpace(meta["fee_target"]), "wallet") 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 { if payment == nil {
return nil, merrors.InvalidArgument("crypto send: payment is required") 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), Memo: strings.TrimSpace(destination.ExternalChain.Memo),
}, nil }, nil
case model.EndpointTypeCard: case model.EndpointTypeCard:
address, err := e.resolveCardFundingAddress(payment, action) address, err := e.resolveCardFundingAddress(ctx, client, payment, action)
if err != nil { if err != nil {
return nil, err 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 { if payment == nil {
return "", merrors.InvalidArgument("crypto send: payment is required") return "", merrors.InvalidArgument("crypto send: payment is required")
} }
@@ -395,6 +405,13 @@ func (e *gatewayCryptoExecutor) resolveCardFundingAddress(payment *agg.Payment,
} }
switch action { switch action {
case discovery.RailOperationFee: 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 != "" { if feeAddress := strings.TrimSpace(route.FeeAddress); feeAddress != "" {
return feeAddress, nil return feeAddress, nil
} }
@@ -406,6 +423,28 @@ func (e *gatewayCryptoExecutor) resolveCardFundingAddress(payment *agg.Payment,
return address, nil 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 { func destinationCardGatewayKey(payment *agg.Payment) string {
if payment == nil || payment.QuoteSnapshot == nil || payment.QuoteSnapshot.Route == nil { if payment == nil || payment.QuoteSnapshot == nil || payment.QuoteSnapshot.Route == nil {
return "" return ""

View File

@@ -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) { func TestGatewayCryptoExecutor_ExecuteCrypto_FeeActionUsesWalletFeeAmount(t *testing.T) {
orgID := bson.NewObjectID() orgID := bson.NewObjectID()

View File

@@ -31,10 +31,10 @@
reverse_proxy dev-notification:8081 reverse_proxy dev-notification:8081
} }
# Monetix callbacks -> mntx gateway # Aurora callbacks -> aurora gateway
handle /gateway/m/* { handle /gateway/m/* {
rewrite * /monetix/callback rewrite * /aurora/callback
reverse_proxy dev-mntx-gateway:8084 reverse_proxy dev-aurora-gateway:8084
header Cache-Control "no-cache, no-store, must-revalidate" header Cache-Control "no-cache, no-store, must-revalidate"
} }

View File

@@ -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"]

View File

@@ -736,24 +736,24 @@ services:
TRON_GATEWAY_GRPC_TOKEN: ${TRON_GATEWAY_GRPC_TOKEN:-} 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 <<: *common-env
build: build:
context: . context: .
dockerfile: ci/dev/mntx-gateway.dockerfile dockerfile: ci/dev/aurora-gateway.dockerfile
image: sendico-dev/mntx-gateway:latest image: sendico-dev/aurora-gateway:latest
container_name: dev-mntx-gateway container_name: dev-aurora-gateway
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
dev-nats: { condition: service_started } dev-nats: { condition: service_started }
dev-discovery: { condition: service_started } dev-discovery: { condition: service_started }
dev-vault: { condition: service_healthy } dev-vault: { condition: service_healthy }
volumes: volumes:
- ./api/gateway/mntx:/src/api/gateway/mntx - ./api/gateway/aurora:/src/api/gateway/aurora
- ./api/gateway/common:/src/api/gateway/common - ./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: ports:
- "50075:50075" - "50075:50075"
- "9405:9405" - "9405:9405"
@@ -761,21 +761,21 @@ services:
networks: networks:
- sendico-dev - sendico-dev
environment: environment:
MNTX_GATEWAY_MONGO_HOST: dev-mongo-1 AURORA_GATEWAY_MONGO_HOST: dev-mongo-1
MNTX_GATEWAY_MONGO_PORT: 27017 AURORA_GATEWAY_MONGO_PORT: 27017
MNTX_GATEWAY_MONGO_DATABASE: mntx_gateway AURORA_GATEWAY_MONGO_DATABASE: aurora_gateway
MNTX_GATEWAY_MONGO_USER: ${MONGO_USER} AURORA_GATEWAY_MONGO_USER: ${MONGO_USER}
MNTX_GATEWAY_MONGO_PASSWORD: ${MONGO_PASSWORD} AURORA_GATEWAY_MONGO_PASSWORD: ${MONGO_PASSWORD}
MNTX_GATEWAY_MONGO_AUTH_SOURCE: admin AURORA_GATEWAY_MONGO_AUTH_SOURCE: admin
MNTX_GATEWAY_MONGO_REPLICA_SET: dev-rs AURORA_GATEWAY_MONGO_REPLICA_SET: dev-rs
NATS_HOST: dev-nats NATS_HOST: dev-nats
NATS_PORT: 4222 NATS_PORT: 4222
NATS_USER: ${NATS_USER} NATS_USER: ${NATS_USER}
NATS_PASSWORD: ${NATS_PASSWORD} NATS_PASSWORD: ${NATS_PASSWORD}
NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222 NATS_URL: nats://${NATS_USER}:${NATS_PASSWORD}@dev-nats:4222
MNTX_GATEWAY_GRPC_PORT: 50075 AURORA_GATEWAY_GRPC_PORT: 50075
MNTX_GATEWAY_METRICS_PORT: 9405 AURORA_GATEWAY_METRICS_PORT: 9405
MNTX_GATEWAY_HTTP_PORT: 8084 AURORA_GATEWAY_HTTP_PORT: 8084
VAULT_ADDR: ${VAULT_ADDR} VAULT_ADDR: ${VAULT_ADDR}
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------