diff --git a/api/billing/documents/go.mod b/api/billing/documents/go.mod index 44b00889..02e5d52a 100644 --- a/api/billing/documents/go.mod +++ b/api/billing/documents/go.mod @@ -65,6 +65,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/billing/documents/go.sum b/api/billing/documents/go.sum index 8e4995f3..41276eb7 100644 --- a/api/billing/documents/go.sum +++ b/api/billing/documents/go.sum @@ -258,8 +258,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/billing/fees/go.mod b/api/billing/fees/go.mod index e708bbc0..21e4ad0d 100644 --- a/api/billing/fees/go.mod +++ b/api/billing/fees/go.mod @@ -50,6 +50,6 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect google.golang.org/protobuf v1.36.11 ) diff --git a/api/billing/fees/go.sum b/api/billing/fees/go.sum index 7e99ce3a..6eb3c464 100644 --- a/api/billing/fees/go.sum +++ b/api/billing/fees/go.sum @@ -208,8 +208,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/discovery/go.mod b/api/discovery/go.mod index 9687a652..fb45e3e7 100644 --- a/api/discovery/go.mod +++ b/api/discovery/go.mod @@ -43,7 +43,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/discovery/go.sum b/api/discovery/go.sum index 7e99ce3a..6eb3c464 100644 --- a/api/discovery/go.sum +++ b/api/discovery/go.sum @@ -208,8 +208,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/ingestor/go.mod b/api/fx/ingestor/go.mod index 3a091a87..9f8034a7 100644 --- a/api/fx/ingestor/go.mod +++ b/api/fx/ingestor/go.mod @@ -47,7 +47,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/fx/ingestor/go.sum b/api/fx/ingestor/go.sum index 7e99ce3a..6eb3c464 100644 --- a/api/fx/ingestor/go.sum +++ b/api/fx/ingestor/go.sum @@ -208,8 +208,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/oracle/go.mod b/api/fx/oracle/go.mod index f08bbac6..3864d580 100644 --- a/api/fx/oracle/go.mod +++ b/api/fx/oracle/go.mod @@ -48,5 +48,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/fx/oracle/go.sum b/api/fx/oracle/go.sum index 7e99ce3a..6eb3c464 100644 --- a/api/fx/oracle/go.sum +++ b/api/fx/oracle/go.sum @@ -208,8 +208,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/fx/oracle/internal/service/oracle/calculator.go b/api/fx/oracle/internal/service/oracle/calculator.go index 251b99c9..cb4790ca 100644 --- a/api/fx/oracle/internal/service/oracle/calculator.go +++ b/api/fx/oracle/internal/service/oracle/calculator.go @@ -8,7 +8,7 @@ import ( "github.com/google/uuid" "github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/pkg/merrors" - smodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -138,11 +138,11 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req * Pair: qc.pair.Pair, Side: qc.sideModel, Price: formatRat(qc.priceRounded, qc.priceScale), - BaseAmount: smodel.Money{ + BaseAmount: paymenttypes.Money{ Currency: qc.pair.Pair.Base, Amount: formatRat(qc.baseRounded, qc.baseScale), }, - QuoteAmount: smodel.Money{ + QuoteAmount: paymenttypes.Money{ Currency: qc.pair.Pair.Quote, Amount: formatRat(qc.quoteRounded, qc.quoteScale), }, diff --git a/api/fx/oracle/internal/service/oracle/service_test.go b/api/fx/oracle/internal/service/oracle/service_test.go index d1635628..be573e63 100644 --- a/api/fx/oracle/internal/service/oracle/service_test.go +++ b/api/fx/oracle/internal/service/oracle/service_test.go @@ -9,7 +9,7 @@ import ( "github.com/tech/sendico/fx/storage" "github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/pkg/merrors" - smodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" @@ -382,8 +382,8 @@ func TestServiceValidateQuote(t *testing.T) { Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}, Side: model.QuoteSideBuyBaseSellQuote, Price: "1.10", - BaseAmount: smodel.Money{Currency: "USD", Amount: "100"}, - QuoteAmount: smodel.Money{Currency: "EUR", Amount: "110"}, + BaseAmount: paymenttypes.Money{Currency: "USD", Amount: "100"}, + QuoteAmount: paymenttypes.Money{Currency: "EUR", Amount: "110"}, ExpiresAtUnixMs: now.UnixMilli(), Status: model.QuoteStatusIssued, }, nil diff --git a/api/fx/oracle/internal/service/oracle/transform.go b/api/fx/oracle/internal/service/oracle/transform.go index 44634823..b96f8aae 100644 --- a/api/fx/oracle/internal/service/oracle/transform.go +++ b/api/fx/oracle/internal/service/oracle/transform.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/tech/sendico/fx/storage/model" - smodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -42,7 +42,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote { } } -func moneyModelToProto(m *smodel.Money) *moneyv1.Money { +func moneyModelToProto(m *paymenttypes.Money) *moneyv1.Money { if m == nil { return nil } diff --git a/api/fx/storage/model/quote.go b/api/fx/storage/model/quote.go index 92ee894a..7b15580f 100644 --- a/api/fx/storage/model/quote.go +++ b/api/fx/storage/model/quote.go @@ -4,32 +4,32 @@ import ( "time" "github.com/tech/sendico/pkg/db/storable" - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" ) // Quote represents a firm or indicative quote persisted by the oracle. type Quote struct { storable.Base `bson:",inline" json:",inline"` - QuoteRef string `bson:"quoteRef" json:"quoteRef"` - Firm bool `bson:"firm" json:"firm"` - Status QuoteStatus `bson:"status" json:"status"` - Pair CurrencyPair `bson:"pair" json:"pair"` - Side QuoteSide `bson:"side" json:"side"` - Price string `bson:"price" json:"price"` - BaseAmount model.Money `bson:"baseAmount" json:"baseAmount"` - QuoteAmount model.Money `bson:"quoteAmount" json:"quoteAmount"` - AmountType QuoteAmountType `bson:"amountType" json:"amountType"` - ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"` - ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` - RateRef string `bson:"rateRef" json:"rateRef"` - Provider string `bson:"provider" json:"provider"` - PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"` - RequestedTTLMs int64 `bson:"requestedTtlMs,omitempty" json:"requestedTtlMs,omitempty"` - MaxAgeToleranceMs int64 `bson:"maxAgeToleranceMs,omitempty" json:"maxAgeToleranceMs,omitempty"` - ConsumedByLedgerTxnRef string `bson:"consumedByLedgerTxnRef,omitempty" json:"consumedByLedgerTxnRef,omitempty"` - ConsumedAtUnixMs *int64 `bson:"consumedAtUnixMs,omitempty" json:"consumedAtUnixMs,omitempty"` - Meta *QuoteMeta `bson:"meta,omitempty" json:"meta,omitempty"` + QuoteRef string `bson:"quoteRef" json:"quoteRef"` + Firm bool `bson:"firm" json:"firm"` + Status QuoteStatus `bson:"status" json:"status"` + Pair CurrencyPair `bson:"pair" json:"pair"` + Side QuoteSide `bson:"side" json:"side"` + Price string `bson:"price" json:"price"` + BaseAmount paymenttypes.Money `bson:"baseAmount" json:"baseAmount"` + QuoteAmount paymenttypes.Money `bson:"quoteAmount" json:"quoteAmount"` + AmountType QuoteAmountType `bson:"amountType" json:"amountType"` + ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"` + ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` + RateRef string `bson:"rateRef" json:"rateRef"` + Provider string `bson:"provider" json:"provider"` + PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"` + RequestedTTLMs int64 `bson:"requestedTtlMs,omitempty" json:"requestedTtlMs,omitempty"` + MaxAgeToleranceMs int64 `bson:"maxAgeToleranceMs,omitempty" json:"maxAgeToleranceMs,omitempty"` + ConsumedByLedgerTxnRef string `bson:"consumedByLedgerTxnRef,omitempty" json:"consumedByLedgerTxnRef,omitempty"` + ConsumedAtUnixMs *int64 `bson:"consumedAtUnixMs,omitempty" json:"consumedAtUnixMs,omitempty"` + Meta *QuoteMeta `bson:"meta,omitempty" json:"meta,omitempty"` } // Collection implements storable.Storable. diff --git a/api/gateway/chain/client/client.go b/api/gateway/chain/client/client.go index 94d8507f..39fd0820 100644 --- a/api/gateway/chain/client/client.go +++ b/api/gateway/chain/client/client.go @@ -9,7 +9,7 @@ import ( chainasset "github.com/tech/sendico/pkg/chain" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" @@ -426,7 +426,7 @@ func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Ope params := map[string]interface{}{ "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), - "client_reference": strings.TrimSpace(req.GetClientReference()), + "payment_ref": strings.TrimSpace(req.GetPaymentRef()), } if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" { params["destination_memo"] = memo @@ -444,6 +444,8 @@ func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Ope From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}}, Money: req.GetAmount(), Params: structFromMap(params), + IntentRef: strings.TrimSpace(req.GetIntentRef()), + OperationRef: strings.TrimSpace(req.GetOperationRef()), } to, err := destinationToParty(req.GetDestination()) if err != nil { @@ -472,14 +474,14 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin if op == nil || len(metadata) == 0 { return } - if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyFromRole]); raw != "" { - if role, ok := pmodel.Parse(raw); ok && role != "" { - op.FromRole = pmodel.ToProto(role) + 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[pmodel.MetadataKeyToRole]); raw != "" { - if role, ok := pmodel.Parse(raw); ok && role != "" { - op.ToRole = pmodel.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) } } } @@ -619,7 +621,7 @@ func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.O "mode": "ensure", "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), "target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()), - "client_reference": strings.TrimSpace(req.GetClientReference()), + "payment_ref": strings.TrimSpace(req.GetPaymentRef()), "estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()}, } if len(req.GetMetadata()) > 0 { @@ -765,28 +767,54 @@ func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.Mana } } -func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { - switch status { - case connectorv1.OperationStatus_CONFIRMED: - return chainv1.TransferStatus_TRANSFER_CONFIRMED - case connectorv1.OperationStatus_FAILED: - return chainv1.TransferStatus_TRANSFER_FAILED - case connectorv1.OperationStatus_CANCELED: - return chainv1.TransferStatus_TRANSFER_CANCELLED - default: - return chainv1.TransferStatus_TRANSFER_PENDING - } -} - func operationStatusFromTransfer(status chainv1.TransferStatus) connectorv1.OperationStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return connectorv1.OperationStatus_CONFIRMED + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + case chainv1.TransferStatus_TRANSFER_FAILED: - return connectorv1.OperationStatus_FAILED + return connectorv1.OperationStatus_OPERATION_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return connectorv1.OperationStatus_CANCELED + return connectorv1.OperationStatus_OPERATION_CANCELLED + default: return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED } } + +func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { + switch status { + + case connectorv1.OperationStatus_OPERATION_CREATED: + return chainv1.TransferStatus_TRANSFER_CREATED + + case connectorv1.OperationStatus_OPERATION_PROCESSING: + return chainv1.TransferStatus_TRANSFER_PROCESSING + + case connectorv1.OperationStatus_OPERATION_WAITING: + return chainv1.TransferStatus_TRANSFER_WAITING + + case connectorv1.OperationStatus_OPERATION_SUCCESS: + return chainv1.TransferStatus_TRANSFER_SUCCESS + + case connectorv1.OperationStatus_OPERATION_FAILED: + return chainv1.TransferStatus_TRANSFER_FAILED + + case connectorv1.OperationStatus_OPERATION_CANCELLED: + return chainv1.TransferStatus_TRANSFER_CANCELLED + + default: + return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED + } +} diff --git a/api/gateway/chain/client/rail_gateway.go b/api/gateway/chain/client/rail_gateway.go index 3f128c83..ce4b84d4 100644 --- a/api/gateway/chain/client/rail_gateway.go +++ b/api/gateway/chain/client/rail_gateway.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" @@ -102,13 +102,15 @@ func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) ( OrganizationRef: orgRef, SourceWalletRef: source, Destination: dest, + IntentRef: strings.TrimSpace(req.IntentRef), + OperationRef: strings.TrimSpace(req.OperationRef), + PaymentRef: strings.TrimSpace(req.PaymentRef), Amount: &moneyv1.Money{ Currency: currency, Amount: amountValue, }, - Fees: fees, - Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole), - ClientReference: strings.TrimSpace(req.ClientReference), + Fees: fees, + Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole), }) if err != nil { return rail.RailResult{}, err @@ -186,20 +188,29 @@ func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string return true, nil } -func statusFromTransfer(status chainv1.TransferStatus) string { +func statusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: + + case chainv1.TransferStatus_TRANSFER_CREATED: + return rail.TransferStatusCreated + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return rail.TransferStatusProcessing + + case chainv1.TransferStatus_TRANSFER_WAITING: + return rail.TransferStatusProcessing + + case chainv1.TransferStatus_TRANSFER_SUCCESS: return rail.TransferStatusSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: return rail.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return rail.TransferStatusRejected - case chainv1.TransferStatus_TRANSFER_SIGNING, - chainv1.TransferStatus_TRANSFER_PENDING, - chainv1.TransferStatus_TRANSFER_SUBMITTED: - return rail.TransferStatusPending + return rail.TransferStatusCancelled + default: - return rail.TransferStatusPending + return rail.TransferStatusUnspecified } } @@ -255,19 +266,19 @@ func railMoneyFromProto(m *moneyv1.Money) *rail.Money { } } -func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole pmodel.AccountRole) map[string]string { +func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole account_role.AccountRole) map[string]string { result := cloneMetadata(metadata) if strings.TrimSpace(string(fromRole)) != "" { if result == nil { result = map[string]string{} } - result[pmodel.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole)) + result[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole)) } if strings.TrimSpace(string(toRole)) != "" { if result == nil { result = map[string]string{} } - result[pmodel.MetadataKeyToRole] = strings.TrimSpace(string(toRole)) + result[account_role.MetadataKeyToRole] = strings.TrimSpace(string(toRole)) } if len(result) == 0 { return nil diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index b234fdd4..3062b6ee 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -22,7 +22,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect @@ -84,5 +84,5 @@ require ( golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 3bb6c62c..8263ab07 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61 h1:iLc9NjmJ3AdAl5VoiRSDXzEmmW8kvHp3E2vJ2eKKc7s= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 h1:0uY5Ooun4eqGmP0IrQhiKVqeeEXoeEcL8KVRtug8+r8= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -360,8 +360,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/chain/internal/keymanager/keymanager.go b/api/gateway/chain/internal/keymanager/keymanager.go index 9b20c9c8..2def3926 100644 --- a/api/gateway/chain/internal/keymanager/keymanager.go +++ b/api/gateway/chain/internal/keymanager/keymanager.go @@ -5,6 +5,7 @@ import ( "math/big" "github.com/ethereum/go-ethereum/core/types" + pmodel "github.com/tech/sendico/pkg/model" ) // ManagedWalletKey captures information returned after provisioning a managed wallet key. @@ -17,7 +18,7 @@ type ManagedWalletKey struct { // Manager defines the contract for managing managed wallet keys. type Manager interface { // CreateManagedWalletKey provisions a new managed wallet key for the provided wallet reference and network. - CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*ManagedWalletKey, error) + CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*ManagedWalletKey, error) // SignTransaction signs the provided transaction using the identified key material. SignTransaction(ctx context.Context, keyID string, tx *types.Transaction, chainID *big.Int) (*types.Transaction, error) } diff --git a/api/gateway/chain/internal/keymanager/vault/manager.go b/api/gateway/chain/internal/keymanager/vault/manager.go index 6d47f837..bbe9fbe9 100644 --- a/api/gateway/chain/internal/keymanager/vault/manager.go +++ b/api/gateway/chain/internal/keymanager/vault/manager.go @@ -19,6 +19,7 @@ import ( "github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" ) // Config describes how to connect to Vault for managed wallet keys. @@ -92,19 +93,19 @@ func New(logger mlogger.Logger, cfg Config) (*Manager, error) { } // CreateManagedWalletKey creates a new managed wallet key and stores it in Vault. -func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { +func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*keymanager.ManagedWalletKey, error) { if strings.TrimSpace(walletRef) == "" { - m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", network)) + m.logger.Warn("WalletRef missing for managed key creation", zap.String("network", string(network))) return nil, merrors.InvalidArgument("vault key manager: walletRef is required") } - if strings.TrimSpace(network) == "" { + if network == pmodel.ChainNetworkUnspecified { m.logger.Warn("Network missing for managed key creation", zap.String("wallet_ref", walletRef)) return nil, merrors.InvalidArgument("vault key manager: network is required") } privateKey, err := ecdsa.GenerateKey(secp256k1.S256(), rand.Reader) if err != nil { - m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) + m.logger.Warn("Failed to generate managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", string(network)), zap.Error(err)) return nil, merrors.Internal("vault key manager: failed to generate key: " + err.Error()) } privateKeyBytes := crypto.FromECDSA(privateKey) @@ -113,9 +114,9 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, publicKeyHex := hex.EncodeToString(publicKeyBytes) address := crypto.PubkeyToAddress(publicKey).Hex() - err = m.persistKey(ctx, walletRef, network, privateKeyBytes, publicKeyBytes, address) + err = m.persistKey(ctx, walletRef, string(network), privateKeyBytes, publicKeyBytes, address) if err != nil { - m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", network), zap.Error(err)) + m.logger.Warn("Failed to persist managed wallet key", zap.String("wallet_ref", walletRef), zap.String("network", string(network)), zap.Error(err)) zeroBytes(privateKeyBytes) zeroBytes(publicKeyBytes) return nil, err @@ -125,12 +126,12 @@ func (m *Manager) CreateManagedWalletKey(ctx context.Context, walletRef string, m.logger.Info("Managed wallet key created", zap.String("wallet_ref", walletRef), - zap.String("network", network), + zap.String("network", string(network)), zap.String("address", strings.ToLower(address)), ) return &keymanager.ManagedWalletKey{ - KeyID: m.buildKeyID(network, walletRef), + KeyID: m.buildKeyID(string(network), walletRef), Address: strings.ToLower(address), PublicKey: publicKeyHex, }, nil diff --git a/api/gateway/chain/internal/server/internal/serverimp.go b/api/gateway/chain/internal/server/internal/serverimp.go index 0496613a..5ec1c204 100644 --- a/api/gateway/chain/internal/server/internal/serverimp.go +++ b/api/gateway/chain/internal/server/internal/serverimp.go @@ -22,6 +22,7 @@ import ( "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/server/grpcapp" "go.uber.org/zap" "gopkg.in/yaml.v3" @@ -48,7 +49,7 @@ type config struct { } type chainConfig struct { - Name string `yaml:"name"` + Name pmodel.ChainNetwork `yaml:"name"` RPCURLEnv string `yaml:"rpc_url_env"` ChainID uint64 `yaml:"chain_id"` NativeToken string `yaml:"native_token"` @@ -57,10 +58,10 @@ type chainConfig struct { } type serviceWalletConfig struct { - Chain string `yaml:"chain"` - Address string `yaml:"address"` - AddressEnv string `yaml:"address_env"` - PrivateKeyEnv string `yaml:"private_key_env"` + Chain pmodel.ChainNetwork `yaml:"chain"` + Address string `yaml:"address"` + AddressEnv string `yaml:"address_env"` + PrivateKeyEnv string `yaml:"private_key_env"` } type tokenConfig struct { @@ -209,20 +210,16 @@ func (i *Imp) loadConfig() (*config, error) { func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatewayshared.Network, error) { result := make([]gatewayshared.Network, 0, len(chains)) for _, chain := range chains { - if strings.TrimSpace(chain.Name) == "" { - logger.Warn("Skipping unnamed chain configuration") - continue - } rpcURL := strings.TrimSpace(os.Getenv(chain.RPCURLEnv)) if rpcURL == "" { - logger.Error("RPC url not configured", zap.String("chain", chain.Name), zap.String("env", chain.RPCURLEnv)) + logger.Error("RPC url not configured", zap.String("chain", string(chain.Name)), zap.String("env", chain.RPCURLEnv)) return nil, merrors.InvalidArgument(fmt.Sprintf("chain RPC endpoint not configured (chain=%s env=%s)", chain.Name, chain.RPCURLEnv)) } contracts := make([]gatewayshared.TokenContract, 0, len(chain.Tokens)) for _, token := range chain.Tokens { symbol := strings.TrimSpace(token.Symbol) if symbol == "" { - logger.Warn("Skipping token with empty symbol", zap.String("chain", chain.Name)) + logger.Warn("Skipping token with empty symbol", zap.String("chain", string(chain.Name))) continue } addr := strings.TrimSpace(token.Contract) @@ -232,9 +229,9 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew } if addr == "" { if env != "" { - logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", chain.Name)) + logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("env", env), zap.String("chain", string(chain.Name))) } else { - logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", chain.Name)) + logger.Warn("Token contract not configured", zap.String("token", symbol), zap.String("chain", string(chain.Name))) } continue } @@ -246,7 +243,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew gasPolicy, err := buildGasTopUpPolicy(chain.Name, chain.GasTopUpPolicy) if err != nil { - logger.Error("Invalid gas top-up policy", zap.String("chain", chain.Name), zap.Error(err)) + logger.Error("Invalid gas top-up policy", zap.String("chain", string(chain.Name)), zap.Error(err)) return nil, err } @@ -262,7 +259,7 @@ func resolveNetworkConfigs(logger mlogger.Logger, chains []chainConfig) ([]gatew return result, nil } -func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) { +func buildGasTopUpPolicy(chainName pmodel.ChainNetwork, cfg *gasTopUpPolicyConfig) (*gatewayshared.GasTopUpPolicy, error) { if cfg == nil { return nil, nil } @@ -300,7 +297,7 @@ func buildGasTopUpPolicy(chainName string, cfg *gasTopUpPolicyConfig) (*gateways return policy, nil } -func parseGasTopUpRule(chainName, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) { +func parseGasTopUpRule(chainName pmodel.ChainNetwork, label string, cfg gasTopUpRuleConfig) (gatewayshared.GasTopUpRule, bool, error) { if cfg.BufferPercent == 0 && cfg.MinNativeBalanceTRX == 0 && cfg.RoundingUnitTRX == 0 && cfg.MaxTopUpTRX == 0 { return gatewayshared.GasTopUpRule{}, false, nil } @@ -336,7 +333,7 @@ func resolveServiceWallet(logger mlogger.Logger, cfg serviceWalletConfig) gatewa if cfg.AddressEnv != "" { logger.Warn("Service wallet address not configured", zap.String("env", cfg.AddressEnv)) } else { - logger.Warn("Service wallet address not configured", zap.String("chain", cfg.Chain)) + logger.Warn("Service wallet address not configured", zap.String("chain", string(cfg.Chain))) } } if privateKey == "" { diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go b/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go index 99880130..1c7feb56 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/destination.go @@ -26,8 +26,8 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe deps.Logger.Warn("Destination wallet lookup failed", zap.Error(err), zap.String("managed_wallet_ref", managedRef)) return model.TransferDestination{}, err } - if !strings.EqualFold(wallet.Network, source.Network) { - deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", source.Network), zap.String("dest_network", wallet.Network)) + if wallet.Network != source.Network { + deps.Logger.Warn("Destination wallet network mismatch", zap.String("source_network", string(source.Network)), zap.String("dest_network", string(wallet.Network))) return model.TransferDestination{}, merrors.InvalidArgument("destination wallet network mismatch") } if strings.TrimSpace(wallet.DepositAddress) == "" { @@ -44,12 +44,12 @@ func resolveDestination(ctx context.Context, deps Deps, dest *chainv1.TransferDe return model.TransferDestination{}, merrors.InvalidArgument("destination is required") } if deps.Drivers == nil { - deps.Logger.Warn("Chain drivers missing", zap.String("network", source.Network)) + deps.Logger.Warn("Chain drivers missing", zap.String("network", string(source.Network))) return model.TransferDestination{}, merrors.Internal("chain drivers not configured") } chainDriver, err := deps.Drivers.Driver(source.Network) if err != nil { - deps.Logger.Warn("Unsupported chain driver", zap.String("network", source.Network), zap.Error(err)) + deps.Logger.Warn("Unsupported chain driver", zap.String("network", string(source.Network)), zap.Error(err)) return model.TransferDestination{}, merrors.InvalidArgument("unsupported chain for wallet") } normalized, err := chainDriver.NormalizeAddress(external) diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go b/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go index 2d1c711e..2c3e0e7c 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/fee.go @@ -53,19 +53,18 @@ func (c *estimateTransferFeeCommand) Execute(ctx context.Context, req *chainv1.E return gsresponse.Auto[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, err) } - networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) - networkCfg, ok := c.deps.Networks.Network(networkKey) + networkCfg, ok := c.deps.Networks.Network(sourceWallet.Network) if !ok { - c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey)) + c.deps.Logger.Warn("Unsupported chain", zap.String("network", string(sourceWallet.Network))) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) } if c.deps.Drivers == nil { - c.deps.Logger.Warn("Chain drivers missing", zap.String("network", networkKey)) + c.deps.Logger.Warn("Chain drivers missing", zap.String("network", string(sourceWallet.Network))) return gsresponse.Internal[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured")) } - chainDriver, err := c.deps.Drivers.Driver(networkKey) + chainDriver, err := c.deps.Drivers.Driver(sourceWallet.Network) if err != nil { - c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", networkKey), zap.Error(err)) + c.deps.Logger.Warn("Unsupported chain driver", zap.String("network", string(sourceWallet.Network)), zap.Error(err)) return gsresponse.InvalidArgument[chainv1.EstimateTransferFeeResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) } diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go b/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go index 8faa9a68..bdb60ebe 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/gas_topup.go @@ -125,9 +125,9 @@ func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.Ensure Destination: &chainv1.TransferDestination{ Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef}, }, - Amount: topUp, - Metadata: shared.CloneMetadata(req.GetMetadata()), - ClientReference: strings.TrimSpace(req.GetClientReference()), + Amount: topUp, + Metadata: shared.CloneMetadata(req.GetMetadata()), + PaymentRef: strings.TrimSpace(req.GetPaymentRef()), } submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq) @@ -152,12 +152,7 @@ func computeGasTopUp(ctx context.Context, deps Deps, walletRef string, estimated return nil, false, nil, nil, err } - networkKey := strings.ToLower(strings.TrimSpace(walletModel.Network)) - if strings.HasPrefix(networkKey, "tron") { - return nil, false, nil, nil, merrors.InvalidArgument("tron networks must use the tron gateway") - } - - networkCfg, ok := deps.Networks.Network(networkKey) + networkCfg, ok := deps.Networks.Network(walletModel.Network) if !ok { return nil, false, nil, nil, merrors.InvalidArgument("unsupported chain for wallet") } @@ -248,7 +243,7 @@ func logDecision(logger mlogger.Logger, walletRef string, estimatedFee *moneyv1. zap.Bool("cap_hit", capHit), } if walletModel != nil { - fields = append(fields, zap.String("network", strings.TrimSpace(walletModel.Network))) + fields = append(fields, zap.String("network", string(walletModel.Network))) } logger.Info("Gas top-up decision", fields...) } diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/proto.go b/api/gateway/chain/internal/service/gateway/commands/transfer/proto.go index ac63997a..86f4b4dd 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/proto.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/proto.go @@ -41,8 +41,8 @@ func toProtoTransfer(transfer *model.Transfer) *chainv1.Transfer { SourceWalletRef: transfer.SourceWalletRef, Destination: destination, Asset: asset, - RequestedAmount: shared.CloneMoney(transfer.RequestedAmount), - NetAmount: shared.CloneMoney(transfer.NetAmount), + RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount), + NetAmount: shared.MonenyToProto(transfer.NetAmount), Fees: protoFees, Status: shared.TransferStatusToProto(transfer.Status), TransactionHash: transfer.TxHash, diff --git a/api/gateway/chain/internal/service/gateway/commands/transfer/submit.go b/api/gateway/chain/internal/service/gateway/commands/transfer/submit.go index 54ad268a..dd6da26f 100644 --- a/api/gateway/chain/internal/service/gateway/commands/transfer/submit.go +++ b/api/gateway/chain/internal/service/gateway/commands/transfer/submit.go @@ -77,10 +77,9 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit c.deps.Logger.Warn("Organization mismatch", zap.String("wallet_org", sourceWallet.OrganizationRef), zap.String("req_org", organizationRef)) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("organization_ref mismatch with wallet")) } - networkKey := strings.ToLower(strings.TrimSpace(sourceWallet.Network)) - networkCfg, ok := c.deps.Networks.Network(networkKey) + networkCfg, ok := c.deps.Networks.Network(sourceWallet.Network) if !ok { - c.deps.Logger.Warn("Unsupported chain", zap.String("network", networkKey)) + c.deps.Logger.Warn("Unsupported chain", zap.String("network", string(sourceWallet.Network))) return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain for wallet")) } @@ -124,17 +123,19 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit transfer := &model.Transfer{ IdempotencyKey: idempotencyKey, TransferRef: shared.GenerateTransferRef(), + IntentRef: req.IntentRef, + OperationRef: req.OperationRef, OrganizationRef: organizationRef, SourceWalletRef: sourceWalletRef, Destination: destination, Network: sourceWallet.Network, TokenSymbol: effectiveTokenSymbol, ContractAddress: effectiveContractAddress, - RequestedAmount: shared.CloneMoney(amount), - NetAmount: netAmount, + RequestedAmount: shared.ProtoToMoney(amount), + NetAmount: shared.ProtoToMoney(netAmount), Fees: fees, - Status: model.TransferStatusPending, - ClientReference: strings.TrimSpace(req.GetClientReference()), + Status: model.TransferStatusCreated, + PaymentRef: strings.TrimSpace(req.GetPaymentRef()), LastStatusAt: c.deps.Clock.Now().UTC(), } diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/create.go b/api/gateway/chain/internal/service/gateway/commands/wallet/create.go index a33e58ee..2dfcdfef 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/create.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/create.go @@ -51,23 +51,19 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("asset is required")) } - chainKey, _ := shared.ChainKeyFromEnum(asset.GetChain()) - if chainKey == "" { - c.deps.Logger.Warn("Unsupported chain", zap.Any("chain", asset.GetChain())) - return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) - } + chainKey := shared.ChainKeyFromEnum(asset.GetChain()) networkCfg, ok := c.deps.Networks.Network(chainKey) if !ok { - c.deps.Logger.Warn("Unsupported chain in config", zap.String("chain", chainKey)) + c.deps.Logger.Warn("Unsupported chain in config", zap.String("chain", string(chainKey))) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) } if c.deps.Drivers == nil { - c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", chainKey)) + c.deps.Logger.Warn("Chain drivers missing", zap.String("chain", string(chainKey))) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("chain drivers not configured")) } chainDriver, err := c.deps.Drivers.Driver(chainKey) if err != nil { - c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", chainKey), zap.Error(err)) + c.deps.Logger.Warn("Unsupported chain driver", zap.String("chain", string(chainKey)), zap.Error(err)) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported chain")) } @@ -81,7 +77,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C if !strings.EqualFold(tokenSymbol, networkCfg.NativeToken) { contractAddress = shared.ResolveContractAddress(networkCfg.TokenConfigs, tokenSymbol) if contractAddress == "" { - c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", chainKey)) + c.deps.Logger.Warn("Unsupported token", zap.String("token", tokenSymbol), zap.String("chain", string(chainKey))) return gsresponse.InvalidArgument[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("unsupported token for chain")) } } diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/list.go b/api/gateway/chain/internal/service/gateway/commands/wallet/list.go index 5db440d3..2513a2b5 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/list.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/list.go @@ -34,7 +34,7 @@ func (c *listManagedWalletsCommand) Execute(ctx context.Context, req *chainv1.Li filter.OwnerRefFilter = &ownerRef } if asset := req.GetAsset(); asset != nil { - filter.Network, _ = shared.ChainKeyFromEnum(asset.GetChain()) + filter.Network = shared.ChainKeyFromEnum(asset.GetChain()) filter.TokenSymbol = strings.TrimSpace(asset.GetTokenSymbol()) } if page := req.GetPage(); page != nil { diff --git a/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go b/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go index aa59404f..a1630475 100644 --- a/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go +++ b/api/gateway/chain/internal/service/gateway/commands/wallet/onchain_balance.go @@ -3,7 +3,6 @@ package wallet import ( "context" "fmt" - "strings" "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" "github.com/tech/sendico/gateway/chain/storage/model" @@ -24,21 +23,20 @@ func OnChainWalletBalances(ctx context.Context, deps Deps, wallet *model.Managed return nil, nil, merrors.Internal("chain drivers not configured") } - networkKey := strings.ToLower(strings.TrimSpace(wallet.Network)) - network, ok := deps.Networks.Network(networkKey) + network, ok := deps.Networks.Network(wallet.Network) if !ok { logger.Warn("Requested network is not configured", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", networkKey), + zap.String("network", string(wallet.Network)), ) - return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", networkKey)) + return nil, nil, merrors.Internal(fmt.Sprintf("Requested network '%s' is not configured", wallet.Network)) } - chainDriver, err := deps.Drivers.Driver(networkKey) + chainDriver, err := deps.Drivers.Driver(wallet.Network) if err != nil { logger.Warn("Chain driver not configured", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", networkKey), + zap.String("network", string(wallet.Network)), zap.Error(err), ) return nil, nil, merrors.InvalidArgument("unsupported chain") diff --git a/api/gateway/chain/internal/service/gateway/connector.go b/api/gateway/chain/internal/service/gateway/connector.go index 0fa3af65..788bc39b 100644 --- a/api/gateway/chain/internal/service/gateway/connector.go +++ b/api/gateway/chain/internal/service/gateway/connector.go @@ -165,11 +165,13 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), OrganizationRef: orgRef, SourceWalletRef: source, + IntentRef: strings.TrimSpace(op.GetIntentRef()), + OperationRef: op.GetOperationRef(), Destination: dest, Amount: amount, Fees: parseChainFees(reader), Metadata: shared.CloneMetadata(reader.StringMap("metadata")), - ClientReference: strings.TrimSpace(reader.String("client_reference")), + PaymentRef: strings.TrimSpace(reader.String("payment_ref")), }) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil @@ -208,7 +210,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp return &connectorv1.SubmitOperationResponse{ Receipt: &connectorv1.OperationReceipt{ OperationId: opID, - Status: connectorv1.OperationStatus_CONFIRMED, + Status: connectorv1.OperationStatus_OPERATION_SUCCESS, Result: result, }, }, nil @@ -238,7 +240,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp return &connectorv1.SubmitOperationResponse{ Receipt: &connectorv1.OperationReceipt{ OperationId: opID, - Status: connectorv1.OperationStatus_CONFIRMED, + Status: connectorv1.OperationStatus_OPERATION_SUCCESS, Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""), }, }, nil @@ -256,12 +258,14 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp } resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + IntentRef: strings.TrimSpace(op.GetIntentRef()), + OperationRef: strings.TrimSpace(op.GetOperationRef()), OrganizationRef: orgRef, SourceWalletRef: source, TargetWalletRef: target, EstimatedTotalFee: fee, Metadata: shared.CloneMetadata(reader.StringMap("metadata")), - ClientReference: strings.TrimSpace(reader.String("client_reference")), + PaymentRef: strings.TrimSpace(reader.String("payment_ref")), }) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil @@ -273,7 +277,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp return &connectorv1.SubmitOperationResponse{ Receipt: &connectorv1.OperationReceipt{ OperationId: opID, - Status: connectorv1.OperationStatus_CONFIRMED, + Status: shared.СhainTransferStatusToOperation(resp.GetTransfer().GetStatus()), Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef), }, }, nil @@ -544,25 +548,51 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return connectorv1.OperationStatus_CONFIRMED + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + case chainv1.TransferStatus_TRANSFER_FAILED: - return connectorv1.OperationStatus_FAILED + return connectorv1.OperationStatus_OPERATION_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return connectorv1.OperationStatus_CANCELED + return connectorv1.OperationStatus_OPERATION_CANCELLED + default: - return connectorv1.OperationStatus_PENDING + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED } } func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { switch status { - case connectorv1.OperationStatus_CONFIRMED: - return chainv1.TransferStatus_TRANSFER_CONFIRMED - case connectorv1.OperationStatus_FAILED: + + case connectorv1.OperationStatus_OPERATION_CREATED: + return chainv1.TransferStatus_TRANSFER_CREATED + + case connectorv1.OperationStatus_OPERATION_PROCESSING: + return chainv1.TransferStatus_TRANSFER_PROCESSING + + case connectorv1.OperationStatus_OPERATION_WAITING: + return chainv1.TransferStatus_TRANSFER_WAITING + + case connectorv1.OperationStatus_OPERATION_SUCCESS: + return chainv1.TransferStatus_TRANSFER_SUCCESS + + case connectorv1.OperationStatus_OPERATION_FAILED: return chainv1.TransferStatus_TRANSFER_FAILED - case connectorv1.OperationStatus_CANCELED: + + case connectorv1.OperationStatus_OPERATION_CANCELLED: return chainv1.TransferStatus_TRANSFER_CANCELLED + default: return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED } diff --git a/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go b/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go index e895da83..1210df53 100644 --- a/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go +++ b/api/gateway/chain/internal/service/gateway/driver/arbitrum/driver.go @@ -47,7 +47,7 @@ func (d *Driver) NormalizeAddress(address string) (string, error) { func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { d.logger.Debug("Balance request", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) driverDeps := deps driverDeps.Logger = d.logger @@ -55,13 +55,13 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N if err != nil { d.logger.Warn("Balance failed", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if result != nil { d.logger.Debug("Balance result", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("amount", result.Amount), zap.String("currency", result.Currency), ) @@ -72,7 +72,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) driverDeps := deps driverDeps.Logger = d.logger @@ -80,13 +80,13 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh if err != nil { d.logger.Warn("Native balance failed", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if result != nil { d.logger.Debug("Native balance result", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("amount", result.Amount), zap.String("currency", result.Currency), ) @@ -97,7 +97,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { d.logger.Debug("Estimate fee request", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("destination", destination), ) driverDeps := deps @@ -106,13 +106,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar if err != nil { d.logger.Warn("Estimate fee failed", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if result != nil { d.logger.Debug("Estimate fee result", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("amount", result.Amount), zap.String("currency", result.Currency), ) @@ -123,7 +123,7 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) { d.logger.Debug("Submit transfer request", zap.String("transfer_ref", transfer.TransferRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("destination", destination), ) driverDeps := deps @@ -132,13 +132,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s if err != nil { d.logger.Warn("Submit transfer failed", zap.String("transfer_ref", transfer.TransferRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else { d.logger.Debug("Submit transfer result", zap.String("transfer_ref", transfer.TransferRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("tx_hash", txHash), ) } @@ -148,7 +148,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) { d.logger.Debug("Await confirmation", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) driverDeps := deps driverDeps.Logger = d.logger @@ -156,13 +156,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ if err != nil { d.logger.Warn("Await confirmation failed", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if receipt != nil { d.logger.Debug("Await confirmation result", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Uint64("block_number", receipt.BlockNumber.Uint64()), zap.Uint64("status", receipt.Status), ) diff --git a/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go b/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go index 0f1ed985..2e82187c 100644 --- a/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go +++ b/api/gateway/chain/internal/service/gateway/driver/ethereum/driver.go @@ -47,7 +47,7 @@ func (d *Driver) NormalizeAddress(address string) (string, error) { func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { d.logger.Debug("Balance request", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) driverDeps := deps driverDeps.Logger = d.logger @@ -55,13 +55,13 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N if err != nil { d.logger.Warn("Balance failed", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if result != nil { d.logger.Debug("Balance result", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("amount", result.Amount), zap.String("currency", result.Currency), ) @@ -72,7 +72,7 @@ func (d *Driver) Balance(ctx context.Context, deps driver.Deps, network shared.N func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet) (*moneyv1.Money, error) { d.logger.Debug("Native balance request", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) driverDeps := deps driverDeps.Logger = d.logger @@ -80,13 +80,13 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh if err != nil { d.logger.Warn("Native balance failed", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if result != nil { d.logger.Debug("Native balance result", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("amount", result.Amount), zap.String("currency", result.Currency), ) @@ -97,7 +97,7 @@ func (d *Driver) NativeBalance(ctx context.Context, deps driver.Deps, network sh func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, wallet *model.ManagedWallet, destination string, amount *moneyv1.Money) (*moneyv1.Money, error) { d.logger.Debug("Estimate fee request", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("destination", destination), ) driverDeps := deps @@ -106,13 +106,13 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar if err != nil { d.logger.Warn("Estimate fee failed", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if result != nil { d.logger.Debug("Estimate fee result", zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("amount", result.Amount), zap.String("currency", result.Currency), ) @@ -123,7 +123,7 @@ func (d *Driver) EstimateFee(ctx context.Context, deps driver.Deps, network shar func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Network, transfer *model.Transfer, source *model.ManagedWallet, destination string) (string, error) { d.logger.Debug("Submit transfer request", zap.String("transfer_ref", transfer.TransferRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("destination", destination), ) driverDeps := deps @@ -132,13 +132,13 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s if err != nil { d.logger.Warn("Submit transfer failed", zap.String("transfer_ref", transfer.TransferRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else { d.logger.Debug("Submit transfer result", zap.String("transfer_ref", transfer.TransferRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("tx_hash", txHash), ) } @@ -148,7 +148,7 @@ func (d *Driver) SubmitTransfer(ctx context.Context, deps driver.Deps, network s func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Network, txHash string) (*types.Receipt, error) { d.logger.Debug("Await confirmation", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) driverDeps := deps driverDeps.Logger = d.logger @@ -156,13 +156,13 @@ func (d *Driver) AwaitConfirmation(ctx context.Context, deps driver.Deps, networ if err != nil { d.logger.Warn("Await confirmation failed", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) } else if receipt != nil { d.logger.Debug("Await confirmation result", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Uint64("block_number", receipt.BlockNumber.Uint64()), zap.Uint64("status", receipt.Status), ) diff --git a/api/gateway/chain/internal/service/gateway/driver/evm/evm.go b/api/gateway/chain/internal/service/gateway/driver/evm/evm.go index 00d1a260..e9398c8b 100644 --- a/api/gateway/chain/internal/service/gateway/driver/evm/evm.go +++ b/api/gateway/chain/internal/service/gateway/driver/evm/evm.go @@ -74,7 +74,7 @@ func NormalizeAddress(address string) (string, error) { func nativeCurrency(network shared.Network) string { currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) if currency == "" { - currency = strings.ToUpper(network.Name) + currency = strings.ToUpper(string(network.Name)) } return currency } @@ -114,7 +114,7 @@ func Balance(ctx context.Context, deps driver.Deps, network shared.Network, wall rpcURL := strings.TrimSpace(network.RPCURL) logFields := []zap.Field{ zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))), + zap.String("network", strings.ToLower(strings.TrimSpace(string(network.Name)))), zap.String("token_symbol", strings.ToUpper(strings.TrimSpace(wallet.TokenSymbol))), zap.String("contract", strings.ToLower(strings.TrimSpace(wallet.ContractAddress))), zap.String("wallet_address", normalizedAddress), @@ -194,7 +194,7 @@ func NativeBalance(ctx context.Context, deps driver.Deps, network shared.Network rpcURL := strings.TrimSpace(network.RPCURL) logFields := []zap.Field{ zap.String("wallet_ref", wallet.WalletRef), - zap.String("network", strings.ToLower(strings.TrimSpace(network.Name))), + zap.String("network", string(network.Name)), zap.String("wallet_address", normalizedAddress), } if rpcURL == "" { @@ -260,12 +260,12 @@ func EstimateFee(ctx context.Context, deps driver.Deps, network shared.Network, client, err := registry.Client(network.Name) if err != nil { - logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", network.Name)) + logger.Warn("Failed to resolve client", zap.Error(err), zap.String("network_name", string(network.Name))) return nil, err } rpcClient, err := registry.RPCClient(network.Name) if err != nil { - logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", network.Name)) + logger.Warn("Failed to resolve RPC client", zap.Error(err), zap.String("network_name", string(network.Name))) return nil, err } @@ -374,7 +374,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ } rpcURL := strings.TrimSpace(network.RPCURL) if rpcURL == "" { - logger.Warn("Network rpc url missing", zap.String("network", network.Name)) + logger.Warn("Network rpc url missing", zap.String("network", string(network.Name))) return "", executorInvalid("network rpc url is not configured") } if source == nil || transfer == nil { @@ -397,18 +397,18 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ logger.Info("Submitting transfer", zap.String("transfer_ref", transfer.TransferRef), zap.String("source_wallet_ref", source.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("destination", strings.ToLower(destination)), ) client, err := registry.Client(network.Name) if err != nil { - logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name)) + logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", string(network.Name))) return "", err } rpcClient, err := registry.RPCClient(network.Name) if err != nil { - logger.Warn("Failed to initialise RPC client", zap.String("network", network.Name)) + logger.Warn("Failed to initialise RPC client", zap.String("network", string(network.Name))) return "", err } @@ -429,7 +429,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ gasPrice, err := client.SuggestGasPrice(ctx) if err != nil { logger.Warn("Failed to suggest gas price", zap.Error(err), - zap.String("transfer_ref", transfer.TransferRef), zap.String("network", network.Name), + zap.String("transfer_ref", transfer.TransferRef), zap.String("network", string(network.Name)), ) return "", executorInternal("failed to suggest gas price", err) } @@ -532,7 +532,7 @@ func SubmitTransfer(ctx context.Context, deps driver.Deps, network shared.Networ txHash := signedTx.Hash().Hex() logger.Info("Transaction submitted", zap.String("transfer_ref", transfer.TransferRef), - zap.String("tx_hash", txHash), zap.String("network", network.Name), + zap.String("tx_hash", txHash), zap.String("network", string(network.Name)), ) return txHash, nil @@ -544,7 +544,7 @@ func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Net registry := deps.Registry if strings.TrimSpace(txHash) == "" { - logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name)) + logger.Warn("Missing transaction hash for confirmation", zap.String("network", string(network.Name))) return nil, executorInvalid("tx hash is required") } rpcURL := strings.TrimSpace(network.RPCURL) @@ -572,23 +572,23 @@ func AwaitConfirmation(ctx context.Context, deps driver.Deps, network shared.Net select { case <-ticker.C: logger.Debug("Transaction not yet mined", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) continue case <-ctx.Done(): logger.Warn("Context cancelled while awaiting confirmation", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) return nil, ctx.Err() } } logger.Warn("Failed to fetch transaction receipt", zap.Error(err), - zap.String("tx_hash", txHash), zap.String("network", network.Name), + zap.String("tx_hash", txHash), zap.String("network", string(network.Name)), ) return nil, executorInternal("failed to fetch transaction receipt", err) } logger.Info("Transaction confirmed", zap.String("tx_hash", txHash), - zap.String("network", network.Name), zap.Uint64("status", receipt.Status), + zap.String("network", string(network.Name)), zap.Uint64("status", receipt.Status), zap.Uint64("block_number", receipt.BlockNumber.Uint64()), ) return receipt, nil @@ -654,12 +654,6 @@ type gasEstimator interface { } func estimateGas(ctx context.Context, network shared.Network, client gasEstimator, rpcClient *rpc.Client, callMsg ethereum.CallMsg) (uint64, error) { - if isTronNetwork(network) { - if rpcClient == nil { - return 0, merrors.Internal("rpc client not initialised") - } - return estimateGasTron(ctx, rpcClient, callMsg) - } return client.EstimateGas(ctx, callMsg) } @@ -702,10 +696,6 @@ func tronEstimateCall(callMsg ethereum.CallMsg) map[string]string { return call } -func isTronNetwork(network shared.Network) bool { - return strings.HasPrefix(strings.ToLower(strings.TrimSpace(network.Name)), "tron") -} - func toBaseUnits(amount string, decimals uint8) (*big.Int, error) { value, err := decimal.NewFromString(strings.TrimSpace(amount)) if err != nil { diff --git a/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go b/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go index 75fdbe20..793aeda6 100644 --- a/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go +++ b/api/gateway/chain/internal/service/gateway/driver/evm/gas_topup.go @@ -29,9 +29,6 @@ func ComputeGasTopUp(network shared.Network, wallet *model.ManagedWallet, estima } nativeCurrency := strings.TrimSpace(network.NativeToken) - if nativeCurrency == "" { - nativeCurrency = strings.ToUpper(strings.TrimSpace(network.Name)) - } if !strings.EqualFold(nativeCurrency, estimatedFee.GetCurrency()) { return nil, false, merrors.InvalidArgument(fmt.Sprintf("estimated fee currency mismatch (expected %s)", nativeCurrency)) } diff --git a/api/gateway/chain/internal/service/gateway/drivers/registry.go b/api/gateway/chain/internal/service/gateway/drivers/registry.go index 662a70ce..78c8b45a 100644 --- a/api/gateway/chain/internal/service/gateway/drivers/registry.go +++ b/api/gateway/chain/internal/service/gateway/drivers/registry.go @@ -2,7 +2,6 @@ package drivers import ( "fmt" - "strings" "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver" "github.com/tech/sendico/gateway/chain/internal/service/gateway/driver/arbitrum" @@ -10,12 +9,13 @@ import ( "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" "go.uber.org/zap" ) // Registry maps configured network keys to chain drivers. type Registry struct { - byNetwork map[string]driver.Driver + byNetwork map[pmodel.ChainNetwork]driver.Driver } // NewRegistry selects drivers for the configured networks. @@ -23,18 +23,14 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e if logger == nil { return nil, merrors.InvalidArgument("driver registry: logger is required") } - result := &Registry{byNetwork: map[string]driver.Driver{}} + result := &Registry{byNetwork: map[pmodel.ChainNetwork]driver.Driver{}} for _, network := range networks { - name := strings.ToLower(strings.TrimSpace(network.Name)) - if name == "" { - continue - } - chainDriver, err := resolveDriver(logger, name) + chainDriver, err := resolveDriver(logger, network.Name) if err != nil { - logger.Error("Unsupported chain driver", zap.String("network", name), zap.Error(err)) + logger.Error("Unsupported chain driver", zap.String("network", string(network.Name)), zap.Error(err)) return nil, err } - result.byNetwork[name] = chainDriver + result.byNetwork[network.Name] = chainDriver } if len(result.byNetwork) == 0 { return nil, merrors.InvalidArgument("driver registry: no supported networks configured") @@ -44,30 +40,25 @@ func NewRegistry(logger mlogger.Logger, networks []shared.Network) (*Registry, e } // Driver resolves a driver for the provided network key. -func (r *Registry) Driver(network string) (driver.Driver, error) { +func (r *Registry) Driver(network pmodel.ChainNetwork) (driver.Driver, error) { if r == nil || len(r.byNetwork) == 0 { return nil, merrors.Internal("driver registry is not configured") } - key := strings.ToLower(strings.TrimSpace(network)) - if key == "" { - return nil, merrors.InvalidArgument("network is required") - } - chainDriver, ok := r.byNetwork[key] + chainDriver, ok := r.byNetwork[network] if !ok { - return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", key)) + return nil, merrors.InvalidArgument(fmt.Sprintf("unsupported chain network %s", network)) } return chainDriver, nil } -func resolveDriver(logger mlogger.Logger, network string) (driver.Driver, error) { - switch { - case strings.HasPrefix(network, "tron"): - return nil, merrors.InvalidArgument("tron networks must use the tron gateway, not chain gateway") - case strings.HasPrefix(network, "arbitrum"): +func resolveDriver(logger mlogger.Logger, network pmodel.ChainNetwork) (driver.Driver, error) { + switch network { + case pmodel.ChainNetworkArbitrumOne: + case pmodel.ChainNetworkArbitrumSepolia: return arbitrum.New(logger), nil - case strings.HasPrefix(network, "ethereum"): + case pmodel.ChainNetworkEthereumMainnet: return ethereum.New(logger), nil default: - return nil, merrors.InvalidArgument("unsupported chain network " + network) } + return nil, merrors.InvalidArgument("unsupported chain network " + string(network)) } diff --git a/api/gateway/chain/internal/service/gateway/executor.go b/api/gateway/chain/internal/service/gateway/executor.go index ab231949..bfc63153 100644 --- a/api/gateway/chain/internal/service/gateway/executor.go +++ b/api/gateway/chain/internal/service/gateway/executor.go @@ -52,7 +52,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr } rpcURL := strings.TrimSpace(network.RPCURL) if rpcURL == "" { - o.logger.Warn("Network rpc url missing", zap.String("network", network.Name)) + o.logger.Warn("Network rpc url missing", zap.String("network", string(network.Name))) return "", executorInvalid("network rpc url is not configured") } if source == nil || transfer == nil { @@ -75,19 +75,19 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr o.logger.Info("Submitting transfer", zap.String("transfer_ref", transfer.TransferRef), zap.String("source_wallet_ref", source.WalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.String("destination", strings.ToLower(destinationAddress)), ) client, err := o.clients.Client(network.Name) if err != nil { - o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", network.Name)) + o.logger.Warn("Failed to initialise RPC client", zap.Error(err), zap.String("network", string(network.Name))) return "", err } rpcClient, err := o.clients.RPCClient(network.Name) if err != nil { o.logger.Warn("Failed to initialise RPC client", - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) return "", err @@ -112,7 +112,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr if err != nil { o.logger.Warn("Failed to suggest gas price", zap.String("transfer_ref", transfer.TransferRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) return "", executorInternal("failed to suggest gas price", err) @@ -206,7 +206,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr o.logger.Info("Transaction submitted", zap.String("transfer_ref", transfer.TransferRef), zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) return txHash, nil @@ -214,7 +214,7 @@ func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Tr func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared.Network, txHash string) (*types.Receipt, error) { if strings.TrimSpace(txHash) == "" { - o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", network.Name)) + o.logger.Warn("Missing transaction hash for confirmation", zap.String("network", string(network.Name))) return nil, executorInvalid("tx hash is required") } rpcURL := strings.TrimSpace(network.RPCURL) @@ -240,27 +240,27 @@ func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network shared. case <-ticker.C: o.logger.Debug("Transaction not yet mined", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) continue case <-ctx.Done(): o.logger.Warn("Context cancelled while awaiting confirmation", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) return nil, ctx.Err() } } o.logger.Warn("Failed to fetch transaction receipt", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Error(err), ) return nil, executorInternal("failed to fetch transaction receipt", err) } o.logger.Info("Transaction confirmed", zap.String("tx_hash", txHash), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), zap.Uint64("block_number", receipt.BlockNumber.Uint64()), zap.Uint64("status", receipt.Status), ) diff --git a/api/gateway/chain/internal/service/gateway/options.go b/api/gateway/chain/internal/service/gateway/options.go index 885003d7..03485833 100644 --- a/api/gateway/chain/internal/service/gateway/options.go +++ b/api/gateway/chain/internal/service/gateway/options.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/gateway/chain/internal/service/gateway/rpcclient" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" clockpkg "github.com/tech/sendico/pkg/clock" + pmodel "github.com/tech/sendico/pkg/model" ) // Option configures the Service. @@ -34,7 +35,7 @@ func WithNetworks(networks []shared.Network) Option { return } if s.networks == nil { - s.networks = make(map[string]shared.Network, len(networks)) + s.networks = make(map[pmodel.ChainNetwork]shared.Network, len(networks)) } for _, network := range networks { if network.Name == "" { @@ -48,7 +49,7 @@ func WithNetworks(networks []shared.Network) Option { clone.TokenConfigs[i].Symbol = strings.ToUpper(strings.TrimSpace(clone.TokenConfigs[i].Symbol)) clone.TokenConfigs[i].ContractAddress = strings.ToLower(strings.TrimSpace(clone.TokenConfigs[i].ContractAddress)) } - clone.Name = strings.ToLower(strings.TrimSpace(clone.Name)) + clone.Name = clone.Name s.networks[clone.Name] = clone } } diff --git a/api/gateway/chain/internal/service/gateway/rpcclient/clients.go b/api/gateway/chain/internal/service/gateway/rpcclient/clients.go index 009c8afd..effb9672 100644 --- a/api/gateway/chain/internal/service/gateway/rpcclient/clients.go +++ b/api/gateway/chain/internal/service/gateway/rpcclient/clients.go @@ -14,13 +14,14 @@ import ( "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" + pmodel "github.com/tech/sendico/pkg/model" "go.uber.org/zap" ) // Clients holds pre-initialised RPC clients keyed by network name. type Clients struct { logger mlogger.Logger - clients map[string]clientEntry + clients map[pmodel.ChainNetwork]clientEntry } type clientEntry struct { @@ -36,25 +37,20 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo clientLogger := logger.Named("rpc_client") result := &Clients{ logger: clientLogger, - clients: make(map[string]clientEntry), + clients: make(map[pmodel.ChainNetwork]clientEntry), } for _, network := range networks { - name := strings.ToLower(strings.TrimSpace(network.Name)) rpcURL := strings.TrimSpace(network.RPCURL) - if name == "" { - clientLogger.Warn("Skipping network with empty name during rpc client preparation") - continue - } if rpcURL == "" { result.Close() - err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", name)) - clientLogger.Warn("Rpc url missing", zap.String("network", name)) + err := merrors.InvalidArgument(fmt.Sprintf("rpc url not configured for network %s", network.Name)) + clientLogger.Warn("Rpc url missing", zap.String("network", string(network.Name))) return nil, err } fields := []zap.Field{ - zap.String("network", name), + zap.String("network", string(network.Name)), } clientLogger.Info("Initialising rpc client", fields...) @@ -62,7 +58,7 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo httpClient := &http.Client{ Transport: &loggingRoundTripper{ logger: clientLogger, - network: name, + network: network.Name, endpoint: rpcURL, base: http.DefaultTransport, }, @@ -72,10 +68,10 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo if err != nil { result.Close() clientLogger.Warn("Failed to dial rpc endpoint", append(fields, zap.Error(err))...) - return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", name, err.Error())) + return nil, merrors.Internal(fmt.Sprintf("rpc dial failed for %s: %s", network.Name, err.Error())) } client := ethclient.NewClient(rpcCli) - result.clients[name] = clientEntry{ + result.clients[network.Name] = clientEntry{ eth: client, rpc: rpcCli, } @@ -93,27 +89,25 @@ func Prepare(ctx context.Context, logger mlogger.Logger, networks []shared.Netwo } // Client returns a prepared client for the given network name. -func (c *Clients) Client(network string) (*ethclient.Client, error) { +func (c *Clients) Client(network pmodel.ChainNetwork) (*ethclient.Client, error) { if c == nil { return nil, merrors.Internal("RPC clients not initialised") } - name := strings.ToLower(strings.TrimSpace(network)) - entry, ok := c.clients[name] + entry, ok := c.clients[network] if !ok || entry.eth == nil { - return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", name)) + return nil, merrors.InvalidArgument(fmt.Sprintf("RPC client not configured for network %s", network)) } return entry.eth, nil } // RPCClient returns the raw RPC client for low-level calls. -func (c *Clients) RPCClient(network string) (*rpc.Client, error) { +func (c *Clients) RPCClient(network pmodel.ChainNetwork) (*rpc.Client, error) { if c == nil { return nil, merrors.Internal("rpc clients not initialised") } - name := strings.ToLower(strings.TrimSpace(network)) - entry, ok := c.clients[name] + entry, ok := c.clients[network] if !ok || entry.rpc == nil { - return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", name)) + return nil, merrors.InvalidArgument(fmt.Sprintf("rpc client not configured for network %s", network)) } return entry.rpc, nil } @@ -130,14 +124,14 @@ func (c *Clients) Close() { entry.eth.Close() } if c.logger != nil { - c.logger.Info("RPC client closed", zap.String("network", name)) + c.logger.Info("RPC client closed", zap.String("network", string(name))) } } } type loggingRoundTripper struct { logger mlogger.Logger - network string + network pmodel.ChainNetwork endpoint string base http.RoundTripper } @@ -155,7 +149,7 @@ func (l *loggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, erro } fields := []zap.Field{ - zap.String("network", l.network), + zap.String("network", string(l.network)), } if len(reqBody) > 0 { fields = append(fields, zap.String("rpc_request", truncate(string(reqBody), 2048))) diff --git a/api/gateway/chain/internal/service/gateway/rpcclient/registry.go b/api/gateway/chain/internal/service/gateway/rpcclient/registry.go index 185477fa..69ba5833 100644 --- a/api/gateway/chain/internal/service/gateway/rpcclient/registry.go +++ b/api/gateway/chain/internal/service/gateway/rpcclient/registry.go @@ -1,22 +1,21 @@ package rpcclient import ( - "strings" - "github.com/ethereum/go-ethereum/ethclient" "github.com/ethereum/go-ethereum/rpc" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/pkg/merrors" + pmodel "github.com/tech/sendico/pkg/model" ) // Registry binds static network metadata with prepared RPC clients. type Registry struct { - networks map[string]shared.Network + networks map[pmodel.ChainNetwork]shared.Network clients *Clients } // NewRegistry constructs a registry keyed by lower-cased network name. -func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry { +func NewRegistry(networks map[pmodel.ChainNetwork]shared.Network, clients *Clients) *Registry { return &Registry{ networks: networks, clients: clients, @@ -24,31 +23,31 @@ func NewRegistry(networks map[string]shared.Network, clients *Clients) *Registry } // Network fetches network metadata by key (case-insensitive). -func (r *Registry) Network(key string) (shared.Network, bool) { +func (r *Registry) Network(key pmodel.ChainNetwork) (shared.Network, bool) { if r == nil || len(r.networks) == 0 { return shared.Network{}, false } - n, ok := r.networks[strings.ToLower(strings.TrimSpace(key))] + n, ok := r.networks[key] return n, ok } // Client returns the prepared RPC client for the given network name. -func (r *Registry) Client(key string) (*ethclient.Client, error) { +func (r *Registry) Client(key pmodel.ChainNetwork) (*ethclient.Client, error) { if r == nil || r.clients == nil { return nil, merrors.Internal("rpc clients not initialised") } - return r.clients.Client(strings.ToLower(strings.TrimSpace(key))) + return r.clients.Client(key) } // RPCClient returns the raw RPC client for low-level calls. -func (r *Registry) RPCClient(key string) (*rpc.Client, error) { +func (r *Registry) RPCClient(key pmodel.ChainNetwork) (*rpc.Client, error) { if r == nil || r.clients == nil { return nil, merrors.Internal("rpc clients not initialised") } - return r.clients.RPCClient(strings.ToLower(strings.TrimSpace(key))) + return r.clients.RPCClient(key) } // Networks exposes the registry map for iteration when needed. -func (r *Registry) Networks() map[string]shared.Network { +func (r *Registry) Networks() map[pmodel.ChainNetwork]shared.Network { return r.networks } diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index cf43ad86..db2e8de8 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -18,6 +18,7 @@ import ( "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" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" @@ -43,7 +44,7 @@ type Service struct { settings CacheSettings - networks map[string]shared.Network + networks map[pmodel.ChainNetwork]shared.Network serviceWallet shared.ServiceWallet keyManager keymanager.Manager rpcClients *rpcclient.Clients @@ -64,7 +65,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro producer: producer, clock: clockpkg.System{}, settings: defaultSettings(), - networks: map[string]shared.Network{}, + networks: map[pmodel.ChainNetwork]shared.Network{}, } initMetrics() @@ -79,7 +80,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro svc.clock = clockpkg.System{} } if svc.networks == nil { - svc.networks = map[string]shared.Network{} + svc.networks = map[pmodel.ChainNetwork]shared.Network{} } svc.settings = svc.settings.withDefaults() svc.networkRegistry = rpcclient.NewRegistry(svc.networks, svc.rpcClients) @@ -207,7 +208,7 @@ func (s *Service) startDiscoveryAnnouncers() { announce := discovery.Announcement{ Service: "CRYPTO_RAIL_GATEWAY", Rail: "CRYPTO", - Network: network.Name, + Network: string(network.Name), Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send", "observe.confirm"}, Currencies: currencies, InvokeURI: s.invokeURI, diff --git a/api/gateway/chain/internal/service/gateway/service_test.go b/api/gateway/chain/internal/service/gateway/service_test.go index 54e2d569..132bf46f 100644 --- a/api/gateway/chain/internal/service/gateway/service_test.go +++ b/api/gateway/chain/internal/service/gateway/service_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/stretchr/testify/require" + pmodel "github.com/tech/sendico/pkg/model" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ichainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.mongodb.org/mongo-driver/v2/bson" @@ -170,6 +171,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) { Amount: &moneyv1.Money{Currency: "USDC", Amount: "5"}, }, }, + IntentRef: "intent-1", }) require.NoError(t, err) require.NotNil(t, transferResp.GetTransfer()) @@ -177,7 +179,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) { stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef()) require.NotNil(t, stored) - require.Equal(t, model.TransferStatusPending, stored.Status) + require.Equal(t, model.TransferStatusCreated, stored.Status) // GetTransfer getResp, err := svc.GetTransfer(ctx, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef}) @@ -335,7 +337,7 @@ func (w *inMemoryWallets) List(ctx context.Context, filter model.ManagedWalletFi continue } } - if filter.Network != "" && !strings.EqualFold(wallet.Network, filter.Network) { + if wallet.Network != filter.Network { continue } if filter.TokenSymbol != "" && !strings.EqualFold(wallet.TokenSymbol, filter.TokenSymbol) { @@ -644,9 +646,9 @@ func newTestService(t *testing.T) (*Service, *inMemoryRepository) { type fakeKeyManager struct{} -func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network string) (*keymanager.ManagedWalletKey, error) { +func (f *fakeKeyManager) CreateManagedWalletKey(ctx context.Context, walletRef string, network pmodel.ChainNetwork) (*keymanager.ManagedWalletKey, error) { return &keymanager.ManagedWalletKey{ - KeyID: fmt.Sprintf("%s/%s", strings.ToLower(network), walletRef), + KeyID: fmt.Sprintf("%s/%s", network, walletRef), Address: "0x" + strings.Repeat("a", 40), PublicKey: strings.Repeat("b", 128), }, nil diff --git a/api/gateway/chain/internal/service/gateway/shared/gas_topup.go b/api/gateway/chain/internal/service/gateway/shared/gas_topup.go index cfb2b51e..c4b369b5 100644 --- a/api/gateway/chain/internal/service/gateway/shared/gas_topup.go +++ b/api/gateway/chain/internal/service/gateway/shared/gas_topup.go @@ -1,6 +1,10 @@ package shared -import "github.com/shopspring/decimal" +import ( + "github.com/shopspring/decimal" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) // GasTopUpRule defines buffer, minimum, rounding, and cap behavior for native gas top-ups. type GasTopUpRule struct { @@ -30,3 +34,20 @@ func (p *GasTopUpPolicy) Rule(contractTransfer bool) (GasTopUpRule, bool) { } return p.Default, true } + +func СhainTransferStatusToOperation(ts chainv1.TransferStatus) connectorv1.OperationStatus { + switch ts { + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return connectorv1.OperationStatus_OPERATION_CANCELLED + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + case chainv1.TransferStatus_TRANSFER_FAILED: + return connectorv1.OperationStatus_OPERATION_FAILED + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + default: + } + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED +} diff --git a/api/gateway/chain/internal/service/gateway/shared/helpers.go b/api/gateway/chain/internal/service/gateway/shared/helpers.go index b13e9065..76ab2e44 100644 --- a/api/gateway/chain/internal/service/gateway/shared/helpers.go +++ b/api/gateway/chain/internal/service/gateway/shared/helpers.go @@ -4,8 +4,10 @@ import ( "strings" "github.com/tech/sendico/gateway/chain/storage/model" - chainasset "github.com/tech/sendico/pkg/chain" + pmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -49,16 +51,38 @@ func GenerateTransferRef() string { return bson.NewObjectID().Hex() } -func ChainKeyFromEnum(chain chainv1.ChainNetwork) (string, chainv1.ChainNetwork) { - if name, ok := chainv1.ChainNetwork_name[int32(chain)]; ok { - key := strings.ToLower(strings.TrimPrefix(name, "CHAIN_NETWORK_")) - return key, chain +func ChainKeyFromEnum(chain chainv1.ChainNetwork) pmodel.ChainNetwork { + switch chain { + case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE: + return pmodel.ChainNetworkArbitrumOne + case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA: + return pmodel.ChainNetworkArbitrumSepolia + case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET: + return pmodel.ChainNetworkEthereumMainnet + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET: + return pmodel.ChainNetworkTronMainnet + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE: + return pmodel.ChainNetworkTronNile + default: + return pmodel.ChainNetworkUnspecified } - return "", chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED } -func ChainEnumFromName(name string) chainv1.ChainNetwork { - return chainasset.NetworkFromString(name) +func ChainEnumFromName(name pmodel.ChainNetwork) chainv1.ChainNetwork { + switch name { + case pmodel.ChainNetworkArbitrumOne: + return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE + case pmodel.ChainNetworkArbitrumSepolia: + return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA + case pmodel.ChainNetworkEthereumMainnet: + return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET + case pmodel.ChainNetworkTronMainnet: + return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET + case pmodel.ChainNetworkTronNile: + return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE + default: + return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED + } } func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.ManagedWalletStatus { @@ -76,54 +100,94 @@ func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.Manage func TransferStatusToModel(status chainv1.TransferStatus) model.TransferStatus { switch status { - case chainv1.TransferStatus_TRANSFER_PENDING: - return model.TransferStatusPending - case chainv1.TransferStatus_TRANSFER_SIGNING: - return model.TransferStatusSigning - case chainv1.TransferStatus_TRANSFER_SUBMITTED: - return model.TransferStatusSubmitted - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return model.TransferStatusConfirmed + + case chainv1.TransferStatus_TRANSFER_CREATED: + return model.TransferStatusCreated + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return model.TransferStatusProcessing + + case chainv1.TransferStatus_TRANSFER_WAITING: + return model.TransferStatusWaiting + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return model.TransferStatusSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: return model.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: return model.TransferStatusCancelled + default: - return "" + return model.TransferStatus("") } } func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus { switch status { - case model.TransferStatusPending: - return chainv1.TransferStatus_TRANSFER_PENDING - case model.TransferStatusSigning: - return chainv1.TransferStatus_TRANSFER_SIGNING - case model.TransferStatusSubmitted: - return chainv1.TransferStatus_TRANSFER_SUBMITTED - case model.TransferStatusConfirmed: - return chainv1.TransferStatus_TRANSFER_CONFIRMED + + case model.TransferStatusCreated: + return chainv1.TransferStatus_TRANSFER_CREATED + + case model.TransferStatusProcessing: + return chainv1.TransferStatus_TRANSFER_PROCESSING + + case model.TransferStatusWaiting: + return chainv1.TransferStatus_TRANSFER_WAITING + + case model.TransferStatusSuccess: + return chainv1.TransferStatus_TRANSFER_SUCCESS + case model.TransferStatusFailed: return chainv1.TransferStatus_TRANSFER_FAILED + case model.TransferStatusCancelled: return chainv1.TransferStatus_TRANSFER_CANCELLED + default: return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED } } +func ChainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { + switch status { + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + + case chainv1.TransferStatus_TRANSFER_FAILED: + return connectorv1.OperationStatus_OPERATION_FAILED + + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return connectorv1.OperationStatus_OPERATION_CANCELLED + + default: + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED + } +} + // NativeCurrency returns the canonical native token symbol for a network. func NativeCurrency(network Network) string { currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) if currency == "" { - currency = strings.ToUpper(strings.TrimSpace(network.Name)) + currency = strings.ToUpper(network.Name.String()) } return currency } // Network describes a supported blockchain network and known token contracts. type Network struct { - Name string + Name pmodel.ChainNetwork RPCURL string ChainID uint64 NativeToken string @@ -139,7 +203,27 @@ type TokenContract struct { // ServiceWallet captures the managed service wallet configuration. type ServiceWallet struct { - Network string + Network pmodel.ChainNetwork Address string PrivateKey string } + +func ProtoToMoney(money *moneyv1.Money) *paymenttypes.Money { + if money == nil { + return &paymenttypes.Money{} + } + return &paymenttypes.Money{ + Amount: money.GetAmount(), + Currency: money.GetCurrency(), + } +} + +func MonenyToProto(money *paymenttypes.Money) *moneyv1.Money { + if money == nil { + return &moneyv1.Money{} + } + return &moneyv1.Money{ + Amount: money.Amount, + Currency: money.Currency, + } +} diff --git a/api/gateway/chain/internal/service/gateway/transfer_execution.go b/api/gateway/chain/internal/service/gateway/transfer_execution.go index 6c694f09..11152555 100644 --- a/api/gateway/chain/internal/service/gateway/transfer_execution.go +++ b/api/gateway/chain/internal/service/gateway/transfer_execution.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/pkg/merrors" + pmodel "github.com/tech/sendico/pkg/model" "go.uber.org/zap" ) @@ -40,35 +41,35 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet return err } - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil { + if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusProcessing, "", ""); err != nil { s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) } driverDeps := s.driverDeps() chainDriver, err := s.driverForNetwork(network.Name) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + _, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") return err } destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + _, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") return err } sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + _, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") return err } if chainDriver.Name() == "tron" && sourceAddress == destinationAddress { s.logger.Info("Self transfer detected; skipping submission", zap.String("transfer_ref", transferRef), zap.String("wallet_ref", sourceWalletRef), - zap.String("network", network.Name), + zap.String("network", string(network.Name)), ) - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil { + if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusSuccess, "", ""); err != nil { s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) } return nil @@ -76,11 +77,14 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + s.logger.Warn("Failed to submit transfer", zap.String("transfer_ref", transferRef), zap.Error(err)) + if _, e := s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), ""); e != nil { + s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(e)) + } return err } - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil { + if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusWaiting, "", txHash); err != nil { s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err)) } @@ -94,15 +98,15 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet return err } - if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil { - s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) - } - return nil + failureReason := "" + pStatus := model.TransferStatusSuccess + if receipt != nil && receipt.Status != types.ReceiptStatusSuccessful { + failureReason = "transaction reverted" + pStatus = model.TransferStatusFailed } - - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil { - s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err)) + if _, err := s.updateTransferStatus(ctx, transferRef, pStatus, failureReason, txHash); err != nil { + s.logger.Warn("Failed to update transfer status", zap.Error(err), + zap.String("transfer_ref", transferRef), zap.String("status", string(pStatus))) } return nil } @@ -133,7 +137,7 @@ func (s *Service) driverDeps() driver.Deps { } } -func (s *Service) driverForNetwork(network string) (driver.Driver, error) { +func (s *Service) driverForNetwork(network pmodel.ChainNetwork) (driver.Driver, error) { if s.drivers == nil { return nil, merrors.Internal("chain drivers not configured") } diff --git a/api/gateway/chain/internal/service/gateway/transfer_notifications.go b/api/gateway/chain/internal/service/gateway/transfer_notifications.go new file mode 100644 index 00000000..5abed7cb --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/transfer_notifications.go @@ -0,0 +1,77 @@ +package gateway + +import ( + "context" + "fmt" + + "github.com/tech/sendico/gateway/chain/storage/model" + 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/payments/rail" + "go.uber.org/zap" +) + +func isFinalStatus(t *model.Transfer) bool { + switch t.Status { + case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.Transfer) rail.OperationResult { + switch t.Status { + case model.TransferStatusFailed: + return rail.OperationResultFailed + case model.TransferStatusSuccess: + return rail.OperationResultSuccess + case model.TransferStatusCancelled: + return rail.OperationResultCancelled + default: + panic(fmt.Sprintf("toOpStatus: unexpected transfer status: %s", t.Status)) + } +} + +func toError(t *model.Transfer) string { + if t == nil { + return "" + } + if t.Status == model.TransferStatusSuccess { + return "" + } + return t.FailureReason +} + +func (s *Service) updateTransferStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason, txHash string) (*model.Transfer, error) { + transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash) + if err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err)) + } + if isFinalStatus(transfer) { + s.emitTransferStatusEvent(transfer) + } + return transfer, err +} + +func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) { + if s == nil || s.producer == nil || transfer == nil { + return + } + + exec := pmodel.PaymentGatewayExecution{ + PaymentIntentID: transfer.IntentRef, + IdempotencyKey: transfer.IdempotencyKey, + ExecutedMoney: transfer.NetAmount, + PaymentRef: transfer.PaymentRef, + Status: toOpStatus(transfer), + OperationRef: transfer.OperationRef, + Error: toError(transfer), + TransferRef: transfer.TransferRef, + } + env := paymentgateway.PaymentGatewayExecution(mservice.ChainGateway, &exec) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + } +} diff --git a/api/gateway/chain/storage/model/transfer.go b/api/gateway/chain/storage/model/transfer.go index fe4ecf6a..9b708760 100644 --- a/api/gateway/chain/storage/model/transfer.go +++ b/api/gateway/chain/storage/model/transfer.go @@ -5,19 +5,22 @@ import ( "time" "github.com/tech/sendico/pkg/db/storable" + pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" + paytypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) type TransferStatus string const ( - TransferStatusPending TransferStatus = "pending" - TransferStatusSigning TransferStatus = "signing" - TransferStatusSubmitted TransferStatus = "submitted" - TransferStatusConfirmed TransferStatus = "confirmed" - TransferStatusFailed TransferStatus = "failed" - TransferStatusCancelled TransferStatus = "cancelled" + TransferStatusCreated TransferStatus = "created" // record exists, not started + TransferStatusProcessing TransferStatus = "processing" // we are working on it + TransferStatusWaiting TransferStatus = "waiting" // waiting external world + + TransferStatusSuccess TransferStatus = "success" // final success + TransferStatusFailed TransferStatus = "failed" // final failure + TransferStatusCancelled TransferStatus = "cancelled" // final cancelled ) // ServiceFee represents a fee component applied to a transfer. @@ -38,21 +41,23 @@ type TransferDestination struct { type Transfer struct { storable.Base `bson:",inline" json:",inline"` + OperationRef string `bson:"operationRef" json:"operationRef"` TransferRef string `bson:"transferRef" json:"transferRef"` - IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + IdempotencyKey string `bson:"intentRef" json:"intentRef"` + IntentRef string `bson:"idempotencyKey" json:"idempotencyKey"` OrganizationRef string `bson:"organizationRef" json:"organizationRef"` SourceWalletRef string `bson:"sourceWalletRef" json:"sourceWalletRef"` Destination TransferDestination `bson:"destination" json:"destination"` - Network string `bson:"network" json:"network"` + Network pmodel.ChainNetwork `bson:"network" json:"network"` TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` ContractAddress string `bson:"contractAddress" json:"contractAddress"` - RequestedAmount *moneyv1.Money `bson:"requestedAmount" json:"requestedAmount"` - NetAmount *moneyv1.Money `bson:"netAmount" json:"netAmount"` + RequestedAmount *paytypes.Money `bson:"requestedAmount" json:"requestedAmount"` + NetAmount *paytypes.Money `bson:"netAmount" json:"netAmount"` Fees []ServiceFee `bson:"fees,omitempty" json:"fees,omitempty"` Status TransferStatus `bson:"status" json:"status"` TxHash string `bson:"txHash,omitempty" json:"txHash,omitempty"` FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"` - ClientReference string `bson:"clientReference,omitempty" json:"clientReference,omitempty"` + PaymentRef string `bson:"paymentRef,omitempty" json:"paymentRef,omitempty"` LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"` } @@ -82,12 +87,11 @@ func (t *Transfer) Normalize() { t.IdempotencyKey = strings.TrimSpace(t.IdempotencyKey) t.OrganizationRef = strings.TrimSpace(t.OrganizationRef) t.SourceWalletRef = strings.TrimSpace(t.SourceWalletRef) - t.Network = strings.TrimSpace(strings.ToLower(t.Network)) t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol)) t.ContractAddress = strings.TrimSpace(strings.ToLower(t.ContractAddress)) t.Destination.ManagedWalletRef = strings.TrimSpace(t.Destination.ManagedWalletRef) t.Destination.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress) t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal) t.Destination.Memo = strings.TrimSpace(t.Destination.Memo) - t.ClientReference = strings.TrimSpace(t.ClientReference) + t.PaymentRef = strings.TrimSpace(t.PaymentRef) } diff --git a/api/gateway/chain/storage/model/wallet.go b/api/gateway/chain/storage/model/wallet.go index 5e6d7347..1bc9d18c 100644 --- a/api/gateway/chain/storage/model/wallet.go +++ b/api/gateway/chain/storage/model/wallet.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/pkg/db/storable" pkgmodel "github.com/tech/sendico/pkg/model" + pmodel "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) @@ -23,17 +24,17 @@ type ManagedWallet struct { storable.Base `bson:",inline" json:",inline"` pkgmodel.Describable `bson:",inline" json:",inline"` - IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` - WalletRef string `bson:"walletRef" json:"walletRef"` - OrganizationRef string `bson:"organizationRef" json:"organizationRef"` - OwnerRef string `bson:"ownerRef" json:"ownerRef"` - Network string `bson:"network" json:"network"` - TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` - ContractAddress string `bson:"contractAddress" json:"contractAddress"` - DepositAddress string `bson:"depositAddress" json:"depositAddress"` - KeyReference string `bson:"keyReference,omitempty" json:"keyReference,omitempty"` - Status ManagedWalletStatus `bson:"status" json:"status"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + WalletRef string `bson:"walletRef" json:"walletRef"` + OrganizationRef string `bson:"organizationRef" json:"organizationRef"` + OwnerRef string `bson:"ownerRef" json:"ownerRef"` + Network pkgmodel.ChainNetwork `bson:"network" json:"network"` + TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` + ContractAddress string `bson:"contractAddress" json:"contractAddress"` + DepositAddress string `bson:"depositAddress" json:"depositAddress"` + KeyReference string `bson:"keyReference,omitempty" json:"keyReference,omitempty"` + Status ManagedWalletStatus `bson:"status" json:"status"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } // Collection implements storable.Storable. @@ -66,7 +67,7 @@ type ManagedWalletFilter struct { // - pointer to empty string: filter for wallets where owner_ref is empty // - pointer to a value: filter for wallets where owner_ref matches OwnerRefFilter *string - Network string + Network pmodel.ChainNetwork TokenSymbol string Cursor string Limit int32 @@ -93,7 +94,6 @@ func (m *ManagedWallet) Normalize() { m.Description = &desc } } - m.Network = strings.TrimSpace(strings.ToLower(m.Network)) m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol)) m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress)) m.DepositAddress = normalizeWalletAddress(m.DepositAddress) diff --git a/api/gateway/chain/storage/mongo/store/transfers.go b/api/gateway/chain/storage/mongo/store/transfers.go index cba38e07..d0062a88 100644 --- a/api/gateway/chain/storage/mongo/store/transfers.go +++ b/api/gateway/chain/storage/mongo/store/transfers.go @@ -79,7 +79,7 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey") } if transfer.Status == "" { - transfer.Status = model.TransferStatusPending + transfer.Status = model.TransferStatusCreated } if transfer.LastStatusAt.IsZero() { transfer.LastStatusAt = time.Now().UTC() diff --git a/api/gateway/chain/storage/mongo/store/wallets.go b/api/gateway/chain/storage/mongo/store/wallets.go index c932d24d..f7c3fb15 100644 --- a/api/gateway/chain/storage/mongo/store/wallets.go +++ b/api/gateway/chain/storage/mongo/store/wallets.go @@ -111,7 +111,7 @@ func (w *Wallets) Create(ctx context.Context, wallet *model.ManagedWallet) (*mod fields = append(fields, zap.String("owner_ref", wallet.OwnerRef)) } if wallet.Network != "" { - fields = append(fields, zap.String("network", wallet.Network)) + fields = append(fields, zap.String("network", string(wallet.Network))) } if wallet.TokenSymbol != "" { fields = append(fields, zap.String("token_symbol", wallet.TokenSymbol)) @@ -161,11 +161,7 @@ func (w *Wallets) List(ctx context.Context, filter model.ManagedWalletFilter) (* query = query.Filter(repository.Field("ownerRef"), ownerRef) fields = append(fields, zap.String("owner_ref_filter", ownerRef)) } - if network := strings.TrimSpace(filter.Network); network != "" { - normalized := strings.ToLower(network) - query = query.Filter(repository.Field("network"), normalized) - fields = append(fields, zap.String("network", normalized)) - } + fields = append(fields, zap.String("network", string(filter.Network))) if token := strings.TrimSpace(filter.TokenSymbol); token != "" { normalized := strings.ToUpper(token) query = query.Filter(repository.Field("tokenSymbol"), normalized) diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index 978c4608..dcee28b2 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -7,7 +7,7 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "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" @@ -179,14 +179,14 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin if op == nil || len(metadata) == 0 { return } - if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyFromRole]); raw != "" { - if role, ok := pmodel.Parse(raw); ok && role != "" { - op.FromRole = pmodel.ToProto(role) + 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[pmodel.MetadataKeyToRole]); raw != "" { - if role, ok := pmodel.Parse(raw); ok && role != "" { - op.ToRole = pmodel.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) } } } @@ -298,12 +298,22 @@ func minorFromMoney(m *moneyv1.Money) int64 { func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus { switch status { - case connectorv1.OperationStatus_CONFIRMED: - return mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED - case connectorv1.OperationStatus_FAILED: + + 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_PENDING, connectorv1.OperationStatus_SUBMITTED: - return mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + + case connectorv1.OperationStatus_OPERATION_CANCELLED: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED + default: return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED } diff --git a/api/gateway/mntx/config.dev.yml b/api/gateway/mntx/config.dev.yml index eed8596c..befe15fd 100644 --- a/api/gateway/mntx/config.dev.yml +++ b/api/gateway/mntx/config.dev.yml @@ -11,6 +11,17 @@ grpc: metrics: address: ":9404" +database: + driver: mongodb + settings: + host_env: MNTX_GATEWAY_MONGO_HOST + port_env: MNTX_GATEWAY_MONGO_PORT + database_env: MNTX_GATEWAY_MONGO_DATABASE + user_env: MNTX_GATEWAY_MONGO_USER + password_env: MNTX_GATEWAY_MONGO_PASSWORD + auth_source_env: MNTX_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: MNTX_GATEWAY_MONGO_REPLICA_SET + messaging: driver: NATS settings: diff --git a/api/gateway/mntx/config.yml b/api/gateway/mntx/config.yml index f0cb0d8f..29039442 100644 --- a/api/gateway/mntx/config.yml +++ b/api/gateway/mntx/config.yml @@ -11,6 +11,17 @@ grpc: metrics: address: ":9404" +database: + driver: mongodb + settings: + host_env: MNTX_GATEWAY_MONGO_HOST + port_env: MNTX_GATEWAY_MONGO_PORT + database_env: MNTX_GATEWAY_MONGO_DATABASE + user_env: MNTX_GATEWAY_MONGO_USER + password_env: MNTX_GATEWAY_MONGO_PASSWORD + auth_source_env: MNTX_GATEWAY_MONGO_AUTH_SOURCE + replica_set_env: MNTX_GATEWAY_MONGO_REPLICA_SET + messaging: driver: NATS settings: diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index a2bb3641..0bc10c4a 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -9,6 +9,7 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.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.78.0 google.golang.org/protobuf v1.36.11 @@ -40,7 +41,6 @@ require ( 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.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.47.0 // indirect @@ -48,5 +48,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index 9ec69a0f..d19b0e99 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -210,8 +210,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index d31b0e44..e5573542 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -15,7 +15,10 @@ import ( "github.com/tech/sendico/gateway/mntx/internal/appversion" mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/gateway/mntx/storage" + gatewaymongo "github.com/tech/sendico/gateway/mntx/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" @@ -31,7 +34,7 @@ type Imp struct { debug bool config *config - app *grpcapp.App[struct{}] + app *grpcapp.App[storage.Repository] http *http.Server service *mntxservice.Service } @@ -183,7 +186,7 @@ func (i *Imp) Start() error { zap.Int64("max_body_bytes", callbackCfg.MaxBodyBytes), ) - serviceFactory := func(logger mlogger.Logger, _ struct{}, producer msg.Producer) (grpcapp.Service, error) { + serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { invokeURI := "" if cfg.GRPC != nil { invokeURI = cfg.GRPC.DiscoveryInvokeURI() @@ -194,6 +197,7 @@ func (i *Imp) Start() error { mntxservice.WithMonetixConfig(monetixCfg), mntxservice.WithGatewayDescriptor(gatewayDescriptor), mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}), + mntxservice.WithStorage(repo), ) i.service = svc @@ -204,7 +208,11 @@ func (i *Imp) Start() error { return svc, nil } - app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, nil, serviceFactory) + repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { + return gatewaymongo.New(logger, conn) + } + + app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, repoFactory, serviceFactory) if err != nil { return err } diff --git a/api/gateway/mntx/internal/service/gateway/callback.go b/api/gateway/mntx/internal/service/gateway/callback.go index 6533aec1..ed2211f7 100644 --- a/api/gateway/mntx/internal/service/gateway/callback.go +++ b/api/gateway/mntx/internal/service/gateway/callback.go @@ -92,10 +92,10 @@ func mapCallbackToState(clock clockpkg.Clock, cfg monetix.Config, cb monetixCall internalStatus := mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED if status == cfg.SuccessStatus() && opStatus == cfg.SuccessStatus() && (code == "" || code == "0") { - internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED + internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS outcome = monetix.OutcomeSuccess } else if status == cfg.ProcessingStatus() || opStatus == cfg.ProcessingStatus() { - internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + internalStatus = mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING outcome = monetix.OutcomeProcessing } diff --git a/api/gateway/mntx/internal/service/gateway/callback_test.go b/api/gateway/mntx/internal/service/gateway/callback_test.go index 0fe6a7fd..c3bbd737 100644 --- a/api/gateway/mntx/internal/service/gateway/callback_test.go +++ b/api/gateway/mntx/internal/service/gateway/callback_test.go @@ -51,7 +51,7 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) { paymentStatus: "success", operationStatus: "success", code: "0", - expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED, + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS, expectedOutcome: monetix.OutcomeSuccess, }, { @@ -59,7 +59,7 @@ func TestMapCallbackToState_StatusMapping(t *testing.T) { paymentStatus: "processing", operationStatus: "success", code: "", - expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, + expectedStatus: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING, expectedOutcome: monetix.OutcomeProcessing, }, { diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_store.go b/api/gateway/mntx/internal/service/gateway/card_payout_store.go deleted file mode 100644 index 62334969..00000000 --- a/api/gateway/mntx/internal/service/gateway/card_payout_store.go +++ /dev/null @@ -1,55 +0,0 @@ -package gateway - -import ( - "strings" - "sync" - - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - "google.golang.org/protobuf/proto" -) - -type cardPayoutStore struct { - mu sync.RWMutex - payouts map[string]*mntxv1.CardPayoutState -} - -func newCardPayoutStore() *cardPayoutStore { - return &cardPayoutStore{ - payouts: make(map[string]*mntxv1.CardPayoutState), - } -} - -func (s *cardPayoutStore) Save(p *mntxv1.CardPayoutState) { - if p == nil { - return - } - key := strings.TrimSpace(p.GetPayoutId()) - if key == "" { - return - } - s.mu.Lock() - defer s.mu.Unlock() - s.payouts[key] = cloneCardPayoutState(p) -} - -func (s *cardPayoutStore) Get(payoutID string) (*mntxv1.CardPayoutState, bool) { - id := strings.TrimSpace(payoutID) - if id == "" { - return nil, false - } - s.mu.RLock() - defer s.mu.RUnlock() - val, ok := s.payouts[id] - return cloneCardPayoutState(val), ok -} - -func cloneCardPayoutState(p *mntxv1.CardPayoutState) *mntxv1.CardPayoutState { - if p == nil { - return nil - } - cloned := proto.Clone(p) - if cp, ok := cloned.(*mntxv1.CardPayoutState); ok { - return cp - } - return nil -} diff --git a/api/gateway/mntx/internal/service/gateway/card_payout_store_test.go b/api/gateway/mntx/internal/service/gateway/card_payout_store_test.go new file mode 100644 index 00000000..33889fe4 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/card_payout_store_test.go @@ -0,0 +1,67 @@ +package gateway + +import ( + "context" + + "github.com/tech/sendico/gateway/mntx/storage" + "github.com/tech/sendico/gateway/mntx/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 { + data map[string]*model.CardPayout +} + +func newCardPayoutStore() *cardPayoutStore { + return &cardPayoutStore{ + data: make(map[string]*model.CardPayout), + } +} + +func (s *cardPayoutStore) FindByIdempotencyKey(_ context.Context, key string) (*model.CardPayout, error) { + for _, v := range s.data { + if v.IdempotencyKey == key { + return v, nil + } + } + return nil, nil +} + +func (s *cardPayoutStore) FindByPaymentID(_ context.Context, id string) (*model.CardPayout, error) { + v, ok := s.data[id] + if !ok { + return nil, nil + } + return v, nil +} + +func (s *cardPayoutStore) Upsert(_ context.Context, record *model.CardPayout) error { + s.data[record.PayoutID] = record + return nil +} + +// Save is a helper for tests to pre-populate data. +func (s *cardPayoutStore) Save(state *model.CardPayout) { + s.data[state.PayoutID] = state +} + +// Get is a helper for tests to retrieve data. +func (s *cardPayoutStore) Get(id string) (*model.CardPayout, bool) { + v, ok := s.data[id] + return v, ok +} diff --git a/api/gateway/mntx/internal/service/gateway/card_processor.go b/api/gateway/mntx/internal/service/gateway/card_processor.go index b66f0bbc..cdd6d3cc 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor.go @@ -8,30 +8,33 @@ import ( "strings" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/gateway/mntx/storage" + "github.com/tech/sendico/gateway/mntx/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" - messaging "github.com/tech/sendico/pkg/messaging/envelope" "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model" - nm "github.com/tech/sendico/pkg/model/notification" - "github.com/tech/sendico/pkg/mservice" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" "go.uber.org/zap" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/types/known/timestamppb" ) type cardPayoutProcessor struct { logger mlogger.Logger config monetix.Config clock clockpkg.Clock - store *cardPayoutStore + store storage.Repository httpClient *http.Client producer msg.Producer } -func newCardPayoutProcessor(logger mlogger.Logger, cfg monetix.Config, clock clockpkg.Clock, store *cardPayoutStore, client *http.Client, producer msg.Producer) *cardPayoutProcessor { +func newCardPayoutProcessor( + logger mlogger.Logger, + cfg monetix.Config, + clock clockpkg.Clock, + store storage.Repository, + client *http.Client, + producer msg.Producer, +) *cardPayoutProcessor { return &cardPayoutProcessor{ logger: logger.Named("card_payout_processor"), config: cfg, @@ -46,18 +49,23 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout if p == nil { return nil, merrors.Internal("card payout processor not initialised") } + + req = sanitizeCardPayoutRequest(req) + p.logger.Info("Submitting card payout", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), ) + if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { p.logger.Warn("Monetix configuration is incomplete for payout submission") return nil, merrors.Internal("monetix configuration is incomplete") } - req = sanitizeCardPayoutRequest(req) if err := validateCardPayoutRequest(req, p.config); err != nil { p.logger.Warn("Card payout validation failed", zap.String("payout_id", req.GetPayoutId()), @@ -76,53 +84,88 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout return nil, merrors.Internal("monetix project_id is not configured") } - now := timestamppb.New(p.clock.Now()) - state := &mntxv1.CardPayoutState{ - PayoutId: req.GetPayoutId(), - ProjectId: projectID, - CustomerId: req.GetCustomerId(), - AmountMinor: req.GetAmountMinor(), - Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), - Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, - CreatedAt: now, - UpdatedAt: now, + now := p.clock.Now() + + state := &model.CardPayout{ + PayoutID: strings.TrimSpace(req.GetPayoutId()), + OperationRef: strings.TrimSpace(req.GetOperationRef()), + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + ProjectID: projectID, + CustomerID: strings.TrimSpace(req.GetCustomerId()), + AmountMinor: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + Status: model.PayoutStatusWaiting, + CreatedAt: now, + UpdatedAt: now, } - if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil { - if existing.GetCreatedAt() != nil { - state.CreatedAt = existing.GetCreatedAt() + // Keep CreatedAt/refs if record already exists. + if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err == nil && existing != nil { + if !existing.CreatedAt.IsZero() { + state.CreatedAt = existing.CreatedAt + } + if state.OperationRef == "" { + state.OperationRef = existing.OperationRef + } + if state.IdempotencyKey == "" { + state.IdempotencyKey = existing.IdempotencyKey } } client := monetix.NewClient(p.config, p.httpClient, p.logger) apiReq := buildCardPayoutRequest(projectID, req) + result, err := client.CreateCardPayout(ctx, apiReq) if err != nil { - state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + state.Status = model.PayoutStatusFailed state.ProviderMessage = err.Error() - state.UpdatedAt = timestamppb.New(p.clock.Now()) - p.store.Save(state) + state.UpdatedAt = p.clock.Now() + + if e := p.updatePayoutStatus(ctx, state); e != nil { + p.logger.Warn("Failed to update payout status", + zap.Error(e), + zap.String("payout_id", state.PayoutID), + zap.String("customer_id", state.CustomerID), + zap.String("operation_ref", state.OperationRef), + zap.String("idempotency_key", state.IdempotencyKey), + ) + } + p.logger.Warn("Monetix payout submission failed", - zap.String("payout_id", req.GetPayoutId()), - zap.String("customer_id", req.GetCustomerId()), zap.Error(err), + zap.String("payout_id", state.PayoutID), + zap.String("customer_id", state.CustomerID), + zap.String("operation_ref", state.OperationRef), + zap.String("idempotency_key", state.IdempotencyKey), ) + return nil, err } - state.ProviderPaymentId = result.ProviderRequestID + // Provider request id is the provider-side payment id in your model. + state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID) if result.Accepted { - state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + state.Status = model.PayoutStatusWaiting } else { - state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED - state.ProviderCode = result.ErrorCode - state.ProviderMessage = result.ErrorMessage + state.Status = model.PayoutStatusFailed + state.ProviderCode = strings.TrimSpace(result.ErrorCode) + state.ProviderMessage = strings.TrimSpace(result.ErrorMessage) + } + + state.UpdatedAt = p.clock.Now() + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to store payout", + zap.Error(err), + zap.String("payout_id", state.PayoutID), + zap.String("customer_id", state.CustomerID), + zap.String("operation_ref", state.OperationRef), + zap.String("idempotency_key", state.IdempotencyKey), + ) + // do not fail request here: provider already answered and client expects response } - state.UpdatedAt = timestamppb.New(p.clock.Now()) - p.store.Save(state) resp := &mntxv1.CardPayoutResponse{ - Payout: state, + Payout: StateToProto(state), Accepted: result.Accepted, ProviderRequestId: result.ProviderRequestID, ErrorCode: result.ErrorCode, @@ -130,8 +173,8 @@ func (p *cardPayoutProcessor) Submit(ctx context.Context, req *mntxv1.CardPayout } p.logger.Info("Card payout submission stored", - zap.String("payout_id", state.GetPayoutId()), - zap.String("status", state.GetStatus().String()), + zap.String("payout_id", state.PayoutID), + zap.String("status", string(state.Status)), zap.Bool("accepted", result.Accepted), zap.String("provider_request_id", result.ProviderRequestID), ) @@ -143,18 +186,23 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT if p == nil { return nil, merrors.Internal("card payout processor not initialised") } + + req = sanitizeCardTokenPayoutRequest(req) + p.logger.Info("Submitting card token payout", zap.String("payout_id", strings.TrimSpace(req.GetPayoutId())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), zap.Int64("amount_minor", req.GetAmountMinor()), zap.String("currency", strings.ToUpper(strings.TrimSpace(req.GetCurrency()))), + zap.String("operation_ref", strings.TrimSpace(req.GetOperationRef())), + zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), ) + if strings.TrimSpace(p.config.BaseURL) == "" || strings.TrimSpace(p.config.SecretKey) == "" { p.logger.Warn("Monetix configuration is incomplete for token payout submission") return nil, merrors.Internal("monetix configuration is incomplete") } - req = sanitizeCardTokenPayoutRequest(req) if err := validateCardTokenPayoutRequest(req, p.config); err != nil { p.logger.Warn("Card token payout validation failed", zap.String("payout_id", req.GetPayoutId()), @@ -173,53 +221,69 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT return nil, merrors.Internal("monetix project_id is not configured") } - now := timestamppb.New(p.clock.Now()) - state := &mntxv1.CardPayoutState{ - PayoutId: req.GetPayoutId(), - ProjectId: projectID, - CustomerId: req.GetCustomerId(), - AmountMinor: req.GetAmountMinor(), - Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), - Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, - CreatedAt: now, - UpdatedAt: now, + now := p.clock.Now() + state := &model.CardPayout{ + PayoutID: strings.TrimSpace(req.GetPayoutId()), + OperationRef: strings.TrimSpace(req.GetOperationRef()), + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + ProjectID: projectID, + CustomerID: strings.TrimSpace(req.GetCustomerId()), + AmountMinor: req.GetAmountMinor(), + Currency: strings.ToUpper(strings.TrimSpace(req.GetCurrency())), + Status: model.PayoutStatusWaiting, + CreatedAt: now, + UpdatedAt: now, } - if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil { - if existing.GetCreatedAt() != nil { - state.CreatedAt = existing.GetCreatedAt() + if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err == nil && existing != nil { + if !existing.CreatedAt.IsZero() { + state.CreatedAt = existing.CreatedAt + } + if state.OperationRef == "" { + state.OperationRef = existing.OperationRef + } + if state.IdempotencyKey == "" { + state.IdempotencyKey = existing.IdempotencyKey } } client := monetix.NewClient(p.config, p.httpClient, p.logger) apiReq := buildCardTokenPayoutRequest(projectID, req) + result, err := client.CreateCardTokenPayout(ctx, apiReq) if err != nil { - state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + state.Status = model.PayoutStatusFailed state.ProviderMessage = err.Error() - state.UpdatedAt = timestamppb.New(p.clock.Now()) - p.store.Save(state) + state.UpdatedAt = p.clock.Now() + + _ = p.updatePayoutStatus(ctx, state) + p.logger.Warn("Monetix token payout submission failed", - zap.String("payout_id", req.GetPayoutId()), - zap.String("customer_id", req.GetCustomerId()), + zap.String("payout_id", state.PayoutID), + zap.String("customer_id", state.CustomerID), zap.Error(err), ) + return nil, err } - state.ProviderPaymentId = result.ProviderRequestID + state.ProviderPaymentID = strings.TrimSpace(result.ProviderRequestID) if result.Accepted { - state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + state.Status = model.PayoutStatusWaiting } else { - state.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED - state.ProviderCode = result.ErrorCode - state.ProviderMessage = result.ErrorMessage + state.Status = model.PayoutStatusFailed + state.ProviderCode = strings.TrimSpace(result.ErrorCode) + state.ProviderMessage = strings.TrimSpace(result.ErrorMessage) + } + + state.UpdatedAt = p.clock.Now() + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to update payout status", zap.Error(err)) + return nil, err } - state.UpdatedAt = timestamppb.New(p.clock.Now()) - p.store.Save(state) resp := &mntxv1.CardTokenPayoutResponse{ - Payout: state, + Payout: StateToProto(state), Accepted: result.Accepted, ProviderRequestId: result.ProviderRequestID, ErrorCode: result.ErrorCode, @@ -227,8 +291,8 @@ func (p *cardPayoutProcessor) SubmitToken(ctx context.Context, req *mntxv1.CardT } p.logger.Info("Card token payout submission stored", - zap.String("payout_id", state.GetPayoutId()), - zap.String("status", state.GetStatus().String()), + zap.String("payout_id", state.PayoutID), + zap.String("status", string(state.Status)), zap.Bool("accepted", result.Accepted), zap.String("provider_request_id", result.ProviderRequestID), ) @@ -240,10 +304,12 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke if p == nil { return nil, merrors.Internal("card payout processor not initialised") } + p.logger.Info("Submitting card tokenization", zap.String("request_id", strings.TrimSpace(req.GetRequestId())), zap.String("customer_id", strings.TrimSpace(req.GetCustomerId())), ) + cardInput, err := validateCardTokenizeRequest(req, p.config) if err != nil { p.logger.Warn("Card tokenization validation failed", @@ -265,8 +331,10 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke req = sanitizeCardTokenizeRequest(req) cardInput = extractTokenizeCard(req) + client := monetix.NewClient(p.config, p.httpClient, p.logger) apiReq := buildCardTokenizeRequest(projectID, req, cardInput) + result, err := client.CreateCardTokenization(ctx, apiReq) if err != nil { p.logger.Warn("Monetix tokenization request failed", @@ -298,36 +366,45 @@ func (p *cardPayoutProcessor) Tokenize(ctx context.Context, req *mntxv1.CardToke return resp, nil } -func (p *cardPayoutProcessor) Status(_ context.Context, payoutID string) (*mntxv1.CardPayoutState, error) { +func (p *cardPayoutProcessor) Status(ctx context.Context, payoutID string) (*mntxv1.CardPayoutState, error) { if p == nil { return nil, merrors.Internal("card payout processor not initialised") } id := strings.TrimSpace(payoutID) p.logger.Info("Card payout status requested", zap.String("payout_id", id)) + if id == "" { p.logger.Warn("Payout status requested with empty payout_id") return nil, merrors.InvalidArgument("payout_id is required", "payout_id") } - state, ok := p.store.Get(id) - if !ok || state == nil { - p.logger.Warn("Payout status not found", zap.String("payout_id", id)) + state, err := p.store.Payouts().FindByPaymentID(ctx, id) + if err != nil || state == nil { + p.logger.Warn("Payout status not found", zap.String("payout_id", id), zap.Error(err)) return nil, merrors.NoData("payout not found") } - p.logger.Info("Card payout status resolved", zap.String("payout_id", state.GetPayoutId()), zap.String("status", state.GetStatus().String())) - return state, nil + + p.logger.Info("Card payout status resolved", + zap.String("payout_id", state.PayoutID), + zap.String("status", string(state.Status)), + ) + + return StateToProto(state), nil } func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byte) (int, error) { if p == nil { return http.StatusInternalServerError, merrors.Internal("card payout processor not initialised") } + p.logger.Debug("Processing Monetix callback", zap.Int("payload_bytes", len(payload))) + if len(payload) == 0 { p.logger.Warn("Received empty Monetix callback payload") return http.StatusBadRequest, merrors.InvalidArgument("callback body is empty") } + if strings.TrimSpace(p.config.SecretKey) == "" { p.logger.Warn("Monetix secret key is not configured; cannot verify callback") return http.StatusInternalServerError, merrors.Internal("monetix secret key is not configured") @@ -354,45 +431,48 @@ func (p *cardPayoutProcessor) ProcessCallback(ctx context.Context, payload []byt return status, err } - state, statusLabel := mapCallbackToState(p.clock, p.config, cb) - if existing, ok := p.store.Get(state.GetPayoutId()); ok && existing != nil { - if existing.GetCreatedAt() != nil { - state.CreatedAt = existing.GetCreatedAt() + // mapCallbackToState currently returns proto-state in your code. + // Convert it to mongo model and preserve internal refs if record exists. + pbState, statusLabel := mapCallbackToState(p.clock, p.config, cb) + + // Convert proto -> mongo (operationRef/idempotencyKey are internal; keep empty for now) + state := CardPayoutStateFromProto(p.clock, pbState) + + // Preserve CreatedAt + internal keys from existing record if present. + if existing, err := p.store.Payouts().FindByPaymentID(ctx, state.PayoutID); err != nil { + p.logger.Warn("Failed to fetch payout state while processing callback", + zap.Error(err), + zap.String("payout_id", state.PayoutID), + ) + return http.StatusInternalServerError, err + } else if existing != nil { + if !existing.CreatedAt.IsZero() { + state.CreatedAt = existing.CreatedAt + } + if state.OperationRef == "" { + state.OperationRef = existing.OperationRef + } + if state.IdempotencyKey == "" { + state.IdempotencyKey = existing.IdempotencyKey + } + // keep failure reason if you want, or override depending on callback semantics + if state.FailureReason == "" { + state.FailureReason = existing.FailureReason } } - p.store.Save(state) - p.emitCardPayoutEvent(state) + + if err := p.updatePayoutStatus(ctx, state); err != nil { + p.logger.Warn("Failed to update payout state while processing callback", zap.Error(err)) + } monetix.ObserveCallback(statusLabel) p.logger.Info("Monetix payout callback processed", - zap.String("payout_id", state.GetPayoutId()), + zap.String("payout_id", state.PayoutID), zap.String("status", statusLabel), - zap.String("provider_code", state.GetProviderCode()), - zap.String("provider_message", state.GetProviderMessage()), + zap.String("provider_code", state.ProviderCode), + zap.String("provider_message", state.ProviderMessage), zap.String("masked_account", cb.Account.Number), ) return http.StatusOK, nil } - -func (p *cardPayoutProcessor) emitCardPayoutEvent(state *mntxv1.CardPayoutState) { - if state == nil || p.producer == nil { - return - } - - event := &mntxv1.CardPayoutStatusChangedEvent{Payout: state} - payload, err := protojson.Marshal(event) - if err != nil { - p.logger.Warn("Failed to marshal payout callback event", zap.Error(err)) - return - } - - env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, nm.NAUpdated)) - if _, err := env.Wrap(payload); err != nil { - p.logger.Warn("Failed to wrap payout callback event payload", zap.Error(err)) - return - } - if err := p.producer.SendMessage(env); err != nil { - p.logger.Warn("Failed to publish payout callback event", zap.Error(err)) - } -} diff --git a/api/gateway/mntx/internal/service/gateway/card_processor_test.go b/api/gateway/mntx/internal/service/gateway/card_processor_test.go index d4c73e6a..126f093b 100644 --- a/api/gateway/mntx/internal/service/gateway/card_processor_test.go +++ b/api/gateway/mntx/internal/service/gateway/card_processor_test.go @@ -11,11 +11,11 @@ import ( "time" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/gateway/mntx/storage/model" 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 roundTripperFunc func(*http.Request) (*http.Response, error) @@ -40,10 +40,11 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) { AllowedCurrencies: []string{"RUB"}, } - existingCreated := timestamppb.New(time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC)) - store := newCardPayoutStore() - store.Save(&mntxv1.CardPayoutState{ - PayoutId: "payout-1", + existingCreated := time.Date(2020, 2, 3, 0, 0, 0, 0, time.UTC) + + repo := newMockRepository() + repo.payouts.Save(&model.CardPayout{ + PayoutID: "payout-1", CreatedAt: existingCreated, }) @@ -61,7 +62,7 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) { } now := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) - processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, store, httpClient, nil) + processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: now}, repo, httpClient, nil) req := validCardPayoutRequest() req.ProjectId = 0 @@ -76,27 +77,38 @@ func TestCardPayoutProcessor_Submit_Success(t *testing.T) { 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_PENDING { - t.Fatalf("expected pending status, got %v", resp.GetPayout().GetStatus()) + 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.AsTime()) { + if !resp.GetPayout().GetCreatedAt().AsTime().Equal(existingCreated) { t.Fatalf("expected created_at preserved, got %v", resp.GetPayout().GetCreatedAt().AsTime()) } - stored, ok := store.Get(req.GetPayoutId()) + stored, ok := repo.payouts.Get(req.GetPayoutId()) if !ok || stored == nil { t.Fatalf("expected payout state stored") } - if stored.GetProviderPaymentId() == "" { + 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 := monetix.Config{ AllowedCurrencies: []string{"RUB"}, } - processor := newCardPayoutProcessor(zap.NewNop(), cfg, clockpkg.NewSystem(), newCardPayoutStore(), &http.Client{}, nil) + + processor := newCardPayoutProcessor( + zap.NewNop(), + cfg, + clockpkg.NewSystem(), + newMockRepository(), + &http.Client{}, + nil, + ) _, err := processor.Submit(context.Background(), validCardPayoutRequest()) if err == nil { @@ -114,12 +126,21 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) { StatusProcessing: "processing", AllowedCurrencies: []string{"RUB"}, } - store := newCardPayoutStore() - processor := newCardPayoutProcessor(zap.NewNop(), cfg, staticClock{now: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)}, store, &http.Client{}, nil) + + 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" - cb.Signature = "" + sig, err := monetix.SignPayload(cb, cfg.SecretKey) if err != nil { t.Fatalf("failed to sign callback: %v", err) @@ -139,11 +160,12 @@ func TestCardPayoutProcessor_ProcessCallback(t *testing.T) { t.Fatalf("expected status ok, got %d", status) } - state, ok := store.Get(cb.Payment.ID) + state, ok := repo.payouts.Get(cb.Payment.ID) if !ok || state == nil { t.Fatalf("expected payout state stored") } - if state.GetStatus() != mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED { - t.Fatalf("expected processed status, got %v", state.GetStatus()) + + if state.Status != model.PayoutStatusSuccess { + t.Fatalf("expected success status in model, got %v", state.Status) } } diff --git a/api/gateway/mntx/internal/service/gateway/connector.go b/api/gateway/mntx/internal/service/gateway/connector.go index 30243375..59de971d 100644 --- a/api/gateway/mntx/internal/service/gateway/connector.go +++ b/api/gateway/mntx/internal/service/gateway/connector.go @@ -9,8 +9,8 @@ import ( "github.com/tech/sendico/gateway/mntx/internal/appversion" "github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/merrors" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" 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" ) @@ -19,9 +19,9 @@ const mntxConnectorID = "mntx" func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { return &connectorv1.GetCapabilitiesResponse{ Capabilities: &connectorv1.ConnectorCapabilities{ - ConnectorType: mntxConnectorID, - Version: appversion.Create().Short(), - SupportedAccountKinds: nil, + ConnectorType: mntxConnectorID, + Version: appversion.Create().Short(), + SupportedAccountKinds: nil, SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT}, OperationParams: mntxOperationParams(), }, @@ -161,49 +161,49 @@ func currencyFromOperation(op *connectorv1.Operation) string { func buildCardTokenPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - ProjectId: readerInt64(reader, "project_id"), - CustomerId: strings.TrimSpace(reader.String("customer_id")), - CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), + PayoutId: payoutID, + 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: reader.StringMap("metadata"), + 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: reader.StringMap("metadata"), } return req } func buildCardPayoutRequestFromParams(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest { return &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - ProjectId: readerInt64(reader, "project_id"), - CustomerId: strings.TrimSpace(reader.String("customer_id")), - CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), + PayoutId: payoutID, + 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: reader.StringMap("metadata"), + 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: reader.StringMap("metadata"), } } @@ -217,7 +217,7 @@ func readerInt64(reader params.Reader, key string) int64 { func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt { if state == nil { return &connectorv1.OperationReceipt{ - Status: connectorv1.OperationStatus_PENDING, + Status: connectorv1.OperationStatus_OPERATION_PROCESSING, } } return &connectorv1.OperationReceipt{ @@ -252,14 +252,24 @@ func minorToDecimal(amount int64) string { func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus { switch status { - case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: - return connectorv1.OperationStatus_CONFIRMED + + 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_FAILED - case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING: - return connectorv1.OperationStatus_PENDING + return connectorv1.OperationStatus_OPERATION_FAILED + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + return connectorv1.OperationStatus_OPERATION_CANCELLED + default: - return connectorv1.OperationStatus_PENDING + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED } } diff --git a/api/gateway/mntx/internal/service/gateway/helpers.go b/api/gateway/mntx/internal/service/gateway/helpers.go new file mode 100644 index 00000000..0c2078f1 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/helpers.go @@ -0,0 +1,97 @@ +package gateway + +import ( + "time" + + "github.com/tech/sendico/gateway/mntx/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{ + PayoutID: p.PayoutId, + 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: m.PayoutID, + ProjectId: m.ProjectID, + CustomerId: m.CustomerID, + AmountMinor: m.AmountMinor, + Currency: m.Currency, + Status: payoutStatusToProto(m.Status), + ProviderCode: m.ProviderCode, + ProviderMessage: m.ProviderMessage, + ProviderPaymentId: m.ProviderPaymentID, + CreatedAt: timestamppb.New(m.CreatedAt), + UpdatedAt: timestamppb.New(m.UpdatedAt), + } +} + +func payoutStatusToProto(s model.PayoutStatus) mntxv1.PayoutStatus { + switch s { + case model.PayoutStatusCreated: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED + case model.PayoutStatusProcessing: + return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING + case model.PayoutStatusWaiting: + return mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING + case model.PayoutStatusSuccess: + return mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS + case model.PayoutStatusFailed: + return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + case model.PayoutStatusCancelled: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED + default: + return mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED + } +} + +func payoutStatusFromProto(s mntxv1.PayoutStatus) model.PayoutStatus { + switch s { + case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: + return model.PayoutStatusCreated + + case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: + return model.PayoutStatusWaiting + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: + return model.PayoutStatusSuccess + + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + return model.PayoutStatusFailed + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + return model.PayoutStatusCancelled + + default: + return model.PayoutStatusCreated + } +} diff --git a/api/gateway/mntx/internal/service/gateway/options.go b/api/gateway/mntx/internal/service/gateway/options.go index 015bb2d8..b0686a88 100644 --- a/api/gateway/mntx/internal/service/gateway/options.go +++ b/api/gateway/mntx/internal/service/gateway/options.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/gateway/mntx/storage" "github.com/tech/sendico/pkg/clock" msg "github.com/tech/sendico/pkg/messaging" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" @@ -29,6 +30,12 @@ func WithProducer(p msg.Producer) Option { } } +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) { diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index ec6666f1..37a90425 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/gateway/mntx/internal/appversion" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" + "github.com/tech/sendico/gateway/mntx/storage" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers/gsresponse" clockpkg "github.com/tech/sendico/pkg/clock" @@ -24,7 +25,7 @@ type Service struct { logger mlogger.Logger clock clockpkg.Clock producer msg.Producer - cardStore *cardPayoutStore + storage storage.Repository config monetix.Config httpClient *http.Client card *cardPayoutProcessor @@ -60,10 +61,9 @@ func (r reasonedError) Reason() string { // NewService constructs the Monetix gateway service skeleton. func NewService(logger mlogger.Logger, opts ...Option) *Service { svc := &Service{ - logger: logger.Named("service"), - clock: clockpkg.NewSystem(), - cardStore: newCardPayoutStore(), - config: monetix.DefaultConfig(), + logger: logger.Named("service"), + clock: clockpkg.NewSystem(), + config: monetix.DefaultConfig(), } initMetrics() @@ -84,11 +84,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service { svc.httpClient.Timeout = svc.config.Timeout() } - if svc.cardStore == nil { - svc.cardStore = newCardPayoutStore() - } - - svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer) + svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.storage, svc.httpClient, svc.producer) svc.startDiscoveryAnnouncer() return svc diff --git a/api/gateway/mntx/internal/service/gateway/transfer_notifications.go b/api/gateway/mntx/internal/service/gateway/transfer_notifications.go new file mode 100644 index 00000000..2c674317 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/transfer_notifications.go @@ -0,0 +1,81 @@ +package gateway + +import ( + "context" + "fmt" + + "github.com/tech/sendico/gateway/mntx/storage/model" + 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" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + "go.uber.org/zap" +) + +func isFinalStatus(t *model.CardPayout) bool { + switch t.Status { + case model.PayoutStatusFailed, model.PayoutStatusSuccess, model.PayoutStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.CardPayout) rail.OperationResult { + switch t.Status { + case model.PayoutStatusFailed: + return rail.OperationResultFailed + case model.PayoutStatusSuccess: + return rail.OperationResultSuccess + case model.PayoutStatusCancelled: + return rail.OperationResultCancelled + default: + panic(fmt.Sprintf("unexpected transfer status, %s", t.Status)) + } +} + +func toError(t *model.CardPayout) *gatewayv1.OperationError { + if t.Status == model.PayoutStatusSuccess { + return nil + } + return &gatewayv1.OperationError{ + Message: t.FailureReason, + } +} + +func (p *cardPayoutProcessor) updatePayoutStatus(ctx context.Context, state *model.CardPayout) error { + if err := p.store.Payouts().Upsert(ctx, state); err != nil { + p.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", state.PayoutID), zap.String("status", string(state.Status)), zap.Error(err)) + } + if isFinalStatus(state) { + p.emitTransferStatusEvent(state) + } + return nil +} + +func (p *cardPayoutProcessor) emitTransferStatusEvent(payout *model.CardPayout) { + if p == nil || p.producer == nil || payout == nil { + return + } + + 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: toOpStatus(payout), + OperationRef: payout.OperationRef, + Error: payout.FailureReason, + TransferRef: payout.GetID().Hex(), + } + env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec) + if err := p.producer.SendMessage(env); err != nil { + p.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", payout.ID)) + } +} diff --git a/api/gateway/mntx/storage/model/state.go b/api/gateway/mntx/storage/model/state.go new file mode 100644 index 00000000..70a9996e --- /dev/null +++ b/api/gateway/mntx/storage/model/state.go @@ -0,0 +1,29 @@ +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"` + PayoutID string `bson:"payoutId" json:"payout_id"` + PaymentRef string `bson:"paymentRef" json:"payment_ref"` + OperationRef string `bson:"operationRef" json:"operation_ref"` + IdempotencyKey string `bson:"idempotencyKey" json:"idempotency_key"` + IntentRef string `bson:"intentRef" json:"intentRef"` + ProjectID int64 `bson:"projectId" json:"project_id"` + CustomerID string `bson:"customerId" json:"customer_id"` + AmountMinor int64 `bson:"amountMinor" json:"amount_minor"` + Currency string `bson:"currency" json:"currency"` + Status PayoutStatus `bson:"status" json:"status"` + ProviderCode string `bson:"providerCode,omitempty" json:"provider_code,omitempty"` + ProviderMessage string `bson:"providerMessage,omitempty" json:"provider_message,omitempty"` + ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"provider_payment_id,omitempty"` + FailureReason string `bson:"failureReason,omitempty" json:"failure_reason,omitempty"` + + CreatedAt time.Time `bson:"createdAt" json:"created_at"` + UpdatedAt time.Time `bson:"updatedAt" json:"updated_at"` +} diff --git a/api/gateway/mntx/storage/model/status.go b/api/gateway/mntx/storage/model/status.go new file mode 100644 index 00000000..1fc14d70 --- /dev/null +++ b/api/gateway/mntx/storage/model/status.go @@ -0,0 +1,13 @@ +package model + +type PayoutStatus string + +const ( + PayoutStatusCreated PayoutStatus = "created" // record exists, not started + PayoutStatusProcessing PayoutStatus = "processing" // we are working on it + PayoutStatusWaiting PayoutStatus = "waiting" // waiting external world + + PayoutStatusSuccess PayoutStatus = "success" // final success + PayoutStatusFailed PayoutStatus = "failed" // final failure + PayoutStatusCancelled PayoutStatus = "cancelled" // final cancelled +) diff --git a/api/gateway/mntx/storage/mongo/repository.go b/api/gateway/mntx/storage/mongo/repository.go new file mode 100644 index 00000000..c48c08d2 --- /dev/null +++ b/api/gateway/mntx/storage/mongo/repository.go @@ -0,0 +1,69 @@ +package mongo + +import ( + "context" + "time" + + "github.com/tech/sendico/gateway/mntx/storage" + "github.com/tech/sendico/gateway/mntx/storage/mongo/store" + "github.com/tech/sendico/pkg/db" + "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 + + payouts storage.PayoutsStore +} + +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, + } + 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 + } + result.payouts = payoutsStore + result.logger.Info("Payouts gateway MongoDB storage initialised") + return result, nil +} + +func (r *Repository) Payouts() storage.PayoutsStore { + return r.payouts +} + +var _ storage.Repository = (*Repository)(nil) diff --git a/api/gateway/mntx/storage/mongo/store/payouts.go b/api/gateway/mntx/storage/mongo/store/payouts.go new file mode 100644 index 00000000..700a3fc9 --- /dev/null +++ b/api/gateway/mntx/storage/mongo/store/payouts.go @@ -0,0 +1,137 @@ +package store + +import ( + "context" + "errors" + "strings" + "time" + + storage "github.com/tech/sendico/gateway/mntx/storage" + "github.com/tech/sendico/gateway/mntx/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" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.uber.org/zap" +) + +const ( + payoutsCollection = "card_payouts" + payoutIdemField = "idempotencyKey" + payoutIdField = "payoutId" +) + +type Payouts struct { + logger mlogger.Logger + coll *mongo.Collection +} + +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: 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, + coll: db.Collection(payoutsCollection), + } + p.logger.Debug("Payouts store initialised") + return p, nil +} + +func (p *Payouts) findOneByField(ctx context.Context, field, value string) (*model.CardPayout, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, merrors.InvalidArgument("lookup key is required", field) + } + + var result model.CardPayout + err := p.coll.FindOne(ctx, bson.M{field: value}).Decode(&result) + if err == mongo.ErrNoDocuments { + return nil, nil + } + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + p.logger.Warn("Payout record lookup failed", + zap.String("field", field), + zap.String("value", value), + zap.Error(err)) + } + return nil, err + } + return &result, nil +} + +func (p *Payouts) FindByIdempotencyKey(ctx context.Context, key string) (*model.CardPayout, error) { + return p.findOneByField(ctx, payoutIdemField, key) // 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 { + return merrors.InvalidArgument("payout record is nil", "record") + } + + record.OperationRef = strings.TrimSpace(record.OperationRef) + record.PayoutID = strings.TrimSpace(record.PayoutID) + record.CustomerID = strings.TrimSpace(record.CustomerID) + record.ProviderCode = strings.TrimSpace(record.ProviderCode) + record.ProviderPaymentID = strings.TrimSpace(record.ProviderPaymentID) + + if record.OperationRef == "" { + return merrors.InvalidArgument("operation ref is required", "operation_ref") + } + + now := time.Now() + if record.CreatedAt.IsZero() { + record.CreatedAt = now + } + record.UpdatedAt = now + + // critical: never let caller override _id + record.ID = bson.NilObjectID + + update := bson.M{ + "$set": record, + } + + _, err := p.coll.UpdateOne( + ctx, + bson.M{payoutIdemField: record.OperationRef}, + update, + options.UpdateOne().SetUpsert(true), + ) + if err != nil { + if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + p.logger.Warn("Failed to upsert payout record", + zap.String("operation_ref", record.OperationRef), + zap.String("payout_id", record.PayoutID), + zap.Error(err)) + } + return err + } + return nil +} + +var _ storage.PayoutsStore = (*Payouts)(nil) diff --git a/api/gateway/mntx/storage/storage.go b/api/gateway/mntx/storage/storage.go new file mode 100644 index 00000000..f330a62a --- /dev/null +++ b/api/gateway/mntx/storage/storage.go @@ -0,0 +1,20 @@ +package storage + +import ( + "context" + + "github.com/tech/sendico/gateway/mntx/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) + FindByPaymentID(ctx context.Context, key string) (*model.CardPayout, error) + Upsert(ctx context.Context, record *model.CardPayout) error +} diff --git a/api/gateway/tgsettle/go.mod b/api/gateway/tgsettle/go.mod index 361ef24c..eb7312d1 100644 --- a/api/gateway/tgsettle/go.mod +++ b/api/gateway/tgsettle/go.mod @@ -45,5 +45,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/gateway/tgsettle/go.sum b/api/gateway/tgsettle/go.sum index 7e99ce3a..6eb3c464 100644 --- a/api/gateway/tgsettle/go.sum +++ b/api/gateway/tgsettle/go.sum @@ -208,8 +208,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tgsettle/internal/service/gateway/connector.go b/api/gateway/tgsettle/internal/service/gateway/connector.go index 3fcd6097..012d7562 100644 --- a/api/gateway/tgsettle/internal/service/gateway/connector.go +++ b/api/gateway/tgsettle/internal/service/gateway/connector.go @@ -7,8 +7,8 @@ import ( "github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/merrors" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.uber.org/zap" ) @@ -18,9 +18,9 @@ const tgsettleConnectorID = "tgsettle" func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { return &connectorv1.GetCapabilitiesResponse{ Capabilities: &connectorv1.ConnectorCapabilities{ - ConnectorType: tgsettleConnectorID, - Version: "", - SupportedAccountKinds: nil, + ConnectorType: tgsettleConnectorID, + Version: "", + SupportedAccountKinds: nil, SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER}, OperationParams: tgsettleOperationParams(), }, @@ -64,7 +64,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp } paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id")) if paymentIntentID == "" { - paymentIntentID = strings.TrimSpace(reader.String("client_reference")) + paymentIntentID = strings.TrimSpace(reader.String("payment_ref")) } if paymentIntentID == "" { paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID]) @@ -122,7 +122,9 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp Destination: dest, Amount: normalizedAmount, Metadata: metadata, - ClientReference: paymentIntentID, + PaymentRef: paymentIntentID, + IntentRef: strings.TrimSpace(op.GetIntentRef()), + OperationRef: strings.TrimSpace(op.GetOperationRef()), }) if err != nil { s.logger.Warn("Submit operation transfer failed", append(logFields, zap.Error(err))...) @@ -239,14 +241,29 @@ func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation { func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return connectorv1.OperationStatus_CONFIRMED + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + case chainv1.TransferStatus_TRANSFER_FAILED: - return connectorv1.OperationStatus_FAILED + return connectorv1.OperationStatus_OPERATION_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return connectorv1.OperationStatus_CANCELED + return connectorv1.OperationStatus_OPERATION_CANCELLED + + case chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED: + fallthrough default: - return connectorv1.OperationStatus_PENDING + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED } } diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index bb77e175..c0d28f50 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -16,7 +16,6 @@ import ( mb "github.com/tech/sendico/pkg/messaging/broker" cons "github.com/tech/sendico/pkg/messaging/consumer" confirmations "github.com/tech/sendico/pkg/messaging/notifications/confirmations" - paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" np "github.com/tech/sendico/pkg/messaging/notifications/processor" tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram" "github.com/tech/sendico/pkg/mlogger" @@ -188,12 +187,7 @@ func (s *Service) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransfe return nil, err } if existing != nil { - existing, err = s.expirePaymentIfNeeded(ctx, existing) - if err != nil { - s.logger.Warn("Submit transfer status refresh failed", append(logFields, zap.Error(err))...) - return nil, err - } - s.logger.Info("Submit transfer idempotent hit", append(logFields, zap.String("status", string(paymentStatus(existing))))...) + s.logger.Info("Submit transfer idempotent hit", append(logFields, zap.String("status", string(existing.Status)))...) return &chainv1.SubmitTransferResponse{Transfer: transferFromPayment(existing, req)}, nil } if err := s.onIntent(ctx, intent); err != nil { @@ -225,14 +219,9 @@ func (s *Service) GetTransfer(ctx context.Context, req *chainv1.GetTransferReque return nil, err } if existing != nil { - existing, err = s.expirePaymentIfNeeded(ctx, existing) - if err != nil { - s.logger.Warn("Get transfer status refresh failed", append(logFields, zap.Error(err))...) - return nil, err - } s.logger.Info("Get transfer resolved from execution", append(logFields, zap.String("payment_intent_id", strings.TrimSpace(existing.PaymentIntentID)), - zap.String("status", string(paymentStatus(existing))), + zap.String("status", string(existing.Status)), )...) return &chainv1.GetTransferResponse{Transfer: transferFromPayment(existing, nil)}, nil } @@ -274,27 +263,30 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte return err } if existing != nil { - existing, err = s.expirePaymentIfNeeded(ctx, existing) - if err != nil { - return err - } s.logger.Info("Payment gateway intent already recorded", zap.String("idempotency_key", confirmReq.RequestID), zap.String("payment_intent_id", confirmReq.PaymentIntentID), zap.String("quote_ref", confirmReq.QuoteRef), zap.String("rail", confirmReq.Rail), - zap.String("status", string(paymentStatus(existing)))) + zap.String("status", string(existing.Status))) return nil } record := paymentRecordFromIntent(intent, confirmReq) - if err := s.repo.Payments().Upsert(ctx, record); err != nil { - s.logger.Warn("Failed to persist pending payment", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID)) + if err := s.updateTransferStatus(ctx, record); err != nil { + s.logger.Warn("Failed to persist payment record", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID)) return err } if err := s.sendConfirmationRequest(confirmReq); err != nil { s.logger.Warn("Failed to publish confirmation request", zap.Error(err), zap.String("idempotency_key", confirmReq.RequestID)) - s.markPaymentExpired(ctx, record, time.Now()) + // If the confirmation request was not sent, we keep the record in waiting + // (or it can be marked as failed — depending on your semantics). + // Here, failed is chosen to avoid it hanging indefinitely. + record.Status = storagemodel.PaymentStatusFailed + record.UpdatedAt = time.Now() + if e := s.updateTransferStatus(ctx, record); e != nil { + s.logger.Warn("Failed to update payment status change", zap.Error(e), zap.String("idempotency_key", confirmReq.RequestID)) + } return err } return nil @@ -302,65 +294,88 @@ func (s *Service) onIntent(ctx context.Context, intent *model.PaymentGatewayInte func (s *Service) onConfirmationResult(ctx context.Context, result *model.ConfirmationResult) error { if result == nil { - s.logger.Warn("Confirmation result rejected", zap.String("reason", "result is nil")) return merrors.InvalidArgument("confirmation result is nil", "result") } + requestID := strings.TrimSpace(result.RequestID) if requestID == "" { - s.logger.Warn("Confirmation result rejected", zap.String("reason", "request_id is required")) return merrors.InvalidArgument("confirmation request_id is required", "request_id") } + record, err := s.loadPayment(ctx, requestID) if err != nil { - s.logger.Warn("Confirmation result lookup failed", zap.Error(err), zap.String("request_id", requestID)) return err } if record == nil { - s.logger.Warn("Confirmation result ignored: payment not found", zap.String("request_id", requestID)) return nil } + // Store raw reply for audit/debug purposes. This does NOT affect payment state. if result.RawReply != nil && s.repo != nil && s.repo.TelegramConfirmations() != nil { - if err := s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{ + if e := s.repo.TelegramConfirmations().Upsert(ctx, &storagemodel.TelegramConfirmation{ RequestID: requestID, PaymentIntentID: record.PaymentIntentID, QuoteRef: record.QuoteRef, RawReply: result.RawReply, - }); err != nil { - s.logger.Warn("Failed to store telegram confirmation", zap.Error(err), zap.String("request_id", requestID)) - } else { - s.logger.Info("Stored telegram confirmation", zap.String("request_id", requestID), - zap.String("payment_intent_id", record.PaymentIntentID), - zap.String("reply_text", result.RawReply.Text), zap.String("reply_user_id", result.RawReply.FromUserID), - zap.String("reply_user", result.RawReply.FromUsername)) + }); e != nil { + s.logger.Warn("Failed to store confirmation error", zap.Error(e), + zap.String("request_id", requestID), + zap.String("status", string(result.Status))) } } - nextStatus := paymentStatusFromResult(result) - currentStatus := paymentStatus(record) - if currentStatus == storagemodel.PaymentStatusExecuted || currentStatus == storagemodel.PaymentStatusExpired { - s.logger.Info("Confirmation result ignored: payment already finalized", - zap.String("request_id", requestID), - zap.String("status", string(currentStatus))) + // If the payment is already finalized — ignore the result. + switch record.Status { + case storagemodel.PaymentStatusSuccess, + storagemodel.PaymentStatusFailed, + storagemodel.PaymentStatusCancelled: return nil } - s.applyPaymentResult(record, nextStatus, result) - if err := s.repo.Payments().Upsert(ctx, record); err != nil { - s.logger.Warn("Failed to persist payment status", zap.Error(err), zap.String("request_id", requestID)) + + now := time.Now() + + switch result.Status { + + // FINAL: confirmation succeeded + case model.ConfirmationStatusConfirmed: + record.Status = storagemodel.PaymentStatusSuccess + record.ExecutedMoney = result.Money + if record.ExecutedAt.IsZero() { + record.ExecutedAt = now + } + record.UpdatedAt = now + + // FINAL: confirmation rejected or timed out + case model.ConfirmationStatusRejected, + model.ConfirmationStatusTimeout: + record.Status = storagemodel.PaymentStatusFailed + record.UpdatedAt = now + + // NOT FINAL: do absolutely nothing + case model.ConfirmationStatusClarified: + s.logger.Debug("Confirmation clarified — no state change", + zap.String("request_id", requestID)) + return nil + + default: + s.logger.Debug("Non-final confirmation status — ignored", + zap.String("request_id", requestID), + zap.String("status", string(result.Status))) + return nil + } + + // The ONLY place where state is persisted and rail event may be emitted + if err := s.updateTransferStatus(ctx, record); err != nil { return err } - intent := intentFromPayment(record) - s.publishExecution(intent, result) s.publishTelegramReaction(result) + return nil } func (s *Service) buildConfirmationRequest(intent *model.PaymentGatewayIntent) (*model.ConfirmationRequest, error) { - targetChatID := strings.TrimSpace(intent.TargetChatID) - if targetChatID == "" { - targetChatID = s.chatID - } + targetChatID := s.chatID if targetChatID == "" { return nil, merrors.InvalidArgument("target_chat_id is required", "target_chat_id") } @@ -414,38 +429,6 @@ func (s *Service) sendConfirmationRequest(request *model.ConfirmationRequest) er return nil } -func (s *Service) publishExecution(intent *model.PaymentGatewayIntent, result *model.ConfirmationResult) { - if s == nil || intent == nil || result == nil || s.producer == nil { - return - } - exec := &model.PaymentGatewayExecution{ - PaymentIntentID: intent.PaymentIntentID, - IdempotencyKey: intent.IdempotencyKey, - QuoteRef: intent.QuoteRef, - ExecutedMoney: result.Money, - Status: result.Status, - RequestID: result.RequestID, - RawReply: result.RawReply, - } - env := paymentgateway.PaymentGatewayExecution(string(mservice.PaymentGateway), exec) - if err := s.producer.SendMessage(env); err != nil { - s.logger.Warn("Failed to publish gateway execution result", - zap.Error(err), - zap.String("request_id", result.RequestID), - zap.String("idempotency_key", intent.IdempotencyKey), - zap.String("payment_intent_id", intent.PaymentIntentID), - zap.String("quote_ref", intent.QuoteRef), - zap.String("status", string(result.Status))) - return - } - s.logger.Info("Published gateway execution result", - zap.String("request_id", result.RequestID), - zap.String("idempotency_key", intent.IdempotencyKey), - zap.String("payment_intent_id", intent.PaymentIntentID), - zap.String("quote_ref", intent.QuoteRef), - zap.String("status", string(result.Status))) -} - func (s *Service) publishTelegramReaction(result *model.ConfirmationResult) { if s == nil || s.producer == nil || result == nil || result.RawReply == nil { return @@ -490,46 +473,6 @@ func (s *Service) loadPayment(ctx context.Context, requestID string) (*storagemo return s.repo.Payments().FindByIdempotencyKey(ctx, requestID) } -func (s *Service) expirePaymentIfNeeded(ctx context.Context, record *storagemodel.PaymentRecord) (*storagemodel.PaymentRecord, error) { - if record == nil { - return nil, nil - } - status := paymentStatus(record) - if status != storagemodel.PaymentStatusPending { - return record, nil - } - if record.ExpiresAt.IsZero() { - return record, nil - } - if time.Now().Before(record.ExpiresAt) { - return record, nil - } - record.Status = storagemodel.PaymentStatusExpired - if record.ExpiredAt.IsZero() { - record.ExpiredAt = time.Now() - } - if s != nil && s.repo != nil && s.repo.Payments() != nil { - if err := s.repo.Payments().Upsert(ctx, record); err != nil { - return record, err - } - } - return record, nil -} - -func (s *Service) markPaymentExpired(ctx context.Context, record *storagemodel.PaymentRecord, when time.Time) { - if record == nil || s == nil || s.repo == nil || s.repo.Payments() == nil { - return - } - if when.IsZero() { - when = time.Now() - } - record.Status = storagemodel.PaymentStatusExpired - record.ExpiredAt = when - if err := s.repo.Payments().Upsert(ctx, record); err != nil { - s.logger.Warn("Failed to mark payment as expired", zap.Error(err), zap.String("request_id", record.IdempotencyKey)) - } -} - func (s *Service) startAnnouncer() { if s == nil || s.producer == nil { return @@ -557,78 +500,37 @@ func normalizeIntent(intent *model.PaymentGatewayIntent) *model.PaymentGatewayIn cp.IdempotencyKey = strings.TrimSpace(cp.IdempotencyKey) cp.OutgoingLeg = strings.TrimSpace(cp.OutgoingLeg) cp.QuoteRef = strings.TrimSpace(cp.QuoteRef) - cp.TargetChatID = strings.TrimSpace(cp.TargetChatID) if cp.RequestedMoney != nil { cp.RequestedMoney.Amount = strings.TrimSpace(cp.RequestedMoney.Amount) cp.RequestedMoney.Currency = strings.TrimSpace(cp.RequestedMoney.Currency) } + cp.IntentRef = strings.TrimSpace(cp.IntentRef) + cp.OperationRef = strings.TrimSpace(cp.OperationRef) return &cp } -func paymentStatus(record *storagemodel.PaymentRecord) storagemodel.PaymentStatus { - if record == nil { - return storagemodel.PaymentStatusPending - } - if record.Status != "" { - return record.Status - } - if record.ExecutedMoney != nil || !record.ExecutedAt.IsZero() { - return storagemodel.PaymentStatusExecuted - } - return storagemodel.PaymentStatusPending -} - -func paymentStatusFromResult(result *model.ConfirmationResult) storagemodel.PaymentStatus { - if result == nil { - return storagemodel.PaymentStatusPending - } - switch result.Status { - case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified: - return storagemodel.PaymentStatusExecuted - case model.ConfirmationStatusTimeout, model.ConfirmationStatusRejected: - return storagemodel.PaymentStatusExpired - default: - return storagemodel.PaymentStatusPending - } -} - -func (s *Service) applyPaymentResult(record *storagemodel.PaymentRecord, status storagemodel.PaymentStatus, result *model.ConfirmationResult) { - if record == nil { - return - } - record.Status = status - switch status { - case storagemodel.PaymentStatusExecuted: - record.ExecutedMoney = result.Money - if record.ExecutedAt.IsZero() { - record.ExecutedAt = time.Now() - } - case storagemodel.PaymentStatusExpired: - if record.ExpiredAt.IsZero() { - record.ExpiredAt = time.Now() - } - } -} - func paymentRecordFromIntent(intent *model.PaymentGatewayIntent, confirmReq *model.ConfirmationRequest) *storagemodel.PaymentRecord { record := &storagemodel.PaymentRecord{ - Status: storagemodel.PaymentStatusPending, + Status: storagemodel.PaymentStatusWaiting, } if intent != nil { record.IdempotencyKey = strings.TrimSpace(intent.IdempotencyKey) record.PaymentIntentID = strings.TrimSpace(intent.PaymentIntentID) record.QuoteRef = strings.TrimSpace(intent.QuoteRef) record.OutgoingLeg = strings.TrimSpace(intent.OutgoingLeg) - record.TargetChatID = strings.TrimSpace(intent.TargetChatID) record.RequestedMoney = intent.RequestedMoney + record.IntentRef = intent.IntentRef + record.OperationRef = intent.OperationRef } if confirmReq != nil { record.IdempotencyKey = strings.TrimSpace(confirmReq.RequestID) record.PaymentIntentID = strings.TrimSpace(confirmReq.PaymentIntentID) record.QuoteRef = strings.TrimSpace(confirmReq.QuoteRef) record.OutgoingLeg = strings.TrimSpace(confirmReq.Rail) - record.TargetChatID = strings.TrimSpace(confirmReq.TargetChatID) record.RequestedMoney = confirmReq.RequestedMoney + record.IntentRef = strings.TrimSpace(confirmReq.IntentRef) + record.OperationRef = strings.TrimSpace(confirmReq.OperationRef) + // ExpiresAt is not used to derive an "expired" status — it can be kept for informational purposes only. if confirmReq.TimeoutSeconds > 0 { record.ExpiresAt = time.Now().Add(time.Duration(confirmReq.TimeoutSeconds) * time.Second) } @@ -641,12 +543,14 @@ func intentFromPayment(record *storagemodel.PaymentRecord) *model.PaymentGateway return nil } return &model.PaymentGatewayIntent{ + PaymentRef: strings.TrimSpace(record.PaymentRef), PaymentIntentID: strings.TrimSpace(record.PaymentIntentID), IdempotencyKey: strings.TrimSpace(record.IdempotencyKey), OutgoingLeg: strings.TrimSpace(record.OutgoingLeg), QuoteRef: strings.TrimSpace(record.QuoteRef), + IntentRef: strings.TrimSpace(record.IntentRef), + OperationRef: strings.TrimSpace(record.OperationRef), RequestedMoney: record.RequestedMoney, - TargetChatID: strings.TrimSpace(record.TargetChatID), } } @@ -678,13 +582,17 @@ func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, d Currency: sourceCurrency, } } - paymentIntentID := strings.TrimSpace(req.GetClientReference()) + paymentIntentID := strings.TrimSpace(req.GetIntentRef()) if paymentIntentID == "" { paymentIntentID = strings.TrimSpace(metadata[metadataPaymentIntentID]) } if paymentIntentID == "" { return nil, merrors.InvalidArgument("submit_transfer: payment_intent_id is required") } + paymentRef := strings.TrimSpace(req.PaymentRef) + if paymentRef == "" { + return nil, merrors.InvalidArgument("submit_transfer: payment_ref is required") + } quoteRef := strings.TrimSpace(metadata[metadataQuoteRef]) targetChatID := strings.TrimSpace(metadata[metadataTargetChatID]) outgoingLeg := strings.TrimSpace(metadata[metadataOutgoingLeg]) @@ -695,12 +603,12 @@ func intentFromSubmitTransfer(req *chainv1.SubmitTransferRequest, defaultRail, d targetChatID = strings.TrimSpace(defaultChatID) } return &model.PaymentGatewayIntent{ + PaymentRef: paymentRef, PaymentIntentID: paymentIntentID, IdempotencyKey: idempotencyKey, OutgoingLeg: outgoingLeg, QuoteRef: quoteRef, RequestedMoney: requestedMoney, - TargetChatID: targetChatID, }, nil } @@ -708,15 +616,14 @@ func transferFromRequest(req *chainv1.SubmitTransferRequest) *chainv1.Transfer { if req == nil { return nil } - amount := req.GetAmount() return &chainv1.Transfer{ TransferRef: strings.TrimSpace(req.GetIdempotencyKey()), IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()), SourceWalletRef: strings.TrimSpace(req.GetSourceWalletRef()), Destination: req.GetDestination(), - RequestedAmount: amount, - Status: chainv1.TransferStatus_TRANSFER_SUBMITTED, + RequestedAmount: req.GetAmount(), + Status: chainv1.TransferStatus_TRANSFER_CREATED, } } @@ -724,20 +631,32 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit if record == nil { return nil } + var requested *moneyv1.Money if req != nil && req.GetAmount() != nil { requested = req.GetAmount() } else { requested = moneyFromPayment(record.RequestedMoney) } - net := moneyFromPayment(record.ExecutedMoney) - status := chainv1.TransferStatus_TRANSFER_SUBMITTED - switch paymentStatus(record) { - case storagemodel.PaymentStatusExecuted: - status = chainv1.TransferStatus_TRANSFER_CONFIRMED - case storagemodel.PaymentStatusExpired: + net := moneyFromPayment(record.RequestedMoney) + + var status chainv1.TransferStatus + + switch record.Status { + case storagemodel.PaymentStatusSuccess: + status = chainv1.TransferStatus_TRANSFER_SUCCESS + case storagemodel.PaymentStatusCancelled: status = chainv1.TransferStatus_TRANSFER_CANCELLED + case storagemodel.PaymentStatusFailed: + status = chainv1.TransferStatus_TRANSFER_FAILED + case storagemodel.PaymentStatusProcessing: + status = chainv1.TransferStatus_TRANSFER_PROCESSING + case storagemodel.PaymentStatusWaiting: + status = chainv1.TransferStatus_TRANSFER_WAITING + default: + status = chainv1.TransferStatus_TRANSFER_CREATED } + transfer := &chainv1.Transfer{ TransferRef: strings.TrimSpace(record.IdempotencyKey), IdempotencyKey: strings.TrimSpace(record.IdempotencyKey), @@ -745,11 +664,13 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit NetAmount: net, Status: status, } + if req != nil { transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef()) transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef()) transfer.Destination = req.GetDestination() } + if !record.ExecutedAt.IsZero() { ts := timestamppb.New(record.ExecutedAt) transfer.CreatedAt = ts @@ -761,6 +682,7 @@ func transferFromPayment(record *storagemodel.PaymentRecord, req *chainv1.Submit transfer.CreatedAt = timestamppb.New(record.CreatedAt) } } + return transfer } @@ -787,3 +709,27 @@ func readEnv(env string) string { } var _ grpcapp.Service = (*Service)(nil) + +func statusFromConfirmationResult(r *model.ConfirmationResult) storagemodel.PaymentStatus { + if r == nil { + return storagemodel.PaymentStatusWaiting + } + + switch r.Status { + + case model.ConfirmationStatusConfirmed: + return storagemodel.PaymentStatusProcessing + + case model.ConfirmationStatusClarified: + return storagemodel.PaymentStatusWaiting + + case model.ConfirmationStatusRejected: + return storagemodel.PaymentStatusFailed + + case model.ConfirmationStatusTimeout: + return storagemodel.PaymentStatusFailed + + default: + return storagemodel.PaymentStatusFailed + } +} diff --git a/api/gateway/tgsettle/internal/service/gateway/service_test.go b/api/gateway/tgsettle/internal/service/gateway/service_test.go index e7a27e45..2d50fcbe 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service_test.go +++ b/api/gateway/tgsettle/internal/service/gateway/service_test.go @@ -2,22 +2,23 @@ package gateway import ( "context" - "encoding/json" "sync" "testing" "github.com/tech/sendico/gateway/tgsettle/storage" storagemodel "github.com/tech/sendico/gateway/tgsettle/storage/model" envelope "github.com/tech/sendico/pkg/messaging/envelope" + tnotifications "github.com/tech/sendico/pkg/messaging/notifications/telegram" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" "github.com/tech/sendico/pkg/model" - notification "github.com/tech/sendico/pkg/model/notification" "github.com/tech/sendico/pkg/mservice" paymenttypes "github.com/tech/sendico/pkg/payments/types" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ) +// +// FAKE STORES +// + type fakePaymentsStore struct { mu sync.Mutex records map[string]*storagemodel.PaymentRecord @@ -26,6 +27,9 @@ type fakePaymentsStore struct { func (f *fakePaymentsStore) FindByIdempotencyKey(_ context.Context, key string) (*storagemodel.PaymentRecord, error) { f.mu.Lock() defer f.mu.Unlock() + if f.records == nil { + return nil, nil + } return f.records[key], nil } @@ -67,286 +71,212 @@ func (f *fakeRepo) TelegramConfirmations() storage.TelegramConfirmationsStore { return f.tg } -type captureProducer struct { - mu sync.Mutex - confirmationRequests []*model.ConfirmationRequest - executions []*model.PaymentGatewayExecution -} +// +// FAKE BROKER (ОБЯЗАТЕЛЕН ДЛЯ СЕРВИСА) +// -func (c *captureProducer) SendMessage(env envelope.Envelope) error { - _, _ = env.Serialize() - switch env.GetSignature().ToString() { - case model.NewNotification(mservice.Notifications, notification.NAConfirmationRequest).ToString(): - var req model.ConfirmationRequest - if err := json.Unmarshal(env.GetData(), &req); err == nil { - c.mu.Lock() - c.confirmationRequests = append(c.confirmationRequests, &req) - c.mu.Unlock() - } - case model.NewNotification(mservice.PaymentGateway, notification.NAPaymentGatewayExecution).ToString(): - var exec model.PaymentGatewayExecution - if err := json.Unmarshal(env.GetData(), &exec); err == nil { - c.mu.Lock() - c.executions = append(c.executions, &exec) - c.mu.Unlock() - } - } +type fakeBroker struct{} + +func (f *fakeBroker) Publish(_ envelope.Envelope) error { return nil } -func (c *captureProducer) Reset() { +func (f *fakeBroker) Subscribe(event model.NotificationEvent) (<-chan envelope.Envelope, error) { + return nil, nil +} + +func (f *fakeBroker) Unsubscribe(event model.NotificationEvent, subChan <-chan envelope.Envelope) error { + return nil +} + +// +// CAPTURE ONLY TELEGRAM REACTIONS +// + +type captureProducer struct { + mu sync.Mutex + reactions []envelope.Envelope + sig string +} + +func (c *captureProducer) SendMessage(env envelope.Envelope) error { + if env.GetSignature().ToString() != c.sig { + return nil + } c.mu.Lock() - defer c.mu.Unlock() - c.confirmationRequests = nil - c.executions = nil + c.reactions = append(c.reactions, env) + c.mu.Unlock() + return nil } -func TestOnIntentCreatesConfirmationRequest(t *testing.T) { +// +// TESTS +// + +func newTestService(_ *testing.T) (*Service, *fakeRepo, *captureProducer) { logger := mloggerfactory.NewLogger(false) - repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} - prod := &captureProducer{} - t.Setenv("PGS_CHAT_ID", "-100") - svc := NewService(logger, repo, prod, nil, Config{ - Rail: "card", - TargetChatIDEnv: "PGS_CHAT_ID", - TimeoutSeconds: 90, - AcceptedUserIDs: []string{"42"}, + + repo := &fakeRepo{ + payments: &fakePaymentsStore{}, + tg: &fakeTelegramStore{}, + } + + sigEnv := tnotifications.TelegramReaction(string(mservice.PaymentGateway), &model.TelegramReactionRequest{ + RequestID: "x", + ChatID: "1", + MessageID: "2", + Emoji: "ok", }) - prod.Reset() - intent := &model.PaymentGatewayIntent{ + prod := &captureProducer{ + sig: sigEnv.GetSignature().ToString(), + } + + svc := NewService(logger, repo, prod, &fakeBroker{}, Config{ + Rail: "card", + SuccessReaction: "👍", + }) + + return svc, repo, prod +} + +func TestConfirmed(t *testing.T) { + svc, repo, prod := newTestService(t) + + _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ + IdempotencyKey: "idem-1", PaymentIntentID: "pi-1", - IdempotencyKey: "idem-1", - OutgoingLeg: "card", QuoteRef: "quote-1", - RequestedMoney: &paymenttypes.Money{Amount: "10.50", Currency: "USD"}, - TargetChatID: "", - } - if err := svc.onIntent(context.Background(), intent); err != nil { - t.Fatalf("onIntent error: %v", err) - } - if len(prod.confirmationRequests) != 1 { - t.Fatalf("expected 1 confirmation request, got %d", len(prod.confirmationRequests)) - } - req := prod.confirmationRequests[0] - if req.RequestID != "idem-1" || req.PaymentIntentID != "pi-1" || req.QuoteRef != "quote-1" { - t.Fatalf("unexpected confirmation request fields: %#v", req) - } - if req.TargetChatID != "-100" { - t.Fatalf("expected target chat id -100, got %q", req.TargetChatID) - } - if req.RequestedMoney == nil || req.RequestedMoney.Amount != "10.50" || req.RequestedMoney.Currency != "USD" { - t.Fatalf("requested money mismatch: %#v", req.RequestedMoney) - } - if req.TimeoutSeconds != 90 { - t.Fatalf("expected timeout 90, got %d", req.TimeoutSeconds) - } - if req.SourceService != string(mservice.PaymentGateway) || req.Rail != "card" { - t.Fatalf("unexpected source/rail: %#v", req) - } - record := repo.payments.records["idem-1"] - if record == nil { - t.Fatalf("expected pending payment to be stored") - } - if record.Status != storagemodel.PaymentStatusPending { - t.Fatalf("expected pending status, got %q", record.Status) - } - if record.RequestedMoney == nil || record.RequestedMoney.Amount != "10.50" { - t.Fatalf("requested money not stored correctly: %#v", record.RequestedMoney) - } -} - -func TestIntentFromSubmitTransferUsesSourceMoney(t *testing.T) { - req := &chainv1.SubmitTransferRequest{ - IdempotencyKey: "idem-1", - ClientReference: "pi-1", - Amount: &moneyv1.Money{Amount: "10.00", Currency: "EUR"}, - Metadata: map[string]string{ - metadataSourceAmount: "12.34", - metadataSourceCurrency: "USD", - }, - } - intent, err := intentFromSubmitTransfer(req, "provider_settlement", "") - if err != nil { - t.Fatalf("intentFromSubmitTransfer error: %v", err) - } - if intent.RequestedMoney == nil || intent.RequestedMoney.Amount != "12.34" || intent.RequestedMoney.Currency != "USD" { - t.Fatalf("expected source money override, got: %#v", intent.RequestedMoney) - } -} - -func TestConfirmationResultPersistsExecutionAndReply(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} - prod := &captureProducer{} - svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) - intent := &model.PaymentGatewayIntent{ - PaymentIntentID: "pi-2", - IdempotencyKey: "idem-2", - QuoteRef: "quote-2", OutgoingLeg: "card", RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + Status: storagemodel.PaymentStatusWaiting, + }) + + result := &model.ConfirmationResult{ + RequestID: "idem-1", + Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + Status: model.ConfirmationStatusConfirmed, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-1"] + + if rec.Status != storagemodel.PaymentStatusSuccess { + t.Fatalf("expected success, got %s", rec.Status) + } + if rec.RequestedMoney == nil { + t.Fatalf("requested money not set") + } + if rec.ExecutedAt.IsZero() { + t.Fatalf("executedAt not set") + } + if repo.tg.records["idem-1"] == nil { + t.Fatalf("telegram confirmation not stored") + } + if len(prod.reactions) != 1 { + t.Fatalf("reaction must be published") + } +} + +func TestClarified(t *testing.T) { + svc, repo, prod := newTestService(t) + _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ - IdempotencyKey: "idem-2", - PaymentIntentID: intent.PaymentIntentID, - QuoteRef: intent.QuoteRef, - OutgoingLeg: intent.OutgoingLeg, - RequestedMoney: intent.RequestedMoney, - Status: storagemodel.PaymentStatusPending, + IdempotencyKey: "idem-2", + Status: storagemodel.PaymentStatusWaiting, }) result := &model.ConfirmationResult{ RequestID: "idem-2", - Money: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, - Status: model.ConfirmationStatusConfirmed, - RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2", Text: "5 EUR"}, + Status: model.ConfirmationStatusClarified, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } - if err := svc.onConfirmationResult(context.Background(), result); err != nil { - t.Fatalf("onConfirmationResult error: %v", err) + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-2"] + + if rec.Status != storagemodel.PaymentStatusWaiting { + t.Fatalf("clarified must not change status") } - record := repo.payments.records["idem-2"] - if record == nil { - t.Fatalf("expected payment record to be stored") + if repo.tg.records["idem-2"] == nil { + t.Fatalf("telegram confirmation must be stored") } - if record.Status != storagemodel.PaymentStatusExecuted { - t.Fatalf("expected executed status, got %q", record.Status) - } - if record.ExecutedMoney == nil || record.ExecutedMoney.Amount != "5" { - t.Fatalf("executed money not stored correctly: %#v", record.ExecutedMoney) - } - if repo.tg.records["idem-2"] == nil || repo.tg.records["idem-2"].RawReply.Text != "5 EUR" { - t.Fatalf("telegram reply not stored correctly") + if len(prod.reactions) != 0 { + t.Fatalf("clarified must not publish reaction") } } -func TestClarifiedResultPersistsExecution(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} - prod := &captureProducer{} - svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) - intent := &model.PaymentGatewayIntent{ - PaymentIntentID: "pi-clarified", - IdempotencyKey: "idem-clarified", - QuoteRef: "quote-clarified", - OutgoingLeg: "card", - RequestedMoney: &paymenttypes.Money{Amount: "12", Currency: "USD"}, - } +func TestRejected(t *testing.T) { + svc, repo, prod := newTestService(t) + + // ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil, + // даем минимально ожидаемые поля + non-nil ExecutedMoney. _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ - IdempotencyKey: "idem-clarified", - PaymentIntentID: intent.PaymentIntentID, - QuoteRef: intent.QuoteRef, - OutgoingLeg: intent.OutgoingLeg, - RequestedMoney: intent.RequestedMoney, - Status: storagemodel.PaymentStatusPending, + IdempotencyKey: "idem-3", + PaymentIntentID: "pi-3", + QuoteRef: "quote-3", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"}, + Status: storagemodel.PaymentStatusWaiting, }) result := &model.ConfirmationResult{ - RequestID: "idem-clarified", - Money: &paymenttypes.Money{Amount: "12", Currency: "USD"}, - Status: model.ConfirmationStatusClarified, - RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "3", Text: "12 USD"}, + RequestID: "idem-3", + Status: model.ConfirmationStatusRejected, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } - if err := svc.onConfirmationResult(context.Background(), result); err != nil { - t.Fatalf("onConfirmationResult error: %v", err) + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-3"] + + if rec.Status != storagemodel.PaymentStatusFailed { + t.Fatalf("expected failed") } - record := repo.payments.records["idem-clarified"] - if record == nil || record.Status != storagemodel.PaymentStatusExecuted { - t.Fatalf("expected payment executed status, got %#v", record) + if repo.tg.records["idem-3"] == nil { + t.Fatalf("telegram confirmation must be stored") + } + if len(prod.reactions) != 0 { + t.Fatalf("rejected must not publish reaction") } } -func TestIdempotencyPreventsDuplicateWrites(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - repo := &fakeRepo{payments: &fakePaymentsStore{records: map[string]*storagemodel.PaymentRecord{ - "idem-3": {IdempotencyKey: "idem-3", Status: storagemodel.PaymentStatusPending}, - }}, tg: &fakeTelegramStore{}} - prod := &captureProducer{} - svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) - intent := &model.PaymentGatewayIntent{ - PaymentIntentID: "pi-3", - IdempotencyKey: "idem-3", - OutgoingLeg: "card", - QuoteRef: "quote-3", - RequestedMoney: &paymenttypes.Money{Amount: "1", Currency: "USD"}, - TargetChatID: "chat", - } - if err := svc.onIntent(context.Background(), intent); err != nil { - t.Fatalf("onIntent error: %v", err) - } - if len(prod.confirmationRequests) != 0 { - t.Fatalf("expected no confirmation request for duplicate intent") - } -} +func TestTimeout(t *testing.T) { + svc, repo, prod := newTestService(t) -func TestTimeoutDoesNotPersistExecution(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} - prod := &captureProducer{} - svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) - intent := &model.PaymentGatewayIntent{ - PaymentIntentID: "pi-4", - IdempotencyKey: "idem-4", - QuoteRef: "quote-4", - OutgoingLeg: "card", - RequestedMoney: &paymenttypes.Money{Amount: "8", Currency: "USD"}, - } + // ВАЖНО: чтобы текущий emitTransferStatusEvent не падал на nil, + // даем минимально ожидаемые поля + non-nil ExecutedMoney. _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ IdempotencyKey: "idem-4", - PaymentIntentID: intent.PaymentIntentID, - QuoteRef: intent.QuoteRef, - OutgoingLeg: intent.OutgoingLeg, - RequestedMoney: intent.RequestedMoney, - Status: storagemodel.PaymentStatusPending, + PaymentIntentID: "pi-4", + QuoteRef: "quote-4", + OutgoingLeg: "card", + RequestedMoney: &paymenttypes.Money{Amount: "5", Currency: "EUR"}, + ExecutedMoney: &paymenttypes.Money{Amount: "0", Currency: "EUR"}, + Status: storagemodel.PaymentStatusWaiting, }) result := &model.ConfirmationResult{ RequestID: "idem-4", Status: model.ConfirmationStatusTimeout, + RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "2"}, } - if err := svc.onConfirmationResult(context.Background(), result); err != nil { - t.Fatalf("onConfirmationResult error: %v", err) + + _ = svc.onConfirmationResult(context.Background(), result) + + rec := repo.payments.records["idem-4"] + + if rec.Status != storagemodel.PaymentStatusFailed { + t.Fatalf("timeout must be failed") } - record := repo.payments.records["idem-4"] - if record == nil || record.Status != storagemodel.PaymentStatusExpired { - t.Fatalf("expected expired status for timeout, got %#v", record) - } -} - -func TestRejectedDoesNotPersistExecution(t *testing.T) { - logger := mloggerfactory.NewLogger(false) - repo := &fakeRepo{payments: &fakePaymentsStore{}, tg: &fakeTelegramStore{}} - prod := &captureProducer{} - svc := NewService(logger, repo, prod, nil, Config{Rail: "card"}) - intent := &model.PaymentGatewayIntent{ - PaymentIntentID: "pi-reject", - IdempotencyKey: "idem-reject", - QuoteRef: "quote-reject", - OutgoingLeg: "card", - RequestedMoney: &paymenttypes.Money{Amount: "3", Currency: "USD"}, - } - _ = repo.payments.Upsert(context.Background(), &storagemodel.PaymentRecord{ - IdempotencyKey: "idem-reject", - PaymentIntentID: intent.PaymentIntentID, - QuoteRef: intent.QuoteRef, - OutgoingLeg: intent.OutgoingLeg, - RequestedMoney: intent.RequestedMoney, - Status: storagemodel.PaymentStatusPending, - }) - - result := &model.ConfirmationResult{ - RequestID: "idem-reject", - Status: model.ConfirmationStatusRejected, - RawReply: &model.TelegramMessage{ChatID: "1", MessageID: "4", Text: "no"}, - } - if err := svc.onConfirmationResult(context.Background(), result); err != nil { - t.Fatalf("onConfirmationResult error: %v", err) - } - record := repo.payments.records["idem-reject"] - if record == nil || record.Status != storagemodel.PaymentStatusExpired { - t.Fatalf("expected expired status for rejection, got %#v", record) - } - if repo.tg.records["idem-reject"] == nil { - t.Fatalf("expected raw reply to be stored for rejection") + if repo.tg.records["idem-4"] == nil { + t.Fatalf("telegram confirmation must be stored") + } + if len(prod.reactions) != 0 { + t.Fatalf("timeout must not publish reaction") } } diff --git a/api/gateway/tgsettle/internal/service/gateway/transfer_notifications.go b/api/gateway/tgsettle/internal/service/gateway/transfer_notifications.go new file mode 100644 index 00000000..aa212c7b --- /dev/null +++ b/api/gateway/tgsettle/internal/service/gateway/transfer_notifications.go @@ -0,0 +1,77 @@ +package gateway + +import ( + "context" + + "github.com/tech/sendico/gateway/tgsettle/storage/model" + 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" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + "go.uber.org/zap" +) + +func isFinalStatus(t *model.PaymentRecord) bool { + switch t.Status { + case model.PaymentStatusFailed, model.PaymentStatusSuccess, model.PaymentStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.PaymentRecord) rail.OperationResult { + switch t.Status { + case model.PaymentStatusFailed: + return rail.OperationResultFailed + case model.PaymentStatusSuccess: + return rail.OperationResultSuccess + case model.PaymentStatusCancelled: + return rail.OperationResultCancelled + default: + panic("unexpected transfer status") + } +} + +func toError(t *model.PaymentRecord) *gatewayv1.OperationError { + if t.Status == model.PaymentStatusSuccess { + return nil + } + return &gatewayv1.OperationError{ + Message: t.FailureReason, + } +} + +func (s *Service) updateTransferStatus(ctx context.Context, record *model.PaymentRecord) error { + if err := s.repo.Payments().Upsert(ctx, record); err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("payment_ref", record.PaymentIntentID), zap.String("status", string(record.Status)), zap.Error(err)) + return err + } + if isFinalStatus(record) { + s.emitTransferStatusEvent(ctx, record) + } + return nil +} + +func (s *Service) emitTransferStatusEvent(_ context.Context, record *model.PaymentRecord) { + if s == nil || s.producer == nil || record == nil { + return + } + + exec := pmodel.PaymentGatewayExecution{ + PaymentIntentID: record.PaymentIntentID, + IdempotencyKey: record.IdempotencyKey, + ExecutedMoney: record.ExecutedMoney, + PaymentRef: record.PaymentRef, + Status: toOpStatus(record), + OperationRef: record.OperationRef, + Error: record.FailureReason, + TransferRef: record.ID.Hex(), + } + env := paymentgateway.PaymentGatewayExecution(mservice.MntxGateway, &exec) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish transfer status event", zap.Error(err), mzap.ObjRef("transfer_ref", record.ID)) + } +} diff --git a/api/gateway/tgsettle/storage/model/execution.go b/api/gateway/tgsettle/storage/model/execution.go index 1c7ddb17..0d00cad1 100644 --- a/api/gateway/tgsettle/storage/model/execution.go +++ b/api/gateway/tgsettle/storage/model/execution.go @@ -11,21 +11,28 @@ import ( type PaymentStatus string const ( - PaymentStatusPending PaymentStatus = "pending" - PaymentStatusExpired PaymentStatus = "expired" - PaymentStatusExecuted PaymentStatus = "executed" + PaymentStatusCreated PaymentStatus = "created" // created + PaymentStatusProcessing PaymentStatus = "processing" // processing + PaymentStatusWaiting PaymentStatus = "waiting" // waiting external action + PaymentStatusSuccess PaymentStatus = "success" // final success + PaymentStatusFailed PaymentStatus = "failed" // final failure + PaymentStatusCancelled PaymentStatus = "cancelled" // cancelled final ) type PaymentRecord struct { ID bson.ObjectID `bson:"_id,omitempty" json:"id"` + OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"` IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"` + PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"` OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"` TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` Status PaymentStatus `bson:"status,omitempty" json:"status,omitempty"` + FailureReason string `bson:"failureReason,omitempty" json:"Failure_reason,omitempty"` CreatedAt time.Time `bson:"createdAt,omitempty" json:"created_at,omitempty"` UpdatedAt time.Time `bson:"updatedAt,omitempty" json:"updated_at,omitempty"` ExecutedAt time.Time `bson:"executedAt,omitempty" json:"executed_at,omitempty"` diff --git a/api/gateway/tgsettle/storage/mongo/store/payments.go b/api/gateway/tgsettle/storage/mongo/store/payments.go index 6228f4b0..b255b2ff 100644 --- a/api/gateway/tgsettle/storage/mongo/store/payments.go +++ b/api/gateway/tgsettle/storage/mongo/store/payments.go @@ -85,6 +85,9 @@ func (p *Payments) Upsert(ctx context.Context, record *model.PaymentRecord) erro if record.IdempotencyKey == "" { return merrors.InvalidArgument("idempotency key is required", "idempotency_key") } + if record.IntentRef == "" { + return merrors.InvalidArgument("intention reference key is required", "intent_ref") + } now := time.Now() if record.CreatedAt.IsZero() { record.CreatedAt = now diff --git a/api/gateway/tron/client/client.go b/api/gateway/tron/client/client.go index 90abc5e2..a54b3b46 100644 --- a/api/gateway/tron/client/client.go +++ b/api/gateway/tron/client/client.go @@ -9,7 +9,7 @@ import ( chainasset "github.com/tech/sendico/pkg/chain" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" @@ -426,7 +426,7 @@ func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Ope params := map[string]interface{}{ "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), - "client_reference": strings.TrimSpace(req.GetClientReference()), + "payment_ref": strings.TrimSpace(req.GetPaymentRef()), } if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" { params["destination_memo"] = memo @@ -472,14 +472,14 @@ func setOperationRolesFromMetadata(op *connectorv1.Operation, metadata map[strin if op == nil || len(metadata) == 0 { return } - if raw := strings.TrimSpace(metadata[pmodel.MetadataKeyFromRole]); raw != "" { - if role, ok := pmodel.Parse(raw); ok && role != "" { - op.FromRole = pmodel.ToProto(role) + 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[pmodel.MetadataKeyToRole]); raw != "" { - if role, ok := pmodel.Parse(raw); ok && role != "" { - op.ToRole = pmodel.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) } } } @@ -619,7 +619,7 @@ func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.O "mode": "ensure", "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), "target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()), - "client_reference": strings.TrimSpace(req.GetClientReference()), + "payment_ref": strings.TrimSpace(req.GetPaymentRef()), "estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()}, } if len(req.GetMetadata()) > 0 { @@ -765,28 +765,54 @@ func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.Mana } } -func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { - switch status { - case connectorv1.OperationStatus_CONFIRMED: - return chainv1.TransferStatus_TRANSFER_CONFIRMED - case connectorv1.OperationStatus_FAILED: - return chainv1.TransferStatus_TRANSFER_FAILED - case connectorv1.OperationStatus_CANCELED: - return chainv1.TransferStatus_TRANSFER_CANCELLED - default: - return chainv1.TransferStatus_TRANSFER_PENDING - } -} - func operationStatusFromTransfer(status chainv1.TransferStatus) connectorv1.OperationStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return connectorv1.OperationStatus_CONFIRMED + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + case chainv1.TransferStatus_TRANSFER_FAILED: - return connectorv1.OperationStatus_FAILED + return connectorv1.OperationStatus_OPERATION_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return connectorv1.OperationStatus_CANCELED + return connectorv1.OperationStatus_OPERATION_CANCELLED + default: return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED } } + +func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { + switch status { + + case connectorv1.OperationStatus_OPERATION_CREATED: + return chainv1.TransferStatus_TRANSFER_CREATED + + case connectorv1.OperationStatus_OPERATION_PROCESSING: + return chainv1.TransferStatus_TRANSFER_PROCESSING + + case connectorv1.OperationStatus_OPERATION_WAITING: + return chainv1.TransferStatus_TRANSFER_WAITING + + case connectorv1.OperationStatus_OPERATION_SUCCESS: + return chainv1.TransferStatus_TRANSFER_SUCCESS + + case connectorv1.OperationStatus_OPERATION_FAILED: + return chainv1.TransferStatus_TRANSFER_FAILED + + case connectorv1.OperationStatus_OPERATION_CANCELLED: + return chainv1.TransferStatus_TRANSFER_CANCELLED + + default: + return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED + } +} diff --git a/api/gateway/tron/client/rail_gateway.go b/api/gateway/tron/client/rail_gateway.go index 3f128c83..c66eff9f 100644 --- a/api/gateway/tron/client/rail_gateway.go +++ b/api/gateway/tron/client/rail_gateway.go @@ -5,7 +5,7 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" @@ -106,9 +106,11 @@ func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) ( Currency: currency, Amount: amountValue, }, - Fees: fees, - Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole), - ClientReference: strings.TrimSpace(req.ClientReference), + Fees: fees, + Metadata: transferMetadataWithRoles(req.Metadata, req.FromRole, req.ToRole), + PaymentRef: strings.TrimSpace(req.PaymentRef), + OperationRef: strings.TrimSpace(req.OperationRef), + IntentRef: strings.TrimSpace(req.OperationRef), }) if err != nil { return rail.RailResult{}, err @@ -186,20 +188,29 @@ func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string return true, nil } -func statusFromTransfer(status chainv1.TransferStatus) string { +func statusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: + + case chainv1.TransferStatus_TRANSFER_CREATED: + return rail.TransferStatusCreated + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return rail.TransferStatusProcessing + + case chainv1.TransferStatus_TRANSFER_WAITING: + return rail.TransferStatusProcessing + + case chainv1.TransferStatus_TRANSFER_SUCCESS: return rail.TransferStatusSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: return rail.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return rail.TransferStatusRejected - case chainv1.TransferStatus_TRANSFER_SIGNING, - chainv1.TransferStatus_TRANSFER_PENDING, - chainv1.TransferStatus_TRANSFER_SUBMITTED: - return rail.TransferStatusPending + return rail.TransferStatusCancelled + default: - return rail.TransferStatusPending + return rail.TransferStatusUnspecified } } @@ -255,19 +266,19 @@ func railMoneyFromProto(m *moneyv1.Money) *rail.Money { } } -func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole pmodel.AccountRole) map[string]string { +func transferMetadataWithRoles(metadata map[string]string, fromRole, toRole account_role.AccountRole) map[string]string { result := cloneMetadata(metadata) if strings.TrimSpace(string(fromRole)) != "" { if result == nil { result = map[string]string{} } - result[pmodel.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole)) + result[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(fromRole)) } if strings.TrimSpace(string(toRole)) != "" { if result == nil { result = map[string]string{} } - result[pmodel.MetadataKeyToRole] = strings.TrimSpace(string(toRole)) + result[account_role.MetadataKeyToRole] = strings.TrimSpace(string(toRole)) } if len(result) == 0 { return nil diff --git a/api/gateway/tron/go.mod b/api/gateway/tron/go.mod index 41a74af6..bf736bce 100644 --- a/api/gateway/tron/go.mod +++ b/api/gateway/tron/go.mod @@ -23,7 +23,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect @@ -92,6 +92,6 @@ require ( golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/gateway/tron/go.sum b/api/gateway/tron/go.sum index c63fbb03..6382bffc 100644 --- a/api/gateway/tron/go.sum +++ b/api/gateway/tron/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61 h1:iLc9NjmJ3AdAl5VoiRSDXzEmmW8kvHp3E2vJ2eKKc7s= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260131145833-e3fabd62fc61/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02 h1:0uY5Ooun4eqGmP0IrQhiKVqeeEXoeEcL8KVRtug8+r8= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20260201044653-ee82dce4af02/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -379,10 +379,10 @@ 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/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= -google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b h1:SGYyueaEovpqmWmtTvwtVgo638V/QFE2zlTCnRrR3jg= +google.golang.org/genproto/googleapis/api v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go b/api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go index cdcf5585..2f67f40e 100644 --- a/api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/gas_topup.go @@ -126,9 +126,11 @@ func (c *ensureGasTopUpCommand) Execute(ctx context.Context, req *chainv1.Ensure Destination: &chainv1.TransferDestination{ Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: targetWalletRef}, }, - Amount: topUp, - Metadata: shared.CloneMetadata(req.GetMetadata()), - ClientReference: strings.TrimSpace(req.GetClientReference()), + Amount: topUp, + Metadata: shared.CloneMetadata(req.GetMetadata()), + PaymentRef: strings.TrimSpace(req.GetPaymentRef()), + IntentRef: strings.TrimSpace(req.GetIntentRef()), + OperationRef: strings.TrimSpace(req.GetOperationRef()), } submitResponder := NewSubmitTransfer(c.deps.WithLogger("transfer.submit")).Execute(ctx, submitReq) diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/proto.go b/api/gateway/tron/internal/service/gateway/commands/transfer/proto.go index 6f5ccf92..d8f0514e 100644 --- a/api/gateway/tron/internal/service/gateway/commands/transfer/proto.go +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/proto.go @@ -38,11 +38,12 @@ func toProtoTransfer(transfer *model.Transfer) *chainv1.Transfer { TransferRef: transfer.TransferRef, IdempotencyKey: transfer.IdempotencyKey, OrganizationRef: transfer.OrganizationRef, + IntentRef: transfer.IntentRef, SourceWalletRef: transfer.SourceWalletRef, Destination: destination, Asset: asset, - RequestedAmount: shared.CloneMoney(transfer.RequestedAmount), - NetAmount: shared.CloneMoney(transfer.NetAmount), + RequestedAmount: shared.MonenyToProto(transfer.RequestedAmount), + NetAmount: shared.MonenyToProto(transfer.NetAmount), Fees: protoFees, Status: shared.TransferStatusToProto(transfer.Status), TransactionHash: transfer.TxHash, diff --git a/api/gateway/tron/internal/service/gateway/commands/transfer/submit.go b/api/gateway/tron/internal/service/gateway/commands/transfer/submit.go index 089e3dc5..e6c88af1 100644 --- a/api/gateway/tron/internal/service/gateway/commands/transfer/submit.go +++ b/api/gateway/tron/internal/service/gateway/commands/transfer/submit.go @@ -38,6 +38,17 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit c.deps.Logger.Warn("Missing idempotency key") return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("idempotency_key is required")) } + intentRef := strings.TrimSpace(req.GetIntentRef()) + if intentRef == "" { + c.deps.Logger.Warn("Missing intent reference") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("intentRef is required")) + } + operationRef := strings.TrimSpace(req.GetOperationRef()) + if operationRef == "" { + c.deps.Logger.Warn("Missing operation reference") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("operationRef is required")) + } + organizationRef := strings.TrimSpace(req.GetOrganizationRef()) if organizationRef == "" { c.deps.Logger.Warn("Missing organization ref") @@ -63,6 +74,11 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit c.deps.Logger.Warn("Missing amount value") return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("amount.amount is required")) } + paymentRef := strings.TrimSpace(req.GetPaymentRef()) + if paymentRef == "" { + c.deps.Logger.Warn("Missing payment reference") + return gsresponse.InvalidArgument[chainv1.SubmitTransferResponse](c.deps.Logger, mservice.ChainGateway, merrors.InvalidArgument("payment reference is required", "paymentRef")) + } sourceWallet, err := c.deps.Storage.Wallets().Get(ctx, sourceWalletRef) if err != nil { @@ -123,6 +139,8 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit transfer := &model.Transfer{ IdempotencyKey: idempotencyKey, + OperationRef: operationRef, + IntentRef: intentRef, TransferRef: shared.GenerateTransferRef(), OrganizationRef: organizationRef, SourceWalletRef: sourceWalletRef, @@ -130,11 +148,11 @@ func (c *submitTransferCommand) Execute(ctx context.Context, req *chainv1.Submit Network: sourceWallet.Network, TokenSymbol: effectiveTokenSymbol, ContractAddress: effectiveContractAddress, - RequestedAmount: shared.CloneMoney(amount), - NetAmount: netAmount, + RequestedAmount: shared.ProtoToMoney(amount), + NetAmount: shared.ProtoToMoney(netAmount), + PaymentRef: paymentRef, Fees: fees, - Status: model.TransferStatusPending, - ClientReference: strings.TrimSpace(req.GetClientReference()), + Status: model.TransferStatusCreated, LastStatusAt: c.deps.Clock.Now().UTC(), } diff --git a/api/gateway/tron/internal/service/gateway/connector.go b/api/gateway/tron/internal/service/gateway/connector.go index 2da8d59e..8ce87775 100644 --- a/api/gateway/tron/internal/service/gateway/connector.go +++ b/api/gateway/tron/internal/service/gateway/connector.go @@ -169,7 +169,9 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp Amount: amount, Fees: parseChainFees(reader), Metadata: shared.CloneMetadata(reader.StringMap("metadata")), - ClientReference: strings.TrimSpace(reader.String("client_reference")), + PaymentRef: strings.TrimSpace(reader.String("payment_ref")), + IntentRef: strings.TrimSpace(op.GetIntentRef()), + OperationRef: strings.TrimSpace(op.GetOperationRef()), }) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil @@ -208,7 +210,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp return &connectorv1.SubmitOperationResponse{ Receipt: &connectorv1.OperationReceipt{ OperationId: opID, - Status: connectorv1.OperationStatus_CONFIRMED, + Status: connectorv1.OperationStatus_OPERATION_SUCCESS, Result: result, }, }, nil @@ -238,7 +240,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp return &connectorv1.SubmitOperationResponse{ Receipt: &connectorv1.OperationReceipt{ OperationId: opID, - Status: connectorv1.OperationStatus_CONFIRMED, + Status: connectorv1.OperationStatus_OPERATION_SUCCESS, Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""), }, }, nil @@ -256,12 +258,14 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp } resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + IntentRef: strings.TrimSpace(op.GetIntentRef()), + OperationRef: strings.TrimSpace(op.GetOperationRef()), OrganizationRef: orgRef, SourceWalletRef: source, TargetWalletRef: target, EstimatedTotalFee: fee, Metadata: shared.CloneMetadata(reader.StringMap("metadata")), - ClientReference: strings.TrimSpace(reader.String("client_reference")), + PaymentRef: strings.TrimSpace(reader.String("payment_ref")), }) if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil @@ -273,7 +277,7 @@ func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOp return &connectorv1.SubmitOperationResponse{ Receipt: &connectorv1.OperationReceipt{ OperationId: opID, - Status: connectorv1.OperationStatus_CONFIRMED, + Status: shared.ChainTransferStatusToOperation(resp.GetTransfer().GetStatus()), Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef), }, }, nil @@ -544,25 +548,51 @@ func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return connectorv1.OperationStatus_CONFIRMED + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + case chainv1.TransferStatus_TRANSFER_FAILED: - return connectorv1.OperationStatus_FAILED + return connectorv1.OperationStatus_OPERATION_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return connectorv1.OperationStatus_CANCELED + return connectorv1.OperationStatus_OPERATION_CANCELLED + default: - return connectorv1.OperationStatus_PENDING + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED } } func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { switch status { - case connectorv1.OperationStatus_CONFIRMED: - return chainv1.TransferStatus_TRANSFER_CONFIRMED - case connectorv1.OperationStatus_FAILED: + + case connectorv1.OperationStatus_OPERATION_CREATED: + return chainv1.TransferStatus_TRANSFER_CREATED + + case connectorv1.OperationStatus_OPERATION_PROCESSING: + return chainv1.TransferStatus_TRANSFER_PROCESSING + + case connectorv1.OperationStatus_OPERATION_WAITING: + return chainv1.TransferStatus_TRANSFER_WAITING + + case connectorv1.OperationStatus_OPERATION_SUCCESS: + return chainv1.TransferStatus_TRANSFER_SUCCESS + + case connectorv1.OperationStatus_OPERATION_FAILED: return chainv1.TransferStatus_TRANSFER_FAILED - case connectorv1.OperationStatus_CANCELED: + + case connectorv1.OperationStatus_OPERATION_CANCELLED: return chainv1.TransferStatus_TRANSFER_CANCELLED + default: return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED } diff --git a/api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go b/api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go index 44c52c93..9e4497d9 100644 --- a/api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go +++ b/api/gateway/tron/internal/service/gateway/driver/tron/confirmation.go @@ -3,7 +3,6 @@ package tron import ( "context" "math/big" - "strings" "time" "github.com/ethereum/go-ethereum/core/types" @@ -157,9 +156,3 @@ func GetTransactionStatus( } return 0, nil } - -// isTronNetwork checks if the network name indicates a TRON network. -func isTronNetwork(networkName string) bool { - name := strings.ToLower(strings.TrimSpace(networkName)) - return strings.HasPrefix(name, "tron") -} diff --git a/api/gateway/tron/internal/service/gateway/service_test.go b/api/gateway/tron/internal/service/gateway/service_test.go index f679344d..682180e7 100644 --- a/api/gateway/tron/internal/service/gateway/service_test.go +++ b/api/gateway/tron/internal/service/gateway/service_test.go @@ -162,6 +162,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) { IdempotencyKey: "transfer-1", OrganizationRef: "org-1", SourceWalletRef: srcRef, + PaymentRef: "ref-1", Destination: &ichainv1.TransferDestination{ Destination: &ichainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: dstRef}, }, @@ -172,6 +173,8 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) { Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, }, }, + OperationRef: "oper-1", + IntentRef: "intent-1", }) require.NoError(t, err) require.NotNil(t, transferResp.GetTransfer()) @@ -179,7 +182,7 @@ func TestSubmitTransfer_ManagedDestination(t *testing.T) { stored := repo.transfers.get(transferResp.GetTransfer().GetTransferRef()) require.NotNil(t, stored) - require.Equal(t, model.TransferStatusPending, stored.Status) + require.Equal(t, model.TransferStatusCreated, stored.Status) // GetTransfer getResp, err := svc.GetTransfer(ctx, &ichainv1.GetTransferRequest{TransferRef: stored.TransferRef}) diff --git a/api/gateway/tron/internal/service/gateway/shared/helpers.go b/api/gateway/tron/internal/service/gateway/shared/helpers.go index 961c47bc..6677ab02 100644 --- a/api/gateway/tron/internal/service/gateway/shared/helpers.go +++ b/api/gateway/tron/internal/service/gateway/shared/helpers.go @@ -6,7 +6,9 @@ import ( "github.com/tech/sendico/gateway/tron/storage/model" chainasset "github.com/tech/sendico/pkg/chain" pmodel "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -77,42 +79,82 @@ func ManagedWalletStatusToProto(status model.ManagedWalletStatus) chainv1.Manage func TransferStatusToModel(status chainv1.TransferStatus) model.TransferStatus { switch status { - case chainv1.TransferStatus_TRANSFER_PENDING: - return model.TransferStatusPending - case chainv1.TransferStatus_TRANSFER_SIGNING: - return model.TransferStatusSigning - case chainv1.TransferStatus_TRANSFER_SUBMITTED: - return model.TransferStatusSubmitted - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return model.TransferStatusConfirmed + + case chainv1.TransferStatus_TRANSFER_CREATED: + return model.TransferStatusCreated + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return model.TransferStatusProcessing + + case chainv1.TransferStatus_TRANSFER_WAITING: + return model.TransferStatusWaiting + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return model.TransferStatusSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: return model.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: return model.TransferStatusCancelled + default: - return "" + return model.TransferStatus("") } } func TransferStatusToProto(status model.TransferStatus) chainv1.TransferStatus { switch status { - case model.TransferStatusPending: - return chainv1.TransferStatus_TRANSFER_PENDING - case model.TransferStatusSigning: - return chainv1.TransferStatus_TRANSFER_SIGNING - case model.TransferStatusSubmitted: - return chainv1.TransferStatus_TRANSFER_SUBMITTED - case model.TransferStatusConfirmed: - return chainv1.TransferStatus_TRANSFER_CONFIRMED + + case model.TransferStatusCreated: + return chainv1.TransferStatus_TRANSFER_CREATED + + case model.TransferStatusProcessing: + return chainv1.TransferStatus_TRANSFER_PROCESSING + + case model.TransferStatusWaiting: + return chainv1.TransferStatus_TRANSFER_WAITING + + case model.TransferStatusSuccess: + return chainv1.TransferStatus_TRANSFER_SUCCESS + case model.TransferStatusFailed: return chainv1.TransferStatus_TRANSFER_FAILED + case model.TransferStatusCancelled: return chainv1.TransferStatus_TRANSFER_CANCELLED + default: return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED } } +func ChainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { + switch status { + + case chainv1.TransferStatus_TRANSFER_CREATED: + return connectorv1.OperationStatus_OPERATION_CREATED + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return connectorv1.OperationStatus_OPERATION_PROCESSING + + case chainv1.TransferStatus_TRANSFER_WAITING: + return connectorv1.OperationStatus_OPERATION_WAITING + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return connectorv1.OperationStatus_OPERATION_SUCCESS + + case chainv1.TransferStatus_TRANSFER_FAILED: + return connectorv1.OperationStatus_OPERATION_FAILED + + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return connectorv1.OperationStatus_OPERATION_CANCELLED + + default: + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED + } +} + // NativeCurrency returns the canonical native token symbol for a network. func NativeCurrency(network Network) string { currency := strings.ToUpper(strings.TrimSpace(network.NativeToken)) @@ -146,3 +188,23 @@ type ServiceWallet struct { Address string PrivateKey string } + +func ProtoToMoney(money *moneyv1.Money) *paymenttypes.Money { + if money == nil { + return &paymenttypes.Money{} + } + return &paymenttypes.Money{ + Amount: money.GetAmount(), + Currency: money.GetCurrency(), + } +} + +func MonenyToProto(money *paymenttypes.Money) *moneyv1.Money { + if money == nil { + return &moneyv1.Money{} + } + return &moneyv1.Money{ + Amount: money.Amount, + Currency: money.Currency, + } +} diff --git a/api/gateway/tron/internal/service/gateway/transfer_execution.go b/api/gateway/tron/internal/service/gateway/transfer_execution.go index 5983840a..1be0685d 100644 --- a/api/gateway/tron/internal/service/gateway/transfer_execution.go +++ b/api/gateway/tron/internal/service/gateway/transfer_execution.go @@ -40,26 +40,26 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet return err } - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSigning, "", ""); err != nil { + if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusProcessing, "", ""); err != nil { s.logger.Warn("Failed to update transfer status to signing", zap.String("transfer_ref", transferRef), zap.Error(err)) } driverDeps := s.driverDeps() chainDriver, err := s.driverForNetwork(network.Name.String()) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + _, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") return err } destinationAddress, err := s.destinationAddress(ctx, chainDriver, transfer.Destination) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + _, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") return err } sourceAddress, err := chainDriver.NormalizeAddress(sourceWallet.DepositAddress) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + _, _ = s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") return err } if chainDriver.Name() == "tron" && sourceAddress == destinationAddress { @@ -68,7 +68,7 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet zap.String("wallet_ref", sourceWalletRef), zap.String("network", network.Name.String()), ) - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", ""); err != nil { + if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusSuccess, "", ""); err != nil { s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) } return nil @@ -76,11 +76,14 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet txHash, err := chainDriver.SubmitTransfer(ctx, driverDeps, network, transfer, sourceWallet, destinationAddress) if err != nil { - _, _ = s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), "") + s.logger.Warn("Failed to submit transfer", zap.String("transfer_ref", transferRef), zap.Error(err)) + if _, e := s.updateTransferStatus(ctx, transferRef, model.TransferStatusFailed, err.Error(), ""); e != nil { + s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(e)) + } return err } - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusSubmitted, "", txHash); err != nil { + if _, err := s.updateTransferStatus(ctx, transferRef, model.TransferStatusWaiting, "", txHash); err != nil { s.logger.Warn("Failed to update transfer status to submitted", zap.String("transfer_ref", transferRef), zap.Error(err)) } @@ -94,15 +97,15 @@ func (s *Service) executeTransfer(ctx context.Context, transferRef, sourceWallet return err } - if receipt != nil && receipt.Status == types.ReceiptStatusSuccessful { - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusConfirmed, "", txHash); err != nil { - s.logger.Warn("Failed to update transfer status to confirmed", zap.String("transfer_ref", transferRef), zap.Error(err)) - } - return nil + failureReason := "" + pStatus := model.TransferStatusSuccess + if receipt != nil && receipt.Status != types.ReceiptStatusSuccessful { + failureReason = "transaction reverted" + pStatus = model.TransferStatusFailed } - - if _, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, model.TransferStatusFailed, "transaction reverted", txHash); err != nil { - s.logger.Warn("Failed to update transfer status to failed", zap.String("transfer_ref", transferRef), zap.Error(err)) + if _, err := s.updateTransferStatus(ctx, transferRef, pStatus, failureReason, txHash); err != nil { + s.logger.Warn("Failed to update transfer status", zap.Error(err), + zap.String("transfer_ref", transferRef), zap.String("status", string(pStatus))) } return nil } diff --git a/api/gateway/tron/internal/service/gateway/transfer_notifications.go b/api/gateway/tron/internal/service/gateway/transfer_notifications.go new file mode 100644 index 00000000..e3750c84 --- /dev/null +++ b/api/gateway/tron/internal/service/gateway/transfer_notifications.go @@ -0,0 +1,77 @@ +package gateway + +import ( + "context" + "fmt" + + "github.com/tech/sendico/gateway/tron/storage/model" + 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/payments/rail" + "go.uber.org/zap" +) + +func isFinalStatus(t *model.Transfer) bool { + switch t.Status { + case model.TransferStatusFailed, model.TransferStatusSuccess, model.TransferStatusCancelled: + return true + default: + return false + } +} + +func toOpStatus(t *model.Transfer) rail.OperationResult { + switch t.Status { + case model.TransferStatusFailed: + return rail.OperationResultFailed + case model.TransferStatusSuccess: + return rail.OperationResultSuccess + case model.TransferStatusCancelled: + return rail.OperationResultCancelled + default: + panic(fmt.Sprintf("toOpStatus: unexpected transfer status: %s", t.Status)) + } +} + +func toError(t *model.Transfer) string { + if t == nil { + return "" + } + if t.Status == model.TransferStatusSuccess { + return "" + } + return t.FailureReason +} + +func (s *Service) updateTransferStatus(ctx context.Context, transferRef string, status model.TransferStatus, failureReason, txHash string) (*model.Transfer, error) { + transfer, err := s.storage.Transfers().UpdateStatus(ctx, transferRef, status, failureReason, txHash) + if err != nil { + s.logger.Warn("Failed to update transfer status", zap.String("transfer_ref", transferRef), zap.String("status", string(status)), zap.Error(err)) + } + if isFinalStatus(transfer) { + s.emitTransferStatusEvent(transfer) + } + return transfer, err +} + +func (s *Service) emitTransferStatusEvent(transfer *model.Transfer) { + if s == nil || s.producer == nil || transfer == nil { + return + } + + exec := pmodel.PaymentGatewayExecution{ + PaymentIntentID: transfer.IntentRef, + IdempotencyKey: transfer.IdempotencyKey, + ExecutedMoney: transfer.NetAmount, + PaymentRef: transfer.PaymentRef, + Status: toOpStatus(transfer), + OperationRef: transfer.OperationRef, + Error: toError(transfer), + TransferRef: transfer.TransferRef, + } + env := paymentgateway.PaymentGatewayExecution(mservice.ChainGateway, &exec) + if err := s.producer.SendMessage(env); err != nil { + s.logger.Warn("Failed to publish transfer status event", zap.Error(err), zap.String("transfer_ref", transfer.TransferRef)) + } +} diff --git a/api/gateway/tron/storage/model/transfer.go b/api/gateway/tron/storage/model/transfer.go index fe4ecf6a..cc7e61a0 100644 --- a/api/gateway/tron/storage/model/transfer.go +++ b/api/gateway/tron/storage/model/transfer.go @@ -6,18 +6,20 @@ import ( "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/mservice" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) type TransferStatus string const ( - TransferStatusPending TransferStatus = "pending" - TransferStatusSigning TransferStatus = "signing" - TransferStatusSubmitted TransferStatus = "submitted" - TransferStatusConfirmed TransferStatus = "confirmed" - TransferStatusFailed TransferStatus = "failed" - TransferStatusCancelled TransferStatus = "cancelled" + TransferStatusCreated TransferStatus = "created" // record exists, not started + TransferStatusProcessing TransferStatus = "processing" // we are working on it + TransferStatusWaiting TransferStatus = "waiting" // waiting external world + + TransferStatusSuccess TransferStatus = "success" // final success + TransferStatusFailed TransferStatus = "failed" // final failure + TransferStatusCancelled TransferStatus = "cancelled" // final cancelled ) // ServiceFee represents a fee component applied to a transfer. @@ -38,21 +40,23 @@ type TransferDestination struct { type Transfer struct { storable.Base `bson:",inline" json:",inline"` + OperationRef string `bson:"operationRef" json:"operationRef"` TransferRef string `bson:"transferRef" json:"transferRef"` IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` + IntentRef string `bson:"intentRef" json:"intentRef"` OrganizationRef string `bson:"organizationRef" json:"organizationRef"` SourceWalletRef string `bson:"sourceWalletRef" json:"sourceWalletRef"` Destination TransferDestination `bson:"destination" json:"destination"` Network string `bson:"network" json:"network"` TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol"` ContractAddress string `bson:"contractAddress" json:"contractAddress"` - RequestedAmount *moneyv1.Money `bson:"requestedAmount" json:"requestedAmount"` - NetAmount *moneyv1.Money `bson:"netAmount" json:"netAmount"` + RequestedAmount *paymenttypes.Money `bson:"requestedAmount" json:"requestedAmount"` + NetAmount *paymenttypes.Money `bson:"netAmount" json:"netAmount"` Fees []ServiceFee `bson:"fees,omitempty" json:"fees,omitempty"` Status TransferStatus `bson:"status" json:"status"` TxHash string `bson:"txHash,omitempty" json:"txHash,omitempty"` FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"` - ClientReference string `bson:"clientReference,omitempty" json:"clientReference,omitempty"` + PaymentRef string `bson:"paymentRef,omitempty" json:"paymentRef,omitempty"` LastStatusAt time.Time `bson:"lastStatusAt" json:"lastStatusAt"` } @@ -81,6 +85,7 @@ func (t *Transfer) Normalize() { t.TransferRef = strings.TrimSpace(t.TransferRef) t.IdempotencyKey = strings.TrimSpace(t.IdempotencyKey) t.OrganizationRef = strings.TrimSpace(t.OrganizationRef) + t.IntentRef = strings.TrimSpace(t.IntentRef) t.SourceWalletRef = strings.TrimSpace(t.SourceWalletRef) t.Network = strings.TrimSpace(strings.ToLower(t.Network)) t.TokenSymbol = strings.TrimSpace(strings.ToUpper(t.TokenSymbol)) @@ -89,5 +94,5 @@ func (t *Transfer) Normalize() { t.Destination.ExternalAddress = normalizeWalletAddress(t.Destination.ExternalAddress) t.Destination.ExternalAddressOriginal = strings.TrimSpace(t.Destination.ExternalAddressOriginal) t.Destination.Memo = strings.TrimSpace(t.Destination.Memo) - t.ClientReference = strings.TrimSpace(t.ClientReference) + t.PaymentRef = strings.TrimSpace(t.PaymentRef) } diff --git a/api/gateway/tron/storage/mongo/store/transfers.go b/api/gateway/tron/storage/mongo/store/transfers.go index 205cfda5..906a6e5b 100644 --- a/api/gateway/tron/storage/mongo/store/transfers.go +++ b/api/gateway/tron/storage/mongo/store/transfers.go @@ -79,7 +79,7 @@ func (t *Transfers) Create(ctx context.Context, transfer *model.Transfer) (*mode return nil, merrors.InvalidArgument("transfersStore: empty idempotencyKey") } if transfer.Status == "" { - transfer.Status = model.TransferStatusPending + transfer.Status = model.TransferStatusCreated } if transfer.LastStatusAt.IsZero() { transfer.LastStatusAt = time.Now().UTC() diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index 81217fcf..161961e9 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -9,7 +9,7 @@ import ( "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" @@ -317,7 +317,7 @@ func (c *ledgerClient) BlockAccount(ctx context.Context, req *ledgerv1.BlockAcco if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" { return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required") } - sourceRole := model.ToProto(accountRoleFromLedgerProto(req.GetRole())) + sourceRole := account_role.ToProto(accountRoleFromLedgerProto(req.GetRole())) resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{ AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())}, TargetState: connectorv1.AccountState_ACCOUNT_SUSPENDED, @@ -338,7 +338,7 @@ func (c *ledgerClient) UnblockAccount(ctx context.Context, req *ledgerv1.Unblock if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" { return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required") } - sourceRole := model.ToProto(accountRoleFromLedgerProto(req.GetRole())) + sourceRole := account_role.ToProto(accountRoleFromLedgerProto(req.GetRole())) resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{ AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())}, TargetState: connectorv1.AccountState_ACCOUNT_ACTIVE, @@ -430,8 +430,8 @@ func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opTy charges []*ledgerv1.PostingLine eventTime *timestamppb.Timestamp contraRef string - fromRole model.AccountRole - toRole model.AccountRole + fromRole account_role.AccountRole + toRole account_role.AccountRole ) switch r := req.(type) { @@ -487,10 +487,10 @@ func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opTy op.To = accountParty(toRef) } if fromRole != "" { - op.FromRole = model.ToProto(fromRole) + op.FromRole = account_role.ToProto(fromRole) } if toRole != "" { - op.ToRole = model.ToProto(toRole) + op.ToRole = account_role.ToProto(toRole) } resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: op}) @@ -503,30 +503,30 @@ func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opTy return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: entryTypeFromOperation(opType)}, nil } -func accountRoleFromLedgerProto(role ledgerv1.AccountRole) model.AccountRole { +func accountRoleFromLedgerProto(role ledgerv1.AccountRole) account_role.AccountRole { switch role { case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING: - return model.AccountRoleOperating + return account_role.AccountRoleOperating case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD: - return model.AccountRoleHold + return account_role.AccountRoleHold case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT: - return model.AccountRoleTransit + return account_role.AccountRoleTransit case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT: - return model.AccountRoleSettlement + return account_role.AccountRoleSettlement case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING: - return model.AccountRoleClearing + return account_role.AccountRoleClearing case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING: - return model.AccountRolePending + return account_role.AccountRolePending case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE: - return model.AccountRoleReserve + return account_role.AccountRoleReserve case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY: - return model.AccountRoleLiquidity + return account_role.AccountRoleLiquidity case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE: - return model.AccountRoleFee + return account_role.AccountRoleFee case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK: - return model.AccountRoleChargeback + return account_role.AccountRoleChargeback case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT: - return model.AccountRoleAdjustment + return account_role.AccountRoleAdjustment default: return "" } diff --git a/api/ledger/go.mod b/api/ledger/go.mod index a7843d19..ab4bf46c 100644 --- a/api/ledger/go.mod +++ b/api/ledger/go.mod @@ -49,5 +49,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/ledger/go.sum b/api/ledger/go.sum index 56858006..bd5356f2 100644 --- a/api/ledger/go.sum +++ b/api/ledger/go.sum @@ -210,8 +210,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/ledger/internal/model/account.go b/api/ledger/internal/model/account.go index 6f74218f..d5b65b7b 100644 --- a/api/ledger/internal/model/account.go +++ b/api/ledger/internal/model/account.go @@ -7,6 +7,7 @@ import ( "time" "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -40,7 +41,7 @@ type Account struct { // Posting policy & lifecycle AllowNegative bool `bson:"allowNegative" json:"allowNegative"` Status model.LedgerAccountStatus `bson:"status" json:"status"` - Role model.AccountRole `bson:"role,omitempty" json:"role,omitempty"` + Role account_role.AccountRole `bson:"role,omitempty" json:"role,omitempty"` // Legal ownership history Ownerships []Ownership `bson:"ownerships,omitempty" json:"ownerships,omitempty"` @@ -78,17 +79,17 @@ func (a *Account) Validate() error { if role := strings.TrimSpace(string(a.Role)); role != "" { switch a.Role { - case model.AccountRoleOperating, - model.AccountRoleHold, - model.AccountRoleTransit, - model.AccountRoleSettlement, - model.AccountRoleClearing, - model.AccountRolePending, - model.AccountRoleReserve, - model.AccountRoleLiquidity, - model.AccountRoleFee, - model.AccountRoleChargeback, - model.AccountRoleAdjustment: + case account_role.AccountRoleOperating, + account_role.AccountRoleHold, + account_role.AccountRoleTransit, + account_role.AccountRoleSettlement, + account_role.AccountRoleClearing, + account_role.AccountRolePending, + account_role.AccountRoleReserve, + account_role.AccountRoleLiquidity, + account_role.AccountRoleFee, + account_role.AccountRoleChargeback, + account_role.AccountRoleAdjustment: default: veAdd(&verr, "role", "invalid", "unknown account role") } diff --git a/api/ledger/internal/service/ledger/accounts.go b/api/ledger/internal/service/ledger/accounts.go index 0a492b16..6d007a99 100644 --- a/api/ledger/internal/service/ledger/accounts.go +++ b/api/ledger/internal/service/ledger/accounts.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" @@ -25,7 +26,7 @@ type createAccountParams struct { currency string modelType pmodel.LedgerAccountType modelStatus pmodel.LedgerAccountStatus - modelRole pmodel.AccountRole + modelRole account_role.AccountRole } // validateCreateAccountInput validates and normalizes all fields from the request. @@ -93,7 +94,7 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create } // Non-settlement accounts require a settlement account to exist first. - if p.modelRole != pmodel.AccountRoleSettlement { + if p.modelRole != account_role.AccountRoleSettlement { if _, err := s.ensureSettlementAccount(ctx, p.orgRef, p.currency); err != nil { return nil, err } @@ -104,7 +105,7 @@ func (s *Service) createAccountResponder(_ context.Context, req *ledgerv1.Create } // resolveTopologyAccount ensures ledger topology is initialized and returns the system account for the given role. -func (s *Service) resolveTopologyAccount(ctx context.Context, orgRef bson.ObjectID, currency string, role pmodel.AccountRole) (*ledgerv1.CreateAccountResponse, error) { +func (s *Service) resolveTopologyAccount(ctx context.Context, orgRef bson.ObjectID, currency string, role account_role.AccountRole) (*ledgerv1.CreateAccountResponse, error) { if err := s.ensureLedgerTopology(ctx, orgRef, currency); err != nil { recordAccountOperation("create", "error") return nil, err @@ -244,58 +245,58 @@ func modelAccountTypeToProto(t pmodel.LedgerAccountType) ledgerv1.AccountType { } } -func protoAccountRoleToModel(r ledgerv1.AccountRole) (pmodel.AccountRole, error) { +func protoAccountRoleToModel(r ledgerv1.AccountRole) (account_role.AccountRole, error) { switch r { case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED: - return pmodel.AccountRoleOperating, nil + return account_role.AccountRoleOperating, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD: - return pmodel.AccountRoleHold, nil + return account_role.AccountRoleHold, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT: - return pmodel.AccountRoleTransit, nil + return account_role.AccountRoleTransit, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT: - return pmodel.AccountRoleSettlement, nil + return account_role.AccountRoleSettlement, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING: - return pmodel.AccountRoleClearing, nil + return account_role.AccountRoleClearing, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING: - return pmodel.AccountRolePending, nil + return account_role.AccountRolePending, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE: - return pmodel.AccountRoleReserve, nil + return account_role.AccountRoleReserve, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY: - return pmodel.AccountRoleLiquidity, nil + return account_role.AccountRoleLiquidity, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE: - return pmodel.AccountRoleFee, nil + return account_role.AccountRoleFee, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK: - return pmodel.AccountRoleChargeback, nil + return account_role.AccountRoleChargeback, nil case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT: - return pmodel.AccountRoleAdjustment, nil + return account_role.AccountRoleAdjustment, nil default: return "", merrors.InvalidArgument("invalid account role") } } -func modelAccountRoleToProto(r pmodel.AccountRole) ledgerv1.AccountRole { +func modelAccountRoleToProto(r account_role.AccountRole) ledgerv1.AccountRole { switch r { - case pmodel.AccountRoleOperating, "": + case account_role.AccountRoleOperating, "": return ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING - case pmodel.AccountRoleHold: + case account_role.AccountRoleHold: return ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD - case pmodel.AccountRoleTransit: + case account_role.AccountRoleTransit: return ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT - case pmodel.AccountRoleSettlement: + case account_role.AccountRoleSettlement: return ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT - case pmodel.AccountRoleClearing: + case account_role.AccountRoleClearing: return ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING - case pmodel.AccountRolePending: + case account_role.AccountRolePending: return ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING - case pmodel.AccountRoleReserve: + case account_role.AccountRoleReserve: return ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE - case pmodel.AccountRoleLiquidity: + case account_role.AccountRoleLiquidity: return ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY - case pmodel.AccountRoleFee: + case account_role.AccountRoleFee: return ledgerv1.AccountRole_ACCOUNT_ROLE_FEE - case pmodel.AccountRoleChargeback: + case account_role.AccountRoleChargeback: return ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK - case pmodel.AccountRoleAdjustment: + case account_role.AccountRoleAdjustment: return ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT default: return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED @@ -414,7 +415,7 @@ func describableToProto(desc pmodel.Describable) *describablev1.Describable { } func (s *Service) ensureSettlementAccount(ctx context.Context, orgRef bson.ObjectID, currency string) (*pmodel.LedgerAccount, error) { - return s.ensureRoleAccount(ctx, orgRef, currency, pmodel.AccountRoleSettlement) + return s.ensureRoleAccount(ctx, orgRef, currency, account_role.AccountRoleSettlement) } func generateAccountCode(accountType pmodel.LedgerAccountType, currency string, id bson.ObjectID) string { diff --git a/api/ledger/internal/service/ledger/accounts_test.go b/api/ledger/internal/service/ledger/accounts_test.go index d7cf8658..5ac656e4 100644 --- a/api/ledger/internal/service/ledger/accounts_test.go +++ b/api/ledger/internal/service/ledger/accounts_test.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ) @@ -21,14 +22,14 @@ type accountStoreStub struct { created []*pmodel.LedgerAccount existing *pmodel.LedgerAccount existingErr error - existingByRole map[pmodel.AccountRole]*pmodel.LedgerAccount + existingByRole map[account_role.AccountRole]*pmodel.LedgerAccount defaultSettlement *pmodel.LedgerAccount defaultErr error createErrs []error } func (s *accountStoreStub) Create(_ context.Context, account *pmodel.LedgerAccount) error { - if account.Role == pmodel.AccountRoleSettlement { + if account.Role == account_role.AccountRoleSettlement { if s.createErrSettlement != nil { return s.createErrSettlement } @@ -66,7 +67,7 @@ func (s *accountStoreStub) Get(context.Context, bson.ObjectID) (*pmodel.LedgerAc return nil, storage.ErrAccountNotFound } -func (s *accountStoreStub) GetByRole(_ context.Context, orgRef bson.ObjectID, currency string, role pmodel.AccountRole) (*pmodel.LedgerAccount, error) { +func (s *accountStoreStub) GetByRole(_ context.Context, orgRef bson.ObjectID, currency string, role account_role.AccountRole) (*pmodel.LedgerAccount, error) { if s.existingByRole != nil { if acc, ok := s.existingByRole[role]; ok { return acc, nil @@ -190,14 +191,14 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) { var settlement *pmodel.LedgerAccount var operating *pmodel.LedgerAccount - roles := make(map[pmodel.AccountRole]bool) + roles := make(map[account_role.AccountRole]bool) for _, acc := range accountStore.created { roles[acc.Role] = true - if acc.Role == pmodel.AccountRoleSettlement { + if acc.Role == account_role.AccountRoleSettlement { settlement = acc } - if acc.Role == pmodel.AccountRoleOperating { + if acc.Role == account_role.AccountRoleOperating { operating = acc } @@ -230,7 +231,7 @@ func TestCreateAccountResponder_AutoCreatesSettlementAccount(t *testing.T) { require.Equal(t, pmodel.LedgerAccountTypeAsset, settlement.AccountType) require.Equal(t, "USD", settlement.Currency) require.False(t, settlement.AllowNegative) - require.Equal(t, pmodel.AccountRoleSettlement, settlement.Role) + require.Equal(t, account_role.AccountRoleSettlement, settlement.Role) require.Equal(t, "true", settlement.Metadata["system"]) } @@ -265,7 +266,7 @@ func TestCreateAccountResponder_RetriesOnConflict(t *testing.T) { var createdFee *pmodel.LedgerAccount for _, acc := range accountStore.created { - if acc.Role == pmodel.AccountRoleFee { + if acc.Role == account_role.AccountRoleFee { createdFee = acc break } diff --git a/api/ledger/internal/service/ledger/connector.go b/api/ledger/internal/service/ledger/connector.go index 3229ddfe..934feecd 100644 --- a/api/ledger/internal/service/ledger/connector.go +++ b/api/ledger/internal/service/ledger/connector.go @@ -252,7 +252,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil } - return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_OPERATION_SUCCESS)}, nil case connectorv1.OperationType_DEBIT: accountID := operationAccountID(op.GetFrom()) if accountID == "" && op.GetFromRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { @@ -280,7 +280,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil } - return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_OPERATION_SUCCESS)}, nil case connectorv1.OperationType_TRANSFER: fromID := operationAccountID(op.GetFrom()) toID := operationAccountID(op.GetTo()) @@ -306,7 +306,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } - return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_OPERATION_SUCCESS)}, nil case connectorv1.OperationType_FX: fromID := operationAccountID(op.GetFrom()) toID := operationAccountID(op.GetTo()) @@ -333,7 +333,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil } - return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_OPERATION_SUCCESS)}, nil default: return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil } @@ -535,7 +535,7 @@ func ledgerEntryToOperation(entry *ledgerv1.JournalEntryResponse) *connectorv1.O op := &connectorv1.Operation{ OperationId: strings.TrimSpace(entry.GetEntryRef()), Type: ledgerEntryType(entry.GetEntryType()), - Status: connectorv1.OperationStatus_CONFIRMED, + Status: connectorv1.OperationStatus_OPERATION_SUCCESS, CreatedAt: entry.GetEventTime(), UpdatedAt: entry.GetEventTime(), } diff --git a/api/ledger/internal/service/ledger/external_operations_test.go b/api/ledger/internal/service/ledger/external_operations_test.go index 3a4dc814..e30d22fa 100644 --- a/api/ledger/internal/service/ledger/external_operations_test.go +++ b/api/ledger/internal/service/ledger/external_operations_test.go @@ -16,6 +16,7 @@ import ( "github.com/tech/sendico/pkg/db/transaction" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.mongodb.org/mongo-driver/v2/bson" @@ -86,7 +87,7 @@ func (s *memoryAccountsStore) GetByAccountCode(context.Context, bson.ObjectID, s return nil, merrors.NotImplemented("get by code") } -func (s *memoryAccountsStore) GetByRole(context.Context, bson.ObjectID, string, pmodel.AccountRole) (*pmodel.LedgerAccount, error) { +func (s *memoryAccountsStore) GetByRole(context.Context, bson.ObjectID, string, account_role.AccountRole) (*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("get by role") } @@ -242,7 +243,7 @@ func newTestService() (*Service, *memoryRepository) { return svc, repo } -func newOrgAccount(orgRef bson.ObjectID, currency string, role pmodel.AccountRole) *pmodel.LedgerAccount { +func newOrgAccount(orgRef bson.ObjectID, currency string, role account_role.AccountRole) *pmodel.LedgerAccount { account := &pmodel.LedgerAccount{ AccountCode: "test:" + strings.ToLower(currency) + ":" + bson.NewObjectID().Hex(), Currency: currency, @@ -290,7 +291,7 @@ func TestExternalCreditAndDebit(t *testing.T) { require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() - pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending) + pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) require.NoError(t, repo.accounts.Create(ctx, pending)) creditResp, err := svc.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ @@ -334,7 +335,7 @@ func TestExternalCreditCurrencyMismatch(t *testing.T) { require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() - pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending) + pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) require.NoError(t, repo.accounts.Create(ctx, pending)) source, err := repo.accounts.GetSystemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, "USD") @@ -396,8 +397,8 @@ func TestExternalFlowInvariant(t *testing.T) { require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() - pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending) - transit := newOrgAccount(orgRef, "USD", pmodel.AccountRoleTransit) + pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) + transit := newOrgAccount(orgRef, "USD", account_role.AccountRoleTransit) require.NoError(t, repo.accounts.Create(ctx, pending)) require.NoError(t, repo.accounts.Create(ctx, transit)) @@ -442,8 +443,8 @@ func TestExternalInvariantRandomSequence(t *testing.T) { require.NoError(t, svc.ensureSystemAccounts(ctx)) orgRef := bson.NewObjectID() - pending := newOrgAccount(orgRef, "USD", pmodel.AccountRolePending) - transit := newOrgAccount(orgRef, "USD", pmodel.AccountRoleTransit) + pending := newOrgAccount(orgRef, "USD", account_role.AccountRolePending) + transit := newOrgAccount(orgRef, "USD", account_role.AccountRoleTransit) require.NoError(t, repo.accounts.Create(ctx, pending)) require.NoError(t, repo.accounts.Create(ctx, transit)) diff --git a/api/ledger/internal/service/ledger/posting.go b/api/ledger/internal/service/ledger/posting.go index e8a222cd..047747b9 100644 --- a/api/ledger/internal/service/ledger/posting.go +++ b/api/ledger/internal/service/ledger/posting.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.mongodb.org/mongo-driver/v2/bson" @@ -28,7 +29,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi if req.OrganizationRef == "" { return nil, merrors.InvalidArgument("organization_ref is required") } - roleModel := pmodel.AccountRole("") + roleModel := account_role.AccountRole("") if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { var err error roleModel, err = protoAccountRoleToModel(req.Role) @@ -36,7 +37,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi return nil, err } } else if strings.TrimSpace(req.LedgerAccountRef) == "" { - roleModel = pmodel.AccountRoleOperating + roleModel = account_role.AccountRoleOperating } if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" { return nil, merrors.InvalidArgument("ledger_account_ref or role is required") diff --git a/api/ledger/internal/service/ledger/posting_debit.go b/api/ledger/internal/service/ledger/posting_debit.go index bd684642..5e02ad0f 100644 --- a/api/ledger/internal/service/ledger/posting_debit.go +++ b/api/ledger/internal/service/ledger/posting_debit.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.mongodb.org/mongo-driver/v2/bson" @@ -26,7 +27,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR if req.OrganizationRef == "" { return nil, merrors.InvalidArgument("organization_ref is required") } - roleModel := pmodel.AccountRole("") + roleModel := account_role.AccountRole("") if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { var err error roleModel, err = protoAccountRoleToModel(req.Role) @@ -34,7 +35,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR return nil, err } } else if strings.TrimSpace(req.LedgerAccountRef) == "" { - roleModel = pmodel.AccountRoleOperating + roleModel = account_role.AccountRoleOperating } if strings.TrimSpace(req.LedgerAccountRef) == "" && roleModel == "" { return nil, merrors.InvalidArgument("ledger_account_ref or role is required") diff --git a/api/ledger/internal/service/ledger/posting_external.go b/api/ledger/internal/service/ledger/posting_external.go index acec8147..f52f4c2d 100644 --- a/api/ledger/internal/service/ledger/posting_external.go +++ b/api/ledger/internal/service/ledger/posting_external.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.mongodb.org/mongo-driver/v2/bson" @@ -25,7 +26,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P if req.OrganizationRef == "" { return nil, merrors.InvalidArgument("organization_ref is required") } - roleModel := pmodel.AccountRole("") + roleModel := account_role.AccountRole("") if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { var err error roleModel, err = protoAccountRoleToModel(req.Role) @@ -258,7 +259,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po if req.OrganizationRef == "" { return nil, merrors.InvalidArgument("organization_ref is required") } - roleModel := pmodel.AccountRole("") + roleModel := account_role.AccountRole("") if req.Role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { var err error roleModel, err = protoAccountRoleToModel(req.Role) diff --git a/api/ledger/internal/service/ledger/posting_support.go b/api/ledger/internal/service/ledger/posting_support.go index 80c00869..b799e12b 100644 --- a/api/ledger/internal/service/ledger/posting_support.go +++ b/api/ledger/internal/service/ledger/posting_support.go @@ -12,6 +12,7 @@ import ( "github.com/tech/sendico/ledger/storage/model" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" @@ -33,7 +34,7 @@ type outboxJournalPayload struct { Lines []outboxLinePayload `json:"lines"` } -func validateAccountRole(account *pmodel.LedgerAccount, expected pmodel.AccountRole, label string) error { +func validateAccountRole(account *pmodel.LedgerAccount, expected account_role.AccountRole, label string) error { if expected == "" { return nil } @@ -47,7 +48,7 @@ func validateAccountRole(account *pmodel.LedgerAccount, expected pmodel.AccountR // If accountRefStr is non-empty, it fetches by ID and optionally asserts the role. // If accountRefStr is empty and role is set, it resolves via GetByRole(orgRef, currency, role). // Returns the account and its ObjectID, or an error. -func (s *Service) resolveAccount(ctx context.Context, accountRefStr string, role pmodel.AccountRole, orgRef bson.ObjectID, currency, label string) (*pmodel.LedgerAccount, bson.ObjectID, error) { +func (s *Service) resolveAccount(ctx context.Context, accountRefStr string, role account_role.AccountRole, orgRef bson.ObjectID, currency, label string) (*pmodel.LedgerAccount, bson.ObjectID, error) { if accountRefStr != "" { ref, err := parseObjectID(accountRefStr) if err != nil { diff --git a/api/ledger/internal/service/ledger/posting_support_test.go b/api/ledger/internal/service/ledger/posting_support_test.go index 59ba519f..090deba1 100644 --- a/api/ledger/internal/service/ledger/posting_support_test.go +++ b/api/ledger/internal/service/ledger/posting_support_test.go @@ -13,6 +13,7 @@ import ( "github.com/tech/sendico/ledger/storage/model" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -52,7 +53,7 @@ func (s *stubAccountsStore) Get(ctx context.Context, accountRef bson.ObjectID) ( func (s *stubAccountsStore) GetByAccountCode(context.Context, bson.ObjectID, string, string) (*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("get by code") } -func (s *stubAccountsStore) GetByRole(context.Context, bson.ObjectID, string, pmodel.AccountRole) (*pmodel.LedgerAccount, error) { +func (s *stubAccountsStore) GetByRole(context.Context, bson.ObjectID, string, account_role.AccountRole) (*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("get by role") } func (s *stubAccountsStore) GetSystemAccount(context.Context, pmodel.SystemAccountPurpose, string) (*pmodel.LedgerAccount, error) { diff --git a/api/ledger/internal/service/ledger/posting_transfer.go b/api/ledger/internal/service/ledger/posting_transfer.go index 4c5bfacd..693fe279 100644 --- a/api/ledger/internal/service/ledger/posting_transfer.go +++ b/api/ledger/internal/service/ledger/posting_transfer.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "go.mongodb.org/mongo-driver/v2/bson" @@ -27,7 +28,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq if req.OrganizationRef == "" { return nil, merrors.InvalidArgument("organization_ref is required") } - fromRoleModel := pmodel.AccountRole("") + fromRoleModel := account_role.AccountRole("") if req.FromRole != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { var err error fromRoleModel, err = protoAccountRoleToModel(req.FromRole) @@ -35,9 +36,9 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq return nil, err } } else if strings.TrimSpace(req.FromLedgerAccountRef) == "" { - fromRoleModel = pmodel.AccountRoleOperating + fromRoleModel = account_role.AccountRoleOperating } - toRoleModel := pmodel.AccountRole("") + toRoleModel := account_role.AccountRole("") if req.ToRole != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { var err error toRoleModel, err = protoAccountRoleToModel(req.ToRole) @@ -45,7 +46,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq return nil, err } } else if strings.TrimSpace(req.ToLedgerAccountRef) == "" { - toRoleModel = pmodel.AccountRoleOperating + toRoleModel = account_role.AccountRoleOperating } if strings.TrimSpace(req.FromLedgerAccountRef) == "" && fromRoleModel == "" { return nil, merrors.InvalidArgument("from_ledger_account_ref or from_role is required") diff --git a/api/ledger/internal/service/ledger/system_accounts_test.go b/api/ledger/internal/service/ledger/system_accounts_test.go index dbe4234d..48c5cb9c 100644 --- a/api/ledger/internal/service/ledger/system_accounts_test.go +++ b/api/ledger/internal/service/ledger/system_accounts_test.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" ) @@ -40,7 +41,7 @@ func (s *systemAccountsStoreStub) GetByAccountCode(context.Context, bson.ObjectI return nil, merrors.NotImplemented("get by code") } -func (s *systemAccountsStoreStub) GetByRole(context.Context, bson.ObjectID, string, pmodel.AccountRole) (*pmodel.LedgerAccount, error) { +func (s *systemAccountsStoreStub) GetByRole(context.Context, bson.ObjectID, string, account_role.AccountRole) (*pmodel.LedgerAccount, error) { return nil, merrors.NotImplemented("get by role") } @@ -98,7 +99,7 @@ func TestEnsureSystemAccounts_CreatesAndCaches(t *testing.T) { require.Equal(t, pmodel.LedgerAccountScopeSystem, acc.Scope) require.True(t, acc.AllowNegative) require.Nil(t, acc.OrganizationRef) - require.Equal(t, pmodel.AccountRole(""), acc.Role) + require.Equal(t, account_role.AccountRole(""), acc.Role) require.NotNil(t, acc.SystemPurpose) require.Equal(t, pmodel.LedgerAccountTypeAsset, acc.AccountType) require.Equal(t, pmodel.LedgerAccountStatusActive, acc.Status) diff --git a/api/ledger/internal/service/ledger/topology.go b/api/ledger/internal/service/ledger/topology.go index 53fbc5e7..ca089ec4 100644 --- a/api/ledger/internal/service/ledger/topology.go +++ b/api/ledger/internal/service/ledger/topology.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/pkg/merrors" pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" "go.uber.org/zap" @@ -15,15 +16,15 @@ import ( const LedgerTopologyVersion = 1 -var RequiredRolesV1 = []pmodel.AccountRole{ - pmodel.AccountRoleOperating, - pmodel.AccountRoleHold, - pmodel.AccountRolePending, - pmodel.AccountRoleTransit, - pmodel.AccountRoleSettlement, +var RequiredRolesV1 = []account_role.AccountRole{ + account_role.AccountRoleOperating, + account_role.AccountRoleHold, + account_role.AccountRolePending, + account_role.AccountRoleTransit, + account_role.AccountRoleSettlement, } -func isRequiredTopologyRole(role pmodel.AccountRole) bool { +func isRequiredTopologyRole(role account_role.AccountRole) bool { for _, required := range RequiredRolesV1 { if role == required { return true @@ -52,7 +53,7 @@ func (s *Service) ensureLedgerTopology(ctx context.Context, orgRef bson.ObjectID return nil } -func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, currency string, role pmodel.AccountRole) (*pmodel.LedgerAccount, error) { +func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, currency string, role account_role.AccountRole) (*pmodel.LedgerAccount, error) { if s.storage == nil || s.storage.Accounts() == nil { return nil, errStorageNotInitialized } @@ -104,7 +105,7 @@ func (s *Service) ensureRoleAccount(ctx context.Context, orgRef bson.ObjectID, c return account, nil } -func newSystemAccount(orgRef bson.ObjectID, currency string, role pmodel.AccountRole) *pmodel.LedgerAccount { +func newSystemAccount(orgRef bson.ObjectID, currency string, role account_role.AccountRole) *pmodel.LedgerAccount { ref := bson.NewObjectID() account := &pmodel.LedgerAccount{ AccountCode: generateAccountCode(pmodel.LedgerAccountTypeAsset, currency, ref), diff --git a/api/ledger/storage/mongo/store/accounts.go b/api/ledger/storage/mongo/store/accounts.go index 1aa823f3..ef09a545 100644 --- a/api/ledger/storage/mongo/store/accounts.go +++ b/api/ledger/storage/mongo/store/accounts.go @@ -11,6 +11,7 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" pkm "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mutil/mzap" "go.mongodb.org/mongo-driver/v2/bson" @@ -167,7 +168,7 @@ func (a *accountsStore) GetByAccountCode(ctx context.Context, orgRef bson.Object return result, nil } -func (a *accountsStore) GetByRole(ctx context.Context, orgRef bson.ObjectID, currency string, role pkm.AccountRole) (*pkm.LedgerAccount, error) { +func (a *accountsStore) GetByRole(ctx context.Context, orgRef bson.ObjectID, currency string, role account_role.AccountRole) (*pkm.LedgerAccount, error) { if orgRef.IsZero() { a.logger.Warn("Attempt to get account with zero organization ID") return nil, merrors.InvalidArgument("accountsStore: zero organization ID") @@ -254,7 +255,7 @@ func (a *accountsStore) GetDefaultSettlement(ctx context.Context, orgRef bson.Ob query := repository.Query(). Filter(repository.Field("organizationRef"), orgRef). Filter(repository.Field("currency"), currency). - Filter(repository.Field("role"), pkm.AccountRoleSettlement). + Filter(repository.Field("role"), account_role.AccountRoleSettlement). Limit(&limit) result := &pkm.LedgerAccount{} diff --git a/api/ledger/storage/mongo/store/accounts_test.go b/api/ledger/storage/mongo/store/accounts_test.go index 1e77917c..5e6bef17 100644 --- a/api/ledger/storage/mongo/store/accounts_test.go +++ b/api/ledger/storage/mongo/store/accounts_test.go @@ -14,6 +14,7 @@ import ( "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/merrors" pkm "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.uber.org/zap" @@ -253,23 +254,23 @@ func TestAccountsStore_GetByRole(t *testing.T) { FindOneByFilterFunc: func(ctx context.Context, _ builder.Query, result storable.Storable) error { account := result.(*pkm.LedgerAccount) account.Currency = "USD" - account.Role = pkm.AccountRoleOperating + account.Role = account_role.AccountRoleOperating return nil }, } store := &accountsStore{logger: logger, repo: stub} - result, err := store.GetByRole(ctx, orgRef, "USD", pkm.AccountRoleOperating) + result, err := store.GetByRole(ctx, orgRef, "USD", account_role.AccountRoleOperating) require.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, pkm.AccountRoleOperating, result.Role) + assert.Equal(t, account_role.AccountRoleOperating, result.Role) assert.Equal(t, "USD", result.Currency) }) t.Run("ZeroOrganizationID", func(t *testing.T) { store := &accountsStore{logger: logger, repo: &repositoryStub{}} - result, err := store.GetByRole(ctx, bson.NilObjectID, "USD", pkm.AccountRoleOperating) + result, err := store.GetByRole(ctx, bson.NilObjectID, "USD", account_role.AccountRoleOperating) require.Error(t, err) assert.Nil(t, result) @@ -278,7 +279,7 @@ func TestAccountsStore_GetByRole(t *testing.T) { t.Run("EmptyCurrency", func(t *testing.T) { store := &accountsStore{logger: logger, repo: &repositoryStub{}} - result, err := store.GetByRole(ctx, orgRef, "", pkm.AccountRoleOperating) + result, err := store.GetByRole(ctx, orgRef, "", account_role.AccountRoleOperating) require.Error(t, err) assert.Nil(t, result) @@ -302,7 +303,7 @@ func TestAccountsStore_GetByRole(t *testing.T) { } store := &accountsStore{logger: logger, repo: stub} - result, err := store.GetByRole(ctx, orgRef, "USD", pkm.AccountRoleOperating) + result, err := store.GetByRole(ctx, orgRef, "USD", account_role.AccountRoleOperating) require.Error(t, err) assert.Nil(t, result) @@ -318,7 +319,7 @@ func TestAccountsStore_GetByRole(t *testing.T) { } store := &accountsStore{logger: logger, repo: stub} - result, err := store.GetByRole(ctx, orgRef, "USD", pkm.AccountRoleOperating) + result, err := store.GetByRole(ctx, orgRef, "USD", account_role.AccountRoleOperating) require.Error(t, err) assert.Nil(t, result) @@ -337,7 +338,7 @@ func TestAccountsStore_GetDefaultSettlement(t *testing.T) { account := result.(*pkm.LedgerAccount) account.SetID(bson.NewObjectID()) account.Currency = "USD" - account.Role = pkm.AccountRoleSettlement + account.Role = account_role.AccountRoleSettlement return nil }, } @@ -347,7 +348,7 @@ func TestAccountsStore_GetDefaultSettlement(t *testing.T) { require.NoError(t, err) assert.NotNil(t, result) - assert.Equal(t, pkm.AccountRoleSettlement, result.Role) + assert.Equal(t, account_role.AccountRoleSettlement, result.Role) assert.Equal(t, "USD", result.Currency) }) diff --git a/api/ledger/storage/storage.go b/api/ledger/storage/storage.go index 3571f164..bafce24b 100644 --- a/api/ledger/storage/storage.go +++ b/api/ledger/storage/storage.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/ledger/storage/model" pkm "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -38,7 +39,7 @@ type AccountsStore interface { Create(ctx context.Context, account *pkm.LedgerAccount) error Get(ctx context.Context, accountRef bson.ObjectID) (*pkm.LedgerAccount, error) GetByAccountCode(ctx context.Context, orgRef bson.ObjectID, accountCode, currency string) (*pkm.LedgerAccount, error) - GetByRole(ctx context.Context, orgRef bson.ObjectID, currency string, role pkm.AccountRole) (*pkm.LedgerAccount, error) + GetByRole(ctx context.Context, orgRef bson.ObjectID, currency string, role account_role.AccountRole) (*pkm.LedgerAccount, error) GetSystemAccount(ctx context.Context, purpose pkm.SystemAccountPurpose, currency string) (*pkm.LedgerAccount, error) GetDefaultSettlement(ctx context.Context, orgRef bson.ObjectID, currency string) (*pkm.LedgerAccount, error) ListByOrganization(ctx context.Context, orgRef bson.ObjectID, filter *AccountsFilter, limit int, offset int) ([]*pkm.LedgerAccount, error) diff --git a/api/notification/go.mod b/api/notification/go.mod index d5ff1970..8468d47a 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -50,7 +50,7 @@ require ( golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect google.golang.org/grpc v1.78.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/api/notification/go.sum b/api/notification/go.sum index 895ba47d..40032018 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -225,8 +225,8 @@ 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-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/go.mod b/api/payments/orchestrator/go.mod index ea05f352..1aa4cb24 100644 --- a/api/payments/orchestrator/go.mod +++ b/api/payments/orchestrator/go.mod @@ -15,6 +15,7 @@ replace github.com/tech/sendico/fx/oracle => ../../fx/oracle replace github.com/tech/sendico/ledger => ../../ledger require ( + github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 @@ -55,7 +56,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/klauspost/compress v1.18.3 // indirect github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect github.com/magiconair/properties v1.8.7 // indirect @@ -106,5 +106,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/payments/orchestrator/go.sum b/api/payments/orchestrator/go.sum index efcb1e04..41634a67 100644 --- a/api/payments/orchestrator/go.sum +++ b/api/payments/orchestrator/go.sum @@ -266,8 +266,8 @@ 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/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go index 51813e3b..f1abc371 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go @@ -152,7 +152,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model if topUpMoney != nil && topUpPositive { gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) setExecutionStepRole(gasStep, executionStepRoleSource) - setExecutionStepStatus(gasStep, executionStepStatusPlanned) + setExecutionStepStatus(gasStep, model.OperationStatePlanned) gasStep.Description = "Top up native gas from fee wallet" gasStep.Amount = moneyFromProto(topUpMoney) gasStep.NetworkFee = moneyFromProto(topUpFee) @@ -162,7 +162,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) setExecutionStepRole(fundStep, executionStepRoleSource) - setExecutionStepStatus(fundStep, executionStepStatusPlanned) + setExecutionStepStatus(fundStep, model.OperationStatePlanned) fundStep.Description = "Transfer payout amount to card funding wallet" fundStep.Amount = cloneMoney(intentAmount) fundStep.NetworkFee = moneyFromProto(fundingFee) @@ -172,7 +172,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model if feeRequired { feeStep = ensureExecutionStep(plan, stepCodeFeeTransfer) setExecutionStepRole(feeStep, executionStepRoleSource) - setExecutionStepStatus(feeStep, executionStepStatusPlanned) + setExecutionStepStatus(feeStep, model.OperationStatePlanned) feeStep.Description = "Transfer fee to fee wallet" feeStep.Amount = cloneMoney(feeAmount) feeStep.NetworkFee = moneyFromProto(feeTransferFee) @@ -182,7 +182,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model cardStep := ensureExecutionStep(plan, stepCodeCardPayout) setExecutionStepRole(cardStep, executionStepRoleConsumer) - setExecutionStepStatus(cardStep, executionStepStatusPlanned) + setExecutionStepStatus(cardStep, model.OperationStatePlanned) cardStep.Description = "Submit card payout" cardStep.Amount = cloneMoney(payoutAmount) if card := intent.Destination.Card; card != nil { @@ -202,11 +202,13 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model ensureResp, gasErr := chainClient.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ IdempotencyKey: payment.IdempotencyKey + ":card:gas", OrganizationRef: payment.OrganizationRef.Hex(), + IntentRef: strings.TrimSpace(payment.Intent.Ref), + OperationRef: strings.TrimSpace(cardStep.OperationRef), SourceWalletRef: feeWalletRef, TargetWalletRef: sourceWalletRef, EstimatedTotalFee: estimatedTotalFee, Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, + PaymentRef: payment.PaymentRef, }) if gasErr != nil { s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) @@ -241,12 +243,12 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model return err } gasStep.NetworkFee = moneyFromProto(topUpFee) - setExecutionStepStatus(gasStep, executionStepStatusSubmitted) + setExecutionStepStatus(gasStep, model.OperationStateWaiting) } else { gasStep.Amount = nil gasStep.NetworkFee = nil gasStep.TransferRef = "" - setExecutionStepStatus(gasStep, executionStepStatusSkipped) + setExecutionStepStatus(gasStep, model.OperationStateSkipped) } } if gasStep != nil { @@ -255,6 +257,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model updateExecutionPlanTotalNetworkFee(plan) } + s.logger.Warn("Request", zap.Any("intent", intent)) fundResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ IdempotencyKey: payment.IdempotencyKey + ":card:fund", OrganizationRef: payment.OrganizationRef.Hex(), @@ -262,7 +265,9 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model Destination: fundingDest, Amount: cloneProtoMoney(intentAmountProto), Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, + PaymentRef: payment.PaymentRef, + IntentRef: strings.TrimSpace(intent.Ref), + OperationRef: strings.TrimSpace(cardStep.OperationRef), }) if err != nil { return err @@ -271,20 +276,22 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) fundStep.TransferRef = exec.ChainTransferRef } - setExecutionStepStatus(fundStep, executionStepStatusSubmitted) + setExecutionStepStatus(fundStep, model.OperationStateWaiting) updateExecutionPlanTotalNetworkFee(plan) if feeRequired { feeResp, err := chainClient.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IntentRef: intent.Ref, + OperationRef: feeStep.OperationRef, IdempotencyKey: payment.IdempotencyKey + ":card:fee", OrganizationRef: payment.OrganizationRef.Hex(), SourceWalletRef: sourceWalletRef, Destination: &chainv1.TransferDestination{ Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, }, - Amount: cloneProtoMoney(feeAmountProto), - Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, + Amount: cloneProtoMoney(feeAmountProto), + Metadata: cloneMetadata(payment.Metadata), + PaymentRef: payment.PaymentRef, }) if err != nil { return err @@ -293,7 +300,7 @@ func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) feeStep.TransferRef = exec.FeeTransferRef } - setExecutionStepStatus(feeStep, executionStepStatusSubmitted) + setExecutionStepStatus(feeStep, model.OperationStateWaiting) s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go index 7b9e6445..fb6e1acd 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "github.com/google/uuid" "github.com/shopspring/decimal" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" @@ -87,6 +88,7 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) if token := strings.TrimSpace(card.Token); token != "" { req := &mntxv1.CardTokenPayoutRequest{ PayoutId: payoutID, + IdempotencyKey: payment.IdempotencyKey, CustomerId: customerID, CustomerFirstName: customerFirstName, CustomerMiddleName: customerMiddleName, @@ -113,6 +115,7 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) } else if pan := strings.TrimSpace(card.Pan); pan != "" { req := &mntxv1.CardPayoutRequest{ PayoutId: payoutID, + IdempotencyKey: payment.IdempotencyKey, CustomerId: customerID, CustomerFirstName: customerFirstName, CustomerMiddleName: customerMiddleName, @@ -166,7 +169,7 @@ func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) if exec.CardPayoutRef != "" { step.TransferRef = exec.CardPayoutRef } - setExecutionStepStatus(step, executionStepStatusSubmitted) + setExecutionStepStatus(step, model.OperationStateWaiting) updateExecutionPlanTotalNetworkFee(plan) } @@ -225,21 +228,36 @@ func updateCardPayoutPlanSteps(payment *model.Payment, payout *mntxv1.CardPayout } execStep := plan.Steps[idx] if execStep == nil { - execStep = &model.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)} + execStep = &model.ExecutionStep{ + Code: planStepID(planStep, idx), + Description: describePlanStep(planStep), + OperationRef: uuid.New().String(), + State: model.OperationStateCreated, + } plan.Steps[idx] = execStep } if execStep.TransferRef == "" { execStep.TransferRef = payoutID } switch payout.GetStatus() { - case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: - setExecutionStepStatus(execStep, executionStepStatusConfirmed) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: + setExecutionStepStatus(execStep, model.OperationStateCreated) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: + setExecutionStepStatus(execStep, model.OperationStateWaiting) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: + setExecutionStepStatus(execStep, model.OperationStateSuccess) + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(execStep, executionStepStatusFailed) - case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING: - setExecutionStepStatus(execStep, executionStepStatusSubmitted) + setExecutionStepStatus(execStep, model.OperationStateFailed) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + setExecutionStepStatus(execStep, model.OperationStateCancelled) + default: - setExecutionStepStatus(execStep, executionStepStatusPlanned) + setExecutionStepStatus(execStep, model.OperationStatePlanned) } updated = true } @@ -271,27 +289,46 @@ func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutStat } } switch payout.GetStatus() { - case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: - setExecutionStepStatus(step, executionStepStatusConfirmed) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: + setExecutionStepStatus(step, model.OperationStatePlanned) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: + setExecutionStepStatus(step, model.OperationStateWaiting) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: + setExecutionStepStatus(step, model.OperationStateSuccess) + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - setExecutionStepStatus(step, executionStepStatusFailed) - case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING: - setExecutionStepStatus(step, executionStepStatusSubmitted) + setExecutionStepStatus(step, model.OperationStateFailed) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + setExecutionStepStatus(step, model.OperationStateCancelled) + default: - setExecutionStepStatus(step, executionStepStatusPlanned) + setExecutionStepStatus(step, model.OperationStatePlanned) } + } payment.State = mapMntxStatusToState(payout.GetStatus()) + switch payout.GetStatus() { - case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: payment.FailureCode = model.PaymentFailureCodeUnspecified payment.FailureReason = "" + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: payment.FailureCode = model.PaymentFailureCodePolicy payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = "payout cancelled" + default: - // leave as-is for pending/unspecified + // CREATED / WAITING — keep as is } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go index 70a56850..71e21dfa 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go @@ -89,6 +89,7 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { IdempotencyKey: "pay-1", OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -232,7 +233,7 @@ func TestSubmitCardPayout_UsesSettlementAmount(t *testing.T) { return &mntxv1.CardPayoutResponse{ Payout: &mntxv1.CardPayoutState{ PayoutId: "payout-1", - Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, + Status: mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING, }, }, nil }, @@ -251,6 +252,7 @@ func TestSubmitCardPayout_UsesSettlementAmount(t *testing.T) { IdempotencyKey: "pay-2", OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, Intent: model.PaymentIntent{ + Ref: "ref-2", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -340,6 +342,7 @@ func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) { IdempotencyKey: "pay-3", OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, Intent: model.PaymentIntent{ + Ref: "ref-3", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index 0128ff0f..37c867cb 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -23,6 +23,7 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent { return model.PaymentIntent{} } intent := model.PaymentIntent{ + Ref: src.GetRef(), Kind: modelKindFromProto(src.GetKind()), Source: endpointFromProto(src.GetSource()), Destination: endpointFromProto(src.GetDestination()), @@ -163,6 +164,7 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment { func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent { intent := &orchestratorv1.PaymentIntent{ + Ref: src.Ref, Kind: protoKindFromModel(src.Kind), Source: protoEndpointFromModel(src.Source), Destination: protoEndpointFromModel(src.Destination), @@ -315,6 +317,7 @@ func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.Execu DestinationRef: src.DestinationRef, TransferRef: src.TransferRef, Metadata: cloneMetadata(src.Metadata), + OperationRef: src.OperationRef, } } @@ -346,7 +349,6 @@ func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentSt GatewayId: strings.TrimSpace(src.GatewayID), Action: protoRailOperationFromModel(src.Action), Amount: protoMoney(src.Amount), - Ref: strings.TrimSpace(src.Ref), StepId: strings.TrimSpace(src.StepID), InstanceId: strings.TrimSpace(src.InstanceID), DependsOn: cloneStringList(src.DependsOn), diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go b/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go index fa0d66a0..24426f66 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go +++ b/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go @@ -13,13 +13,6 @@ const ( executionStepRoleSource = "source" executionStepRoleConsumer = "consumer" - - executionStepStatusPlanned = "planned" - executionStepStatusSubmitted = "submitted" - executionStepStatusConfirmed = "confirmed" - executionStepStatusFailed = "failed" - executionStepStatusCancelled = "cancelled" - executionStepStatusSkipped = "skipped" ) func setExecutionStepRole(step *model.ExecutionStep, role string) { @@ -27,9 +20,9 @@ func setExecutionStepRole(step *model.ExecutionStep, role string) { setExecutionStepMetadata(step, executionStepMetadataRole, role) } -func setExecutionStepStatus(step *model.ExecutionStep, status string) { - status = strings.ToLower(strings.TrimSpace(status)) - setExecutionStepMetadata(step, executionStepMetadataStatus, status) +func setExecutionStepStatus(step *model.ExecutionStep, state model.OperationState) { + step.State = state + setExecutionStepMetadata(step, executionStepMetadataStatus, string(state)) } func executionStepRole(step *model.ExecutionStep) string { @@ -45,17 +38,6 @@ func executionStepRole(step *model.ExecutionStep) string { return executionStepRoleSource } -func executionStepStatus(step *model.ExecutionStep) string { - if step == nil { - return "" - } - status := strings.TrimSpace(step.Metadata[executionStepMetadataStatus]) - if status == "" { - return executionStepStatusPlanned - } - return strings.ToLower(status) -} - func isSourceExecutionStep(step *model.ExecutionStep) bool { return executionStepRole(step) == executionStepRoleSource } @@ -69,12 +51,11 @@ func sourceStepsConfirmed(plan *model.ExecutionPlan) bool { if step == nil || !isSourceExecutionStep(step) { continue } - status := executionStepStatus(step) - if status == executionStepStatusSkipped { + if step.State == model.OperationStateSkipped { continue } hasSource = true - if status != executionStepStatusConfirmed { + if step.State != model.OperationStateSuccess { return false } } @@ -131,20 +112,29 @@ func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.T return nil } -func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) string { +func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) model.OperationState { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: - return executionStepStatusConfirmed + + case chainv1.TransferStatus_TRANSFER_CREATED: + return model.OperationStatePlanned + + case chainv1.TransferStatus_TRANSFER_PROCESSING: + return model.OperationStateProcessing + + case chainv1.TransferStatus_TRANSFER_WAITING: + return model.OperationStateWaiting + + case chainv1.TransferStatus_TRANSFER_SUCCESS: + return model.OperationStateSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: - return executionStepStatusFailed + return model.OperationStateFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return executionStepStatusCancelled - case chainv1.TransferStatus_TRANSFER_SIGNING, - chainv1.TransferStatus_TRANSFER_PENDING, - chainv1.TransferStatus_TRANSFER_SUBMITTED: - return executionStepStatusSubmitted + return model.OperationStateCancelled + default: - return "" + return model.OperationStatePlanned } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go index e4b82db0..d62bbb84 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer.go @@ -9,15 +9,18 @@ import ( cons "github.com/tech/sendico/pkg/messaging/consumer" paymentgateway "github.com/tech/sendico/pkg/messaging/notifications/paymentgateway" np "github.com/tech/sendico/pkg/messaging/notifications/processor" + "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" - "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/payments/rail" "go.uber.org/zap" ) func (s *Service) startGatewayConsumers() { if s == nil || s.gatewayBroker == nil { + s.logger.Warn("Missing broker. Gateway feedback consumer has NOT started") return } + s.logger.Info("Gateway feedback consumer started") processor := paymentgateway.NewPaymentGatewayExecutionProcessor(s.logger, s.onGatewayExecution) s.consumeGatewayProcessor(processor) } @@ -36,117 +39,198 @@ func (s *Service) consumeGatewayProcessor(processor np.EnvelopeProcessor) { }() } +func executionPlanSucceeded(plan *paymodel.ExecutionPlan) bool { + for _, s := range plan.Steps { + if !s.IsTerminal() { + return false + } + if s.State != paymodel.OperationStateSuccess { + return false + } + } + return true +} + +func executionPlanFailed(plan *paymodel.ExecutionPlan) bool { + hasFailed := false + + for _, s := range plan.Steps { + if !s.IsTerminal() { + return false + } + if s.State == paymodel.OperationStateFailed { + hasFailed = true + } + } + + return hasFailed +} + func (s *Service) onGatewayExecution(ctx context.Context, exec *model.PaymentGatewayExecution) error { if exec == nil { return merrors.InvalidArgument("payment gateway execution is nil", "execution") } - paymentRef := strings.TrimSpace(exec.PaymentIntentID) + + paymentRef := strings.TrimSpace(exec.PaymentRef) if paymentRef == "" { - return merrors.InvalidArgument("payment_intent_id is required", "payment_intent_id") - } - if s.storage == nil || s.storage.Payments() == nil { - return errStorageUnavailable + return merrors.InvalidArgument("payment_ref is required", "payment_ref") } + store := s.storage.Payments() + payment, err := store.GetByPaymentRef(ctx, paymentRef) if err != nil { return err } + + // --- metadata if payment.Metadata == nil { payment.Metadata = map[string]string{} } - if exec.RequestID != "" { - payment.Metadata["gateway_request_id"] = exec.RequestID - } - if exec.QuoteRef != "" { - payment.Metadata["gateway_quote_ref"] = exec.QuoteRef - } - if exec.ExecutedMoney != nil { - payment.Metadata["gateway_executed_amount"] = exec.ExecutedMoney.Amount - payment.Metadata["gateway_executed_currency"] = exec.ExecutedMoney.Currency - } - payment.Metadata["gateway_confirmation_status"] = string(exec.Status) + payment.Metadata["gateway_operation_result"] = string(exec.Status) + payment.Metadata["gateway_operation_ref"] = exec.OperationRef + payment.Metadata["gateway_request_idempotency"] = exec.IdempotencyKey - updatedPlan := updateExecutionStepsFromGatewayExecution(payment, exec) - switch exec.Status { - case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified: - if payment.PaymentPlan != nil && updatedPlan && payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) { - return s.resumePaymentPlan(ctx, store, payment) - } - payment.State = paymodel.PaymentStateSettled - payment.FailureCode = paymodel.PaymentFailureCodeUnspecified - payment.FailureReason = "" - case model.ConfirmationStatusRejected: - payment.State = paymodel.PaymentStateFailed - payment.FailureCode = paymodel.PaymentFailureCodePolicy - payment.FailureReason = "gateway_rejected" - case model.ConfirmationStatusTimeout: - payment.State = paymodel.PaymentStateFailed - payment.FailureCode = paymodel.PaymentFailureCodePolicy - payment.FailureReason = "confirmation_timeout" - default: - s.logger.Warn("Unhandled gateway confirmation status", zap.String("status", string(exec.Status)), zap.String("payment_ref", paymentRef)) + // --- update exactly ONE step + updated := updateExecutionStepsFromGatewayExecution(s.logger, payment, exec) + if !updated { + s.logger.Warn("No execution step matched gateway result", + zap.String("payment_ref", paymentRef), + zap.String("operation_ref", exec.OperationRef), + zap.String("idempotency", exec.IdempotencyKey), + ) } + if err := store.Update(ctx, payment); err != nil { return err } - s.logger.Info("Payment gateway execution applied", zap.String("payment_ref", paymentRef), zap.String("status", string(exec.Status)), zap.String("service", string(mservice.PaymentGateway))) + + // reload unified state + payment, err = store.GetByPaymentRef(ctx, paymentRef) + if err != nil { + return err + } + + // --- if plan can continue — continue + if payment.ExecutionPlan != nil && !executionPlanComplete(payment.ExecutionPlan) { + return s.resumePaymentPlan(ctx, store, payment) + } + + // --- plan is terminal: decide payment fate by aggregation + if payment.ExecutionPlan != nil && executionPlanComplete(payment.ExecutionPlan) { + switch { + case executionPlanSucceeded(payment.ExecutionPlan): + payment.State = paymodel.PaymentStateSettled + + case executionPlanFailed(payment.ExecutionPlan): + payment.State = paymodel.PaymentStateFailed + payment.FailureReason = "execution_plan_failed" + } + + return store.Update(ctx, payment) + } + return nil } -func updateExecutionStepsFromGatewayExecution(payment *paymodel.Payment, exec *model.PaymentGatewayExecution) bool { - if payment == nil || exec == nil || payment.PaymentPlan == nil { +func updateExecutionStepsFromGatewayExecution( + logger mlogger.Logger, + payment *paymodel.Payment, + exec *model.PaymentGatewayExecution, +) bool { + + if payment == nil || payment.PaymentPlan == nil || exec == nil { + logger.Warn("updateExecutionSteps: invalid input", + zap.String("payment_ref", payment.PaymentRef), + ) return false } - requestID := strings.TrimSpace(exec.RequestID) - if requestID == "" { + + operationRef := strings.TrimSpace(exec.OperationRef) + if operationRef == "" { + logger.Warn("updateExecutionSteps: empty operation_ref from gateway", + zap.String("payment_ref", payment.PaymentRef), + ) return false } + execPlan := ensureExecutionPlanForPlan(payment, payment.PaymentPlan) if execPlan == nil { + logger.Error("updateExecutionSteps: execution plan missing", + zap.String("payment_ref", payment.PaymentRef), + ) return false } + status := executionStepStatusFromGatewayStatus(exec.Status) if status == "" { + logger.Warn("updateExecutionSteps: unknown gateway status", + zap.String("payment_ref", payment.PaymentRef), + zap.String("gateway_status", string(exec.Status)), + ) return false } - updated := false - for idx, planStep := range payment.PaymentPlan.Steps { - if planStep == nil { - continue - } - if idx >= len(execPlan.Steps) { - continue - } - execStep := execPlan.Steps[idx] + + logger.Debug("updateExecutionSteps: matching by operation_ref", + zap.String("payment_ref", payment.PaymentRef), + zap.String("operation_ref", operationRef), + zap.String("mapped_status", string(status)), + ) + + for idx, execStep := range execPlan.Steps { if execStep == nil { - execStep = &paymodel.ExecutionStep{Code: planStepID(planStep, idx), Description: describePlanStep(planStep)} - execPlan.Steps[idx] = execStep - } - if strings.EqualFold(strings.TrimSpace(execStep.TransferRef), requestID) { - setExecutionStepStatus(execStep, status) - updated = true continue } - if execStep.TransferRef == "" && planStep.Rail == paymodel.RailProviderSettlement { - if planStep.Action == paymodel.RailOperationObserveConfirm || planStep.Action == paymodel.RailOperationSend { - execStep.TransferRef = requestID - setExecutionStepStatus(execStep, status) - updated = true + + if strings.EqualFold(strings.TrimSpace(execStep.OperationRef), operationRef) { + + logger.Debug("updateExecutionSteps: matched execution step", + zap.String("payment_ref", payment.PaymentRef), + zap.Int("step_index", idx), + zap.String("step_code", execStep.Code), + zap.String("prev_state", string(execStep.State)), + ) + + // update transfer ref if not set yet + if execStep.TransferRef == "" && exec.TransferRef != "" { + execStep.TransferRef = strings.TrimSpace(exec.TransferRef) } + + setExecutionStepStatus(execStep, status) + + logger.Debug("updateExecutionSteps: step state updated", + zap.String("payment_ref", payment.PaymentRef), + zap.Int("step_index", idx), + zap.String("step_code", execStep.Code), + zap.String("new_state", string(execStep.State)), + ) + + return true } } - return updated + + logger.Error("updateExecutionSteps: no execution step found for operation_ref", + zap.String("payment_ref", payment.PaymentRef), + zap.String("operation_ref", operationRef), + ) + + return false } -func executionStepStatusFromGatewayStatus(status model.ConfirmationStatus) string { +func executionStepStatusFromGatewayStatus(status rail.OperationResult) paymodel.OperationState { switch status { - case model.ConfirmationStatusConfirmed, model.ConfirmationStatusClarified: - return executionStepStatusConfirmed - case model.ConfirmationStatusRejected, model.ConfirmationStatusTimeout: - return executionStepStatusFailed + + case rail.OperationResultSuccess: + return paymodel.OperationStateSuccess + + case rail.OperationResultFailed: + return paymodel.OperationStateFailed + + case rail.OperationResultCancelled: + return paymodel.OperationStateCancelled + default: - return "" + return paymodel.OperationStateFailed } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go index f1eebe51..131af30c 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_execution_consumer_test.go @@ -7,63 +7,89 @@ import ( paymodel "github.com/tech/sendico/payments/orchestrator/storage/model" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/payments/rail" ) -func TestGatewayExecutionConfirmedUpdatesPayment(t *testing.T) { +func TestGatewayExecutionSuccessUpdatesMetadataOnly(t *testing.T) { logger := mloggerfactory.NewLogger(false) store := newHelperPaymentStore() - payment := &paymodel.Payment{PaymentRef: "pi-1", State: paymodel.PaymentStateSubmitted} + + payment := &paymodel.Payment{ + PaymentRef: "pi-1", + State: paymodel.PaymentStateSubmitted, + } if err := store.Create(context.Background(), payment); err != nil { t.Fatalf("failed to seed payment: %v", err) } + svc := &Service{ logger: logger, storage: stubRepo{payments: store}, } + exec := &model.PaymentGatewayExecution{ - PaymentIntentID: "pi-1", - Status: model.ConfirmationStatusConfirmed, - RequestID: "req-1", - QuoteRef: "quote-1", + PaymentRef: "pi-1", + Status: rail.OperationResultSuccess, + IdempotencyKey: "idem-1", + OperationRef: "oper-1", } + if err := svc.onGatewayExecution(context.Background(), exec); err != nil { t.Fatalf("onGatewayExecution error: %v", err) } + updated, _ := store.GetByPaymentRef(context.Background(), "pi-1") - if updated.State != paymodel.PaymentStateSettled { - t.Fatalf("expected payment settled, got %s", updated.State) + + // Should not be Settled without execution plan + if updated.State != paymodel.PaymentStateSubmitted { + t.Fatalf("expected payment to remain submitted, got %s", updated.State) } - if updated.Metadata["gateway_request_id"] != "req-1" { - t.Fatalf("expected gateway_request_id metadata") + + if updated.Metadata["gateway_request_idempotency"] != "idem-1" { + t.Fatalf("expected gateway_request_idempotency metadata") } - if updated.Metadata["gateway_confirmation_status"] != string(model.ConfirmationStatusConfirmed) { - t.Fatalf("expected gateway_confirmation_status metadata") + + if updated.Metadata["gateway_operation_result"] != string(rail.OperationResultSuccess) { + t.Fatalf("expected gateway_operation_result metadata") } } func TestGatewayExecutionRejectedFailsPayment(t *testing.T) { logger := mloggerfactory.NewLogger(false) store := newHelperPaymentStore() - payment := &paymodel.Payment{PaymentRef: "pi-2", State: paymodel.PaymentStateSubmitted} + + payment := &paymodel.Payment{ + PaymentRef: "pi-2", State: paymodel.PaymentStateSubmitted, IdempotencyKey: "idem-1", + ExecutionPlan: &paymodel.ExecutionPlan{ + Steps: []*paymodel.ExecutionStep{ + {OperationRef: "s1", State: paymodel.OperationStatePlanned, TransferRef: "trn-1"}}}} + if err := store.Create(context.Background(), payment); err != nil { t.Fatalf("failed to seed payment: %v", err) } + svc := &Service{ logger: logger, storage: stubRepo{payments: store}, } + exec := &model.PaymentGatewayExecution{ - PaymentIntentID: "pi-2", - Status: model.ConfirmationStatusRejected, + PaymentRef: "pi-2", + TransferRef: "trn-1", + Status: rail.OperationResultFailed, } + if err := svc.onGatewayExecution(context.Background(), exec); err != nil { t.Fatalf("onGatewayExecution error: %v", err) } + updated, _ := store.GetByPaymentRef(context.Background(), "pi-2") + if updated.State != paymodel.PaymentStateFailed { t.Fatalf("expected payment failed, got %s", updated.State) } - if updated.FailureReason != "gateway_rejected" { - t.Fatalf("expected failure reason gateway_rejected, got %q", updated.FailureReason) + + if updated.FailureReason != "execution_plan_failed" { + t.Fatalf("expected failure reason execution_plan_failed, got %q", updated.FailureReason) } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index c5c3d399..68b84a7d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/google/uuid" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" @@ -881,6 +882,7 @@ func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestrat } intentProto := &orchestratorv1.PaymentIntent{ + Ref: uuid.New().String(), Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, Source: req.GetSource(), Destination: req.GetDestination(), diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go index 92617c6b..2cb83211 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go @@ -85,7 +85,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_CONFIRMED: + case chainv1.TransferStatus_TRANSFER_SUCCESS: if h.resumePlan != nil { if err := h.resumePlan(ctx, store, payment); err != nil { return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) @@ -97,9 +97,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or } } return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) - case chainv1.TransferStatus_TRANSFER_SIGNING, - chainv1.TransferStatus_TRANSFER_PENDING, - chainv1.TransferStatus_TRANSFER_SUBMITTED: + case chainv1.TransferStatus_TRANSFER_WAITING: if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { payment.State = model.PaymentStateSubmitted } @@ -133,7 +131,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or payment.State = model.PaymentStateCancelled payment.FailureCode = model.PaymentFailureCodePolicy payment.FailureReason = reason - case chainv1.TransferStatus_TRANSFER_CONFIRMED: + case chainv1.TransferStatus_TRANSFER_SUCCESS: if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { if cardPayoutDependenciesConfirmed(payment.PaymentPlan, payment.ExecutionPlan) { if payment.Execution.CardPayoutRef != "" { @@ -155,9 +153,7 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or payment.State = model.PaymentStateSubmitted } } - case chainv1.TransferStatus_TRANSFER_SIGNING, - chainv1.TransferStatus_TRANSFER_PENDING, - chainv1.TransferStatus_TRANSFER_SUBMITTED: + case chainv1.TransferStatus_TRANSFER_WAITING: if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { payment.State = model.PaymentStateSubmitted } @@ -251,18 +247,72 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * } applyCardPayoutUpdate(payment, payout) + switch payout.GetStatus() { - case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: + h.logger.Info("card payout success received", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + zap.String("payment_state_before", string(payment.State)), + zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), + zap.Bool("resume_plan_present", h.resumePlan != nil), + ) + if h.resumePlan != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { if err := h.resumePlan(ctx, store, payment); err != nil { + h.logger.Error("resumePlan failed after payout success", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + zap.Error(err), + ) return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } + h.logger.Info("resumePlan executed after payout success", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + ) + } else { + h.logger.Warn("payout success but plan cannot be resumed", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + zap.Bool("resume_plan_present", h.resumePlan != nil), + zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), + ) } + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + h.logger.Warn("card payout failed", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + zap.String("provider_message", payout.GetProviderMessage()), + ) + + payment.State = model.PaymentStateFailed + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) + if h.releaseHold != nil && payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { + h.logger.Info("releasing hold after payout failure", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + ) + if err := h.releaseHold(ctx, store, payment); err != nil { + h.logger.Error("releaseHold failed after payout failure", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + zap.Error(err), + ) return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) } + } else { + h.logger.Warn("payout failed but hold cannot be released", + zap.String("payment_ref", payment.PaymentRef), + zap.String("payout_ref", payout.GetPayoutId()), + zap.Bool("release_hold_present", h.releaseHold != nil), + zap.Bool("has_plan", payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0), + ) } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go index 387190c9..3b42580d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers.go @@ -7,7 +7,6 @@ import ( "github.com/shopspring/decimal" oracleclient "github.com/tech/sendico/fx/oracle/client" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/payments/rail" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -416,50 +415,6 @@ func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote return expiry } -func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []rail.FeeBreakdown { - if quote == nil { - return nil - } - lines := quote.GetFeeLines() - breakdown := make([]rail.FeeBreakdown, 0, len(lines)+1) - for _, line := range lines { - if line == nil { - continue - } - amount := moneyFromProto(line.GetMoney()) - if amount == nil { - continue - } - code := strings.TrimSpace(line.GetMeta()["fee_code"]) - if code == "" { - code = strings.TrimSpace(line.GetMeta()["fee_rule_id"]) - } - if code == "" { - code = line.GetLineType().String() - } - desc := strings.TrimSpace(line.GetMeta()["description"]) - breakdown = append(breakdown, rail.FeeBreakdown{ - FeeCode: code, - Amount: amount, - Description: desc, - }) - } - if quote.GetNetworkFee() != nil && quote.GetNetworkFee().GetNetworkFee() != nil { - networkAmount := moneyFromProto(quote.GetNetworkFee().GetNetworkFee()) - if networkAmount != nil { - breakdown = append(breakdown, rail.FeeBreakdown{ - FeeCode: "network_fee", - Amount: networkAmount, - Description: strings.TrimSpace(quote.GetNetworkFee().GetEstimationContext()), - }) - } - } - if len(breakdown) == 0 { - return nil - } - return breakdown -} - func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine { if account == "" || len(lines) == 0 { return lines diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go index 519b2fd7..91b1b90d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers.go @@ -110,12 +110,22 @@ func fxIntentForQuote(intent *orchestratorv1.PaymentIntent) *orchestratorv1.FXIn func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState { switch status { - case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CREATED: + return model.PaymentStateFundsReserved + + case mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING: + return model.PaymentStateSubmitted + + case mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS: return model.PaymentStateSettled + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: return model.PaymentStateFailed - case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING: - return model.PaymentStateSubmitted + + case mntxv1.PayoutStatus_PAYOUT_STATUS_CANCELLED: + return model.PaymentStateCancelled + default: return model.PaymentStateUnspecified } diff --git a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go index 0f9ace20..558b8c74 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/internal_helpers_test.go @@ -10,6 +10,7 @@ import ( func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) { intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT, Destination: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Card{ @@ -24,6 +25,7 @@ func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) { func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) { intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Destination: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{ ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"}, @@ -36,13 +38,13 @@ func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) { } func TestMapMntxStatusToState(t *testing.T) { - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != model.PaymentStateSettled { + if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_SUCCESS) != model.PaymentStateSettled { t.Fatalf("processed should map to settled") } if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed { t.Fatalf("failed should map to failed") } - if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING) != model.PaymentStateSubmitted { + if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_WAITING) != model.PaymentStateSubmitted { t.Fatalf("pending should map to submitted") } if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index f6a4d421..d80666e1 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -207,23 +207,31 @@ func applyTransferStatus(event *chainv1.TransferStatusChangedEvent, payment *mod reason = strings.TrimSpace(transfer.GetFailureReason()) } switch transfer.GetStatus() { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: + + case chainv1.TransferStatus_TRANSFER_SUCCESS: payment.State = model.PaymentStateSettled payment.FailureCode = model.PaymentFailureCodeUnspecified payment.FailureReason = "" + case chainv1.TransferStatus_TRANSFER_FAILED: payment.State = model.PaymentStateFailed payment.FailureCode = model.PaymentFailureCodeChain payment.FailureReason = reason + case chainv1.TransferStatus_TRANSFER_CANCELLED: payment.State = model.PaymentStateCancelled payment.FailureCode = model.PaymentFailureCodePolicy payment.FailureReason = reason - case chainv1.TransferStatus_TRANSFER_SIGNING, - chainv1.TransferStatus_TRANSFER_PENDING, - chainv1.TransferStatus_TRANSFER_SUBMITTED: + + case chainv1.TransferStatus_TRANSFER_WAITING: payment.State = model.PaymentStateSubmitted + + case chainv1.TransferStatus_TRANSFER_CREATED, + chainv1.TransferStatus_TRANSFER_PROCESSING: + // do nothing, retain previous state + default: // retain previous state } + } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go index abc9b204..a0d9c7c8 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go @@ -7,12 +7,12 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" ) -func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, fromRole, toRole *pmodel.AccountRole) (string, error) { +func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, operationRef string, amount *moneyv1.Money, fromRole, toRole *account_role.AccountRole) (string, error) { if payment == nil { return "", merrors.InvalidArgument("payment is required") } @@ -42,13 +42,13 @@ func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *mod if meta == nil { meta = map[string]string{} } - meta[pmodel.MetadataKeyFromRole] = strings.TrimSpace(string(mergeAccountRole(fromRole))) + meta[account_role.MetadataKeyFromRole] = strings.TrimSpace(string(mergeAccountRole(fromRole))) } if strings.TrimSpace(string(mergeAccountRole(toRole))) != "" { if meta == nil { meta = map[string]string{} } - meta[pmodel.MetadataKeyToRole] = strings.TrimSpace(string(mergeAccountRole(toRole))) + meta[account_role.MetadataKeyToRole] = strings.TrimSpace(string(mergeAccountRole(toRole))) } customer := intent.Customer customerID := "" @@ -112,6 +112,7 @@ func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *mod CardHolder: holder, MaskedPan: strings.TrimSpace(card.MaskedPan), Metadata: meta, + OperationRef: operationRef, } resp, err := p.deps.mntx.client.CreateCardTokenPayout(ctx, req) if err != nil { @@ -159,11 +160,11 @@ func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *mod return exec.CardPayoutRef, nil } -func mergeAccountRole(role *pmodel.AccountRole) pmodel.AccountRole { +func mergeAccountRole(role *account_role.AccountRole) account_role.AccountRole { if role == nil { return "" } - return pmodel.AccountRole(strings.TrimSpace(string(*role))) + return account_role.AccountRole(strings.TrimSpace(string(*role))) } func (p *paymentExecutor) resolveCardRoute(intent model.PaymentIntent) (CardGatewayRoute, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go index f0bb8af0..619df9cc 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go @@ -5,13 +5,13 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" paymenttypes "github.com/tech/sendico/pkg/payments/types" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) -func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey string, quote *orchestratorv1.PaymentQuote, fromRole, toRole *pmodel.AccountRole) (rail.TransferRequest, error) { +func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey, operationRef string, quote *orchestratorv1.PaymentQuote, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) { if payment == nil { return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment is required") } @@ -26,8 +26,15 @@ func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amo if err != nil { return rail.TransferRequest{}, err } + paymentRef := strings.TrimSpace(payment.PaymentRef) + if paymentRef == "" { + return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment reference is required") + } req := rail.TransferRequest{ + IntentRef: strings.TrimSpace(payment.Intent.Ref), + OperationRef: strings.TrimSpace(operationRef), OrganizationRef: payment.OrganizationRef.Hex(), + PaymentRef: strings.TrimSpace(payment.PaymentRef), FromAccountID: strings.TrimSpace(source.ManagedWalletRef), ToAccountID: strings.TrimSpace(destRef), Currency: strings.TrimSpace(amount.GetCurrency()), @@ -35,7 +42,6 @@ func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amo Amount: strings.TrimSpace(amount.GetAmount()), IdempotencyKey: strings.TrimSpace(idempotencyKey), Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, DestinationMemo: memo, } if fromRole != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go index 5909aaba..26bb4034 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go @@ -2,107 +2,300 @@ package orchestrator import ( "context" - "strings" + "errors" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" ) -func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { - if store == nil { - return errStorageUnavailable - } - if payment == nil { - return merrors.InvalidArgument("payment plan: payment is required") - } - plan := payment.PaymentPlan +func analyzeExecutionPlan(plan *model.ExecutionPlan) (done bool, failed bool, rootErr error) { if plan == nil || len(plan.Steps) == 0 { - return merrors.InvalidArgument("payment plan: steps are required") + return true, false, nil } + done = true + for _, s := range plan.Steps { + if s == nil { + continue + } + + if s.State == model.OperationStateFailed { + failed = true + if rootErr == nil && s.Error != "" { + rootErr = errors.New(s.Error) + } + } + + if !isStepFinal(s) { // created/waiting/processing + done = false + } + } + return done, failed, rootErr +} + +func buildStepIndex(plan *model.PaymentPlan) map[string]int { + m := make(map[string]int, len(plan.Steps)) + for i, s := range plan.Steps { + if s == nil { + continue + } + m[s.StepID] = i + } + return m +} + +func isPlanComplete(payment *model.Payment) bool { + if (payment.State == model.PaymentStateCancelled) || + (payment.State == model.PaymentStateSettled) || + (payment.State == model.PaymentStateFailed) { + return true + } + return false +} + +func isStepFinal(step *model.ExecutionStep) bool { + if (step.State == model.OperationStateFailed) || (step.State == model.OperationStateSuccess) || (step.State == model.OperationStateCancelled) { + return true + } + return false +} + +func stepCodeIsDependent(code string, previousSteps []string) bool { + for _, ps := range previousSteps { + if ps == code { + return true + } + } + return false +} + +func stepIsIndependent( + step *model.ExecutionStep, + plan *model.PaymentPlan, + execSteps map[string]*model.ExecutionStep, +) bool { + + for _, s := range plan.Steps { + if s.StepID != step.Code { + continue + } + + // Do not process step if it is already in a final state + if isStepFinal(step) { + return false + } + + // If the step has no dependencies, it is independent + if len(s.DependsOn) == 0 { + return true + } + + // All dependent steps must be successfully completed + for _, dep := range s.DependsOn { + depStep := execSteps[dep] + if depStep == nil || depStep.State != model.OperationStateSuccess { + return false + } + } + + return true + } + + return false +} + +func planStep(execStep *model.ExecutionStep, plan *model.PaymentPlan) *model.PaymentStep { + if (execStep == nil) || (plan == nil) { + return nil + } + for _, step := range plan.Steps { + if step != nil { + if step.StepID == execStep.Code { + return step + } + } + } + return nil +} + +func (p *paymentExecutor) pickIndependentSteps( + ctx context.Context, + l *zap.Logger, + store storage.PaymentsStore, + waiting []*model.ExecutionStep, + payment *model.Payment, + quote *orchestratorv1.PaymentQuote, +) error { + + logger := l.With(zap.Int("waiting_steps", len(waiting))) + logger.Debug("Selecting independent steps for execution") + + execSteps := executionStepsByCode(payment.ExecutionPlan) + planSteps := planStepsByID(payment.PaymentPlan) execQuote := executionQuote(payment, quote) charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines()) + stepIdx := buildStepIndex(payment.PaymentPlan) - order, _, err := planExecutionOrder(plan) - if err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) - } - - execPlan := ensureExecutionPlanForPlan(payment, plan) - execSteps := executionStepsByCode(execPlan) - planSteps := planStepsByID(plan) - asyncSubmitted := false - - for _, idx := range order { - step := plan.Steps[idx] - if step == nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment plan: step is required", merrors.InvalidArgument("payment plan: step is required")) - } - stepID := planStepID(step, idx) - execStep := execSteps[stepID] + for _, execStep := range waiting { if execStep == nil { - execStep = &model.ExecutionStep{Code: stepID} - execSteps[stepID] = execStep - } - if step.Action == model.RailOperationRelease { - setExecutionStepStatus(execStep, executionStepStatusSkipped) - continue - } - status := executionStepStatus(execStep) - switch status { - case executionStepStatusConfirmed, executionStepStatusSkipped: - continue - case executionStepStatusFailed: - payment.State = model.PaymentStateFailed - payment.FailureCode = failureCodeForStep(step) - return p.persistPayment(ctx, store, payment) - case executionStepStatusCancelled: - payment.State = model.PaymentStateCancelled - payment.FailureCode = model.PaymentFailureCodePolicy - return p.persistPayment(ctx, store, payment) - case executionStepStatusSubmitted: - asyncSubmitted = true continue } - ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, false) + lg := logger.With( + zap.String("step_code", execStep.Code), + zap.String("step_state", string(execStep.State)), + ) + + planStep := planSteps[execStep.Code] + if planStep == nil { + lg.Warn("Plan step not found") + continue + } + + ready, waitingDep, blocked, err := + stepDependenciesReady(planStep, execSteps, planSteps, true) + if err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + lg.Warn("Dependency evaluation failed", zap.Error(err)) + continue } + if blocked { - if step.CommitPolicy == model.CommitPolicyAfterFailure && commitAfterDependenciesSucceeded(step, execSteps) { - setExecutionStepStatus(execStep, executionStepStatusSkipped) - continue - } - payment.State = model.PaymentStateFailed - payment.FailureCode = failureCodeForStep(step) - return p.persistPayment(ctx, store, payment) + lg.Debug("Step permanently blocked by dependency failure") + continue } + + if waitingDep { + lg.Debug("Step waiting for dependencies") + continue + } + if !ready { continue } - async, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, charges, idx) + lg.Debug("Executing independent step") + idx := stepIdx[execStep.Code] + + async, err := p.executePlanStep( + ctx, + payment, + planStep, + execStep, + quote, + charges, + idx, + ) if err != nil { - return p.failPayment(ctx, store, payment, failureCodeForStep(step), strings.TrimSpace(err.Error()), err) - } - if async { - asyncSubmitted = true + lg.Warn("Step execution failed", zap.Error(err), zap.Bool("async", async)) + return err } } - if asyncSubmitted && !executionPlanComplete(execPlan) { - if blockStepConfirmed(plan, execPlan) { - payment.State = model.PaymentStateFundsReserved - } else { - payment.State = model.PaymentStateSubmitted - } - return p.persistPayment(ctx, store, payment) - } - payment.State = model.PaymentStateSettled - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - return p.persistPayment(ctx, store, payment) + return nil +} + +func (p *paymentExecutor) pickWaitingSteps( + ctx context.Context, + l *zap.Logger, + store storage.PaymentsStore, + payment *model.Payment, + quote *orchestratorv1.PaymentQuote, +) error { + if payment == nil || payment.ExecutionPlan == nil { + l.Debug("No execution plan") + return nil + } + + logger := l.With(zap.Int("total_steps", len(payment.ExecutionPlan.Steps))) + logger.Debug("Collecting waiting steps") + + waitingSteps := make([]*model.ExecutionStep, 0, len(payment.ExecutionPlan.Steps)) + for _, step := range payment.ExecutionPlan.Steps { + if step == nil { + continue + } + if step.State != model.OperationStatePlanned { + continue + } + waitingSteps = append(waitingSteps, step) + } + + if len(waitingSteps) == 0 { + logger.Debug("No waiting steps to process") + return nil + } + + return p.pickIndependentSteps(ctx, logger, store, waitingSteps, payment, quote) +} + +func (p *paymentExecutor) executePaymentPlan( + ctx context.Context, + store storage.PaymentsStore, + payment *model.Payment, + quote *orchestratorv1.PaymentQuote, +) error { + + if payment == nil { + return merrors.InvalidArgument("plan must be provided") + } + + logger := p.logger.With(zap.String("payment_ref", payment.PaymentRef)) + logger.Debug("Starting plan execution") + + if isPlanComplete(payment) { + logger.Debug("Plan already completed") + return nil + } + + if payment.State != model.PaymentStateSubmitted && + payment.State != model.PaymentStateFundsReserved { + + payment.State = model.PaymentStateSubmitted + if err := store.Update(ctx, payment); err != nil { + return err + } + } + + if payment.ExecutionPlan == nil { + logger.Debug("Initializing execution plan from payment plan") + payment.ExecutionPlan = ensureExecutionPlanForPlan(payment, payment.PaymentPlan) + if err := store.Update(ctx, payment); err != nil { + return err + } + } + + // Execute steps + if err := p.pickWaitingSteps(ctx, logger, store, payment, quote); err != nil { + logger.Warn("Step execution returned infrastructure error", zap.Error(err)) + return err + } + + if err := store.Update(ctx, payment); err != nil { + return err + } + + done, failed, rootErr := analyzeExecutionPlan(payment.ExecutionPlan) + if !done { + return nil + } + + if failed { + payment.State = model.PaymentStateFailed + } else { + payment.State = model.PaymentStateSettled + } + + if err := store.Update(ctx, payment); err != nil { + logger.Warn("Failed to update final payment state", zap.Error(err)) + return err + } + + if failed && rootErr != nil { + return rootErr + } + return nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go index 044fa7c8..eb8a5739 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go @@ -9,7 +9,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/storage/model" mo "github.com/tech/sendico/pkg/model" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" paymenttypes "github.com/tech/sendico/pkg/payments/types" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" @@ -34,7 +34,7 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { ref := transferRefs[sendCalls] sendCalls++ - return rail.RailResult{ReferenceID: ref, Status: rail.TransferStatusPending}, nil + return rail.RailResult{ReferenceID: ref, Status: rail.TransferStatusWaiting}, nil }, } @@ -105,6 +105,7 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { OrganizationRef: bson.NewObjectID(), }, Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -141,9 +142,9 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { {StepID: "crypto_send", Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}}, {StepID: "crypto_fee", Rail: model.RailCrypto, Action: model.RailOperationFee, DependsOn: []string{"crypto_send"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}}, {StepID: "crypto_observe", Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm, DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"crypto_observe"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(pmodel.AccountRolePending), ToRole: rolePtr(pmodel.AccountRoleOperating)}, + {StepID: "ledger_credit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"crypto_observe"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, {StepID: "card_payout", Rail: model.RailCardPayout, Action: model.RailOperationSend, DependsOn: []string{"ledger_credit"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, - {StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(pmodel.AccountRoleOperating), ToRole: rolePtr(pmodel.AccountRoleTransit)}, + {StepID: "ledger_debit", Rail: model.RailLedger, Action: model.RailOperationMove, DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, }, }, } @@ -154,64 +155,66 @@ func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { t.Fatalf("executePaymentPlan error: %v", err) } - if sendCalls != 2 { - t.Fatalf("expected 2 rail sends, got %d", sendCalls) + if payment.Execution == nil || payment.Execution.ChainTransferRef == "" { + t.Fatalf("expected chain transfer ref set") } - if moveCalls != 0 { - t.Fatalf("unexpected ledger move calls: %d", moveCalls) - } - if payoutCalls != 0 { - t.Fatalf("expected no payout before source confirmation, got %d", payoutCalls) - } - if payment.State != model.PaymentStateSubmitted { - t.Fatalf("expected submitted state, got %s", payment.State) - } - if payment.Execution == nil || payment.Execution.ChainTransferRef == "" || payment.Execution.FeeTransferRef == "" { - t.Fatalf("expected chain and fee transfer refs set") - } - if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != 6 { - t.Fatalf("expected execution plan with 6 steps") - } - if executionStepStatus(payment.ExecutionPlan.Steps[0]) != executionStepStatusSubmitted { - t.Fatalf("expected send step submitted") - } - if executionStepStatus(payment.ExecutionPlan.Steps[1]) != executionStepStatusSubmitted { - t.Fatalf("expected fee step submitted") - } - if executionStepStatus(payment.ExecutionPlan.Steps[2]) != executionStepStatusSubmitted { - t.Fatalf("expected observe step submitted") + if payment.Execution.FeeTransferRef != "" { + t.Fatalf("fee must NOT be executed before send success") } - setExecutionStepStatus(payment.ExecutionPlan.Steps[0], executionStepStatusConfirmed) - setExecutionStepStatus(payment.ExecutionPlan.Steps[1], executionStepStatusConfirmed) - setExecutionStepStatus(payment.ExecutionPlan.Steps[2], executionStepStatusConfirmed) + steps := executionStepsByCode(payment.ExecutionPlan) + + if steps["crypto_send"].State != model.OperationStateWaiting { + t.Fatalf("send must be waiting") + } + if steps["crypto_fee"].State != model.OperationStatePlanned { + t.Fatalf("fee must NOT start before send success") + } + if steps["crypto_observe"].State != model.OperationStatePlanned { + t.Fatalf("observe must NOT start before send success") + } + + // ---- имитируем подтверждение сети по crypto_send ---- + setExecutionStepStatus(steps["crypto_send"], model.OperationStateSuccess) if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { t.Fatalf("executePaymentPlan resume error: %v", err) } + + // Теперь должны стартовать fee и observe + if steps["crypto_fee"].State != model.OperationStateWaiting { + t.Fatalf("fee must start after send success") + } + if steps["crypto_observe"].State != model.OperationStateWaiting { + t.Fatalf("observe must start after send success") + } + + // Имитируем подтверждение observe (это unlock ledger_credit) + setExecutionStepStatus(steps["crypto_observe"], model.OperationStateSuccess) + + if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { + t.Fatalf("executePaymentPlan resume after observe error: %v", err) + } + if moveCalls != 1 { - t.Fatalf("expected one ledger move after source confirmation, got %d", moveCalls) + t.Fatalf("expected one ledger move after observe confirmation, got %d", moveCalls) } if payoutCalls != 1 { t.Fatalf("expected card payout submitted, got %d", payoutCalls) } - if payment.Execution == nil || payment.Execution.CardPayoutRef == "" { - t.Fatalf("expected card payout ref set") - } - steps := executionStepsByCode(payment.ExecutionPlan) - cardStep := steps["card_payout"] - if cardStep == nil { - t.Fatalf("expected card payout step in execution plan") - } - setExecutionStepStatus(cardStep, executionStepStatusConfirmed) + // Mock card payout confirmation + cardStep := executionStepsByCode(payment.ExecutionPlan)["card_payout"] + setExecutionStepStatus(cardStep, model.OperationStateSuccess) if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { t.Fatalf("executePaymentPlan finalize error: %v", err) } + if moveCalls != 2 { t.Fatalf("expected two ledger moves after payout confirmation, got %d", moveCalls) } + } func TestExecutePaymentPlan_RejectsLegacyLedgerOperations(t *testing.T) { @@ -242,6 +245,7 @@ func TestExecutePaymentPlan_RejectsLegacyLedgerOperations(t *testing.T) { OrganizationRef: bson.NewObjectID(), }, Intent: model.PaymentIntent{ + Ref: "ref-legacy-1", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -269,7 +273,7 @@ func TestExecutePaymentPlan_RejectsLegacyLedgerOperations(t *testing.T) { if err == nil { t.Fatal("expected legacy ledger operation error") } - if !strings.Contains(err.Error(), "unsupported legacy ledger operation") { + if !strings.Contains(err.Error(), "unsupported action") { t.Fatalf("unexpected error: %v", err) } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go index 3ea0536c..c384fc65 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go @@ -4,8 +4,9 @@ import ( "fmt" "strings" + "github.com/google/uuid" "github.com/tech/sendico/payments/orchestrator/storage/model" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -26,40 +27,32 @@ func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) return &orchestratorv1.PaymentQuote{} } -func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan) *model.ExecutionPlan { - if payment == nil || plan == nil { - return nil +func ensureExecutionPlanForPlan( + payment *model.Payment, + plan *model.PaymentPlan, +) *model.ExecutionPlan { + + if payment.ExecutionPlan != nil { + return payment.ExecutionPlan } - execPlan := payment.ExecutionPlan - if execPlan == nil { - execPlan = &model.ExecutionPlan{} - payment.ExecutionPlan = execPlan + + exec := &model.ExecutionPlan{ + Steps: make([]*model.ExecutionStep, 0, len(plan.Steps)), } - existing := map[string]*model.ExecutionStep{} - for _, step := range execPlan.Steps { - if step == nil || strings.TrimSpace(step.Code) == "" { + + for _, step := range plan.Steps { + if step == nil { continue } - existing[strings.TrimSpace(step.Code)] = step + + exec.Steps = append(exec.Steps, &model.ExecutionStep{ + Code: step.StepID, + State: model.OperationStatePlanned, + OperationRef: uuid.New().String(), + }) } - steps := make([]*model.ExecutionStep, len(plan.Steps)) - for idx, planStep := range plan.Steps { - code := planStepID(planStep, idx) - step := existing[code] - if step == nil { - step = &model.ExecutionStep{Code: code} - } - if step.Description == "" { - step.Description = describePlanStep(planStep) - } - step.Amount = cloneMoney(planStep.Amount) - if step.Metadata == nil || strings.TrimSpace(step.Metadata[executionStepMetadataStatus]) == "" { - setExecutionStepStatus(step, executionStepStatusPlanned) - } - steps[idx] = step - } - execPlan.Steps = steps - return execPlan + + return exec } func executionPlanComplete(plan *model.ExecutionPlan) bool { @@ -70,11 +63,10 @@ func executionPlanComplete(plan *model.ExecutionPlan) bool { if step == nil { continue } - status := executionStepStatus(step) - if status == executionStepStatusSkipped { + if step.State == model.OperationStateSkipped { continue } - if status != executionStepStatusConfirmed { + if step.State != model.OperationStateSuccess { return false } } @@ -94,14 +86,14 @@ func blockStepConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) if execStep == nil { continue } - if executionStepStatus(execStep) == executionStepStatusConfirmed { + if execStep.State == model.OperationStateSuccess { return true } } return false } -func roleHintsForStep(plan *model.PaymentPlan, idx int) (*pmodel.AccountRole, *pmodel.AccountRole) { +func roleHintsForStep(plan *model.PaymentPlan, idx int) (*account_role.AccountRole, *account_role.AccountRole) { if plan == nil || idx <= 0 { return nil, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go index 42e4edda..f85dec67 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go @@ -8,7 +8,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" @@ -144,122 +144,6 @@ func (p *paymentExecutor) postLedgerMove(ctx context.Context, payment *model.Pay return entryRef, nil } -func (p *paymentExecutor) postLedgerBlock(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "block"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("ledger: amount is required") - } - sourceAccount, err := ledgerDebitAccountRef(payment) - if err != nil { - return "", err - } - blockAccount, err := p.resolveLedgerBlockAccount(ctx, payment, amount) - if err != nil { - return "", err - } - resp, err := p.deps.ledger.internal.TransferInternal(ctx, &ledgerv1.TransferRequest{ - IdempotencyKey: strings.TrimSpace(idempotencyKey), - OrganizationRef: payment.OrganizationRef.Hex(), - FromLedgerAccountRef: strings.TrimSpace(sourceAccount), - ToLedgerAccountRef: strings.TrimSpace(blockAccount), - Money: cloneProtoMoney(amount), - Description: paymentDescription(payment), - Metadata: cloneMetadata(payment.Metadata), - }) - if err != nil { - p.logger.Warn("Ledger block failed", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("from_account", strings.TrimSpace(sourceAccount)), - zap.String("to_account", strings.TrimSpace(blockAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", strings.TrimSpace(amount.GetCurrency())), - zap.Error(err)) - return "", err - } - entryRef := strings.TrimSpace(resp.GetJournalEntryRef()) - p.logger.Info("Ledger block posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("entry_ref", entryRef), - zap.String("from_account", strings.TrimSpace(sourceAccount)), - zap.String("to_account", strings.TrimSpace(blockAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", strings.TrimSpace(amount.GetCurrency()))) - return entryRef, nil -} - -func (p *paymentExecutor) postLedgerRelease(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int) (string, error) { - paymentRef := "" - if payment != nil { - paymentRef = strings.TrimSpace(payment.PaymentRef) - } - if p.deps.ledger.internal == nil { - p.logger.Error("Ledger client unavailable", zap.String("action", "release"), zap.String("payment_ref", paymentRef)) - return "", merrors.Internal("ledger_client_unavailable") - } - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - if payment.OrganizationRef == bson.NilObjectID { - return "", merrors.InvalidArgument("ledger: organization_ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("ledger: amount is required") - } - sourceAccount, err := ledgerDebitAccountRef(payment) - if err != nil { - return "", err - } - blockAccount, err := p.resolveLedgerBlockAccount(ctx, payment, amount) - if err != nil { - return "", err - } - resp, err := p.deps.ledger.internal.TransferInternal(ctx, &ledgerv1.TransferRequest{ - IdempotencyKey: strings.TrimSpace(idempotencyKey), - OrganizationRef: payment.OrganizationRef.Hex(), - FromLedgerAccountRef: strings.TrimSpace(blockAccount), - ToLedgerAccountRef: strings.TrimSpace(sourceAccount), - Money: cloneProtoMoney(amount), - Description: paymentDescription(payment), - Metadata: cloneMetadata(payment.Metadata), - }) - if err != nil { - p.logger.Warn("Ledger release failed", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("from_account", strings.TrimSpace(blockAccount)), - zap.String("to_account", strings.TrimSpace(sourceAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", strings.TrimSpace(amount.GetCurrency())), - zap.Error(err)) - return "", err - } - entryRef := strings.TrimSpace(resp.GetJournalEntryRef()) - p.logger.Info("Ledger release posted", - zap.String("payment_ref", paymentRef), - zap.Int("step_index", idx), - zap.String("entry_ref", entryRef), - zap.String("from_account", strings.TrimSpace(blockAccount)), - zap.String("to_account", strings.TrimSpace(sourceAccount)), - zap.String("amount", strings.TrimSpace(amount.GetAmount())), - zap.String("currency", strings.TrimSpace(amount.GetCurrency()))) - return entryRef, nil -} - func (p *paymentExecutor) ledgerTxForAction(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (rail.LedgerTx, error) { if payment == nil { return rail.LedgerTx{}, merrors.InvalidArgument("ledger: payment is required") @@ -426,7 +310,7 @@ func ledgerExternalReference(plan *model.ExecutionPlan, idx int) string { return "" } -func ledgerMoveRoles(step *model.PaymentStep) (pmodel.AccountRole, pmodel.AccountRole, error) { +func ledgerMoveRoles(step *model.PaymentStep) (account_role.AccountRole, account_role.AccountRole, error) { if step == nil { return "", "", merrors.InvalidArgument("ledger: step is required") } @@ -441,10 +325,10 @@ func ledgerMoveRoles(step *model.PaymentStep) (pmodel.AccountRole, pmodel.Accoun if from == "" || to == "" || strings.EqualFold(from, to) { return "", "", merrors.InvalidArgument("ledger: from_role and to_role must differ") } - return pmodel.AccountRole(from), pmodel.AccountRole(to), nil + return account_role.AccountRole(from), account_role.AccountRole(to), nil } -func ledgerRoleFromAccountRole(role pmodel.AccountRole) ledgerv1.AccountRole { +func ledgerRoleFromAccountRole(role account_role.AccountRole) ledgerv1.AccountRole { if strings.TrimSpace(string(role)) == "" { return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED } @@ -454,7 +338,7 @@ func ledgerRoleFromAccountRole(role pmodel.AccountRole) ledgerv1.AccountRole { return ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED } -func (p *paymentExecutor) resolveAccount(ctx context.Context, orgRef bson.ObjectID, asset string, rail model.Rail, role pmodel.AccountRole) (string, error) { +func (p *paymentExecutor) resolveAccount(ctx context.Context, orgRef bson.ObjectID, asset string, rail model.Rail, role account_role.AccountRole) (string, error) { switch rail { case model.RailLedger: return p.resolveLedgerAccountByRole(ctx, orgRef, asset, role) @@ -463,7 +347,7 @@ func (p *paymentExecutor) resolveAccount(ctx context.Context, orgRef bson.Object } } -func (p *paymentExecutor) resolveLedgerAccountByRole(ctx context.Context, orgRef bson.ObjectID, asset string, role pmodel.AccountRole) (string, error) { +func (p *paymentExecutor) resolveLedgerAccountByRole(ctx context.Context, orgRef bson.ObjectID, asset string, role account_role.AccountRole) (string, error) { if p == nil || p.deps == nil || p.deps.ledger.client == nil { return "", merrors.Internal("ledger_client_unavailable") } @@ -590,27 +474,27 @@ func (p *paymentExecutor) resolveOrgOwnedLedgerAccount(ctx context.Context, paym } func connectorAccountIsSettlement(account *connectorv1.Account) bool { - return connectorAccountRole(account) == pmodel.AccountRoleSettlement + return connectorAccountRole(account) == account_role.AccountRoleSettlement } -func connectorAccountRole(account *connectorv1.Account) pmodel.AccountRole { +func connectorAccountRole(account *connectorv1.Account) account_role.AccountRole { if account == nil || account.GetProviderDetails() == nil { return "" } details := account.GetProviderDetails().AsMap() if value := strings.TrimSpace(fmt.Sprint(details["role"])); value != "" { - if role, ok := pmodel.Parse(value); ok { + if role, ok := account_role.Parse(value); ok { return role } } switch v := details["is_settlement"].(type) { case bool: if v { - return pmodel.AccountRoleSettlement + return account_role.AccountRoleSettlement } case string: if strings.EqualFold(strings.TrimSpace(v), "true") { - return pmodel.AccountRoleSettlement + return account_role.AccountRoleSettlement } } return "" @@ -631,23 +515,6 @@ func setLedgerAccountAttributes(payment *model.Payment, accountRef string) { } } -func setLedgerBlockAccountAttributes(payment *model.Payment, accountRef string) { - if payment == nil || strings.TrimSpace(accountRef) == "" { - return - } - if payment.Intent.Attributes == nil { - payment.Intent.Attributes = map[string]string{} - } - if attributeLookup(payment.Intent.Attributes, - "ledger_block_account_ref", - "ledgerBlockAccountRef", - "ledger_hold_account_ref", - "ledgerHoldAccountRef", - ) == "" { - payment.Intent.Attributes["ledger_block_account_ref"] = accountRef - } -} - func ledgerDebitAccount(payment *model.Payment) (string, string, error) { if payment == nil { return "", "", merrors.InvalidArgument("ledger: payment is required") @@ -662,11 +529,6 @@ func ledgerDebitAccount(payment *model.Payment) (string, string, error) { return "", "", merrors.InvalidArgument("ledger: source account is required") } -func ledgerDebitAccountRef(payment *model.Payment) (string, error) { - account, _, err := ledgerDebitAccount(payment) - return account, err -} - func ledgerBlockAccount(payment *model.Payment) (string, error) { if payment == nil { return "", merrors.InvalidArgument("ledger: payment is required") @@ -690,24 +552,6 @@ func ledgerBlockAccount(payment *model.Payment) (string, error) { return "", merrors.InvalidArgument("ledger: block account is required") } -func (p *paymentExecutor) resolveLedgerBlockAccount(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) { - if payment == nil { - return "", merrors.InvalidArgument("ledger: payment is required") - } - if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" { - return "", merrors.InvalidArgument("ledger: amount is required") - } - if ref, err := ledgerBlockAccount(payment); err == nil && strings.TrimSpace(ref) != "" { - return ref, nil - } - account, err := p.resolveOrgOwnedLedgerAccount(ctx, payment, amount) - if err != nil { - return "", err - } - setLedgerBlockAccountAttributes(payment, account) - return account, nil -} - func ledgerBlockAccountIfConfirmed(payment *model.Payment) string { if payment == nil { return "" diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go index 186a3e81..3016fd02 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger_test.go @@ -6,7 +6,7 @@ import ( ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/storage/model" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" paymenttypes "github.com/tech/sendico/pkg/payments/types" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" @@ -80,13 +80,14 @@ func TestLedgerAccountResolution_UsesRoleAccounts(t *testing.T) { PaymentRef: "pay-1", IdempotencyKey: "pay-1", Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, }, PaymentPlan: &model.PaymentPlan{ ID: "pay-1", IdempotencyKey: "pay-1", Steps: []*model.PaymentStep{ - {StepID: "ledger_move", Rail: model.RailLedger, Action: model.RailOperationMove, Amount: cloneMoney(amount), FromRole: rolePtr(pmodel.AccountRoleOperating), ToRole: rolePtr(pmodel.AccountRoleTransit)}, + {StepID: "ledger_move", Rail: model.RailLedger, Action: model.RailOperationMove, Amount: cloneMoney(amount), FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, }, }, } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go index f063ed3f..5c71eaa1 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_order.go @@ -103,136 +103,143 @@ func planStepsByID(plan *model.PaymentPlan) map[string]*model.PaymentStep { return result } -func stepDependenciesReady(step *model.PaymentStep, execSteps map[string]*model.ExecutionStep, planSteps map[string]*model.PaymentStep, requireConfirmed bool) (bool, bool, error) { +func stepDependenciesReady( + step *model.PaymentStep, + execSteps map[string]*model.ExecutionStep, + planSteps map[string]*model.PaymentStep, + requireSuccess bool, +) (ready bool, waiting bool, blocked bool, err error) { + if step == nil { - return false, false, merrors.InvalidArgument("payment plan: step is required") + return false, false, false, + merrors.InvalidArgument("payment plan: step is required") } + + // ------------------------------------------------------------ + // DependsOn — это ПРОСТО готовность, не успех + // ------------------------------------------------------------ for _, dep := range step.DependsOn { key := strings.TrimSpace(dep) if key == "" { continue } + execStep := execSteps[key] if execStep == nil { - return false, false, merrors.InvalidArgument("payment plan: dependency missing") + // шага ещё не было → ждём + return false, true, false, nil } - depStep := planSteps[key] - needsConfirm := requireConfirmed - if depStep != nil && depStep.Action == model.RailOperationObserveConfirm { - needsConfirm = true + + if execStep.State == model.OperationStateFailed || + execStep.State == model.OperationStateCancelled { + // зависимость умерла → этот шаг уже невозможен + return false, false, true, nil } - status := executionStepStatus(execStep) - switch status { - case executionStepStatusFailed, executionStepStatusCancelled: - return false, true, nil - case executionStepStatusConfirmed, executionStepStatusSkipped: - continue - case executionStepStatusSubmitted: - if needsConfirm { - return false, false, nil - } - continue - default: - return false, false, nil + + if !execStep.ReadyForNext() { + // шаг ещё в процессе → ждём + return false, true, false, nil } } - // Handle commit policies + // ------------------------------------------------------------ + // Commit policies + // ------------------------------------------------------------ switch step.CommitPolicy { + case model.CommitPolicyImmediate, model.CommitPolicyUnspecified: - // Execute immediately once dependencies are satisfied - return true, false, nil + return true, false, false, nil case model.CommitPolicyAfterSuccess: - // Wait for commitAfter dependencies to succeed (confirmed/skipped) commitAfter := step.CommitAfter if len(commitAfter) == 0 { commitAfter = step.DependsOn } + for _, dep := range commitAfter { key := strings.TrimSpace(dep) if key == "" { continue } + execStep := execSteps[key] if execStep == nil { - return false, false, merrors.InvalidArgument("payment plan: commit dependency missing") + return false, true, false, + merrors.InvalidArgument("commit dependency missing") } - status := executionStepStatus(execStep) - switch status { - case executionStepStatusFailed, executionStepStatusCancelled: - return false, true, nil - case executionStepStatusConfirmed, executionStepStatusSkipped: - continue - default: - return false, false, nil + + if execStep.State == model.OperationStateFailed || + execStep.State == model.OperationStateCancelled { + return false, false, true, nil + } + + if !execStep.IsSuccess() { + return false, true, false, nil } } - return true, false, nil + + return true, false, false, nil case model.CommitPolicyAfterFailure: - // Wait for commitAfter dependencies to fail commitAfter := step.CommitAfter if len(commitAfter) == 0 { commitAfter = step.DependsOn } + for _, dep := range commitAfter { key := strings.TrimSpace(dep) if key == "" { continue } + execStep := execSteps[key] if execStep == nil { - return false, false, merrors.InvalidArgument("payment plan: commit dependency missing") + return false, true, false, + merrors.InvalidArgument("commit dependency missing") } - status := executionStepStatus(execStep) - switch status { - case executionStepStatusFailed: - // Dependency failed - this is what we're waiting for + + if execStep.State == model.OperationStateFailed { continue - case executionStepStatusCancelled: - // If cancelled, also block this step - return false, true, nil - case executionStepStatusConfirmed, executionStepStatusSkipped: - // Dependency succeeded - can't proceed with AFTER_FAILURE - return false, true, nil - default: - // Still waiting for failure - return false, false, nil } + + if execStep.IsTerminal() { + // завершился не фейлом → блокируем + return false, false, true, nil + } + + // ещё выполняется → ждём + return false, true, false, nil } - return true, false, nil + + return true, false, false, nil case model.CommitPolicyAfterCanceled: - // Wait for commitAfter dependencies to reach any terminal state (confirmed, failed, cancelled, skipped) commitAfter := step.CommitAfter if len(commitAfter) == 0 { commitAfter = step.DependsOn } + for _, dep := range commitAfter { key := strings.TrimSpace(dep) if key == "" { continue } + execStep := execSteps[key] if execStep == nil { - return false, false, merrors.InvalidArgument("payment plan: commit dependency missing") + return false, true, false, + merrors.InvalidArgument("commit dependency missing") } - status := executionStepStatus(execStep) - switch status { - case executionStepStatusConfirmed, executionStepStatusFailed, executionStepStatusCancelled, executionStepStatusSkipped: - // Dependency reached terminal state - continue - default: - // Still waiting for terminal state - return false, false, nil + + if !execStep.IsTerminal() { + return false, true, false, nil } } - return true, false, nil + + return true, false, false, nil default: - // Unknown policy - treat as immediate - return true, false, nil + return true, false, false, nil } } @@ -240,6 +247,7 @@ func commitAfterDependenciesSucceeded(step *model.PaymentStep, execSteps map[str if step == nil { return false } + commitAfter := step.CommitAfter if len(commitAfter) == 0 { commitAfter = step.DependsOn @@ -247,47 +255,73 @@ func commitAfterDependenciesSucceeded(step *model.PaymentStep, execSteps map[str if len(commitAfter) == 0 { return false } + for _, dep := range commitAfter { key := strings.TrimSpace(dep) if key == "" { continue } + execStep := execSteps[key] if execStep == nil { return false } - status := executionStepStatus(execStep) - switch status { - case executionStepStatusConfirmed, executionStepStatusSkipped: + + switch execStep.State { + case model.OperationStateSuccess, + model.OperationStateSkipped: continue default: return false } } + return true } -func cardPayoutDependenciesConfirmed(plan *model.PaymentPlan, execPlan *model.ExecutionPlan) bool { +func cardPayoutDependenciesConfirmed( + plan *model.PaymentPlan, + execPlan *model.ExecutionPlan, +) bool { + if execPlan == nil { return false } + if plan == nil || len(plan.Steps) == 0 { return sourceStepsConfirmed(execPlan) } + execSteps := executionStepsByCode(execPlan) planSteps := planStepsByID(plan) + for _, step := range plan.Steps { if step == nil { continue } - if step.Rail != model.RailCardPayout || step.Action != model.RailOperationSend { + + if step.Rail != model.RailCardPayout || + step.Action != model.RailOperationSend { continue } - ready, blocked, err := stepDependenciesReady(step, execSteps, planSteps, true) + + ready, waiting, blocked, err := + stepDependenciesReady(step, execSteps, planSteps, true) + if err != nil || blocked { + // payout definitely cannot run return false } + + if waiting { + // dependencies exist but are not finished yet + // payout must NOT run + return false + } + + // only true when dependencies are REALLY satisfied return ready } + return false } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go index a023ec27..436b5e29 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release.go @@ -35,8 +35,7 @@ func (p *paymentExecutor) releasePaymentHold(ctx context.Context, store storage. execPlan.Steps[idx] = execStep } } - status := executionStepStatus(execStep) - if status == executionStepStatusConfirmed { + if execStep.State == model.OperationStateSuccess { p.logger.Debug("Payment step already confirmed, skipping", zap.String("step_id", stepID), zap.String("quutation", execQuote.QuoteRef)) continue } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go index 2fe5c9db..df232a33 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_release_test.go @@ -41,6 +41,7 @@ func TestReleasePaymentHold_RejectsLegacyLedgerRelease(t *testing.T) { OrganizationRef: bson.NewObjectID(), }, Intent: model.PaymentIntent{ + Ref: "ref-release-1", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -71,13 +72,13 @@ func TestReleasePaymentHold_RejectsLegacyLedgerRelease(t *testing.T) { if blockStep == nil { t.Fatalf("expected block step in execution plan") } - setExecutionStepStatus(blockStep, executionStepStatusConfirmed) + setExecutionStepStatus(blockStep, model.OperationStateSuccess) err := executor.releasePaymentHold(ctx, store, payment) if err == nil { t.Fatal("expected legacy ledger operation error") } - if !strings.Contains(err.Error(), "unsupported legacy ledger operation") { + if !strings.Contains(err.Error(), "unsupported action") { t.Fatalf("unexpected error: %v", err) } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go index 821ce454..d3c27d90 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go @@ -11,216 +11,404 @@ import ( "go.uber.org/zap" ) -func (p *paymentExecutor) executePlanStep(ctx context.Context, payment *model.Payment, step *model.PaymentStep, execStep *model.ExecutionStep, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, idx int) (bool, error) { +func (p *paymentExecutor) executePlanStep( + ctx context.Context, + payment *model.Payment, + step *model.PaymentStep, + execStep *model.ExecutionStep, + quote *orchestratorv1.PaymentQuote, + charges []*ledgerv1.PostingLine, + idx int, +) (bool, error) { + if payment == nil || step == nil || execStep == nil { return false, merrors.InvalidArgument("payment plan: step is required") } - if step.Rail == model.RailLedger { - switch step.Action { - case model.RailOperationBlock, model.RailOperationRelease: - p.logger.Warn("Legacy operation detected", zap.String("action", string(step.Action))) - return false, merrors.InvalidArgument("unsupported legacy ledger operation, use ledger.move with roles") - } + stepID := execStep.Code + logger := p.logger.With( + zap.String("payment_ref", payment.PaymentRef), + zap.String("step_id", stepID), + zap.String("rail", string(step.Rail)), + zap.String("action", string(step.Action)), + zap.Int("idx", idx), + ) + + logger.Debug("Executing payment plan step") + + if isStepFinal(execStep) { + logger.Debug("Step already in final state, skipping execution", + zap.String("state", string(execStep.State)), + ) + return false, nil } switch step.Action { + case model.RailOperationMove: + logger.Debug("Posting ledger move") amount, err := requireMoney(cloneMoney(step.Amount), "ledger move amount") if err != nil { + logger.Warn("Ledger move amount invalid", zap.Error(err)) return false, err } ref, err := p.postLedgerMove(ctx, payment, step, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx) if err != nil { + logger.Warn("Ledger move failed", zap.Error(err)) return false, err } execStep.TransferRef = strings.TrimSpace(ref) - setExecutionStepStatus(execStep, executionStepStatusConfirmed) + setExecutionStepStatus(execStep, model.OperationStateSuccess) + logger.Info("Ledger move completed", zap.String("journal_ref", ref)) return false, nil + case model.RailOperationDebit, model.RailOperationExternalDebit: + logger.Debug("Posting ledger debit") amount, err := requireMoney(cloneMoney(step.Amount), "ledger debit amount") if err != nil { + logger.Warn("Ledger debit amount invalid", zap.Error(err)) return false, err } ref, err := p.postLedgerDebit(ctx, payment, protoMoney(amount), charges, planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote) if err != nil { + logger.Warn("Ledger debit failed", zap.Error(err)) return false, err } ensureExecutionRefs(payment).DebitEntryRef = ref - setExecutionStepStatus(execStep, executionStepStatusConfirmed) + setExecutionStepStatus(execStep, model.OperationStateSuccess) + logger.Info("Ledger debit completed", zap.String("journal_ref", ref)) return false, nil + case model.RailOperationCredit, model.RailOperationExternalCredit: + logger.Debug("Posting ledger credit") amount, err := requireMoney(cloneMoney(step.Amount), "ledger credit amount") if err != nil { + logger.Warn("Ledger credit amount invalid", zap.Error(err)) return false, err } ref, err := p.postLedgerCredit(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx, step.Action, quote) if err != nil { + logger.Warn("Ledger credit failed", zap.Error(err)) return false, err } ensureExecutionRefs(payment).CreditEntryRef = ref - setExecutionStepStatus(execStep, executionStepStatusConfirmed) - return false, nil - case model.RailOperationBlock: - if step.Rail != model.RailLedger { - return false, merrors.InvalidArgument("payment plan: block requires ledger rail") - } - amount, err := requireMoney(cloneMoney(step.Amount), "ledger block amount") - if err != nil { - return false, err - } - ref, err := p.postLedgerBlock(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx) - if err != nil { - return false, err - } - execStep.TransferRef = strings.TrimSpace(ref) - setExecutionStepStatus(execStep, executionStepStatusConfirmed) - return false, nil - case model.RailOperationRelease: - if step.Rail != model.RailLedger { - return false, merrors.InvalidArgument("payment plan: release requires ledger rail") - } - amount, err := requireMoney(cloneMoney(step.Amount), "ledger release amount") - if err != nil { - return false, err - } - ref, err := p.postLedgerRelease(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx) - if err != nil { - return false, err - } - execStep.TransferRef = strings.TrimSpace(ref) - setExecutionStepStatus(execStep, executionStepStatusConfirmed) + setExecutionStepStatus(execStep, model.OperationStateSuccess) + logger.Info("Ledger credit completed", zap.String("journal_ref", ref)) return false, nil + case model.RailOperationFXConvert: + logger.Debug("Applying FX conversion") if err := p.applyFX(ctx, payment, quote, charges, paymentDescription(payment), cloneMetadata(payment.Metadata), ensureExecutionRefs(payment)); err != nil { + logger.Warn("FX conversion failed", zap.Error(err)) return false, err } - setExecutionStepStatus(execStep, executionStepStatusConfirmed) + setExecutionStepStatus(execStep, model.OperationStateSuccess) + logger.Info("FX conversion completed") return false, nil + case model.RailOperationObserveConfirm: - setExecutionStepStatus(execStep, executionStepStatusSubmitted) + setExecutionStepStatus(execStep, model.OperationStateWaiting) + logger.Info("ObserveConfirm step set to waiting for external confirmation") return true, nil + case model.RailOperationSend: - return p.executeSendStep(ctx, payment, step, execStep, quote, idx) + logger.Debug("Executing send step") + async, err := p.executeSendStep(ctx, payment, step, execStep, quote, idx) + if err != nil { + setExecutionStepStatus(execStep, model.OperationStateFailed) + execStep.Error = err.Error() + logger.Warn("Send step failed", zap.Error(err)) + return false, err + } + + return async, nil + case model.RailOperationFee: - return p.executeFeeStep(ctx, payment, step, execStep, idx) + logger.Debug("Executing fee step") + async, err := p.executeFeeStep(ctx, payment, step, execStep, idx) + if err != nil { + logger.Warn("Fee step failed", zap.Error(err)) + return false, err + } + logger.Info("Fee step submitted") + return async, nil + default: + logger.Warn("Unsupported payment plan action") return false, merrors.InvalidArgument("payment plan: unsupported action") } } -func (p *paymentExecutor) executeSendStep(ctx context.Context, payment *model.Payment, step *model.PaymentStep, execStep *model.ExecutionStep, quote *orchestratorv1.PaymentQuote, idx int) (bool, error) { +func (p *paymentExecutor) executeSendStep( + ctx context.Context, + payment *model.Payment, + step *model.PaymentStep, + execStep *model.ExecutionStep, + quote *orchestratorv1.PaymentQuote, + idx int, +) (bool, error) { + + stepID := execStep.Code + logger := p.logger.With( + zap.String("payment_ref", payment.PaymentRef), + zap.String("step_id", stepID), + zap.String("rail", string(step.Rail)), + zap.String("action", string(step.Action)), + zap.Int("idx", idx), + ) + + logger.Debug("Executing send step") + switch step.Rail { + case model.RailCrypto: + logger.Debug("Preparing crypto transfer") + amount, err := requireMoney(cloneMoney(step.Amount), "crypto send amount") if err != nil { + logger.Warn("Invalid crypto amount", zap.Error(err)) return false, err } + if !p.deps.railGateways.available() { + logger.Warn("Rail gateway unavailable") return false, merrors.Internal("rail gateway unavailable") } + fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - req, err := p.buildCryptoTransferRequest(payment, amount, model.RailOperationSend, planStepIdempotencyKey(payment, idx, step), quote, fromRole, toRole) + req, err := p.buildCryptoTransferRequest( + payment, + amount, + model.RailOperationSend, + planStepIdempotencyKey(payment, idx, step), + execStep.OperationRef, + quote, + fromRole, toRole, + ) if err != nil { + logger.Warn("Failed to build crypto transfer request", zap.Error(err)) return false, err } + gw, err := p.deps.railGateways.resolve(ctx, step) if err != nil { + logger.Warn("Failed to resolve rail gateway", zap.Error(err)) return false, err } + + logger.Debug("Sending crypto transfer", + zap.String("idempotency", req.IdempotencyKey), zap.String("intent_ref", req.IntentRef), + zap.String("operation_ref", req.OperationRef), + ) + result, err := gw.Send(ctx, req) if err != nil { - return false, err + execStep.Error = strings.TrimSpace(err.Error()) + setExecutionStepStatus(execStep, model.OperationStateFailed) + + payment.State = model.PaymentStateFailed + payment.FailureCode = model.PaymentFailureCodeChain + + logger.Warn("Send failed; step marked as failed", zap.Error(err)) + return false, nil } - stepID := planStepID(step, idx) + execStep.TransferRef = strings.TrimSpace(result.ReferenceID) + logger.Info("Crypto transfer submitted", + zap.String("transfer_ref", execStep.TransferRef), + ) + exec := ensureExecutionRefs(payment) if exec.ChainTransferRef == "" && execStep.TransferRef != "" { exec.ChainTransferRef = execStep.TransferRef } + if execStep.TransferRef != "" { linkRailObservation(payment, step.Rail, execStep.TransferRef, stepID) } - setExecutionStepStatus(execStep, executionStepStatusSubmitted) + + setExecutionStepStatus(execStep, model.OperationStateWaiting) return true, nil + case model.RailCardPayout: + logger.Debug("Submitting card payout") + amount, err := requireMoney(cloneMoney(step.Amount), "card payout amount") if err != nil { + logger.Warn("Invalid card payout amount", zap.Error(err)) return false, err } + fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - ref, err := p.submitCardPayoutPlan(ctx, payment, protoMoney(amount), fromRole, toRole) + ref, err := p.submitCardPayoutPlan( + ctx, + payment, + execStep.OperationRef, + protoMoney(amount), + fromRole, toRole, + ) if err != nil { + logger.Warn("Card payout submission failed", zap.Error(err)) return false, err } + execStep.TransferRef = ref ensureExecutionRefs(payment).CardPayoutRef = ref - setExecutionStepStatus(execStep, executionStepStatusSubmitted) + + logger.Info("Card payout submitted", zap.String("payout_ref", ref)) + + setExecutionStepStatus(execStep, model.OperationStateWaiting) return true, nil + case model.RailProviderSettlement: + logger.Debug("Preparing provider settlement transfer") + amount, err := requireMoney(cloneMoney(step.Amount), "provider settlement amount") if err != nil { + logger.Warn("Invalid provider settlement amount", zap.Error(err)) return false, err } + if !p.deps.railGateways.available() { + logger.Warn("Rail gateway unavailable") return false, merrors.Internal("rail gateway unavailable") } + fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - req, err := p.buildProviderSettlementTransferRequest(payment, step, amount, quote, idx, fromRole, toRole) + req, err := p.buildProviderSettlementTransferRequest( + payment, + step, + execStep.OperationRef, + amount, + quote, + idx, + fromRole, toRole) if err != nil { + logger.Warn("Failed to build provider settlement request", zap.Error(err)) return false, err } + gw, err := p.deps.railGateways.resolve(ctx, step) if err != nil { + logger.Warn("Failed to resolve rail gateway", zap.Error(err)) return false, err } + + logger.Info("Sending provider settlement transfer", + zap.String("idempotency", req.IdempotencyKey), + ) + result, err := gw.Send(ctx, req) if err != nil { - return false, err + execStep.Error = strings.TrimSpace(err.Error()) + setExecutionStepStatus(execStep, model.OperationStateFailed) + + payment.State = model.PaymentStateFailed + payment.FailureCode = model.PaymentFailureCodeSettlement + + logger.Warn("Send failed; step marked as failed", zap.Error(err)) + return false, nil } + execStep.TransferRef = strings.TrimSpace(result.ReferenceID) if execStep.TransferRef == "" { execStep.TransferRef = strings.TrimSpace(req.IdempotencyKey) } + + logger.Info("Provider settlement submitted", + zap.String("transfer_ref", execStep.TransferRef), + ) + linkProviderSettlementObservation(payment, execStep.TransferRef) - setExecutionStepStatus(execStep, executionStepStatusSubmitted) + setExecutionStepStatus(execStep, model.OperationStateWaiting) return true, nil + case model.RailFiatOnRamp: + logger.Warn("Fiat on-ramp not implemented") return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented") + default: + logger.Warn("Unsupported send rail") return false, merrors.InvalidArgument("payment plan: unsupported send rail") } } -func (p *paymentExecutor) executeFeeStep(ctx context.Context, payment *model.Payment, step *model.PaymentStep, execStep *model.ExecutionStep, idx int) (bool, error) { +func (p *paymentExecutor) executeFeeStep( + ctx context.Context, + payment *model.Payment, + step *model.PaymentStep, + execStep *model.ExecutionStep, + idx int, +) (bool, error) { + + if payment == nil || step == nil || execStep == nil { + return false, merrors.InvalidArgument("payment plan: fee step is required") + } + switch step.Rail { + case model.RailCrypto: amount, err := requireMoney(cloneMoney(step.Amount), "crypto fee amount") if err != nil { return false, err } + if !p.deps.railGateways.available() { return false, merrors.Internal("rail gateway unavailable") } + fromRole, toRole := roleHintsForStep(payment.PaymentPlan, idx) - req, err := p.buildCryptoTransferRequest(payment, amount, model.RailOperationFee, planStepIdempotencyKey(payment, idx, step), nil, fromRole, toRole) + + req, err := p.buildCryptoTransferRequest( + payment, + amount, + model.RailOperationFee, + planStepIdempotencyKey(payment, idx, step), + execStep.OperationRef, + nil, + fromRole, + toRole, + ) if err != nil { return false, err } + gw, err := p.deps.railGateways.resolve(ctx, step) if err != nil { return false, err } + + p.logger.Debug("Executing crypto fee transfer", + zap.String("payment_ref", payment.PaymentRef), + zap.String("step_id", planStepID(step, idx)), + zap.String("amount", amount.GetAmount()), + zap.String("currency", amount.GetCurrency()), + ) + result, err := gw.Send(ctx, req) if err != nil { - return false, err + p.logger.Warn("Crypto fee transfer failed to submit", zap.Error(err), + zap.String("payment_ref", payment.PaymentRef), + ) + return false, nil } + execStep.TransferRef = strings.TrimSpace(result.ReferenceID) + if execStep.TransferRef != "" { ensureExecutionRefs(payment).FeeTransferRef = execStep.TransferRef } - setExecutionStepStatus(execStep, executionStepStatusSubmitted) + + // ВАЖНО: больше не Submitted + setExecutionStepStatus(execStep, model.OperationStateWaiting) + + p.logger.Info("Crypto fee transfer submitted, waiting confirmation", + zap.String("payment_ref", payment.PaymentRef), + zap.String("transfer_ref", execStep.TransferRef), + ) + return true, nil + default: return false, merrors.InvalidArgument("payment plan: unsupported fee rail") } diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go index 65ff87ab..2c80e41e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -7,7 +7,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" @@ -21,6 +21,7 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { PaymentRef: "pay-1", IdempotencyKey: "idem-1", Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -67,9 +68,9 @@ func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, {StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}}, {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(pmodel.AccountRolePending), ToRole: rolePtr(pmodel.AccountRoleOperating)}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, {StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}}, - {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(pmodel.AccountRoleOperating), ToRole: rolePtr(pmodel.AccountRoleTransit)}, + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, }, }, }, @@ -143,6 +144,7 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { PaymentRef: "pay-1", IdempotencyKey: "idem-1", Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -177,6 +179,7 @@ func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t PaymentRef: "pay-settle-1", IdempotencyKey: "idem-settle-1", Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, SettlementMode: model.SettlementModeFixReceived, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, @@ -240,6 +243,7 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) PaymentRef: "pay-2", IdempotencyKey: "idem-2", Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, RequiresFX: true, Source: model.PaymentEndpoint{ @@ -290,9 +294,9 @@ func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, {StepID: "crypto_fee", Rail: model.RailCrypto, Operation: "fee.send", DependsOn: []string{"crypto_send"}}, {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(pmodel.AccountRolePending), ToRole: rolePtr(pmodel.AccountRoleOperating)}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, {StepID: "card_payout", Rail: model.RailCardPayout, Operation: "payout.card", DependsOn: []string{"ledger_credit"}}, - {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(pmodel.AccountRoleOperating), ToRole: rolePtr(pmodel.AccountRoleTransit)}, + {StepID: "ledger_debit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"card_payout"}, CommitPolicy: model.CommitPolicyAfterSuccess, CommitAfter: []string{"card_payout"}, FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, }, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go index bab71b96..37814aa5 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go @@ -6,7 +6,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mutil/mzap" paymenttypes "github.com/tech/sendico/pkg/payments/types" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -116,7 +116,11 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement { amount = cloneMoney(providerSettlementAmount) } - if amount == nil && action != model.RailOperationObserveConfirm { + if amount == nil && + action != model.RailOperationObserveConfirm && + action != model.RailOperationFee { + logger.Warn("Plan template step has no amount for action, skipping", + zap.String("step_id", stepID), zap.String("action", string(action))) continue } @@ -349,7 +353,7 @@ func observeAmountForRail(rail model.Rail, source, settlement, payout *paymentty return source } -func cloneAccountRole(role *pmodel.AccountRole) *pmodel.AccountRole { +func cloneAccountRole(role *account_role.AccountRole) *account_role.AccountRole { if role == nil { return nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go index 92730b0c..5ac6536e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go +++ b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement.go @@ -5,14 +5,14 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/merrors" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" paymenttypes "github.com/tech/sendico/pkg/payments/types" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) const ( - providerSettlementMetaPaymentIntentID = "payment_intent_id" + providerSettlementMetaPaymentIntentID = "payment_ref" providerSettlementMetaQuoteRef = "quote_ref" providerSettlementMetaTargetChatID = "target_chat_id" providerSettlementMetaOutgoingLeg = "outgoing_leg" @@ -20,7 +20,7 @@ const ( providerSettlementMetaSourceCurrency = "source_currency" ) -func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model.Payment, step *model.PaymentStep, amount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote, idx int, fromRole, toRole *pmodel.AccountRole) (rail.TransferRequest, error) { +func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model.Payment, step *model.PaymentStep, operationRef string, amount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote, idx int, fromRole, toRole *account_role.AccountRole) (rail.TransferRequest, error) { if payment == nil || step == nil { return rail.TransferRequest{}, merrors.InvalidArgument("provider settlement: payment and step are required") } @@ -86,7 +86,9 @@ func (p *paymentExecutor) buildProviderSettlementTransferRequest(payment *model. IdempotencyKey: requestID, DestinationMemo: paymentRef, Metadata: metadata, - ClientReference: paymentRef, + PaymentRef: paymentRef, + OperationRef: operationRef, + IntentRef: payment.Intent.Ref, } if fromRole != nil { req.FromRole = *fromRole diff --git a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go index 798ed95b..dbdc8093 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go +++ b/api/payments/orchestrator/internal/service/orchestrator/provider_settlement_gateway.go @@ -61,7 +61,7 @@ func (g *providerSettlementGateway) Send(ctx context.Context, req rail.TransferR metadata = map[string]string{} } if strings.TrimSpace(metadata[providerSettlementMetaPaymentIntentID]) == "" { - if ref := strings.TrimSpace(req.ClientReference); ref != "" { + if ref := strings.TrimSpace(req.PaymentRef); ref != "" { metadata[providerSettlementMetaPaymentIntentID] = ref } } @@ -79,8 +79,10 @@ func (g *providerSettlementGateway) Send(ctx context.Context, req rail.TransferR Currency: currency, Amount: amount, }, - Metadata: metadata, - ClientReference: strings.TrimSpace(req.ClientReference), + Metadata: metadata, + PaymentRef: strings.TrimSpace(req.PaymentRef), + IntentRef: req.IntentRef, + OperationRef: req.OperationRef, } if dest := buildProviderSettlementDestination(req); dest != nil { submitReq.Destination = dest @@ -143,16 +145,22 @@ func buildProviderSettlementDestination(req rail.TransferRequest) *chainv1.Trans } } -func providerSettlementStatusFromTransfer(status chainv1.TransferStatus) string { +func providerSettlementStatusFromTransfer(status chainv1.TransferStatus) rail.TransferStatus { switch status { - case chainv1.TransferStatus_TRANSFER_CONFIRMED: + + case chainv1.TransferStatus_TRANSFER_SUCCESS: return rail.TransferStatusSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: return rail.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: - return rail.TransferStatusRejected + // our cancellation, not from provider + return rail.TransferStatusFailed + default: - return rail.TransferStatusPending + // CREATED, PROCESSING, WAITING + return rail.TransferStatusWaiting } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go index a8441f1f..068307fc 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine_test.go @@ -60,6 +60,7 @@ func TestBuildPaymentQuote_RequestsFXWhenSettlementDiffers(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Source: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, }, @@ -94,6 +95,7 @@ func TestBuildPaymentQuote_FeesRequestedForExternalRails(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Source: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, }, @@ -128,6 +130,7 @@ func TestBuildPaymentQuote_FeesSkippedForLedgerTransfer(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Source: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, }, diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go index bf631cc6..ec1ba5f6 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_payment_idempotency_integration_test.go @@ -83,7 +83,9 @@ func TestQuotePayment_IdempotencyReuseAfterExpiry(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: orgID.Hex()}, IdempotencyKey: "idem-expired-quote", + Ref: "ref-expired", Intent: &orchestratorv1.PaymentIntent{ + Ref: "intent-1", Source: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{ Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}, diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go index fda08ee5..0fe4261f 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go @@ -45,6 +45,7 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, SettlementCurrency: "USD", Fx: &orchestratorv1.FXIntent{ @@ -83,6 +84,7 @@ func TestRequestFXQuoteFailsWhenRequiredAndOracleUnavailable(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ + Ref: "ref-1", RequiresFx: true, Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"}, SettlementCurrency: "RUB", @@ -125,6 +127,7 @@ func TestRequestFXQuoteFailsWhenRequiredAndQuoteMissing(t *testing.T) { req := &orchestratorv1.QuotePaymentRequest{ Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"}, Intent: &orchestratorv1.PaymentIntent{ + Ref: "ref-1", RequiresFx: true, Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"}, SettlementCurrency: "RUB", diff --git a/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go b/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go index 930319ae..765b3745 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go @@ -32,26 +32,26 @@ func (f *fakeRailGateway) Send(ctx context.Context, req rail.TransferRequest) (r if f.sendFn != nil { return f.sendFn(ctx, req) } - return rail.RailResult{ReferenceID: "transfer-1", Status: rail.TransferStatusPending}, nil + return rail.RailResult{ReferenceID: "transfer-1", Status: rail.TransferStatusWaiting}, nil } func (f *fakeRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { if f.observeFn != nil { return f.observeFn(ctx, referenceID) } - return rail.ObserveResult{ReferenceID: referenceID, Status: rail.TransferStatusPending}, nil + return rail.ObserveResult{ReferenceID: referenceID, Status: rail.TransferStatusWaiting}, nil } func (f *fakeRailGateway) Block(ctx context.Context, req rail.BlockRequest) (rail.RailResult, error) { if f.blockFn != nil { return f.blockFn(ctx, req) } - return rail.RailResult{ReferenceID: req.IdempotencyKey, Status: rail.TransferStatusPending}, nil + return rail.RailResult{ReferenceID: req.IdempotencyKey, Status: rail.TransferStatusWaiting}, nil } func (f *fakeRailGateway) Release(ctx context.Context, req rail.ReleaseRequest) (rail.RailResult, error) { if f.releaseFn != nil { return f.releaseFn(ctx, req) } - return rail.RailResult{ReferenceID: req.ReferenceID, Status: rail.TransferStatusPending}, nil + return rail.RailResult{ReferenceID: req.ReferenceID, Status: rail.TransferStatusWaiting}, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go index 6b6a766f..5b00ad43 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -11,7 +11,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" - pmodel "github.com/tech/sendico/pkg/model" + "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" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" @@ -54,6 +54,7 @@ func TestRequireIdempotencyKey(t *testing.T) { func TestNewPayment(t *testing.T) { org := bson.NewObjectID() intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, SettlementCurrency: "USD", @@ -81,10 +82,12 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) { clock: clockpkg.NewSystem(), } _, _, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{ - OrgRef: org.Hex(), - OrgID: org, - Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, - Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}, + OrgRef: org.Hex(), + OrgID: org, + Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}, + Intent: &orchestratorv1.PaymentIntent{ + Ref: "ref-1", + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"}, QuoteRef: "missing", }) if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" { @@ -94,7 +97,9 @@ func TestResolvePaymentQuote_NotFound(t *testing.T) { func TestResolvePaymentQuote_Expired(t *testing.T) { org := bson.NewObjectID() - intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} + intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), @@ -119,7 +124,9 @@ func TestResolvePaymentQuote_Expired(t *testing.T) { func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { org := bson.NewObjectID() - intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} + intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD"} record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), @@ -149,6 +156,7 @@ func TestResolvePaymentQuote_QuoteRefUsesStoredIntent(t *testing.T) { func TestResolvePaymentQuote_QuoteRefSkipsQuoteRecompute(t *testing.T) { org := bson.NewObjectID() intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, SettlementCurrency: "USD", } @@ -222,7 +230,7 @@ func TestInitiatePaymentIdempotency(t *testing.T) { ToRail: model.RailLedger, IsEnabled: true, Steps: []model.OrchestrationStep{ - {StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(pmodel.AccountRoleOperating), ToRole: rolePtr(pmodel.AccountRoleTransit)}, + {StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, }, }, }, @@ -235,6 +243,7 @@ func TestInitiatePaymentIdempotency(t *testing.T) { svc.ensureHandlers() intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Source: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, }, @@ -267,6 +276,7 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { org := bson.NewObjectID() store := newHelperPaymentStore() intent := &orchestratorv1.PaymentIntent{ + Ref: "ref-1", Source: &orchestratorv1.PaymentEndpoint{ Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, }, @@ -308,7 +318,7 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { ToRail: model.RailLedger, IsEnabled: true, Steps: []model.OrchestrationStep{ - {StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(pmodel.AccountRoleOperating), ToRole: rolePtr(pmodel.AccountRoleTransit)}, + {StepID: "ledger_move", Rail: model.RailLedger, Operation: "ledger.move", FromRole: rolePtr(account_role.AccountRoleOperating), ToRole: rolePtr(account_role.AccountRoleTransit)}, }, }, }, @@ -460,6 +470,6 @@ func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, orgRef bson.O return nil, storage.ErrQuoteNotFound } -func rolePtr(role pmodel.AccountRole) *pmodel.AccountRole { +func rolePtr(role account_role.AccountRole) *account_role.AccountRole { return &role } diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index 572628cd..742f4f82 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -13,6 +13,7 @@ import ( "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" mo "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/payments/rail" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" @@ -53,6 +54,7 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { IdempotencyKey: "fx-1", OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindFXConversion, Source: model.PaymentEndpoint{ Type: model.EndpointTypeLedger, @@ -75,7 +77,6 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { Price: &moneyv1.Decimal{Value: "0.9"}, }, } - if err := svc.executePayment(ctx, store, payment, quote); err != nil { t.Fatalf("executePayment returned error: %v", err) } @@ -83,6 +84,7 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { if payment.State != model.PaymentStateSettled { t.Fatalf("expected payment settled, got %s", payment.State) } + if payment.Execution == nil || payment.Execution.FXEntryRef == "" { t.Fatal("expected FX entry ref set on payment execution") } @@ -107,7 +109,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) { Steps: []model.OrchestrationStep{ {StepID: "crypto_send", Rail: model.RailCrypto, Operation: "payout.crypto"}, {StepID: "crypto_observe", Rail: model.RailCrypto, Operation: "observe.confirm", DependsOn: []string{"crypto_send"}}, - {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(mo.AccountRolePending), ToRole: rolePtr(mo.AccountRoleOperating)}, + {StepID: "ledger_credit", Rail: model.RailLedger, Operation: "ledger.move", DependsOn: []string{"crypto_observe"}, FromRole: rolePtr(account_role.AccountRolePending), ToRole: rolePtr(account_role.AccountRoleOperating)}, }, }, }, @@ -156,6 +158,7 @@ func TestExecutePayment_ChainFailure(t *testing.T) { IdempotencyKey: "chain-1", OrganizationBoundBase: mo.OrganizationBoundBase{OrganizationRef: bson.NewObjectID()}, Intent: model.PaymentIntent{ + Ref: "ref-1", Kind: model.PaymentKindPayout, Source: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, @@ -210,7 +213,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) { Event: &chainv1.TransferStatusChangedEvent{ Transfer: &chainv1.Transfer{ TransferRef: "transfer-1", - Status: chainv1.TransferStatus_TRANSFER_CONFIRMED, + Status: chainv1.TransferStatus_TRANSFER_SUCCESS, }, }, } @@ -231,6 +234,7 @@ func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { PaymentRef: "pay-card", State: model.PaymentStateSubmitted, Intent: model.PaymentIntent{ + Ref: "ref-1", Destination: model.PaymentEndpoint{ Type: model.EndpointTypeCard, Card: &model.CardEndpoint{MaskedPan: "4111"}, @@ -243,16 +247,16 @@ func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) fundStep.TransferRef = "fund-1" setExecutionStepRole(fundStep, executionStepRoleSource) - setExecutionStepStatus(fundStep, executionStepStatusSubmitted) + setExecutionStepStatus(fundStep, model.OperationStateWaiting) feeStep := ensureExecutionStep(plan, stepCodeFeeTransfer) feeStep.TransferRef = "fee-1" setExecutionStepRole(feeStep, executionStepRoleSource) - setExecutionStepStatus(feeStep, executionStepStatusSubmitted) + setExecutionStepStatus(feeStep, model.OperationStateWaiting) cardStep := ensureExecutionStep(plan, stepCodeCardPayout) setExecutionStepRole(cardStep, executionStepRoleConsumer) - setExecutionStepStatus(cardStep, executionStepStatusPlanned) + setExecutionStepStatus(cardStep, model.OperationStatePlanned) store := newStubPaymentsStore() store.payments[payment.PaymentRef] = payment @@ -275,7 +279,7 @@ func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { step := ensureExecutionStep(plan, stepCodeCardPayout) setExecutionStepRole(step, executionStepRoleConsumer) step.TransferRef = "payout-1" - setExecutionStepStatus(step, executionStepStatusSubmitted) + setExecutionStepStatus(step, model.OperationStateWaiting) return nil } svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, submit, nil, nil) @@ -284,7 +288,7 @@ func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { Event: &chainv1.TransferStatusChangedEvent{ Transfer: &chainv1.Transfer{ TransferRef: "fund-1", - Status: chainv1.TransferStatus_TRANSFER_CONFIRMED, + Status: chainv1.TransferStatus_TRANSFER_SUCCESS, }, }, } @@ -295,8 +299,8 @@ func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { if payoutCalls != 0 { t.Fatalf("expected no payout on first confirmation, got %d", payoutCalls) } - if executionStepStatus(fundStep) != executionStepStatusConfirmed { - t.Fatalf("expected funding step confirmed, got %s", executionStepStatus(fundStep)) + if fundStep.State != model.OperationStateSuccess { + t.Fatalf("expected funding step confirmed, got %s", feeStep.State) } if resp.GetPayment().GetState() != orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED { t.Fatalf("expected submitted state, got %s", resp.GetPayment().GetState()) @@ -306,7 +310,7 @@ func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { Event: &chainv1.TransferStatusChangedEvent{ Transfer: &chainv1.Transfer{ TransferRef: "fee-1", - Status: chainv1.TransferStatus_TRANSFER_CONFIRMED, + Status: chainv1.TransferStatus_TRANSFER_SUCCESS, }, }, } @@ -317,8 +321,8 @@ func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { if payoutCalls != 1 { t.Fatalf("expected payout after all sources confirmed, got %d", payoutCalls) } - if executionStepStatus(feeStep) != executionStepStatusConfirmed { - t.Fatalf("expected fee step confirmed, got %s", executionStepStatus(feeStep)) + if feeStep.State != model.OperationStateSuccess { + t.Fatalf("expected fee step confirmed, got %s", string(model.OperationStateSuccess)) } if resp.GetPayment().GetExecution().GetCardPayoutRef() != "payout-1" { t.Fatalf("expected card payout ref set, got %s", resp.GetPayment().GetExecution().GetCardPayoutRef()) @@ -331,6 +335,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { PaymentRef: "pay-2", State: model.PaymentStateSubmitted, Intent: model.PaymentIntent{ + Ref: "ref-2", Destination: model.PaymentEndpoint{ Type: model.EndpointTypeManagedWallet, ManagedWallet: &model.ManagedWalletEndpoint{ diff --git a/api/payments/orchestrator/storage/model/operation.go b/api/payments/orchestrator/storage/model/operation.go new file mode 100644 index 00000000..fdc6ff09 --- /dev/null +++ b/api/payments/orchestrator/storage/model/operation.go @@ -0,0 +1,15 @@ +package model + +type OperationState string + +const ( + OperationStateCreated OperationState = "created" // record exists, not started + OperationStateProcessing OperationState = "processing" // we are working on it + OperationStatePlanned OperationState = "planned" // waiting for execution + OperationStateWaiting OperationState = "waiting" // waiting external world + + OperationStateSuccess OperationState = "success" // final success + OperationStateFailed OperationState = "failed" // final failure + OperationStateCancelled OperationState = "cancelled" // final cancelled + OperationStateSkipped OperationState = "skipped" // final skipped +) diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index dffaf7b9..7b138f11 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -6,7 +6,7 @@ import ( "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/model" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mservice" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) @@ -65,6 +65,7 @@ const ( PaymentFailureCodeChain PaymentFailureCode = "chain" PaymentFailureCodeFees PaymentFailureCode = "fees" PaymentFailureCodePolicy PaymentFailureCode = "policy" + PaymentFailureCodeSettlement PaymentFailureCode = "settlement" ) // Rail identifies a payment rail for orchestration. @@ -220,6 +221,7 @@ type FXIntent struct { // PaymentIntent models the requested payment operation. type PaymentIntent struct { + Ref string `bson:"ref" json:"ref"` Kind PaymentKind `bson:"kind" json:"kind"` Source PaymentEndpoint `bson:"source" json:"source"` Destination PaymentEndpoint `bson:"destination" json:"destination"` @@ -271,18 +273,17 @@ type ExecutionRefs struct { // PaymentStep is an explicit action within a payment plan. type PaymentStep struct { - StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"` - Rail Rail `bson:"rail" json:"rail"` - GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` - InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` - Action RailOperation `bson:"action" json:"action"` - DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` - CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` - CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` - Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` - Ref string `bson:"ref,omitempty" json:"ref,omitempty"` - FromRole *pmodel.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"` - ToRole *pmodel.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"` + StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"` + Rail Rail `bson:"rail" json:"rail"` + GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` + InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"` + Action RailOperation `bson:"action" json:"action"` + DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` + CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` + CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` + Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` + FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"` + ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"` } // PaymentPlan captures the ordered list of steps to execute a payment. @@ -304,9 +305,37 @@ type ExecutionStep struct { SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` + OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + State OperationState `bson:"state,omitempty" json:"state,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } +func (s *ExecutionStep) IsTerminal() bool { + if s.State == OperationStateSuccess || + s.State == OperationStateFailed || + s.State == OperationStateCancelled || + s.State == OperationStateSkipped { + return true + } + return false +} + +func (s *ExecutionStep) IsSuccess() bool { + return s.State == OperationStateSuccess +} + +func (s *ExecutionStep) ReadyForNext() bool { + switch s.State { + case OperationStateSuccess, + OperationStateSkipped: + return true + + default: + return false + } +} + // ExecutionPlan captures the ordered list of steps to execute a payment. type ExecutionPlan struct { Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"` @@ -420,7 +449,6 @@ func (p *Payment) Normalize() { step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) step.DependsOn = normalizeStringList(step.DependsOn) step.CommitAfter = normalizeStringList(step.CommitAfter) - step.Ref = strings.TrimSpace(step.Ref) } } } diff --git a/api/payments/orchestrator/storage/model/plan_template.go b/api/payments/orchestrator/storage/model/plan_template.go index 7b9ca9b5..90a652c0 100644 --- a/api/payments/orchestrator/storage/model/plan_template.go +++ b/api/payments/orchestrator/storage/model/plan_template.go @@ -4,20 +4,20 @@ import ( "strings" "github.com/tech/sendico/pkg/db/storable" - pmodel "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mservice" ) // OrchestrationStep defines a template step for execution planning. type OrchestrationStep struct { - StepID string `bson:"stepId" json:"stepId"` - Rail Rail `bson:"rail" json:"rail"` - Operation string `bson:"operation" json:"operation"` - DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` - CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` - CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` - FromRole *pmodel.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"` - ToRole *pmodel.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"` + StepID string `bson:"stepId" json:"stepId"` + Rail Rail `bson:"rail" json:"rail"` + Operation string `bson:"operation" json:"operation"` + DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"` + CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"` + CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"` + FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"` + ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"` } // PaymentPlanTemplate stores reusable orchestration templates. @@ -60,7 +60,7 @@ func (t *PaymentPlanTemplate) Normalize() { } } -func normalizeAccountRole(role *pmodel.AccountRole) *pmodel.AccountRole { +func normalizeAccountRole(role *account_role.AccountRole) *account_role.AccountRole { if role == nil { return nil } @@ -68,14 +68,14 @@ func normalizeAccountRole(role *pmodel.AccountRole) *pmodel.AccountRole { if trimmed == "" { return nil } - if parsed, ok := pmodel.Parse(trimmed); ok { + if parsed, ok := account_role.Parse(trimmed); ok { if parsed == "" { return nil } normalized := parsed return &normalized } - normalized := pmodel.AccountRole(strings.ToLower(trimmed)) + normalized := account_role.AccountRole(strings.ToLower(trimmed)) return &normalized } diff --git a/api/pkg/go.mod b/api/pkg/go.mod index 2556aa0e..cf7cd3b1 100644 --- a/api/pkg/go.mod +++ b/api/pkg/go.mod @@ -92,6 +92,6 @@ require ( golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.5.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/api/pkg/go.sum b/api/pkg/go.sum index 608b40ea..32453a73 100644 --- a/api/pkg/go.sum +++ b/api/pkg/go.sum @@ -271,8 +271,8 @@ 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/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/pkg/model/account_role.go b/api/pkg/model/account_role/account_role.go similarity index 99% rename from api/pkg/model/account_role.go rename to api/pkg/model/account_role/account_role.go index ede14894..2a6e35e9 100644 --- a/api/pkg/model/account_role.go +++ b/api/pkg/model/account_role/account_role.go @@ -1,4 +1,4 @@ -package model +package account_role import ( "strings" diff --git a/api/pkg/model/chains.go b/api/pkg/model/chains.go index 903f5da4..e28b448d 100644 --- a/api/pkg/model/chains.go +++ b/api/pkg/model/chains.go @@ -86,8 +86,9 @@ func (n ChainNetwork) IsEVM() bool { func (n ChainNetwork) IsTestnet() bool { switch n { case ChainNetworkTronNile: + case ChainNetworkArbitrumSepolia: return true default: - return false } + return false } diff --git a/api/pkg/model/confirmation.go b/api/pkg/model/confirmation.go index 5b358c9d..106ff588 100644 --- a/api/pkg/model/confirmation.go +++ b/api/pkg/model/confirmation.go @@ -21,6 +21,8 @@ type ConfirmationRequest struct { TimeoutSeconds int32 `bson:"timeoutSeconds,omitempty" json:"timeout_seconds,omitempty"` SourceService string `bson:"sourceService,omitempty" json:"source_service,omitempty"` Rail string `bson:"rail,omitempty" json:"rail,omitempty"` + OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"` + IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"` } type ConfirmationResult struct { diff --git a/api/pkg/model/ledger.go b/api/pkg/model/ledger.go index 7a1634fb..7738efe0 100644 --- a/api/pkg/model/ledger.go +++ b/api/pkg/model/ledger.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mservice" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -63,7 +64,7 @@ type LedgerAccount struct { // Role defines the functional purpose of the account within an organization // (e.g., pending, operating, settlement, hold, etc.). // Must be set for organization accounts and omitted for system accounts. - Role AccountRole `bson:"role,omitempty" json:"role,omitempty"` + Role account_role.AccountRole `bson:"role,omitempty" json:"role,omitempty"` // AccountCode is a logical classification code of the account // (e.g., "asset:cash:usd") used for reporting and grouping. diff --git a/api/pkg/model/money.go b/api/pkg/model/money.go deleted file mode 100644 index 566a845c..00000000 --- a/api/pkg/model/money.go +++ /dev/null @@ -1,6 +0,0 @@ -package model - -type Money struct { - Currency string `bson:"currency" json:"currency"` - Amount string `bson:"amount" json:"amount"` -} diff --git a/api/pkg/model/payment.go b/api/pkg/model/payment.go index 6caa2472..b0092994 100644 --- a/api/pkg/model/payment.go +++ b/api/pkg/model/payment.go @@ -144,3 +144,24 @@ func (m *PaymentMethod) UnmarshalJSON(data []byte) error { m.Data = raw return nil } + +type PaymentStatus string + +const ( + PaymentStatusUnspecified PaymentStatus = "unspecified" + + // Intent exists, no funds touched yet. + PaymentStatusCreated PaymentStatus = "created" + + // Internal Sendico operations: holds, ledger, FX, fee calc. + PaymentStatusAuthorizing PaymentStatus = "authorizing" + + // Funds are outside Sendico (rail/gateway/blockchain/provider). + // Observe lives ONLY in this state. + PaymentStatusExecuting PaymentStatus = "executing" + + // Final states. + PaymentStatusSucceeded PaymentStatus = "success" + PaymentStatusFailed PaymentStatus = "failed" + PaymentStatusCancelled PaymentStatus = "cancelled" +) diff --git a/api/pkg/model/payment_gateway.go b/api/pkg/model/payment_gateway.go index 8c6bd46b..bbd6315d 100644 --- a/api/pkg/model/payment_gateway.go +++ b/api/pkg/model/payment_gateway.go @@ -1,22 +1,28 @@ package model -import paymenttypes "github.com/tech/sendico/pkg/payments/types" +import ( + "github.com/tech/sendico/pkg/payments/rail" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) type PaymentGatewayIntent struct { + PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"` PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` OutgoingLeg string `bson:"outgoingLeg,omitempty" json:"outgoing_leg,omitempty"` QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` + IntentRef string `bson:"intentRef,omitempty" json:"intent_ref,omitempty"` + OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"` RequestedMoney *paymenttypes.Money `bson:"requestedMoney,omitempty" json:"requested_money,omitempty"` - TargetChatID string `bson:"targetChatId,omitempty" json:"target_chat_id,omitempty"` } type PaymentGatewayExecution struct { - PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` - IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` - QuoteRef string `bson:"quoteRef,omitempty" json:"quote_ref,omitempty"` - ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` - Status ConfirmationStatus `bson:"status,omitempty" json:"status,omitempty"` - RequestID string `bson:"requestId,omitempty" json:"request_id,omitempty"` - RawReply *TelegramMessage `bson:"rawReply,omitempty" json:"raw_reply,omitempty"` + PaymentIntentID string `bson:"paymentIntentId,omitempty" json:"payment_intent_id,omitempty"` + PaymentRef string `bson:"paymentRef,omitempty" json:"payment_ref,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotency_key,omitempty"` + ExecutedMoney *paymenttypes.Money `bson:"executedMoney,omitempty" json:"executed_money,omitempty"` + Status rail.OperationResult `bson:"status,omitempty" json:"status,omitempty"` + OperationRef string `bson:"operationRef,omitempty" json:"operation_ref,omitempty"` + TransferRef string `bson:"transferRef,omitempty" json:"transfer_ref,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` } diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index e2790868..010aa87d 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -48,6 +48,7 @@ const ( RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication Roles Type = "roles" // Represents roles in access control Storage Type = "storage" // Represents statuses of tasks or projects + TgSettle Type = "tgsettle_gateway" // Represents tg settlement gateway Tenants Type = "tenants" // Represents tenants managed in the system Wallets Type = "wallets" // Represents workflows for tasks or projects Workflows Type = "workflows" // Represents workflows for tasks or projects diff --git a/api/pkg/payments/rail/gateway.go b/api/pkg/payments/rail/gateway.go index 3cef11d0..ab9591ca 100644 --- a/api/pkg/payments/rail/gateway.go +++ b/api/pkg/payments/rail/gateway.go @@ -3,19 +3,32 @@ package rail import ( "context" - "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" paymenttypes "github.com/tech/sendico/pkg/payments/types" ) // Money represents a currency amount using decimal-safe strings. type Money = paymenttypes.Money +type TransferStatus string + const ( - TransferStatusUnspecified = "UNSPECIFIED" - TransferStatusSuccess = "SUCCESS" - TransferStatusFailed = "FAILED" - TransferStatusRejected = "REJECTED" - TransferStatusPending = "PENDING" + TransferStatusUnspecified TransferStatus = "unspecified" + TransferStatusCreated TransferStatus = "created" + TransferStatusSuccess TransferStatus = "success" + TransferStatusFailed TransferStatus = "failed" + TransferStatusWaiting TransferStatus = "waiting" + TransferStatusProcessing TransferStatus = "processing" + TransferStatusCancelled TransferStatus = "cancelled" +) + +// OperationResult represents the outcome status of an operation in a gateway +type OperationResult string + +const ( + OperationResultSuccess OperationResult = "success" + OperationResultFailed OperationResult = "failed" + OperationResultCancelled OperationResult = "cancelled" ) // RailCapabilities are declared per gateway instance. @@ -39,6 +52,9 @@ type FeeBreakdown struct { // TransferRequest defines the inputs for sending value through a rail gateway. type TransferRequest struct { OrganizationRef string + IntentRef string + OperationRef string + PaymentRef string FromAccountID string ToAccountID string Currency string @@ -48,10 +64,9 @@ type TransferRequest struct { Fees []FeeBreakdown IdempotencyKey string Metadata map[string]string - ClientReference string DestinationMemo string - FromRole model.AccountRole - ToRole model.AccountRole + FromRole account_role.AccountRole + ToRole account_role.AccountRole } // BlockRequest defines the inputs for reserving value through a rail gateway. @@ -62,35 +77,35 @@ type BlockRequest struct { Amount string IdempotencyKey string Metadata map[string]string - ClientReference string + PaymentRef string } // ReleaseRequest defines the inputs for releasing a prior block. type ReleaseRequest struct { - ReferenceID string - IdempotencyKey string - Metadata map[string]string - ClientReference string + ReferenceID string + IdempotencyKey string + Metadata map[string]string + PaymentRef string } // RailResult reports the outcome of a rail gateway operation. type RailResult struct { ReferenceID string - Status string + Status TransferStatus FinalAmount *Money - Error *RailError + Error *Error } // ObserveResult reports the outcome of a confirmation observation. type ObserveResult struct { ReferenceID string - Status string + Status TransferStatus FinalAmount *Money - Error *RailError + Error *Error } -// RailError captures structured failure details from a gateway. -type RailError struct { +// Error captures structured failure details from a gateway. +type Error struct { Code string Message string CanRetry bool diff --git a/api/proto/common/gateway/v1/gateway.proto b/api/proto/common/gateway/v1/gateway.proto index c01c0a6a..83da719f 100644 --- a/api/proto/common/gateway/v1/gateway.proto +++ b/api/proto/common/gateway/v1/gateway.proto @@ -2,6 +2,9 @@ syntax = "proto3"; package common.gateway.v1; option go_package = "github.com/tech/sendico/pkg/proto/common/gateway/v1;gatewayv1"; +import "common/money/v1/money.proto"; + + enum Operation { OPERATION_UNSPECIFIED = 0; OPERATION_AUTHORIZE = 1; @@ -162,3 +165,26 @@ message GatewayInstanceDescriptor { string version = 7; bool is_enabled = 8; } + +// OperationResult represents the outcome status of an operation in a gateway +enum OperationResult { + OPERATION_RESULT_UNSPECIFIED = 0; + OPERATION_RESULT_SUCCESS = 1; + OPERATION_RESULT_FAILED = 2; + OPERATION_RESULT_CANCELLED = 3; +} + +message OperationError { + string code = 1; + string message = 2; + bool can_retry = 3; + bool should_rollback = 4; +} + +message OperationExecutionStatus { + string idempotency_key = 1; + string operation_ref = 2; + common.money.v1.Money executed_money = 3; + OperationResult status = 4; + OperationError error = 5; +} \ No newline at end of file diff --git a/api/proto/connector/v1/connector.proto b/api/proto/connector/v1/connector.proto index c0dc4e88..fb252e59 100644 --- a/api/proto/connector/v1/connector.proto +++ b/api/proto/connector/v1/connector.proto @@ -54,13 +54,17 @@ enum OperationType { enum OperationStatus { OPERATION_STATUS_UNSPECIFIED = 0; - SUBMITTED = 1; - PENDING = 2; - CONFIRMED = 3; - FAILED = 4; - CANCELED = 5; + + OPERATION_CREATED = 1; // record exists, not started + OPERATION_PROCESSING = 2; // we are working on it + OPERATION_WAITING = 3; // waiting external world + + OPERATION_SUCCESS = 4; // final success + OPERATION_FAILED = 5; // final failure + OPERATION_CANCELLED = 6; // final cancelled } + enum ParamType { PARAM_TYPE_UNSPECIFIED = 0; STRING = 1; @@ -101,7 +105,7 @@ message ConnectorCapabilities { string version = 2; repeated AccountKind supported_account_kinds = 3; repeated OperationType supported_operation_types = 4; - repeated string supported_assets = 5; // canonical asset string (USD, ETH, USDT-TRC20) + repeated string supported_assets = 5; // canonical asset string (USD, ETH, USDT-TRC20) repeated string supported_networks = 6; // optional, connector-defined names repeated ParamSpec open_account_params = 7; repeated OperationParamSpec operation_params = 8; @@ -173,6 +177,8 @@ message Operation { google.protobuf.Timestamp updated_at = 13; common.account_role.v1.AccountRole from_role = 14; common.account_role.v1.AccountRole to_role = 15; + string operation_ref = 16; + string intent_ref = 17; } message OperationReceipt { diff --git a/api/proto/gateway/chain/v1/chain.proto b/api/proto/gateway/chain/v1/chain.proto index ecfc3682..8f33ea8b 100644 --- a/api/proto/gateway/chain/v1/chain.proto +++ b/api/proto/gateway/chain/v1/chain.proto @@ -36,14 +36,17 @@ enum DepositStatus { enum TransferStatus { TRANSFER_STATUS_UNSPECIFIED = 0; - TRANSFER_PENDING = 1; - TRANSFER_SIGNING = 2; - TRANSFER_SUBMITTED = 3; - TRANSFER_CONFIRMED = 4; - TRANSFER_FAILED = 5; - TRANSFER_CANCELLED = 6; + + TRANSFER_CREATED = 1; // record exists, not started + TRANSFER_PROCESSING = 2; // we are working on it (signing, building tx, etc) + TRANSFER_WAITING = 3; // waiting external world (network/provider) + + TRANSFER_SUCCESS = 4; // final success + TRANSFER_FAILED = 5; // final failure + TRANSFER_CANCELLED = 6; // final cancelled } + // Asset captures the chain/token pair so downstream systems can route correctly. message Asset { ChainNetwork chain = 1; @@ -148,6 +151,8 @@ message Transfer { string failure_reason = 12; google.protobuf.Timestamp created_at = 13; google.protobuf.Timestamp updated_at = 14; + string intent_ref = 15; + string payment_ref = 16; } message SubmitTransferRequest { @@ -158,7 +163,9 @@ message SubmitTransferRequest { common.money.v1.Money amount = 5; repeated ServiceFeeBreakdown fees = 6; map metadata = 7; - string client_reference = 8; + string operation_ref = 8; + string intent_ref = 9; + string payment_ref = 10; } message SubmitTransferResponse { @@ -214,7 +221,9 @@ message EnsureGasTopUpRequest { string target_wallet_ref = 4; common.money.v1.Money estimated_total_fee = 5; map metadata = 6; - string client_reference = 7; + string payment_ref = 7; + string intent_ref = 8; + string operation_ref = 9; } message EnsureGasTopUpResponse { diff --git a/api/proto/gateway/mntx/v1/mntx.proto b/api/proto/gateway/mntx/v1/mntx.proto index 7ed8f651..834628c9 100644 --- a/api/proto/gateway/mntx/v1/mntx.proto +++ b/api/proto/gateway/mntx/v1/mntx.proto @@ -7,18 +7,22 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/mntx/v1;mntxv1"; import "google/protobuf/timestamp.proto"; import "common/gateway/v1/gateway.proto"; -// Status of a payout request handled by Monetix. +// Lifecycle status of a payout handled by Monetix. enum PayoutStatus { PAYOUT_STATUS_UNSPECIFIED = 0; - PAYOUT_STATUS_PENDING = 1; - PAYOUT_STATUS_PROCESSED = 2; - PAYOUT_STATUS_FAILED = 3; + + PAYOUT_STATUS_CREATED = 1; // request created, not sent + PAYOUT_STATUS_WAITING = 2; // waiting provider processing + PAYOUT_STATUS_SUCCESS = 3; // final success + PAYOUT_STATUS_FAILED = 4; // final failure + PAYOUT_STATUS_CANCELLED = 5; // final cancelled } + // Request to initiate a Monetix card payout. message CardPayoutRequest { - string payout_id = 1; // internal payout id, mapped to Monetix payment_id - int64 project_id = 2; // optional override; defaults to configured project id + string payout_id = 1; // internal payout id, mapped to Monetix payment_id + int64 project_id = 2; // optional override; defaults to configured project id string customer_id = 3; string customer_first_name = 4; string customer_middle_name = 5; @@ -29,13 +33,16 @@ message CardPayoutRequest { string customer_state = 10; string customer_city = 11; string customer_address = 12; - int64 amount_minor = 13; // amount in minor units - string currency = 14; // ISO-4217 alpha-3 + int64 amount_minor = 13; // amount in minor units + string currency = 14; // ISO-4217 alpha-3 string card_pan = 15; uint32 card_exp_year = 16; uint32 card_exp_month = 17; string card_holder = 18; map metadata = 30; + string operation_ref = 31; + string idempotency_key = 32; + string intent_ref = 33; } // Persisted payout state for retrieval and status updates. @@ -51,6 +58,9 @@ message CardPayoutState { string provider_payment_id = 9; google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp updated_at = 11; + string operation_ref = 12; + string idempotency_key = 13; + string intent_ref = 14; } // Response returned immediately after submitting a payout to Monetix. @@ -105,6 +115,9 @@ message CardTokenPayoutRequest { string card_holder = 16; string masked_pan = 17; map metadata = 30; + string operation_ref = 31; + string idempotency_key = 32; + string intent_ref = 33; } // Response returned immediately after submitting a token payout to Monetix. diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index 8fecbc37..58e67929 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -116,6 +116,7 @@ message PaymentIntent { SettlementMode settlement_mode = 9; Customer customer = 10; string settlement_currency = 11; + string ref = 12; } message Customer { @@ -167,6 +168,7 @@ message ExecutionStep { string destination_ref = 6; string transfer_ref = 7; map metadata = 8; + string operation_ref = 9; } message ExecutionPlan { @@ -179,12 +181,11 @@ message PaymentStep { string gateway_id = 2; // required for external rails common.gateway.v1.RailOperation action = 3; common.money.v1.Money amount = 4; - string ref = 5; - string step_id = 6; - string instance_id = 7; - repeated string depends_on = 8; - string commit_policy = 9; - repeated string commit_after = 10; + string step_id = 5; + string instance_id = 6; + repeated string depends_on = 7; + string commit_policy = 8; + repeated string commit_after = 9; } message PaymentPlan { diff --git a/api/server/go.mod b/api/server/go.mod index 738a3c05..8ed8a70c 100644 --- a/api/server/go.mod +++ b/api/server/go.mod @@ -138,5 +138,5 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b // indirect ) diff --git a/api/server/go.sum b/api/server/go.sum index 8a256d60..e0ddc7b9 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -363,8 +363,8 @@ 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/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b h1:GZxXGdFaHX27ZSMHudWc4FokdD+xl8BC2UJm1OVIEzs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260202165425-ce8ad4cf556b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/api/server/interface/api/srequest/ledger.go b/api/server/interface/api/srequest/ledger.go index e2f2a3bf..5663316c 100644 --- a/api/server/interface/api/srequest/ledger.go +++ b/api/server/interface/api/srequest/ledger.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "go.mongodb.org/mongo-driver/v2/bson" ) @@ -28,13 +29,13 @@ const ( ) type CreateLedgerAccount struct { - AccountType LedgerAccountType `json:"accountType"` - Currency string `json:"currency"` - AllowNegative bool `json:"allowNegative,omitempty"` - Role model.AccountRole `json:"role"` - Describable model.Describable `json:"describable"` - OwnerRef *bson.ObjectID `json:"ownerRef,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` + AccountType LedgerAccountType `json:"accountType"` + Currency string `json:"currency"` + AllowNegative bool `json:"allowNegative,omitempty"` + Role account_role.AccountRole `json:"role"` + Describable model.Describable `json:"describable"` + OwnerRef *bson.ObjectID `json:"ownerRef,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } func (r *CreateLedgerAccount) Validate() error { diff --git a/api/server/interface/api/srequest/payment_intent.go b/api/server/interface/api/srequest/payment_intent.go index 9d21f833..13c24d97 100644 --- a/api/server/interface/api/srequest/payment_intent.go +++ b/api/server/interface/api/srequest/payment_intent.go @@ -4,19 +4,19 @@ import ( "strings" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" ) type PaymentIntent struct { - Kind PaymentKind `json:"kind,omitempty"` - Source *Endpoint `json:"source,omitempty"` - Destination *Endpoint `json:"destination,omitempty"` - Amount *model.Money `json:"amount,omitempty"` - FX *FXIntent `json:"fx,omitempty"` - SettlementMode SettlementMode `json:"settlement_mode,omitempty"` - SettlementCurrency string `json:"settlement_currency,omitempty"` - Attributes map[string]string `json:"attributes,omitempty"` - Customer *Customer `json:"customer,omitempty"` + Kind PaymentKind `json:"kind,omitempty"` + Source *Endpoint `json:"source,omitempty"` + Destination *Endpoint `json:"destination,omitempty"` + Amount *paymenttypes.Money `json:"amount,omitempty"` + FX *FXIntent `json:"fx,omitempty"` + SettlementMode SettlementMode `json:"settlement_mode,omitempty"` + SettlementCurrency string `json:"settlement_currency,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` + Customer *Customer `json:"customer,omitempty"` } type AssetResolverStub struct{} diff --git a/api/server/interface/api/srequest/payment_types_test.go b/api/server/interface/api/srequest/payment_types_test.go index 63131a38..d61d0df6 100644 --- a/api/server/interface/api/srequest/payment_types_test.go +++ b/api/server/interface/api/srequest/payment_types_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" ) func TestEndpointDTOBuildersAndDecoders(t *testing.T) { @@ -255,7 +255,7 @@ func TestPaymentIntentJSONRoundTrip(t *testing.T) { Kind: PaymentKindPayout, Source: &source, Destination: &dest, - Amount: &model.Money{Amount: "10", Currency: "USD"}, + Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"}, FX: &FXIntent{ Pair: &CurrencyPair{Base: "USD", Quote: "EUR"}, Side: FXSideBuyBaseSellQuote, @@ -324,7 +324,7 @@ func TestPaymentIntentMinimalRoundTrip(t *testing.T) { Kind: PaymentKindInternalTransfer, Source: &source, Destination: &dest, - Amount: &model.Money{Amount: "1", Currency: "USD"}, + Amount: &paymenttypes.Money{Amount: "1", Currency: "USD"}, } data, err := json.Marshal(intent) diff --git a/api/server/interface/api/srequest/payment_value_objects.go b/api/server/interface/api/srequest/payment_value_objects.go index 47c1bbbb..35b7cc85 100644 --- a/api/server/interface/api/srequest/payment_value_objects.go +++ b/api/server/interface/api/srequest/payment_value_objects.go @@ -6,7 +6,7 @@ import ( "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" ) // AssetResolver defines environment-specific supported assets. @@ -50,7 +50,7 @@ func ValidateCurrency(cur string, assetResolver AssetResolver) error { return nil } -func ValidateMoney(m *model.Money, assetResolver AssetResolver) error { +func ValidateMoney(m *paymenttypes.Money, assetResolver AssetResolver) error { if m == nil { return merrors.InvalidArgument("money is required", "intent.amount") } diff --git a/api/server/interface/api/sresponse/money.go b/api/server/interface/api/sresponse/money.go index 089632eb..93291d12 100644 --- a/api/server/interface/api/sresponse/money.go +++ b/api/server/interface/api/sresponse/money.go @@ -1,25 +1,25 @@ package sresponse import ( - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) -func toMoney(m *moneyv1.Money) *model.Money { +func toMoney(m *moneyv1.Money) *paymenttypes.Money { if m == nil { return nil } - return &model.Money{ + return &paymenttypes.Money{ Amount: m.GetAmount(), Currency: m.GetCurrency(), } } -func toMoneyList(list []*moneyv1.Money) []*model.Money { +func toMoneyList(list []*moneyv1.Money) []*paymenttypes.Money { if len(list) == 0 { return nil } - result := make([]*model.Money, 0, len(list)) + result := make([]*paymenttypes.Money, 0, len(list)) for _, item := range list { if m := toMoney(item); m != nil { result = append(result, m) diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index c5dc678b..fa55778a 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -5,7 +5,7 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" @@ -13,40 +13,40 @@ import ( ) type FeeLine struct { - LedgerAccountRef string `json:"ledgerAccountRef,omitempty"` - Amount *model.Money `json:"amount,omitempty"` - LineType string `json:"lineType,omitempty"` - Side string `json:"side,omitempty"` - Meta map[string]string `json:"meta,omitempty"` + LedgerAccountRef string `json:"ledgerAccountRef,omitempty"` + Amount *paymenttypes.Money `json:"amount,omitempty"` + LineType string `json:"lineType,omitempty"` + Side string `json:"side,omitempty"` + Meta map[string]string `json:"meta,omitempty"` } type FxQuote struct { - QuoteRef string `json:"quoteRef,omitempty"` - BaseCurrency string `json:"baseCurrency,omitempty"` - QuoteCurrency string `json:"quoteCurrency,omitempty"` - Side string `json:"side,omitempty"` - Price string `json:"price,omitempty"` - BaseAmount *model.Money `json:"baseAmount,omitempty"` - QuoteAmount *model.Money `json:"quoteAmount,omitempty"` - ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"` - Provider string `json:"provider,omitempty"` - RateRef string `json:"rateRef,omitempty"` - Firm bool `json:"firm,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` + BaseCurrency string `json:"baseCurrency,omitempty"` + QuoteCurrency string `json:"quoteCurrency,omitempty"` + Side string `json:"side,omitempty"` + Price string `json:"price,omitempty"` + BaseAmount *paymenttypes.Money `json:"baseAmount,omitempty"` + QuoteAmount *paymenttypes.Money `json:"quoteAmount,omitempty"` + ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"` + Provider string `json:"provider,omitempty"` + RateRef string `json:"rateRef,omitempty"` + Firm bool `json:"firm,omitempty"` } type PaymentQuote struct { - QuoteRef string `json:"quoteRef,omitempty"` - DebitAmount *model.Money `json:"debitAmount,omitempty"` - ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"` - ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"` - FeeLines []FeeLine `json:"feeLines,omitempty"` - FxQuote *FxQuote `json:"fxQuote,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` + DebitAmount *paymenttypes.Money `json:"debitAmount,omitempty"` + ExpectedSettlementAmount *paymenttypes.Money `json:"expectedSettlementAmount,omitempty"` + ExpectedFeeTotal *paymenttypes.Money `json:"expectedFeeTotal,omitempty"` + FeeLines []FeeLine `json:"feeLines,omitempty"` + FxQuote *FxQuote `json:"fxQuote,omitempty"` } type PaymentQuoteAggregate struct { - DebitAmounts []*model.Money `json:"debitAmounts,omitempty"` - ExpectedSettlementAmounts []*model.Money `json:"expectedSettlementAmounts,omitempty"` - ExpectedFeeTotals []*model.Money `json:"expectedFeeTotals,omitempty"` + DebitAmounts []*paymenttypes.Money `json:"debitAmounts,omitempty"` + ExpectedSettlementAmounts []*paymenttypes.Money `json:"expectedSettlementAmounts,omitempty"` + ExpectedFeeTotals []*paymenttypes.Money `json:"expectedFeeTotals,omitempty"` } type PaymentQuotes struct { diff --git a/api/server/interface/api/sresponse/wallet.go b/api/server/interface/api/sresponse/wallet.go index 2470ea64..f4d3aba3 100644 --- a/api/server/interface/api/sresponse/wallet.go +++ b/api/server/interface/api/sresponse/wallet.go @@ -8,7 +8,7 @@ import ( "github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/mlogger" - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" @@ -43,10 +43,10 @@ type walletsResponse struct { } type walletBalance struct { - Available *model.Money `json:"available,omitempty"` - PendingInbound *model.Money `json:"pendingInbound,omitempty"` - PendingOutbound *model.Money `json:"pendingOutbound,omitempty"` - CalculatedAt string `json:"calculatedAt,omitempty"` + Available *paymenttypes.Money `json:"available,omitempty"` + PendingInbound *paymenttypes.Money `json:"pendingInbound,omitempty"` + PendingOutbound *paymenttypes.Money `json:"pendingOutbound,omitempty"` + CalculatedAt string `json:"calculatedAt,omitempty"` } type walletBalanceResponse struct { @@ -281,11 +281,11 @@ func connectorBalanceToWalletBalance(b *connectorv1.Balance) walletBalance { } } -func connectorMoneyToModel(m *moneyv1.Money) *model.Money { +func connectorMoneyToModel(m *moneyv1.Money) *paymenttypes.Money { if m == nil { return nil } - return &model.Money{ + return &paymenttypes.Money{ Amount: m.GetAmount(), Currency: m.GetCurrency(), } diff --git a/api/server/internal/server/ledgerapiimp/create.go b/api/server/internal/server/ledgerapiimp/create.go index 30f60c35..876a72e0 100644 --- a/api/server/internal/server/ledgerapiimp/create.go +++ b/api/server/internal/server/ledgerapiimp/create.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/mservice" describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" @@ -132,7 +133,7 @@ func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.Acco return parsed, nil } -func mapLedgerAccountRole(role model.AccountRole) (ledgerv1.AccountRole, error) { +func mapLedgerAccountRole(role account_role.AccountRole) (ledgerv1.AccountRole, error) { raw := strings.TrimSpace(string(role)) if ledgerconv.IsAccountRoleUnspecified(raw) { return ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, nil diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index f0e67183..16a17513 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -3,8 +3,9 @@ package paymentapiimp import ( "strings" + "github.com/google/uuid" "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" @@ -46,6 +47,7 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn } return &orchestratorv1.PaymentIntent{ + Ref: uuid.New().String(), Kind: kind, Source: source, Destination: destination, @@ -225,7 +227,7 @@ func mapAsset(asset *srequest.Asset) (*chainv1.Asset, error) { }, nil } -func mapMoney(m *model.Money) *moneyv1.Money { +func mapMoney(m *paymenttypes.Money) *moneyv1.Money { if m == nil { return nil } diff --git a/ci/prod/.env.runtime b/ci/prod/.env.runtime index b3233a1e..6a3763f2 100644 --- a/ci/prod/.env.runtime +++ b/ci/prod/.env.runtime @@ -76,6 +76,14 @@ MNTX_GATEWAY_METRICS_PORT=9405 MNTX_GATEWAY_HTTP_PORT=8084 MONETIX_BASE_URL=https://api.txflux.com +# Billing fees Mongo settings +MNTX_GATEWAY_MONGO_HOST=sendico_db1 +MNTX_GATEWAY_MONGO_PORT=27017 +MNTX_GATEWAY_MONGO_DATABASE=mntx_gateway +MNTX_GATEWAY_MONGO_AUTH_SOURCE=admin +MNTX_GATEWAY_MONGO_REPLICA_SET=sendico-rs + + # FX oracle stack FX_ORACLE_DIR=fx_oracle FX_ORACLE_COMPOSE_PROJECT=sendico-fx-oracle @@ -104,7 +112,6 @@ DOCUMENTS_MONGO_DATABASE=billing_fees DOCUMENTS_MONGO_AUTH_SOURCE=admin DOCUMENTS_MONGO_REPLICA_SET=sendico-rs - # Billing fees Mongo settings FEES_MONGO_HOST=sendico_db1 FEES_MONGO_PORT=27017 diff --git a/ci/prod/compose/mntx_gateway.yml b/ci/prod/compose/mntx_gateway.yml index 6237ad61..1c9feec6 100644 --- a/ci/prod/compose/mntx_gateway.yml +++ b/ci/prod/compose/mntx_gateway.yml @@ -18,6 +18,13 @@ services: image: ${REGISTRY_URL}/gateway/mntx:${APP_V} pull_policy: always environment: + MNTX_GATEWAY_MONGO_HOST: ${MNTX_GATEWAY_MONGO_HOST} + MNTX_GATEWAY_MONGO_PORT: ${MNTX_GATEWAY_MONGO_PORT} + MNTX_GATEWAY_MONGO_DATABASE: ${MNTX_GATEWAY_MONGO_DATABASE} + MNTX_GATEWAY_MONGO_USER: ${MNTX_GATEWAY_MONGO_USER} + MNTX_GATEWAY_MONGO_PASSWORD: ${MNTX_GATEWAY_MONGO_PASSWORD} + MNTX_GATEWAY_MONGO_AUTH_SOURCE: ${MNTX_GATEWAY_MONGO_AUTH_SOURCE} + MNTX_GATEWAY_MONGO_REPLICA_SET: ${MNTX_GATEWAY_MONGO_REPLICA_SET} NATS_URL: ${NATS_URL} NATS_HOST: ${NATS_HOST} NATS_PORT: ${NATS_PORT} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f0ea5c21..c01cdf40 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -654,6 +654,13 @@ services: networks: - sendico-dev environment: + MNTX_GATEWAY_MONGO_HOST: dev-mongo-1 + MNTX_GATEWAY_MONGO_PORT: 27017 + MNTX_GATEWAY_MONGO_DATABASE: mntx_gateway + MNTX_GATEWAY_MONGO_USER: ${MONGO_USER} + MNTX_GATEWAY_MONGO_PASSWORD: ${MONGO_PASSWORD} + MNTX_GATEWAY_MONGO_AUTH_SOURCE: admin + MNTX_GATEWAY_MONGO_REPLICA_SET: dev-rs NATS_HOST: dev-nats NATS_PORT: 4222 NATS_USER: ${NATS_USER}