19 Commits

Author SHA1 Message Date
Arseni
83e3af9a42 Fixes for PostHog 2025-12-11 17:41:25 +03:00
97f71d125e Merge pull request 'removed deprecation warnings' (#71) from deprecation-70 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #71
2025-12-11 10:23:12 +00:00
Stephan D
8db2f3926c deprecation fixed 2025-12-11 11:22:51 +01:00
Stephan D
2b68b59eca removed deprecation warnings 2025-12-11 11:11:54 +01:00
d07e64fc4f Merge pull request 'fix fx/oracle compilation' (#68) from bug-66 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #68
2025-12-11 09:36:50 +00:00
Stephan D
8e40e6247b fix fx/oracle compilation 2025-12-11 10:36:31 +01:00
779cb0ead9 Merge pull request 'fix' (#65) from bug-64 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #65
2025-12-11 00:45:07 +00:00
Stephan D
2e0057f839 fix 2025-12-11 01:44:40 +01:00
25080ae168 Merge pull request 'fix' (#63) from bug-62 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #63
2025-12-11 00:31:19 +00:00
Stephan D
e6b001dc61 fix 2025-12-11 01:30:28 +01:00
97d1470515 Merge pull request '+ quotation provider' (#60) from quote-front-59 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #60
2025-12-11 00:13:35 +00:00
Stephan D
a4481fb63d + quotation provider 2025-12-11 01:13:13 +01:00
bdf766075e Merge pull request 'payment rails' (#58) from payment-service-52 into main
Some checks failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
Reviewed-on: #58
2025-12-10 17:50:38 +00:00
Stephan D
47899e25d4 payment rails 2025-12-10 18:40:55 +01:00
4ec934c96b Merge pull request 'fixed CORS wildcard' (#55) from CORS-#54 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #55
2025-12-09 19:00:01 +00:00
Stephan D
19df740550 fixed CORS wildcard 2025-12-09 19:59:33 +01:00
1079ad7d0a Merge pull request 'Organizations now load only once' (#38) from SEND002 into main
Some checks failed
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #38
2025-12-09 18:53:13 +00:00
Arseni
660f689a7a Current org now sets after list gets to the state of the provider 2025-12-09 16:15:36 +03:00
Arseni
8115abb569 Organizations now load only once 2025-12-08 19:10:33 +03:00
146 changed files with 5323 additions and 1211 deletions

3
.gitignore vendored
View File

@@ -8,4 +8,5 @@ devtools_options.yaml
untranslated.txt
generate_protos.sh
update_dep.sh
.vscode/
.vscode/
.gocache/

View File

@@ -8,9 +8,9 @@ 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"
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"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
)
@@ -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: model.Money{
BaseAmount: smodel.Money{
Currency: qc.pair.Pair.Base,
Amount: formatRat(qc.baseRounded, qc.baseScale),
},
QuoteAmount: model.Money{
QuoteAmount: smodel.Money{
Currency: qc.pair.Pair.Quote,
Amount: formatRat(qc.quoteRounded, qc.quoteScale),
},
@@ -170,10 +170,13 @@ func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
}
trace := meta.GetTrace()
qm := &model.QuoteMeta{
RequestRef: deriveRequestRef(meta, trace),
TenantRef: meta.GetTenantRef(),
TraceRef: deriveTraceRef(meta, trace),
IdempotencyKey: deriveIdempotencyKey(meta, trace),
TenantRef: meta.GetTenantRef(),
}
if trace != nil {
qm.RequestRef = trace.GetRequestRef()
qm.TraceRef = trace.GetTraceRef()
qm.IdempotencyKey = trace.GetIdempotencyKey()
}
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
if objID, err := primitive.ObjectIDFromHex(org); err == nil {
@@ -200,24 +203,3 @@ func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
}
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
}
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetRequestRef() != "" {
return trace.GetRequestRef()
}
return meta.GetRequestRef()
}
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetTraceRef() != "" {
return trace.GetTraceRef()
}
return meta.GetTraceRef()
}
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetIdempotencyKey() != "" {
return trace.GetIdempotencyKey()
}
return meta.GetIdempotencyKey()
}

View File

@@ -3,7 +3,6 @@ package oracle
import (
"math/big"
"strings"
"time"
"github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/decimal"
@@ -61,7 +60,3 @@ func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
return ratFromString(priceStr)
}
func timeFromUnixMilli(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}

View File

@@ -9,6 +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"
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"
@@ -381,8 +382,8 @@ func TestServiceValidateQuote(t *testing.T) {
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: model.QuoteSideBuyBaseSellQuote,
Price: "1.10",
BaseAmount: model.Money{Currency: "USD", Amount: "100"},
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"},
BaseAmount: smodel.Money{Currency: "USD", Amount: "100"},
QuoteAmount: smodel.Money{Currency: "EUR", Amount: "110"},
ExpiresAtUnixMs: now.UnixMilli(),
Status: model.QuoteStatusIssued,
}, nil

View File

@@ -4,9 +4,9 @@ import (
"strings"
"github.com/tech/sendico/fx/storage/model"
smodel "github.com/tech/sendico/pkg/model"
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"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
)
@@ -15,18 +15,11 @@ func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
if meta == nil {
return resp
}
resp.RequestRef = meta.GetRequestRef()
resp.TraceRef = meta.GetTraceRef()
trace := meta.GetTrace()
if trace == nil {
trace = &tracev1.TraceContext{
RequestRef: meta.GetRequestRef(),
IdempotencyKey: meta.GetIdempotencyKey(),
TraceRef: meta.GetTraceRef(),
}
if trace != nil {
resp.Trace = trace
}
resp.Trace = trace
return resp
}
@@ -49,7 +42,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
}
}
func moneyModelToProto(m *model.Money) *moneyv1.Money {
func moneyModelToProto(m *smodel.Money) *moneyv1.Money {
if m == nil {
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
// Quote represents a firm or indicative quote persisted by the oracle.
@@ -16,8 +17,8 @@ type Quote struct {
Pair CurrencyPair `bson:"pair" json:"pair"`
Side QuoteSide `bson:"side" json:"side"`
Price string `bson:"price" json:"price"`
BaseAmount Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"`
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"`

View File

@@ -51,12 +51,6 @@ type CurrencyPair struct {
Quote string `bson:"quote" json:"quote"`
}
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}
// QuoteMeta carries request-scoped metadata associated with a quote.
type QuoteMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -0,0 +1,84 @@
package client
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Client wraps the Monetix gateway gRPC API.
type Client interface {
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
Close() error
}
type gatewayClient struct {
conn *grpc.ClientConn
client mntxv1.MntxGatewayServiceClient
cfg Config
}
// New dials the Monetix gateway.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("mntx: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
dialOpts = append(dialOpts, opts...)
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
}
return &gatewayClient{
conn: conn,
client: mntxv1.NewMntxGatewayServiceClient(conn),
cfg: cfg,
}, nil
}
func (g *gatewayClient) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := g.cfg.CallTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return context.WithTimeout(ctx, timeout)
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardPayout(ctx, req)
}
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardTokenPayout(ctx, req)
}
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.GetCardPayoutStatus(ctx, req)
}

View File

@@ -0,0 +1,19 @@
package client
import "time"
// Config holds Monetix gateway client settings.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 10 * time.Second
}
}

View File

@@ -0,0 +1,37 @@
package client
import (
"context"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
}
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if f.CreateCardPayoutFn != nil {
return f.CreateCardPayoutFn(ctx, req)
}
return &mntxv1.CardPayoutResponse{}, nil
}
func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
if f.CreateCardTokenPayoutFn != nil {
return f.CreateCardTokenPayoutFn(ctx, req)
}
return &mntxv1.CardTokenPayoutResponse{}, nil
}
func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
if f.GetCardPayoutStatusFn != nil {
return f.GetCardPayoutStatusFn(ctx, req)
}
return &mntxv1.GetCardPayoutStatusResponse{}, nil
}
func (f *Fake) Close() error { return nil }

View File

@@ -62,12 +62,6 @@ const (
OutboxStatusFailed OutboxStatus = "failed"
)
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
}
// LedgerMeta carries organization-scoped metadata for ledger entities.
type LedgerMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -56,3 +56,8 @@ oracle:
dial_timeout_seconds: 5
call_timeout_seconds: 3
insecure: true
card_gateways:
monetix:
funding_address: "wallet_funding_monetix"
fee_address: "wallet_fee_monetix"

View File

@@ -8,6 +8,8 @@ replace github.com/tech/sendico/billing/fees => ../../billing/fees
replace github.com/tech/sendico/gateway/chain => ../../gateway/chain
replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
replace github.com/tech/sendico/ledger => ../../ledger
@@ -17,6 +19,7 @@ require (
github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6

View File

@@ -119,8 +119,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=

View File

@@ -7,8 +7,8 @@ import (
"strings"
"time"
chainclient "github.com/tech/sendico/gateway/chain/client"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage"
@@ -41,10 +41,11 @@ type Imp struct {
type config struct {
*grpcapp.Config `yaml:",inline"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"`
Fees clientConfig `yaml:"fees"`
Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
}
type clientConfig struct {
@@ -54,6 +55,11 @@ type clientConfig struct {
InsecureTransport bool `yaml:"insecure"`
}
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
}
func (c clientConfig) address() string {
return strings.TrimSpace(c.Address)
}
@@ -107,7 +113,7 @@ func (i *Imp) Shutdown() {
func (i *Imp) Start() error {
cfg, err := i.loadConfig()
if err != nil {
if err != nil {
return err
}
i.config = cfg
@@ -150,6 +156,9 @@ func (i *Imp) Start() error {
if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient))
}
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
}
return orchestrator.NewService(logger, repo, opts...), nil
}
@@ -296,3 +305,21 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil
}
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
if len(src) == 0 {
return nil
}
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
for key, route := range src {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
}
}
return result
}

View File

@@ -0,0 +1,252 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
const defaultCardGateway = "monetix"
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 {
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
}
key := strings.ToLower(strings.TrimSpace(gateway))
if key == "" {
key = defaultCardGateway
}
route, ok := s.deps.cardRoutes[key]
if !ok {
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
}
if strings.TrimSpace(route.FundingAddress) == "" {
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
}
return route, nil
}
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
source := intent.Source.ManagedWallet
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card funding: source managed wallet is required")
}
if !s.deps.gateway.available() {
s.logger.Warn("card funding aborted: chain gateway unavailable")
return merrors.InvalidArgument("card funding: chain gateway unavailable")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
amount := cloneMoney(intent.Amount)
if amount == nil {
return merrors.InvalidArgument("card funding: amount is required")
}
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
// Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)},
},
Amount: amount,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq)
if err != nil {
s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
feeMoney := quote.GetExpectedFeeTotal()
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(route.FeeAddress) == "" {
return merrors.InvalidArgument("card funding: fee address is required when fee exists")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
}
}
payment.Execution = exec
return nil
}
func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
card := intent.Destination.Card
if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required")
}
amount := cloneMoney(intent.Amount)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: amount is required")
}
amtDec, err := decimalFromMoney(amount)
if err != nil {
return err
}
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
payoutID := payment.PaymentRef
currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata)
var (
state *mntxv1.CardPayoutState
)
if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil {
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil {
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else {
return merrors.InvalidArgument("card payout: either token or pan must be provided")
}
if state == nil {
return merrors.Internal("card payout: missing payout state")
}
recordCardPayoutState(payment, state)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef))
return nil
}
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
if payment == nil || state == nil {
return
}
if payment.CardPayout == nil {
payment.CardPayout = &model.CardPayout{}
}
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
payment.CardPayout.Status = state.GetStatus().String()
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
}
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
}
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
}
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
if payment == nil || payout == nil {
return
}
recordCardPayoutState(payment, payout)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
}
payment.State = mapMntxStatusToState(payout.GetStatus())
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
default:
// leave as-is for pending/unspecified
}
}

View File

@@ -0,0 +1,83 @@
package orchestrator
import (
"context"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type paymentEngine interface {
EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
Repository() storage.Repository
}
type defaultPaymentEngine struct {
svc *Service
}
func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
return e.svc.ensureRepository(ctx)
}
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
return e.svc.resolvePaymentQuote(ctx, in)
}
func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
return e.svc.executePayment(ctx, store, payment, quote)
}
func (e defaultPaymentEngine) Repository() storage.Repository {
return e.svc.storage
}
type paymentCommandFactory struct {
engine paymentEngine
logger mlogger.Logger
}
func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory {
return &paymentCommandFactory{
engine: engine,
logger: logger.Named("commands"),
}
}
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
return &quotePaymentCommand{
engine: f.engine,
logger: f.logger.Named("quote_payment"),
}
}
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
return &initiatePaymentCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payment"),
}
}
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
return &cancelPaymentCommand{
engine: f.engine,
logger: f.logger.Named("cancel_payment"),
}
}
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
return &initiateConversionCommand{
engine: f.engine,
logger: f.logger.Named("initiate_conversion"),
}
}

View File

@@ -67,6 +67,19 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
}
return result
}
if card := src.GetCard(); card != nil {
result.Type = model.EndpointTypeCard
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(card.GetPan()),
Token: strings.TrimSpace(card.GetToken()),
Cardholder: strings.TrimSpace(card.GetCardholderName()),
ExpMonth: card.GetExpMonth(),
ExpYear: card.GetExpYear(),
Country: strings.TrimSpace(card.GetCountry()),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
}
return result
}
return result
}
@@ -96,7 +109,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
FeeRules: cloneFeeRules(src.GetFeeRules()),
FXQuote: cloneFXQuote(src.GetFxQuote()),
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
}
}
@@ -116,6 +128,18 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
Execution: protoExecutionFromModel(src.Execution),
Metadata: cloneMetadata(src.Metadata),
}
if src.CardPayout != nil {
payment.CardPayout = &orchestratorv1.CardPayout{
PayoutRef: src.CardPayout.PayoutRef,
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
Status: src.CardPayout.Status,
FailureReason: src.CardPayout.FailureReason,
CardCountry: src.CardPayout.CardCountry,
MaskedPan: src.CardPayout.MaskedPan,
ProviderCode: src.CardPayout.ProviderCode,
GatewayReference: src.CardPayout.GatewayReference,
}
}
if src.CreatedAt.IsZero() {
payment.CreatedAt = timestamppb.New(time.Now().UTC())
} else {
@@ -176,6 +200,23 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
},
}
}
case model.EndpointTypeCard:
if src.Card != nil {
card := &orchestratorv1.CardEndpoint{
CardholderName: src.Card.Cardholder,
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: src.Card.Country,
MaskedPan: src.Card.MaskedPan,
}
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(src.Card.Token); token != "" {
card.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card}
}
default:
// leave unspecified
}
@@ -205,6 +246,8 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
CreditEntryRef: src.CreditEntryRef,
FxEntryRef: src.FXEntryRef,
ChainTransferRef: src.ChainTransferRef,
CardPayoutRef: src.CardPayoutRef,
FeeTransferRef: src.FeeTransferRef,
}
}
@@ -220,7 +263,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
FeeRules: cloneFeeRules(src.FeeRules),
FxQuote: cloneFXQuote(src.FXQuote),
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
FeeQuoteToken: src.FeeQuoteToken,
QuoteRef: strings.TrimSpace(src.QuoteRef),
}
}
@@ -402,6 +444,18 @@ func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) e
dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution())
if src.GetCardPayout() != nil {
dst.CardPayout = &model.CardPayout{
PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()),
ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()),
Status: strings.TrimSpace(src.GetCardPayout().GetStatus()),
FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()),
CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()),
MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()),
ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()),
GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()),
}
}
return nil
}
@@ -414,6 +468,8 @@ func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()),
FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()),
}
}

View File

@@ -0,0 +1,69 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestEndpointFromProtoCard(t *testing.T) {
protoEndpoint := &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
CardholderName: " Jane Doe ",
ExpMonth: 12,
ExpYear: 2030,
Country: " US ",
MaskedPan: " ****1111 ",
},
},
Metadata: map[string]string{"k": "v"},
}
modelEndpoint := endpointFromProto(protoEndpoint)
if modelEndpoint.Type != model.EndpointTypeCard {
t.Fatalf("expected card type, got %s", modelEndpoint.Type)
}
if modelEndpoint.Card == nil {
t.Fatalf("card payload missing")
}
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
}
if modelEndpoint.Metadata["k"] != "v" {
t.Fatalf("metadata not preserved")
}
}
func TestProtoEndpointFromModelCard(t *testing.T) {
modelEndpoint := model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Token: "tok_123",
Cardholder: "Jane",
ExpMonth: 1,
ExpYear: 2028,
Country: "GB",
MaskedPan: "****1234",
},
Metadata: map[string]string{"k": "v"},
}
protoEndpoint := protoEndpointFromModel(modelEndpoint)
card := protoEndpoint.GetCard()
if card == nil {
t.Fatalf("card payload missing in proto")
}
token, ok := card.Card.(*orchestratorv1.CardEndpoint_Token)
if !ok || token.Token != "tok_123" {
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
}
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
t.Fatalf("card details mismatch: %#v", card)
}
if protoEndpoint.GetMetadata()["k"] != "v" {
t.Fatalf("metadata not preserved in proto endpoint")
}
}

View File

@@ -0,0 +1,274 @@
package orchestrator
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type quotePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := requireNonNilIntent(req.GetIntent()); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
}
type initiatePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef,
OrgID: orgID,
Meta: req.GetMeta(),
Intent: intent,
QuoteRef: req.GetQuoteRef(),
IdempotencyKey: req.GetIdempotencyKey(),
})
if err != nil {
if qerr, ok := err.(quoteResolutionError); ok {
switch qerr.code {
case "quote_not_found":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_expired":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_intent_mismatch":
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
default:
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
}
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
type cancelPaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
type initiateConversionCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}

View File

@@ -0,0 +1,139 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentEventHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler {
return &paymentEventHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
}
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
}
payout := req.GetEvent().GetPayout()
paymentRef := strings.TrimSpace(payout.GetPayoutId())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyCardPayoutUpdate(payment, payout)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
Payment: toProtoPayment(payment),
})
}

View File

@@ -0,0 +1,80 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentQueryHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
return &paymentQueryHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
return gsresponse.Success(resp)
}

View File

@@ -4,9 +4,11 @@ import (
"context"
"time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/mservice"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
@@ -51,10 +53,17 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
if intent == nil {
return false
}
dest := intent.GetDestination()
if dest == nil {
return false
}
if dest.GetCard() != nil {
return false
}
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
return true
}
if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil {
if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil {
return true
}
return false
@@ -69,3 +78,16 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
}
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
}
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
return model.PaymentStateSettled
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PaymentStateFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
return model.PaymentStateSubmitted
default:
return model.PaymentStateUnspecified
}
}

View File

@@ -0,0 +1,51 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{},
},
},
}
if shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation to be skipped for card payouts")
}
}
func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"},
},
},
}
if !shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation when destination is managed wallet")
}
}
func TestMapMntxStatusToState(t *testing.T) {
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != 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 {
t.Fatalf("pending should map to submitted")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified {
t.Fatalf("unspecified should map to unspecified")
}
}

View File

@@ -1,10 +1,12 @@
package orchestrator
import (
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client"
clockpkg "github.com/tech/sendico/pkg/clock"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
@@ -46,10 +48,24 @@ func (o oracleDependency) available() bool {
return o.client != nil
}
type mntxDependency struct {
client mntxclient.Client
}
func (m mntxDependency) available() bool {
return m.client != nil
}
// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses).
type CardGatewayRoute struct {
FundingAddress string
FeeAddress string
}
// WithFeeEngine wires the fee engine client.
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
return func(s *Service) {
s.fees = feesDependency{
s.deps.fees = feesDependency{
client: client,
timeout: timeout,
}
@@ -59,21 +75,41 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
// WithLedgerClient wires the ledger client.
func WithLedgerClient(client ledgerclient.Client) Option {
return func(s *Service) {
s.ledger = ledgerDependency{client: client}
s.deps.ledger = ledgerDependency{client: client}
}
}
// WithChainGatewayClient wires the chain gateway client.
func WithChainGatewayClient(client chainclient.Client) Option {
return func(s *Service) {
s.gateway = gatewayDependency{client: client}
s.deps.gateway = gatewayDependency{client: client}
}
}
// WithOracleClient wires the FX oracle client.
func WithOracleClient(client oracleclient.Client) Option {
return func(s *Service) {
s.oracle = oracleDependency{client: client}
s.deps.oracle = oracleDependency{client: client}
}
}
// WithMntxGateway wires the Monetix gateway client.
func WithMntxGateway(client mntxclient.Client) Option {
return func(s *Service) {
s.deps.mntx = mntxDependency{client: client}
}
}
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes))
for k, v := range routes {
s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v
}
}
}

View File

@@ -3,215 +3,30 @@ package orchestrator
import (
"context"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/tech/sendico/pkg/mlogger"
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"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
intent := req.GetIntent()
amount := intent.GetAmount()
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: cloneFeeLines(feeQuote.GetLines()),
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
type paymentExecutor struct {
deps *serviceDependencies
logger mlogger.Logger
svc *Service
}
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.fees.available() {
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.fees.timeout)
defer cancel()
resp, err := s.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Error("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor {
return &paymentExecutor{deps: deps, logger: logger, svc: svc}
}
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
if !s.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Error("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Error("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
return quoteToProto(quote), nil
}
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if store == nil {
return errStorageUnavailable
}
@@ -219,6 +34,7 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
charges := ledgerChargesFromFeeLines(quote.GetFeeLines())
ledgerNeeded := requiresLedger(payment)
chainNeeded := requiresChain(payment)
cardNeeded := payment.Intent.Destination.Type == model.EndpointTypeCard
exec := payment.Execution
if exec == nil {
@@ -226,25 +42,26 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
}
if ledgerNeeded {
if !s.ledger.available() {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable"))
if !p.deps.ledger.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable"))
}
if err := s.performLedgerOperation(ctx, payment, quote, charges); err != nil {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err)
if err := p.performLedgerOperation(ctx, payment, quote, charges); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err)
}
payment.State = model.PaymentStateFundsReserved
if err := s.persistPayment(ctx, store, payment); err != nil {
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("ledger reservation completed", zap.String("payment_ref", payment.PaymentRef))
}
if chainNeeded {
if !s.gateway.available() {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable"))
if !p.deps.gateway.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable"))
}
resp, err := s.submitChainTransfer(ctx, payment, quote)
resp, err := p.submitChainTransfer(ctx, payment, quote)
if err != nil {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
}
exec = payment.Execution
if exec == nil {
@@ -255,17 +72,42 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
}
payment.Execution = exec
payment.State = model.PaymentStateSubmitted
if err := s.persistPayment(ctx, store, payment); err != nil {
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("chain transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
if !cardNeeded {
return nil
}
}
if cardNeeded {
if !p.deps.mntx.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "card_gateway_unavailable", merrors.Internal("card_gateway_unavailable"))
}
if err := p.svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
}
if err := p.svc.submitCardPayout(ctx, payment); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
payment.State = model.PaymentStateSubmitted
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("card_payout_ref", payment.Execution.CardPayoutRef))
return nil
}
payment.State = model.PaymentStateSettled
return s.persistPayment(ctx, store, payment)
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("payment settled without chain", zap.String("payment_ref", payment.PaymentRef))
return nil
}
func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error {
func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error {
intent := payment.Intent
if payment.OrganizationRef == primitive.NilObjectID {
return merrors.InvalidArgument("ledger: organization_ref is required")
@@ -285,7 +127,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
switch intent.Kind {
case model.PaymentKindFXConversion:
if err := s.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil {
if err := p.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil {
return err
}
case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified:
@@ -303,7 +145,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
Charges: charges,
Metadata: metadata,
}
resp, err := s.ledger.client.TransferInternal(ctx, req)
resp, err := p.deps.ledger.client.TransferInternal(ctx, req)
if err != nil {
return err
}
@@ -316,7 +158,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
return nil
}
func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
intent := payment.Intent
source := intent.Source.Ledger
destination := intent.Destination.Ledger
@@ -354,7 +196,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
Charges: charges,
Metadata: metadata,
}
resp, err := s.ledger.client.ApplyFXWithCharges(ctx, req)
resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req)
if err != nil {
return err
}
@@ -363,7 +205,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
return nil
}
func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) {
func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) {
intent := payment.Intent
source := intent.Source.ManagedWallet
destination := intent.Destination
@@ -389,23 +231,23 @@ func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Paymen
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
return s.gateway.client.SubmitTransfer(ctx, req)
return p.deps.gateway.client.SubmitTransfer(ctx, req)
}
func (s *Service) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if store == nil {
return errStorageUnavailable
}
return store.Update(ctx, payment)
}
func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error {
func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error {
payment.State = model.PaymentStateFailed
payment.FailureCode = code
payment.FailureReason = strings.TrimSpace(reason)
if store != nil {
if updateErr := store.Update(ctx, payment); updateErr != nil {
s.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
}
}
if err != nil {
@@ -414,6 +256,21 @@ func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore,
return merrors.Internal(reason)
}
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
source := intent.Source.Ledger
destination := intent.Destination.Ledger
@@ -432,21 +289,6 @@ func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
return strings.TrimSpace(source.LedgerAccountRef), to, nil
}
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func requiresLedger(payment *model.Payment) bool {
if payment == nil {
return false

View File

@@ -0,0 +1,209 @@
package orchestrator
import (
"context"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
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"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
intent := req.GetIntent()
amount := intent.GetAmount()
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef))
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("network fee estimated", zap.String("org_ref", orgRef))
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: cloneFeeLines(feeQuote.GetLines()),
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
}
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.deps.fees.available() {
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
defer cancel()
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Warn("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
}
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
if !s.deps.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.deps.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.deps.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Warn("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
return quoteToProto(quote), nil
}

View File

@@ -19,19 +19,21 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
svc := &Service{
logger: zap.NewNop(),
clock: testClock{now: time.Now()},
oracle: oracleDependency{
client: &oracleclient.Fake{
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
captured = params
return &oracleclient.Quote{
QuoteRef: "q",
Pair: params.Pair,
Side: params.Side,
Price: "1.1",
BaseAmount: params.BaseAmount,
QuoteAmount: params.QuoteAmount,
ExpiresAt: time.Now(),
}, nil
deps: serviceDependencies{
oracle: oracleDependency{
client: &oracleclient.Fake{
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
captured = params
return &oracleclient.Quote{
QuoteRef: "q",
Pair: params.Pair,
Side: params.Side,
Price: "1.1",
BaseAmount: params.BaseAmount,
QuoteAmount: params.QuoteAmount,
ExpiresAt: time.Now(),
}, nil
},
},
},
},

View File

@@ -2,21 +2,14 @@ package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
type serviceError string
@@ -40,14 +33,32 @@ type Service struct {
storage storage.Repository
clock clockpkg.Clock
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
deps serviceDependencies
h handlerSet
comp componentSet
orchestratorv1.UnimplementedPaymentOrchestratorServer
}
type serviceDependencies struct {
fees feesDependency
ledger ledgerDependency
gateway gatewayDependency
oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
}
type handlerSet struct {
commands *paymentCommandFactory
queries *paymentQueryHandler
events *paymentEventHandler
}
type componentSet struct {
executor *paymentExecutor
}
// NewService constructs a payment orchestrator service.
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
svc := &Service{
@@ -68,9 +79,30 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
svc.clock = clockpkg.NewSystem()
}
engine := defaultPaymentEngine{svc: svc}
svc.h.commands = newPaymentCommandFactory(engine, svc.logger)
svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries"))
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"))
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
return svc
}
func (s *Service) ensureHandlers() {
if s.h.commands == nil {
s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger)
}
if s.h.queries == nil {
s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries"))
}
if s.h.events == nil {
s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"))
}
if s.comp.executor == nil {
s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s)
}
}
// Register attaches the service to the supplied gRPC router.
func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) {
@@ -80,474 +112,59 @@ func (s *Service) Register(router routers.GRPC) error {
// QuotePayment aggregates downstream quotes.
func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
return executeUnary(ctx, s, "QuotePayment", s.quotePaymentHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
}
// InitiatePayment captures a payment intent and reserves funds orchestration.
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
return executeUnary(ctx, s, "InitiatePayment", s.initiatePaymentHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req)
}
// CancelPayment attempts to cancel an in-flight payment.
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
return executeUnary(ctx, s, "CancelPayment", s.cancelPaymentHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req)
}
// GetPayment returns a stored payment record.
func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
return executeUnary(ctx, s, "GetPayment", s.getPaymentHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req)
}
// ListPayments lists stored payment records.
func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
return executeUnary(ctx, s, "ListPayments", s.listPaymentsHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req)
}
// InitiateConversion orchestrates standalone FX conversions.
func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
return executeUnary(ctx, s, "InitiateConversion", s.initiateConversionHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req)
}
// ProcessTransferUpdate reconciles chain events back into payment state.
func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
return executeUnary(ctx, s, "ProcessTransferUpdate", s.processTransferUpdateHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req)
}
// ProcessDepositObserved reconciles deposit events to ledger.
func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
return executeUnary(ctx, s, "ProcessDepositObserved", s.processDepositObservedHandler, req)
s.ensureHandlers()
return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req)
}
func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgObjectID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
// ProcessCardPayoutUpdate reconciles card payout events back into payment state.
func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) {
s.ensureHandlers()
return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req)
}
func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey)
if err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(existing),
})
}
if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
quote := strings.TrimSpace(req.GetFeeQuoteToken())
var quoteSnapshot *orchestratorv1.PaymentQuote
if quoteRef != "" {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef)
if err != nil {
if err == storage.ErrQuoteNotFound {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired"))
}
if !proto.Equal(protoIntentFromModel(record.Intent), intent) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent"))
}
quoteSnapshot = modelQuoteToProto(record.Quote)
if quoteSnapshot == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteSnapshot.QuoteRef = quoteRef
} else if quote == "" {
quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: req.GetIntent(),
PreviewOnly: false,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
} else {
quoteSnapshot = &orchestratorv1.PaymentQuote{FeeQuoteToken: quote}
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quoteSnapshot)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
if err := s.executePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
func (s *Service) cancelPaymentHandler(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) getPaymentHandler(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (s *Service) listPaymentsHandler(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
return gsresponse.Success(resp)
}
func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
if existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey); err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intentProto)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if err := s.executePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}
func (s *Service) processTransferUpdateHandler(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) processDepositObservedHandler(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
s.ensureHandlers()
return s.comp.executor.executePayment(ctx, store, payment, quote)
}

View File

@@ -0,0 +1,165 @@
package orchestrator
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/proto"
)
func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, primitive.ObjectID, error) {
if meta == nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("meta is required")
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref is required")
}
orgID, err := primitive.ObjectIDFromHex(orgRef)
if err != nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID")
}
return orgRef, orgID, nil
}
func requireIdempotencyKey(k string) (string, error) {
key := strings.TrimSpace(k)
if key == "" {
return "", merrors.InvalidArgument("idempotency_key is required")
}
return key, nil
}
func requirePaymentRef(ref string) (string, error) {
val := strings.TrimSpace(ref)
if val == "" {
return "", merrors.InvalidArgument("payment_ref is required")
}
return val, nil
}
func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error {
if intent == nil {
return merrors.InvalidArgument("intent is required")
}
if intent.GetAmount() == nil {
return merrors.InvalidArgument("intent.amount is required")
}
return nil
}
func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Payments()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Quotes()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID primitive.ObjectID, key string) (*model.Payment, error) {
payment, err := store.GetByIdempotencyKey(ctx, orgID, key)
if err != nil {
return nil, err
}
return payment, nil
}
type quoteResolutionInput struct {
OrgRef string
OrgID primitive.ObjectID
Meta *orchestratorv1.RequestMeta
Intent *orchestratorv1.PaymentIntent
QuoteRef string
IdempotencyKey string
}
type quoteResolutionError struct {
code string
err error
}
func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
quotesStore, err := ensureQuotesStore(s.storage)
if err != nil {
return nil, err
}
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
}
return nil, err
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
}
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
}
quote := modelQuoteToProto(record.Quote)
if quote == nil {
return nil, merrors.InvalidArgument("stored quote is empty")
}
quote.QuoteRef = ref
return quote, nil
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta,
IdempotencyKey: in.IdempotencyKey,
Intent: in.Intent,
PreviewOnly: false,
}
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil {
return nil, err
}
return quote, nil
}
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(metadata)
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
return entity
}
func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] {
if errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.NotFound[T](logger, svc, err)
}
return gsresponse.Auto[T](logger, svc, err)
}

View File

@@ -0,0 +1,232 @@
package orchestrator
import (
"context"
"testing"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestValidateMetaAndOrgRef(t *testing.T) {
org := primitive.NewObjectID()
meta := &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}
ref, id, err := validateMetaAndOrgRef(meta)
if err != nil {
t.Fatalf("expected nil error: %v", err)
}
if ref != org.Hex() || id != org {
t.Fatalf("unexpected org parsing: %s %s", ref, id.Hex())
}
if _, _, err := validateMetaAndOrgRef(nil); err == nil {
t.Fatalf("expected error on nil meta")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: ""}); err == nil {
t.Fatalf("expected error on empty orgRef")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: "bad"}); err == nil {
t.Fatalf("expected error on invalid orgRef")
}
}
func TestRequireIdempotencyKey(t *testing.T) {
if _, err := requireIdempotencyKey(" "); err == nil {
t.Fatalf("expected error for empty key")
}
val, err := requireIdempotencyKey(" key ")
if err != nil || val != "key" {
t.Fatalf("unexpected result %s err %v", val, err)
}
}
func TestNewPayment(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
}
quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"}
p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote)
if p.PaymentRef == "" || p.IdempotencyKey != "idem" || p.State != model.PaymentStateAccepted {
t.Fatalf("unexpected payment fields: %+v", p)
}
if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" {
t.Fatalf("intent not copied")
}
if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" {
t.Fatalf("quote not copied")
}
}
func TestResolvePaymentQuote_NotFound(t *testing.T) {
org := primitive.NewObjectID()
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{}},
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"}},
QuoteRef: "missing",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" {
t.Fatalf("expected quote_not_found, got %v", err)
}
}
func TestResolvePaymentQuote_Expired(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
ExpiresAt: time.Now().Add(-time.Minute),
}
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
QuoteRef: "q1",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_expired" {
t.Fatalf("expected quote_expired, got %v", err)
}
}
func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
svc := NewService(logger, stubRepo{
payments: store,
}, WithClock(clockpkg.NewSystem()))
svc.ensureHandlers()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}
req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
IdempotencyKey: "k1",
}
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("first call failed: %v", err)
}
resp2, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("second call failed: %v", err)
}
if resp == nil || resp2 == nil || resp.Payment.GetPaymentRef() != resp2.Payment.GetPaymentRef() {
t.Fatalf("idempotent call returned different payments")
}
}
// --- test doubles ---
type stubRepo struct {
payments storage.PaymentsStore
quotes storage.QuotesStore
pingErr error
}
func (s stubRepo) Ping(context.Context) error { return s.pingErr }
func (s stubRepo) Payments() storage.PaymentsStore { return s.payments }
func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes }
type helperPaymentStore struct {
byRef map[string]*model.Payment
byIdem map[string]*model.Payment
byChain map[string]*model.Payment
}
func newHelperPaymentStore() *helperPaymentStore {
return &helperPaymentStore{
byRef: make(map[string]*model.Payment),
byIdem: make(map[string]*model.Payment),
byChain: make(map[string]*model.Payment),
}
}
func (s *helperPaymentStore) Create(_ context.Context, p *model.Payment) error {
if _, ok := s.byRef[p.PaymentRef]; ok {
return storage.ErrDuplicatePayment
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
if p.Execution != nil && p.Execution.ChainTransferRef != "" {
s.byChain[p.Execution.ChainTransferRef] = p
}
return nil
}
func (s *helperPaymentStore) Update(_ context.Context, p *model.Payment) error {
if p == nil {
return storage.ErrPaymentNotFound
}
if _, ok := s.byRef[p.PaymentRef]; !ok {
return storage.ErrPaymentNotFound
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
return nil
}
func (s *helperPaymentStore) GetByPaymentRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byRef[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByIdempotencyKey(_ context.Context, _ primitive.ObjectID, key string) (*model.Payment, error) {
if p, ok := s.byIdem[key]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByChainTransferRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byChain[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) List(_ context.Context, _ *model.PaymentFilter) (*model.PaymentList, error) {
return &model.PaymentList{}, nil
}
type helperQuotesStore struct {
records map[string]*model.PaymentQuoteRecord
}
func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecord) error { return nil }
func (s *helperQuotesStore) GetByRef(_ context.Context, _ primitive.ObjectID, ref string) (*model.PaymentQuoteRecord, error) {
if s.records == nil {
return nil, storage.ErrQuoteNotFound
}
if rec, ok := s.records[ref]; ok {
return rec, nil
}
return nil, storage.ErrQuoteNotFound
}

View File

@@ -32,11 +32,13 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
logger: zap.NewNop(),
clock: testClock{now: time.Now()},
storage: repo,
ledger: ledgerDependency{client: &ledgerclient.Fake{
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
},
}},
deps: serviceDependencies{
ledger: ledgerDependency{client: &ledgerclient.Fake{
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
},
}},
},
}
payment := &model.Payment{
@@ -88,11 +90,13 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
logger: zap.NewNop(),
clock: testClock{now: time.Now()},
storage: repo,
gateway: gatewayDependency{client: &chainclient.Fake{
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
return nil, errors.New("chain failure")
},
}},
deps: serviceDependencies{
gateway: gatewayDependency{client: &chainclient.Fake{
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
return nil, errors.New("chain failure")
},
}},
},
}
payment := &model.Payment{
@@ -146,6 +150,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
clock: testClock{now: time.Now()},
storage: &stubRepository{store: store},
}
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessTransferUpdateRequest{
Event: &chainv1.TransferStatusChangedEvent{
@@ -156,7 +161,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
},
}
reSP, err := gsresponse.Execute(ctx, svc.processTransferUpdateHandler(ctx, req))
reSP, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req))
if err != nil {
t.Fatalf("handler returned error: %v", err)
}
@@ -189,6 +194,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
clock: testClock{now: time.Now()},
storage: &stubRepository{store: store},
}
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessDepositObservedRequest{
Event: &chainv1.WalletDepositObservedEvent{
@@ -197,7 +203,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
},
}
reSP, err := gsresponse.Execute(ctx, svc.processDepositObservedHandler(ctx, req))
reSP, err := gsresponse.Execute(ctx, svc.h.events.processDepositObserved(ctx, req))
if err != nil {
t.Fatalf("handler returned error: %v", err)
}

View File

@@ -57,6 +57,7 @@ const (
EndpointTypeLedger PaymentEndpointType = "ledger"
EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet"
EndpointTypeExternalChain PaymentEndpointType = "external_chain"
EndpointTypeCard PaymentEndpointType = "card"
)
// LedgerEndpoint describes ledger routing.
@@ -78,12 +79,36 @@ type ExternalChainEndpoint struct {
Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
}
// CardEndpoint describes a card payout destination.
type CardEndpoint struct {
Pan string `bson:"pan,omitempty" json:"pan,omitempty"`
Token string `bson:"token,omitempty" json:"token,omitempty"`
Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"`
ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"`
ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
}
// CardPayout stores gateway payout tracking info.
type CardPayout struct {
PayoutRef string `bson:"payoutRef,omitempty" json:"payoutRef,omitempty"`
ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"providerPaymentId,omitempty"`
Status string `bson:"status,omitempty" json:"status,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
CardCountry string `bson:"cardCountry,omitempty" json:"cardCountry,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
ProviderCode string `bson:"providerCode,omitempty" json:"providerCode,omitempty"`
GatewayReference string `bson:"gatewayReference,omitempty" json:"gatewayReference,omitempty"`
}
// PaymentEndpoint is a polymorphic payment destination/source.
type PaymentEndpoint struct {
Type PaymentEndpointType `bson:"type" json:"type"`
Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"`
ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"`
Card *CardEndpoint `bson:"card,omitempty" json:"card,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
}
@@ -118,7 +143,6 @@ type PaymentQuoteSnapshot struct {
FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
}
@@ -128,6 +152,8 @@ type ExecutionRefs struct {
CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"`
FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"`
ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"`
CardPayoutRef string `bson:"cardPayoutRef,omitempty" json:"cardPayoutRef,omitempty"`
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
}
// Payment persists orchestrated payment lifecycle.
@@ -144,6 +170,7 @@ type Payment struct {
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"`
}
// Collection implements storable.Storable.
@@ -223,5 +250,13 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress))
}
}
case EndpointTypeCard:
if ep.Card != nil {
ep.Card.Pan = strings.TrimSpace(ep.Card.Pan)
ep.Card.Token = strings.TrimSpace(ep.Card.Token)
ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder)
ep.Card.Country = strings.TrimSpace(ep.Card.Country)
ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan)
}
}
}

6
api/pkg/model/money.go Normal file
View File

@@ -0,0 +1,6 @@
package model
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}

View File

@@ -19,6 +19,7 @@ const (
PaymentTypeBankAccount
PaymentTypeWallet
PaymentTypeCryptoAddress
PaymentTypeLedger
)
var paymentTypeToString = map[PaymentType]string{
@@ -28,6 +29,7 @@ var paymentTypeToString = map[PaymentType]string{
PaymentTypeBankAccount: "bankAccount",
PaymentTypeWallet: "wallet",
PaymentTypeCryptoAddress: "cryptoAddress",
PaymentTypeLedger: "ledger",
}
var paymentTypeFromString = map[string]PaymentType{
@@ -37,6 +39,7 @@ var paymentTypeFromString = map[string]PaymentType{
"bankAccount": PaymentTypeBankAccount,
"wallet": PaymentTypeWallet,
"cryptoAddress": PaymentTypeCryptoAddress,
"ledger": PaymentTypeLedger,
}
func (t PaymentType) String() string {

View File

@@ -20,17 +20,18 @@ message RateSnapshot {
}
message RequestMeta {
string request_ref = 1 [deprecated = true];
reserved 1, 4, 5;
reserved "request_ref", "idempotency_key", "trace_ref";
string tenant_ref = 2;
string organization_ref = 3;
string idempotency_key = 4 [deprecated = true];
string trace_ref = 5 [deprecated = true];
common.trace.v1.TraceContext trace = 6;
}
message ResponseMeta {
string request_ref = 1 [deprecated = true];
string trace_ref = 2 [deprecated = true];
reserved 1, 2;
reserved "request_ref", "trace_ref";
common.trace.v1.TraceContext trace = 3;
}

View File

@@ -11,6 +11,7 @@ import "common/trace/v1/trace.proto";
import "common/pagination/v1/cursor.proto";
import "billing/fees/v1/fees.proto";
import "gateway/chain/v1/chain.proto";
import "gateway/mntx/v1/mntx.proto";
import "oracle/v1/oracle.proto";
enum PaymentKind {
@@ -20,6 +21,13 @@ enum PaymentKind {
PAYMENT_KIND_FX_CONVERSION = 3;
}
// SettlementMode defines how to treat fees/FX variance for payouts.
enum SettlementMode {
SETTLEMENT_MODE_UNSPECIFIED = 0;
SETTLEMENT_MODE_FIX_SOURCE = 1; // customer pays fees; sent amount fixed
SETTLEMENT_MODE_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes
}
enum PaymentState {
PAYMENT_STATE_UNSPECIFIED = 0;
PAYMENT_STATE_ACCEPTED = 1;
@@ -61,11 +69,26 @@ message ExternalChainEndpoint {
string memo = 3;
}
// Card payout destination.
message CardEndpoint {
oneof card {
string pan = 1; // raw PAN
string token = 2; // network or gateway-issued token
}
string cardholder_name = 3;
string cardholder_surname = 4;
uint32 exp_month = 5;
uint32 exp_year = 6;
string country = 7;
string masked_pan = 8;
}
message PaymentEndpoint {
oneof endpoint {
LedgerEndpoint ledger = 1;
ManagedWalletEndpoint managed_wallet = 2;
ExternalChainEndpoint external_chain = 3;
CardEndpoint card = 4;
}
map<string, string> metadata = 10;
}
@@ -88,6 +111,7 @@ message PaymentIntent {
FXIntent fx = 6;
fees.v1.PolicyOverrides fee_policy = 7;
map<string, string> attributes = 8;
SettlementMode settlement_mode = 9;
}
message PaymentQuote {
@@ -98,8 +122,7 @@ message PaymentQuote {
repeated fees.v1.AppliedRule fee_rules = 5;
oracle.v1.Quote fx_quote = 6;
chain.gateway.v1.EstimateTransferFeeResponse network_fee = 7;
string fee_quote_token = 8;
string quote_ref = 9;
string quote_ref = 8;
}
message ExecutionRefs {
@@ -107,6 +130,20 @@ message ExecutionRefs {
string credit_entry_ref = 2;
string fx_entry_ref = 3;
string chain_transfer_ref = 4;
string card_payout_ref = 5;
string fee_transfer_ref = 6;
}
// Card payout gateway tracking info.
message CardPayout {
string payout_ref = 1;
string provider_payment_id = 2;
string status = 3;
string failure_reason = 4;
string card_country = 5;
string masked_pan = 6;
string provider_code = 7;
string gateway_reference = 8;
}
message Payment {
@@ -121,6 +158,7 @@ message Payment {
map<string, string> metadata = 9;
google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
CardPayout card_payout = 12;
}
message QuotePaymentRequest {
@@ -138,10 +176,8 @@ message InitiatePaymentRequest {
RequestMeta meta = 1;
string idempotency_key = 2;
PaymentIntent intent = 3;
string fee_quote_token = 4;
string fx_quote_ref = 5;
map<string, string> metadata = 6;
string quote_ref = 7;
map<string, string> metadata = 4;
string quote_ref = 5;
}
message InitiatePaymentResponse {
@@ -198,6 +234,15 @@ message ProcessDepositObservedResponse {
Payment payment = 1;
}
message ProcessCardPayoutUpdateRequest {
RequestMeta meta = 1;
mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2;
}
message ProcessCardPayoutUpdateResponse {
Payment payment = 1;
}
message InitiateConversionRequest {
RequestMeta meta = 1;
string idempotency_key = 2;
@@ -221,4 +266,5 @@ service PaymentOrchestrator {
rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse);
rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse);
rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse);
rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse);
}

View File

@@ -16,8 +16,7 @@ api:
CORS:
max_age: 300
allowed_origins:
- "http://*"
- "https://*"
- "*"
allowed_methods:
- "GET"
- "POST"

View File

@@ -12,17 +12,18 @@ replace github.com/tech/sendico/gateway/chain => ../gateway/chain
require (
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.4
github.com/aws/aws-sdk-go-v2/credentials v1.19.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1
github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-chi/metrics v0.1.1
github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.0
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1
github.com/tech/sendico/gateway/chain v0.1.0
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0
@@ -59,7 +60,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect

View File

@@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.4 h1:gl+DxVuadpkYoaDcWllZqLkhGEbvwyqgNVRTmlaf5PI=
github.com/aws/aws-sdk-go-v2/config v1.32.4/go.mod h1:MBUp9Og/bzMmQHjMwace4aJfyvJeadzXjoTcR/SxLV0=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4 h1:KeIZxHVbGWRLhPvhdPbbi/DtFBHNKm6OsVDuiuFefdQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4/go.mod h1:Smw5n0nCZE9PeFEguofdXyt8kUC4JNrkDTfBOioPhFA=
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
@@ -32,16 +32,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 h1:5FhzzN6JmlGQF6c04kDIb5KNGm6KnNdLISNrfivIhHg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 h1:YCu/iAhQer8WZ66lldyKkpvMyv+HkPufMa4dyT6wils=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -213,6 +213,8 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -0,0 +1,76 @@
package srequest
// Asset represents a chain/token pair for blockchain endpoints.
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
// LedgerEndpoint represents a ledger account payload.
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
// ManagedWalletEndpoint represents a managed wallet payload.
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
// ExternalChainEndpoint represents an external chain address payload.
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
// CardEndpoint represents a card payout payload (PAN or network token).
type CardEndpoint struct {
Pan string `json:"pan"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
ExpMonth uint32 `json:"exp_month,omitempty"`
ExpYear uint32 `json:"exp_year,omitempty"`
Country string `json:"country,omitempty"`
}
// CardTokenEndpoint represents a vaulted card token payout payload.
type CardTokenEndpoint struct {
Token string `json:"token"`
MaskedPan string `json:"masked_pan"`
}
// WalletEndpoint represents a Sendico wallet payout payload.
type WalletEndpoint struct {
WalletID string `json:"walletId"`
}
// BankAccountEndpoint represents a domestic bank account payout payload.
type BankAccountEndpoint struct {
RecipientName string `json:"recipientName"`
Inn string `json:"inn"`
Kpp string `json:"kpp"`
BankName string `json:"bankName"`
Bik string `json:"bik"`
AccountNumber string `json:"accountNumber"`
CorrespondentAccount string `json:"correspondentAccount"`
}
// IBANEndpoint represents an international bank account payout payload.
type IBANEndpoint struct {
IBAN string `json:"iban"`
AccountHolder string `json:"accountHolder"`
BIC string `json:"bic,omitempty"`
BankName string `json:"bankName,omitempty"`
}
// LegacyPaymentEndpoint mirrors the previous bag-of-pointers DTO for backward compatibility.
type LegacyPaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Card *CardEndpoint `json:"card,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}

View File

@@ -0,0 +1,266 @@
package srequest
import (
"encoding/json"
"github.com/tech/sendico/pkg/merrors"
)
type EndpointType string
const (
EndpointTypeLedger EndpointType = "ledger"
EndpointTypeManagedWallet EndpointType = "managedWallet"
EndpointTypeExternalChain EndpointType = "cryptoAddress"
EndpointTypeCard EndpointType = "card"
EndpointTypeCardToken EndpointType = "cardToken"
EndpointTypeWallet EndpointType = "wallet"
EndpointTypeBankAccount EndpointType = "bankAccount"
EndpointTypeIBAN EndpointType = "iban"
)
// Endpoint is a discriminated union for payment endpoints.
type Endpoint struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func newEndpoint(kind EndpointType, payload interface{}, metadata map[string]string) (Endpoint, error) {
data, err := json.Marshal(payload)
if err != nil {
return Endpoint{}, merrors.Internal("marshal endpoint payload failed")
}
return Endpoint{
Type: kind,
Data: data,
Metadata: cloneStringMap(metadata),
}, nil
}
func (e Endpoint) decodePayload(expected EndpointType, dst interface{}) error {
actual := normalizeEndpointType(e.Type)
if actual == "" {
return merrors.InvalidArgument("endpoint type is required")
}
if actual != expected {
return merrors.InvalidArgument("expected endpoint type " + string(expected) + ", got " + string(e.Type))
}
if len(e.Data) == 0 {
return merrors.InvalidArgument("endpoint data is required for type " + string(expected))
}
if err := json.Unmarshal(e.Data, dst); err != nil {
return merrors.InvalidArgument("decode " + string(expected) + " endpoint: " + err.Error())
}
return nil
}
func (e *Endpoint) UnmarshalJSON(data []byte) error {
var envelope struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata"`
}
if err := json.Unmarshal(data, &envelope); err == nil {
if envelope.Type != "" || len(envelope.Data) > 0 {
if envelope.Type == "" {
return merrors.InvalidArgument("endpoint type is required")
}
*e = Endpoint{
Type: normalizeEndpointType(envelope.Type),
Data: envelope.Data,
Metadata: cloneStringMap(envelope.Metadata),
}
return nil
}
}
var legacy LegacyPaymentEndpoint
if err := json.Unmarshal(data, &legacy); err != nil {
return err
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(&legacy)
if err != nil {
return err
}
if endpoint == nil {
return merrors.InvalidArgument("endpoint payload is empty")
}
*e = *endpoint
return nil
}
func NewLedgerEndpointDTO(payload LedgerEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeLedger, payload, metadata)
}
func NewManagedWalletEndpointDTO(payload ManagedWalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeManagedWallet, payload, metadata)
}
func NewExternalChainEndpointDTO(payload ExternalChainEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeExternalChain, payload, metadata)
}
func NewCardEndpointDTO(payload CardEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCard, payload, metadata)
}
func NewCardTokenEndpointDTO(payload CardTokenEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCardToken, payload, metadata)
}
func NewWalletEndpointDTO(payload WalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeWallet, payload, metadata)
}
func NewBankAccountEndpointDTO(payload BankAccountEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeBankAccount, payload, metadata)
}
func NewIBANEndpointDTO(payload IBANEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeIBAN, payload, metadata)
}
func (e Endpoint) DecodeLedger() (LedgerEndpoint, error) {
var payload LedgerEndpoint
return payload, e.decodePayload(EndpointTypeLedger, &payload)
}
func (e Endpoint) DecodeManagedWallet() (ManagedWalletEndpoint, error) {
var payload ManagedWalletEndpoint
return payload, e.decodePayload(EndpointTypeManagedWallet, &payload)
}
func (e Endpoint) DecodeExternalChain() (ExternalChainEndpoint, error) {
var payload ExternalChainEndpoint
return payload, e.decodePayload(EndpointTypeExternalChain, &payload)
}
func (e Endpoint) DecodeCard() (CardEndpoint, error) {
var payload CardEndpoint
return payload, e.decodePayload(EndpointTypeCard, &payload)
}
func (e Endpoint) DecodeCardToken() (CardTokenEndpoint, error) {
var payload CardTokenEndpoint
return payload, e.decodePayload(EndpointTypeCardToken, &payload)
}
func (e Endpoint) DecodeWallet() (WalletEndpoint, error) {
var payload WalletEndpoint
return payload, e.decodePayload(EndpointTypeWallet, &payload)
}
func (e Endpoint) DecodeBankAccount() (BankAccountEndpoint, error) {
var payload BankAccountEndpoint
return payload, e.decodePayload(EndpointTypeBankAccount, &payload)
}
func (e Endpoint) DecodeIBAN() (IBANEndpoint, error) {
var payload IBANEndpoint
return payload, e.decodePayload(EndpointTypeIBAN, &payload)
}
func LegacyPaymentEndpointToEndpointDTO(old *LegacyPaymentEndpoint) (*Endpoint, error) {
if old == nil {
return nil, nil
}
count := 0
var endpoint Endpoint
var err error
if old.Ledger != nil {
count++
endpoint, err = NewLedgerEndpointDTO(*old.Ledger, old.Metadata)
}
if old.ManagedWallet != nil {
count++
endpoint, err = NewManagedWalletEndpointDTO(*old.ManagedWallet, old.Metadata)
}
if old.ExternalChain != nil {
count++
endpoint, err = NewExternalChainEndpointDTO(*old.ExternalChain, old.Metadata)
}
if old.Card != nil {
count++
endpoint, err = NewCardEndpointDTO(*old.Card, old.Metadata)
}
if err != nil {
return nil, err
}
if count == 0 {
return nil, merrors.InvalidArgument("exactly one endpoint must be set")
}
if count > 1 {
return nil, merrors.InvalidArgument("only one endpoint can be set")
}
return &endpoint, nil
}
func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint, error) {
if new == nil {
return nil, nil
}
legacy := &LegacyPaymentEndpoint{
Metadata: cloneStringMap(new.Metadata),
}
switch normalizeEndpointType(new.Type) {
case EndpointTypeLedger:
payload, err := new.DecodeLedger()
if err != nil {
return nil, err
}
legacy.Ledger = &payload
case EndpointTypeManagedWallet:
payload, err := new.DecodeManagedWallet()
if err != nil {
return nil, err
}
legacy.ManagedWallet = &payload
case EndpointTypeExternalChain:
payload, err := new.DecodeExternalChain()
if err != nil {
return nil, err
}
legacy.ExternalChain = &payload
case EndpointTypeCard:
payload, err := new.DecodeCard()
if err != nil {
return nil, err
}
legacy.Card = &payload
default:
return nil, merrors.InvalidArgument("unsupported endpoint type: " + string(new.Type))
}
return legacy, nil
}
var endpointTypeAliases = map[EndpointType]EndpointType{
"managed_wallet": EndpointTypeManagedWallet,
"external_chain": EndpointTypeExternalChain,
"card_token": EndpointTypeCardToken,
"bank_account": EndpointTypeBankAccount,
}
func normalizeEndpointType(t EndpointType) EndpointType {
if canonical, ok := endpointTypeAliases[t]; ok {
return canonical
}
return t
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View File

@@ -1,17 +1,70 @@
package srequest
type QuotePayment struct {
import (
"github.com/tech/sendico/pkg/merrors"
)
type PaymentBase struct {
IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func (b *PaymentBase) Validate() error {
if b.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
}
return nil
}
type QuotePayment struct {
PaymentBase `json:",inline"`
Intent PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
}
func (r *QuotePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
// intent is mandatory, so validate always
if err := r.Intent.Validate(); err != nil {
return err
}
return nil
}
type InitiatePayment struct {
IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
Metadata map[string]string `json:"metadata,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FxQuoteRef string `json:"fxQuoteRef,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
PaymentBase `json:",inline"`
Intent *PaymentIntent `json:"intent,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
}
func (r InitiatePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
hasIntent := r.Intent != nil
hasQuote := r.QuoteRef != ""
// must be exactly one
switch {
case !hasIntent && !hasQuote:
return merrors.NoData("either intent or quoteRef must be provided")
case hasIntent && hasQuote:
return merrors.DataConflict("intent and quoteRef are mutually exclusive")
}
// if intent provided → validate it
if hasIntent {
if err := r.Intent.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,50 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
// Strings keep JSON readable; conversion helpers map these to proto enums.
type PaymentKind string
const (
PaymentKindUnspecified PaymentKind = "unspecified"
PaymentKindPayout PaymentKind = "payout"
PaymentKindInternalTransfer PaymentKind = "internal_transfer"
PaymentKindFxConversion PaymentKind = "fx_conversion"
)
// SettlementMode matches orchestrator settlement behavior.
type SettlementMode string
const (
SettlementModeUnspecified SettlementMode = "unspecified"
SettlementModeFixSource SettlementMode = "fix_source"
SettlementModeFixReceived SettlementMode = "fix_received"
)
// FXSide mirrors the common FX side enum.
type FXSide string
const (
FXSideUnspecified FXSide = "unspecified"
FXSideBuyBaseSellQuote FXSide = "buy_base_sell_quote"
FXSideSellBaseBuyQuote FXSide = "sell_base_buy_quote"
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork string
const (
ChainNetworkUnspecified ChainNetwork = "unspecified"
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
ChainNetworkOtherEVM ChainNetwork = "other_evm"
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy string
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = "unspecified"
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = "block_posting"
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = "sweep_org_cash"
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = "invoice_later"
)

View File

@@ -0,0 +1,47 @@
package srequest
import (
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
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"`
Attributes map[string]string `json:"attributes,omitempty"`
}
func (p *PaymentIntent) Validate() error {
// Kind must be set (non-zero)
var zeroKind PaymentKind
if p.Kind == zeroKind {
return merrors.InvalidArgument("kind is required", "intent.kind")
}
if p.Source == nil {
return merrors.InvalidArgument("source is required", "intent.source")
}
if p.Destination == nil {
return merrors.InvalidArgument("destination is required", "intent.destination")
}
if p.Amount == nil {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if err := ValidateMoney(p.Amount); err != nil {
return err
}
if p.FX != nil {
if err := p.FX.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -1,103 +0,0 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
type PaymentKind int32
const (
PaymentKindUnspecified PaymentKind = 0
PaymentKindPayout PaymentKind = 1
PaymentKindInternalTransfer PaymentKind = 2
PaymentKindFxConversion PaymentKind = 3
)
// FXSide mirrors the common FX side enum.
type FXSide int32
const (
FXSideUnspecified FXSide = 0
FXSideBuyBaseSellQuote FXSide = 1
FXSideSellBaseBuyQuote FXSide = 2
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork int32
const (
ChainNetworkUnspecified ChainNetwork = 0
ChainNetworkEthereumMainnet ChainNetwork = 1
ChainNetworkArbitrumOne ChainNetwork = 2
ChainNetworkOtherEVM ChainNetwork = 3
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy int32
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = 0
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = 1
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = 2
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = 3
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *PaymentEndpoint `json:"source,omitempty"`
Destination *PaymentEndpoint `json:"destination,omitempty"`
Amount *Money `json:"amount,omitempty"`
RequiresFX bool `json:"requires_fx,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
FeePolicy *PolicyOverrides `json:"fee_policy,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
type PaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type CurrencyPair struct {
Base string `json:"base"`
Quote string `json:"quote"`
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
Firm bool `json:"firm,omitempty"`
TTLms int64 `json:"ttl_ms,omitempty"`
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}
type PolicyOverrides struct {
InsufficientNet InsufficientNetPolicy `json:"insufficient_net,omitempty"`
}

View File

@@ -0,0 +1,427 @@
package srequest
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/tech/sendico/pkg/model"
)
func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
meta := map[string]string{"note": "meta"}
t.Run("ledger", func(t *testing.T) {
payload := LedgerEndpoint{LedgerAccountRef: "acc-1", ContraLedgerAccountRef: "contra-1"}
endpoint, err := NewLedgerEndpointDTO(payload, meta)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
if string(endpoint.Data) != `{"ledger_account_ref":"acc-1","contra_ledger_account_ref":"contra-1"}` {
t.Fatalf("unexpected data: %s", endpoint.Data)
}
decoded, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
meta["note"] = "changed"
if endpoint.Metadata["note"] != "meta" {
t.Fatalf("metadata should be copied, got %s", endpoint.Metadata["note"])
}
})
t.Run("managed wallet", func(t *testing.T) {
payload := ManagedWalletEndpoint{
ManagedWalletRef: "mw-1",
Asset: &Asset{
Chain: ChainNetworkArbitrumOne,
TokenSymbol: "USDC",
ContractAddress: "0xabc",
},
}
endpoint, err := NewManagedWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build managed wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("external chain", func(t *testing.T) {
payload := ExternalChainEndpoint{
Asset: &Asset{
Chain: ChainNetworkOtherEVM,
TokenSymbol: "ETH",
},
Address: "0x123",
Memo: "memo",
}
endpoint, err := NewExternalChainEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build external chain endpoint: %v", err)
}
if endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("expected type %s got %s", EndpointTypeExternalChain, endpoint.Type)
}
decoded, err := endpoint.DecodeExternalChain()
if err != nil {
t.Fatalf("decode external chain: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("card", func(t *testing.T) {
payload := CardEndpoint{
Pan: "pan",
FirstName: "Jane",
LastName: "Doe",
ExpMonth: 12,
ExpYear: 2030,
Country: "US",
}
endpoint, err := NewCardEndpointDTO(payload, map[string]string{"k": "v"})
if err != nil {
t.Fatalf("build card endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCard {
t.Fatalf("expected type %s got %s", EndpointTypeCard, endpoint.Type)
}
decoded, err := endpoint.DecodeCard()
if err != nil {
t.Fatalf("decode card: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["k"] != "v" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["k"])
}
})
t.Run("card token", func(t *testing.T) {
payload := CardTokenEndpoint{Token: "token", MaskedPan: "****1234"}
endpoint, err := NewCardTokenEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build card token endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCardToken {
t.Fatalf("expected type %s got %s", EndpointTypeCardToken, endpoint.Type)
}
decoded, err := endpoint.DecodeCardToken()
if err != nil {
t.Fatalf("decode card token: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("wallet", func(t *testing.T) {
payload := WalletEndpoint{WalletID: "wallet-1"}
endpoint, err := NewWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeWallet {
t.Fatalf("expected type %s got %s", EndpointTypeWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeWallet()
if err != nil {
t.Fatalf("decode wallet: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("bank account", func(t *testing.T) {
payload := BankAccountEndpoint{
RecipientName: "ACME",
Inn: "inn",
Kpp: "kpp",
BankName: "bank",
Bik: "bik",
AccountNumber: "123",
CorrespondentAccount: "456",
}
endpoint, err := NewBankAccountEndpointDTO(payload, map[string]string{"note": "n"})
if err != nil {
t.Fatalf("build bank account endpoint: %v", err)
}
if endpoint.Type != EndpointTypeBankAccount {
t.Fatalf("expected type %s got %s", EndpointTypeBankAccount, endpoint.Type)
}
decoded, err := endpoint.DecodeBankAccount()
if err != nil {
t.Fatalf("decode bank account: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["note"] != "n" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["note"])
}
})
t.Run("iban", func(t *testing.T) {
payload := IBANEndpoint{
IBAN: "DE123",
AccountHolder: "John Doe",
BIC: "BICCODE",
BankName: "BankName",
}
endpoint, err := NewIBANEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build iban endpoint: %v", err)
}
if endpoint.Type != EndpointTypeIBAN {
t.Fatalf("expected type %s got %s", EndpointTypeIBAN, endpoint.Type)
}
decoded, err := endpoint.DecodeIBAN()
if err != nil {
t.Fatalf("decode iban: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("type mismatch", func(t *testing.T) {
endpoint, err := NewLedgerEndpointDTO(LedgerEndpoint{LedgerAccountRef: "acc"}, nil)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if _, err := endpoint.DecodeCard(); err == nil || !strings.Contains(err.Error(), "expected endpoint type") {
t.Fatalf("expected type mismatch error, got %v", err)
}
})
t.Run("invalid json data", func(t *testing.T) {
endpoint := Endpoint{Type: EndpointTypeLedger, Data: json.RawMessage("not-json")}
if _, err := endpoint.DecodeLedger(); err == nil {
t.Fatalf("expected decode error")
}
})
t.Run("legacy type alias normalizes", func(t *testing.T) {
raw := []byte(`{"type":"managed_wallet","data":{"managed_wallet_ref":"mw-legacy"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal with legacy type: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected normalized type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet with alias: %v", err)
}
if payload.ManagedWalletRef != "mw-legacy" {
t.Fatalf("decoded payload mismatch from alias: %#v", payload)
}
})
}
func TestPaymentIntentJSONRoundTrip(t *testing.T) {
sourcePayload := LedgerEndpoint{LedgerAccountRef: "source"}
source, err := NewLedgerEndpointDTO(sourcePayload, map[string]string{"src": "meta"})
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := ExternalChainEndpoint{Address: "0xabc", Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "USDC"}}
dest, err := NewExternalChainEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindPayout,
Source: &source,
Destination: &dest,
Amount: &model.Money{Amount: "10", Currency: "USD"},
FX: &FXIntent{
Pair: &CurrencyPair{Base: "USD", Quote: "EUR"},
Side: FXSideBuyBaseSellQuote,
Firm: true,
TTLms: 5000,
PreferredProvider: "provider",
MaxAgeMs: 10,
},
SettlementMode: SettlementModeFixReceived,
Attributes: map[string]string{"k": "v"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.SettlementMode != intent.SettlementMode {
t.Fatalf("scalar fields changed after round trip")
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.FX == nil || decoded.FX.PreferredProvider != intent.FX.PreferredProvider {
t.Fatalf("fx mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("source/destination missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeLedger()
if err != nil {
t.Fatalf("decode source after round trip: %v", err)
}
if sourceDecoded != sourcePayload {
t.Fatalf("source payload mismatch after round trip: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeExternalChain()
if err != nil {
t.Fatalf("decode destination after round trip: %v", err)
}
if !reflect.DeepEqual(destDecoded, destPayload) {
t.Fatalf("destination payload mismatch after round trip: %#v vs %#v", destDecoded, destPayload)
}
if decoded.Attributes["k"] != "v" {
t.Fatalf("attributes mismatch after round trip")
}
}
func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
sourcePayload := ManagedWalletEndpoint{ManagedWalletRef: "mw"}
source, err := NewManagedWalletEndpointDTO(sourcePayload, nil)
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := LedgerEndpoint{LedgerAccountRef: "dest-ledger"}
dest, err := NewLedgerEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindInternalTransfer,
Source: &source,
Destination: &dest,
Amount: &model.Money{Amount: "1", Currency: "USD"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.FX != nil {
t.Fatalf("unexpected fx data in minimal intent: %#v", decoded)
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("endpoints missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode source: %v", err)
}
if !reflect.DeepEqual(sourceDecoded, sourcePayload) {
t.Fatalf("source payload mismatch: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeLedger()
if err != nil {
t.Fatalf("decode destination: %v", err)
}
if destDecoded != destPayload {
t.Fatalf("destination payload mismatch: %#v vs %#v", destDecoded, destPayload)
}
}
func TestLegacyEndpointRoundTrip(t *testing.T) {
legacy := &LegacyPaymentEndpoint{
ExternalChain: &ExternalChainEndpoint{
Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"},
Address: "0x123",
Memo: "memo",
},
Metadata: map[string]string{"note": "legacy"},
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(legacy)
if err != nil {
t.Fatalf("convert legacy to dto: %v", err)
}
if endpoint == nil || endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("unexpected endpoint result: %#v", endpoint)
}
legacy.Metadata["note"] = "changed"
if endpoint.Metadata["note"] != "legacy" {
t.Fatalf("metadata should be copied from legacy")
}
roundTrip, err := EndpointDTOToLegacyPaymentEndpoint(endpoint)
if err != nil {
t.Fatalf("convert dto back to legacy: %v", err)
}
if roundTrip == nil || roundTrip.ExternalChain == nil {
t.Fatalf("round trip legacy missing payload: %#v", roundTrip)
}
if !reflect.DeepEqual(roundTrip.ExternalChain, legacy.ExternalChain) {
t.Fatalf("round trip payload mismatch: %#v vs %#v", roundTrip.ExternalChain, legacy.ExternalChain)
}
if roundTrip.Metadata["note"] != "legacy" {
t.Fatalf("metadata mismatch after round trip: %v", roundTrip.Metadata)
}
}
func TestLegacyEndpointConversionRejectsMultiple(t *testing.T) {
_, err := LegacyPaymentEndpointToEndpointDTO(&LegacyPaymentEndpoint{
Ledger: &LedgerEndpoint{LedgerAccountRef: "a"},
Card: &CardEndpoint{Pan: "t"},
})
if err == nil {
t.Fatalf("expected error when multiple legacy endpoints are set")
}
}
func TestEndpointUnmarshalLegacyShape(t *testing.T) {
raw := []byte(`{"ledger":{"ledger_account_ref":"abc"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal legacy shape: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
payload, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger from legacy shape: %v", err)
}
if payload.LedgerAccountRef != "abc" {
t.Fatalf("unexpected payload from legacy shape: %#v", payload)
}
}

View File

@@ -0,0 +1,100 @@
package srequest
import (
"github.com/shopspring/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
func ValidateMoney(m *model.Money) error {
if m.Amount == "" {
return merrors.InvalidArgument("amount is required", "intent.amount")
}
if m.Currency == "" {
return merrors.InvalidArgument("currency is required", "intent.currency")
}
if _, err := decimal.NewFromString(m.Amount); err != nil {
return merrors.InvalidArgument("invalid amount decimal", "intent.amount")
}
if len(m.Currency) != 3 {
return merrors.InvalidArgument("currency must be 3 letters", "intent.currency")
}
for _, c := range m.Currency {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("currency must be uppercase A-Z", "intent.currency")
}
}
return nil
}
type CurrencyPair struct {
Base string `json:"base"`
Quote string `json:"quote"`
}
func (p *CurrencyPair) Validate() error {
if p.Base == "" {
return merrors.InvalidArgument("base currency is required", "intent.fx.pair.base")
}
if p.Quote == "" {
return merrors.InvalidArgument("quote currency is required", "intent.fx.pair.quote")
}
if len(p.Base) != 3 {
return merrors.InvalidArgument("base currency must be 3 letters", "intent.fx.pair.base")
}
if len(p.Quote) != 3 {
return merrors.InvalidArgument("quote currency must be 3 letters", "intent.fx.pair.quote")
}
for _, c := range p.Base {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("base currency must be uppercase A-Z", "intent.fx.pair.base")
}
}
for _, c := range p.Quote {
if c < 'A' || c > 'Z' {
return merrors.InvalidArgument("quote currency must be uppercase A-Z", "intent.fx.pair.quote")
}
}
return nil
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
Firm bool `json:"firm,omitempty"`
TTLms int64 `json:"ttl_ms,omitempty"`
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}
func (fx *FXIntent) Validate() error {
if fx.Pair != nil {
if err := fx.Pair.Validate(); err != nil {
return err
}
}
var zeroSide FXSide
if fx.Side == zeroSide {
return merrors.InvalidArgument("fx side is required", "intent.fx.side")
}
if fx.TTLms < 0 {
return merrors.InvalidArgument("fx ttl_ms cannot be negative", "intent.fx.ttl_ms")
}
if fx.TTLms == 0 && fx.Firm {
return merrors.InvalidArgument("firm quote requires positive ttl_ms", "intent.fx.ttl_ms")
}
if fx.MaxAgeMs < 0 {
return merrors.InvalidArgument("fx max_age_ms cannot be negative", "intent.fx.max_age_ms")
}
return nil
}

View File

@@ -0,0 +1,5 @@
package srequest
type Validatable interface {
Validate() error
}

View File

@@ -1,17 +1,15 @@
package sresponse
import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
import (
"github.com/tech/sendico/pkg/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
func toMoney(m *moneyv1.Money) *Money {
func toMoney(m *moneyv1.Money) *model.Money {
if m == nil {
return nil
}
return &Money{
return &model.Money{
Amount: m.GetAmount(),
Currency: m.GetCurrency(),
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
@@ -13,40 +14,39 @@ import (
type FeeLine struct {
LedgerAccountRef string `json:"ledgerAccountRef,omitempty"`
Amount *Money `json:"amount,omitempty"`
Amount *model.Money `json:"amount,omitempty"`
LineType string `json:"lineType,omitempty"`
Side string `json:"side,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type NetworkFee struct {
NetworkFee *Money `json:"networkFee,omitempty"`
EstimationContext string `json:"estimationContext,omitempty"`
NetworkFee *model.Money `json:"networkFee,omitempty"`
EstimationContext string `json:"estimationContext,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 *Money `json:"baseAmount,omitempty"`
QuoteAmount *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 *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"`
}
type PaymentQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
DebitAmount *Money `json:"debitAmount,omitempty"`
ExpectedSettlementAmount *Money `json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *Money `json:"expectedFeeTotal,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FeeLines []FeeLine `json:"feeLines,omitempty"`
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"`
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"`
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"`
}
type Payment struct {
@@ -152,7 +152,6 @@ func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote {
DebitAmount: toMoney(q.GetDebitAmount()),
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
FeeQuoteToken: q.GetFeeQuoteToken(),
FeeLines: toFeeLines(q.GetFeeLines()),
NetworkFee: toNetworkFee(q.GetNetworkFee()),
FxQuote: toFxQuote(q.GetFxQuote()),

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -36,10 +37,10 @@ type walletsResponse struct {
}
type walletBalance struct {
Available *Money `json:"available,omitempty"`
PendingInbound *Money `json:"pendingInbound,omitempty"`
PendingOutbound *Money `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"`
Available *model.Money `json:"available,omitempty"`
PendingInbound *model.Money `json:"pendingInbound,omitempty"`
PendingOutbound *model.Money `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"`
}
type walletBalanceResponse struct {

View File

@@ -1,8 +1,10 @@
package paymentapiimp
import (
"strings"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
"github.com/tech/sendico/pkg/model"
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"
@@ -15,6 +17,16 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
return nil, merrors.InvalidArgument("intent is required")
}
kind, err := mapPaymentKind(intent.Kind)
if err != nil {
return nil, err
}
settlementMode, err := mapSettlementMode(intent.SettlementMode)
if err != nil {
return nil, err
}
source, err := mapPaymentEndpoint(intent.Source, "source")
if err != nil {
return nil, err
@@ -30,48 +42,76 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
}
return &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind(intent.Kind),
Source: source,
Destination: destination,
Amount: mapMoney(intent.Amount),
RequiresFx: intent.RequiresFX,
Fx: fx,
FeePolicy: mapPolicyOverrides(intent.FeePolicy),
Attributes: copyStringMap(intent.Attributes),
Kind: kind,
Source: source,
Destination: destination,
Amount: mapMoney(intent.Amount),
RequiresFx: fx != nil,
Fx: fx,
SettlementMode: settlementMode,
Attributes: copyStringMap(intent.Attributes),
}, nil
}
func mapPaymentEndpoint(endpoint *srequest.PaymentEndpoint, field string) (*orchestratorv1.PaymentEndpoint, error) {
func mapPaymentEndpoint(endpoint *srequest.Endpoint, field string) (*orchestratorv1.PaymentEndpoint, error) {
if endpoint == nil {
return nil, nil
}
var (
count int
result orchestratorv1.PaymentEndpoint
)
if endpoint.Ledger != nil {
count++
var result orchestratorv1.PaymentEndpoint
switch endpoint.Type {
case srequest.EndpointTypeLedger:
payload, err := endpoint.DecodeLedger()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
result.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{
Ledger: mapLedgerEndpoint(endpoint.Ledger),
Ledger: mapLedgerEndpoint(&payload),
}
case srequest.EndpointTypeManagedWallet:
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
mw, err := mapManagedWalletEndpoint(&payload)
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
}
if endpoint.ManagedWallet != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: mapManagedWalletEndpoint(endpoint.ManagedWallet),
ManagedWallet: mw,
}
case srequest.EndpointTypeExternalChain:
payload, err := endpoint.DecodeExternalChain()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
ext, err := mapExternalChainEndpoint(&payload)
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
}
if endpoint.ExternalChain != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{
ExternalChain: mapExternalChainEndpoint(endpoint.ExternalChain),
ExternalChain: ext,
}
}
if count > 1 {
return nil, merrors.InvalidArgument(field + " endpoint must set only one of ledger, managed_wallet, external_chain")
case srequest.EndpointTypeCard:
payload, err := endpoint.DecodeCard()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
result.Endpoint = &orchestratorv1.PaymentEndpoint_Card{
Card: mapCardEndpoint(&payload),
}
case srequest.EndpointTypeCardToken:
payload, err := endpoint.DecodeCardToken()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
result.Endpoint = &orchestratorv1.PaymentEndpoint_Card{
Card: mapCardTokenEndpoint(&payload),
}
case "":
return nil, merrors.InvalidArgument(field + " endpoint type is required")
default:
return nil, merrors.InvalidArgument(field + " endpoint has unsupported type: " + string(endpoint.Type))
}
result.Metadata = copyStringMap(endpoint.Metadata)
@@ -88,39 +128,51 @@ func mapLedgerEndpoint(endpoint *srequest.LedgerEndpoint) *orchestratorv1.Ledger
}
}
func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) *orchestratorv1.ManagedWalletEndpoint {
func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) (*orchestratorv1.ManagedWalletEndpoint, error) {
if endpoint == nil {
return nil
return nil, nil
}
asset, err := mapAsset(endpoint.Asset)
if err != nil {
return nil, err
}
return &orchestratorv1.ManagedWalletEndpoint{
ManagedWalletRef: endpoint.ManagedWalletRef,
Asset: mapAsset(endpoint.Asset),
}
Asset: asset,
}, nil
}
func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) *orchestratorv1.ExternalChainEndpoint {
func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) (*orchestratorv1.ExternalChainEndpoint, error) {
if endpoint == nil {
return nil
return nil, nil
}
asset, err := mapAsset(endpoint.Asset)
if err != nil {
return nil, err
}
return &orchestratorv1.ExternalChainEndpoint{
Asset: mapAsset(endpoint.Asset),
Asset: asset,
Address: endpoint.Address,
Memo: endpoint.Memo,
}
}, nil
}
func mapAsset(asset *srequest.Asset) *chainv1.Asset {
func mapAsset(asset *srequest.Asset) (*chainv1.Asset, error) {
if asset == nil {
return nil
return nil, nil
}
chain, err := mapChainNetwork(asset.Chain)
if err != nil {
return nil, err
}
return &chainv1.Asset{
Chain: chainv1.ChainNetwork(asset.Chain),
Chain: chain,
TokenSymbol: asset.TokenSymbol,
ContractAddress: asset.ContractAddress,
}
}, nil
}
func mapMoney(m *srequest.Money) *moneyv1.Money {
func mapMoney(m *model.Money) *moneyv1.Money {
if m == nil {
return nil
}
@@ -134,9 +186,13 @@ func mapFXIntent(fx *srequest.FXIntent) (*orchestratorv1.FXIntent, error) {
if fx == nil {
return nil, nil
}
side, err := mapFXSide(fx.Side)
if err != nil {
return nil, err
}
return &orchestratorv1.FXIntent{
Pair: mapCurrencyPair(fx.Pair),
Side: fxv1.Side(fx.Side),
Side: side,
Firm: fx.Firm,
TtlMs: fx.TTLms,
PreferredProvider: fx.PreferredProvider,
@@ -154,12 +210,86 @@ func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair {
}
}
func mapPolicyOverrides(policy *srequest.PolicyOverrides) *feesv1.PolicyOverrides {
if policy == nil {
func mapCardEndpoint(card *srequest.CardEndpoint) *orchestratorv1.CardEndpoint {
if card == nil {
return nil
}
return &feesv1.PolicyOverrides{
InsufficientNet: feesv1.InsufficientNetPolicy(policy.InsufficientNet),
result := &orchestratorv1.CardEndpoint{
CardholderName: strings.TrimSpace(card.FirstName),
CardholderSurname: strings.TrimSpace(card.LastName),
ExpMonth: card.ExpMonth,
ExpYear: card.ExpYear,
Country: strings.TrimSpace(card.Country),
}
if pan := strings.TrimSpace(card.Pan); pan != "" {
result.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
return result
}
func mapCardTokenEndpoint(card *srequest.CardTokenEndpoint) *orchestratorv1.CardEndpoint {
if card == nil {
return nil
}
return &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Token{Token: strings.TrimSpace(card.Token)},
MaskedPan: strings.TrimSpace(card.MaskedPan),
}
}
func mapPaymentKind(kind srequest.PaymentKind) (orchestratorv1.PaymentKind, error) {
switch strings.TrimSpace(string(kind)) {
case "", string(srequest.PaymentKindUnspecified):
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, nil
case string(srequest.PaymentKindPayout):
return orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT, nil
case string(srequest.PaymentKindInternalTransfer):
return orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER, nil
case string(srequest.PaymentKindFxConversion):
return orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, nil
default:
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, merrors.InvalidArgument("unsupported payment kind: " + string(kind))
}
}
func mapSettlementMode(mode srequest.SettlementMode) (orchestratorv1.SettlementMode, error) {
switch strings.TrimSpace(string(mode)) {
case "", string(srequest.SettlementModeUnspecified):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, nil
case string(srequest.SettlementModeFixSource):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE, nil
case string(srequest.SettlementModeFixReceived):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED, nil
default:
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode))
}
}
func mapFXSide(side srequest.FXSide) (fxv1.Side, error) {
switch strings.TrimSpace(string(side)) {
case "", string(srequest.FXSideUnspecified):
return fxv1.Side_SIDE_UNSPECIFIED, nil
case string(srequest.FXSideBuyBaseSellQuote):
return fxv1.Side_BUY_BASE_SELL_QUOTE, nil
case string(srequest.FXSideSellBaseBuyQuote):
return fxv1.Side_SELL_BASE_BUY_QUOTE, nil
default:
return fxv1.Side_SIDE_UNSPECIFIED, merrors.InvalidArgument("unsupported fx side: " + string(side))
}
}
func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) {
switch strings.TrimSpace(string(chain)) {
case "", string(srequest.ChainNetworkUnspecified):
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
case string(srequest.ChainNetworkEthereumMainnet):
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case string(srequest.ChainNetworkArbitrumOne):
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case string(srequest.ChainNetworkOtherEVM):
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
}
}

View File

@@ -39,16 +39,29 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
if expectQuote && strings.TrimSpace(payload.QuoteRef) == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quote_ref is required"))
}
if !expectQuote {
payload.QuoteRef = ""
if expectQuote {
if payload.QuoteRef == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quoteRef is required"))
}
if payload.Intent != nil {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be combined with intent"))
}
} else {
if payload.Intent == nil {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("intent is required"))
}
if payload.QuoteRef != "" {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be used when intent is provided"))
}
}
intent, err := mapPaymentIntent(payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
var intent *orchestratorv1.PaymentIntent
if payload.Intent != nil {
intent, err = mapPaymentIntent(payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
}
req := &orchestratorv1.InitiatePaymentRequest{
@@ -57,8 +70,6 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
},
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
Intent: intent,
FeeQuoteToken: strings.TrimSpace(payload.FeeQuoteToken),
FxQuoteRef: strings.TrimSpace(payload.FxQuoteRef),
QuoteRef: strings.TrimSpace(payload.QuoteRef),
Metadata: payload.Metadata,
}
@@ -80,11 +91,10 @@ func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotencyKey is required")
}
if payload.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}

View File

@@ -36,11 +36,17 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
payload, err := decodeQuotePayload(r)
if err != nil {
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
if err := payload.Validate(); err != nil {
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
intent, err := mapPaymentIntent(payload.Intent)
intent, err := mapPaymentIntent(&payload.Intent)
if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err)
}
@@ -50,7 +56,6 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
},
IdempotencyKey: payload.IdempotencyKey,
Intent: intent,
PreviewOnly: payload.PreviewOnly,
}
resp, err := a.client.QuotePayment(ctx, req)
@@ -67,14 +72,11 @@ func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
payload := &srequest.QuotePayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotencyKey is required")
}
if payload.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
}

View File

@@ -35,7 +35,7 @@ type PaymentAPI struct {
permissionRef primitive.ObjectID
}
func (a *PaymentAPI) Name() mservice.Type { return mservice.PaymentOrchestrator }
func (a *PaymentAPI) Name() mservice.Type { return mservice.Payments }
func (a *PaymentAPI) Finish(ctx context.Context) error {
if a.client != nil {
@@ -48,12 +48,12 @@ func (a *PaymentAPI) Finish(ctx context.Context) error {
func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
p := &PaymentAPI{
logger: apiCtx.Logger().Named(mservice.PaymentOrchestrator),
logger: apiCtx.Logger().Named(mservice.Payments),
enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations),
}
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.PaymentOrchestrator)
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
if err != nil {
p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err))
return nil, err

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'base.g.dart';
@JsonSerializable()
class PaymentBaseRequest {
final String idempotencyKey;
final Map<String, String>? metadata;
const PaymentBaseRequest({
required this.idempotencyKey,
this.metadata,
});
factory PaymentBaseRequest.fromJson(Map<String, dynamic> json) => _$PaymentBaseRequestFromJson(json);
Map<String, dynamic> toJson() => _$PaymentBaseRequestToJson(this);
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'initiate.g.dart';
@JsonSerializable()
class InitiatePaymentRequest extends PaymentBaseRequest {
final PaymentIntentDTO? intent;
final String? quoteRef;
const InitiatePaymentRequest({
required super.idempotencyKey,
super.metadata,
this.intent,
this.quoteRef,
});
factory InitiatePaymentRequest.fromJson(Map<String, dynamic> json) => _$InitiatePaymentRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$InitiatePaymentRequestToJson(this);
}

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'quote.g.dart';
@JsonSerializable()
class QuotePaymentRequest extends PaymentBaseRequest {
final PaymentIntentDTO intent;
@JsonKey(defaultValue: false)
final bool previewOnly;
const QuotePaymentRequest({
required super.idempotencyKey,
super.metadata,
required this.intent,
this.previewOnly = false,
});
factory QuotePaymentRequest.fromJson(Map<String, dynamic> json) => _$QuotePaymentRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$QuotePaymentRequestToJson(this);
}

View File

@@ -4,7 +4,7 @@ import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/method.dart';
part 'payment_method.g.dart';
part 'method.g.dart';
@JsonSerializable(explicitToJson: true)

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'quotation.g.dart';
@JsonSerializable(explicitToJson: true)
class PaymentQuoteResponse extends BaseAuthorizedResponse {
final PaymentQuoteDTO quote;
const PaymentQuoteResponse({required super.accessToken, required this.quote});
factory PaymentQuoteResponse.fromJson(Map<String, dynamic> json) => _$PaymentQuoteResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$PaymentQuoteResponseToJson(this);
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
part 'asset.g.dart';
@JsonSerializable()
class AssetDTO {
final String chain;
@JsonKey(name: 'token_symbol')
final String tokenSymbol;
@JsonKey(name: 'contract_address')
final String? contractAddress;
const AssetDTO({
required this.chain,
required this.tokenSymbol,
this.contractAddress,
});
factory AssetDTO.fromJson(Map<String, dynamic> json) => _$AssetDTOFromJson(json);
Map<String, dynamic> toJson() => _$AssetDTOToJson(this);
}

View File

@@ -4,17 +4,28 @@ part 'card.g.dart';
@JsonSerializable()
class CardPaymentDataDTO {
class CardEndpointDTO {
final String pan;
final String firstName;
final String lastName;
const CardPaymentDataDTO({
@JsonKey(name: 'exp_month')
final int? expMonth;
@JsonKey(name: 'exp_year')
final int? expYear;
final String? country;
const CardEndpointDTO({
required this.pan,
required this.firstName,
required this.lastName,
required this.expMonth,
required this.expYear,
this.country,
});
factory CardPaymentDataDTO.fromJson(Map<String, dynamic> json) => _$CardPaymentDataDTOFromJson(json);
Map<String, dynamic> toJson() => _$CardPaymentDataDTOToJson(this);
factory CardEndpointDTO.fromJson(Map<String, dynamic> json) => _$CardEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$CardEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'card_token.g.dart';
@JsonSerializable()
class CardTokenEndpointDTO {
final String token;
@JsonKey(name: 'masked_pan')
final String maskedPan;
const CardTokenEndpointDTO({
required this.maskedPan,
required this.token,
});
factory CardTokenEndpointDTO.fromJson(Map<String, dynamic> json) => _$CardTokenEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$CardTokenEndpointDTOToJson(this);
}

View File

@@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'crypto_address.g.dart';
@JsonSerializable()
class CryptoAddressPaymentDataDTO {
final String address;
final String network;
final String? destinationTag;
const CryptoAddressPaymentDataDTO({
required this.address,
required this.network,
this.destinationTag,
});
factory CryptoAddressPaymentDataDTO.fromJson(Map<String, dynamic> json) => _$CryptoAddressPaymentDataDTOFromJson(json);
Map<String, dynamic> toJson() => _$CryptoAddressPaymentDataDTOToJson(this);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'currency_pair.g.dart';
@JsonSerializable()
class CurrencyPairDTO {
final String base;
final String quote;
const CurrencyPairDTO({
required this.base,
required this.quote,
});
factory CurrencyPairDTO.fromJson(Map<String, dynamic> json) => _$CurrencyPairDTOFromJson(json);
Map<String, dynamic> toJson() => _$CurrencyPairDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'endpoint.g.dart';
@JsonSerializable()
class PaymentEndpointDTO {
final String type;
final Map<String, dynamic> data;
final Map<String, String>? metadata;
const PaymentEndpointDTO({
required this.type,
required this.data,
this.metadata,
});
factory PaymentEndpointDTO.fromJson(Map<String, dynamic> json) => _$PaymentEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,22 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/asset.dart';
part 'external_chain.g.dart';
@JsonSerializable()
class ExternalChainEndpointDTO {
final AssetDTO? asset;
final String address;
final String? memo;
const ExternalChainEndpointDTO({
this.asset,
required this.address,
this.memo,
});
factory ExternalChainEndpointDTO.fromJson(Map<String, dynamic> json) => _$ExternalChainEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$ExternalChainEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'fee_line.g.dart';
@JsonSerializable()
class FeeLineDTO {
final String? ledgerAccountRef;
final MoneyDTO? amount;
final String? lineType;
final String? side;
final Map<String, String>? meta;
const FeeLineDTO({
this.ledgerAccountRef,
this.amount,
this.lineType,
this.side,
this.meta,
});
factory FeeLineDTO.fromJson(Map<String, dynamic> json) => _$FeeLineDTOFromJson(json);
Map<String, dynamic> toJson() => _$FeeLineDTOToJson(this);
}

View File

@@ -0,0 +1,40 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'fx_quote.g.dart';
@JsonSerializable()
class FxQuoteDTO {
final String? quoteRef;
final String? baseCurrency;
final String? quoteCurrency;
final String? side;
final String? price;
final MoneyDTO? baseAmount;
final MoneyDTO? quoteAmount;
final int? expiresAtUnixMs;
final String? provider;
final String? rateRef;
@JsonKey(defaultValue: false)
final bool? firm;
const FxQuoteDTO({
this.quoteRef,
this.baseCurrency,
this.quoteCurrency,
this.side,
this.price,
this.baseAmount,
this.quoteAmount,
this.expiresAtUnixMs,
this.provider,
this.rateRef,
this.firm = false,
});
factory FxQuoteDTO.fromJson(Map<String, dynamic> json) => _$FxQuoteDTOFromJson(json);
Map<String, dynamic> toJson() => _$FxQuoteDTOToJson(this);
}

View File

@@ -0,0 +1,34 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/currency_pair.dart';
part 'fx.g.dart';
@JsonSerializable()
class FxIntentDTO {
final CurrencyPairDTO? pair;
final String? side;
final bool firm;
@JsonKey(name: 'ttl_ms')
final int? ttlMs;
@JsonKey(name: 'preferred_provider')
final String? preferredProvider;
@JsonKey(name: 'max_age_ms')
final int? maxAgeMs;
const FxIntentDTO({
this.pair,
this.side,
this.firm = false,
this.ttlMs,
this.preferredProvider,
this.maxAgeMs,
});
factory FxIntentDTO.fromJson(Map<String, dynamic> json) => _$FxIntentDTOFromJson(json);
Map<String, dynamic> toJson() => _$FxIntentDTOToJson(this);
}

View File

@@ -0,0 +1,36 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/intent/fx.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'payment.g.dart';
@JsonSerializable()
class PaymentIntentDTO {
final String? kind;
final PaymentEndpointDTO? source;
final PaymentEndpointDTO? destination;
final MoneyDTO? amount;
final FxIntentDTO? fx;
@JsonKey(name: 'settlement_mode')
final String? settlementMode;
final Map<String, String>? attributes;
const PaymentIntentDTO({
this.kind,
this.source,
this.destination,
this.amount,
this.fx,
this.settlementMode,
this.attributes,
});
factory PaymentIntentDTO.fromJson(Map<String, dynamic> json) => _$PaymentIntentDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentIntentDTOToJson(this);
}

View File

@@ -0,0 +1,21 @@
import 'package:json_annotation/json_annotation.dart';
part 'ledger.g.dart';
@JsonSerializable()
class LedgerEndpointDTO {
@JsonKey(name: 'ledger_account_ref')
final String ledgerAccountRef;
@JsonKey(name: 'contra_ledger_account_ref')
final String? contraLedgerAccountRef;
const LedgerEndpointDTO({
required this.ledgerAccountRef,
this.contraLedgerAccountRef,
});
factory LedgerEndpointDTO.fromJson(Map<String, dynamic> json) => _$LedgerEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$LedgerEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,22 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/asset.dart';
part 'managed_wallet.g.dart';
@JsonSerializable()
class ManagedWalletEndpointDTO {
@JsonKey(name: 'managed_wallet_ref')
final String managedWalletRef;
final AssetDTO? asset;
const ManagedWalletEndpointDTO({
required this.managedWalletRef,
this.asset,
});
factory ManagedWalletEndpointDTO.fromJson(Map<String, dynamic> json) => _$ManagedWalletEndpointDTOFromJson(json);
Map<String, dynamic> toJson() => _$ManagedWalletEndpointDTOToJson(this);
}

View File

@@ -0,0 +1,18 @@
import 'package:json_annotation/json_annotation.dart';
part 'money.g.dart';
@JsonSerializable()
class MoneyDTO {
final String amount;
final String currency;
const MoneyDTO({
required this.amount,
required this.currency,
});
factory MoneyDTO.fromJson(Map<String, dynamic> json) => _$MoneyDTOFromJson(json);
Map<String, dynamic> toJson() => _$MoneyDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'network_fee.g.dart';
@JsonSerializable()
class NetworkFeeDTO {
final MoneyDTO? networkFee;
final String? estimationContext;
const NetworkFeeDTO({
this.networkFee,
this.estimationContext,
});
factory NetworkFeeDTO.fromJson(Map<String, dynamic> json) => _$NetworkFeeDTOFromJson(json);
Map<String, dynamic> toJson() => _$NetworkFeeDTOToJson(this);
}

View File

@@ -0,0 +1,28 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'payment.g.dart';
@JsonSerializable()
class PaymentDTO {
final String? paymentRef;
final String? idempotencyKey;
final String? state;
final String? failureCode;
final String? failureReason;
final PaymentQuoteDTO? lastQuote;
const PaymentDTO({
this.paymentRef,
this.idempotencyKey,
this.state,
this.failureCode,
this.failureReason,
this.lastQuote,
});
factory PaymentDTO.fromJson(Map<String, dynamic> json) => _$PaymentDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentDTOToJson(this);
}

View File

@@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/fee_line.dart';
import 'package:pshared/data/dto/payment/fx_quote.dart';
import 'package:pshared/data/dto/payment/money.dart';
import 'package:pshared/data/dto/payment/network_fee.dart';
part 'payment_quote.g.dart';
@JsonSerializable()
class PaymentQuoteDTO {
final String? quoteRef;
final MoneyDTO? debitAmount;
final MoneyDTO? expectedSettlementAmount;
final MoneyDTO? expectedFeeTotal;
final List<FeeLineDTO>? feeLines;
final NetworkFeeDTO? networkFee;
final FxQuoteDTO? fxQuote;
const PaymentQuoteDTO({
this.quoteRef,
this.debitAmount,
this.expectedSettlementAmount,
this.expectedFeeTotal,
this.feeLines,
this.networkFee,
this.fxQuote,
});
factory PaymentQuoteDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuoteDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentQuoteDTOToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:pshared/data/dto/payment/asset.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/models/payment/asset.dart';
extension PaymentAssetMapper on PaymentAsset {
AssetDTO toDTO() => AssetDTO(
chain: chainNetworkToValue(chain),
tokenSymbol: tokenSymbol,
contractAddress: contractAddress,
);
}
extension AssetDTOMapper on AssetDTO {
PaymentAsset toDomain() => PaymentAsset(
chain: chainNetworkFromValue(chain),
tokenSymbol: tokenSymbol,
contractAddress: contractAddress,
);
}

View File

@@ -3,17 +3,23 @@ import 'package:pshared/models/payment/methods/card.dart';
extension CardPaymentMethodMapper on CardPaymentMethod {
CardPaymentDataDTO toDTO() => CardPaymentDataDTO(
CardEndpointDTO toDTO() => CardEndpointDTO(
pan: pan,
firstName: firstName,
lastName: lastName,
expMonth: expMonth,
expYear: expYear,
country: country,
);
}
extension CardPaymentDataDTOMapper on CardPaymentDataDTO {
extension CardPaymentDataDTOMapper on CardEndpointDTO {
CardPaymentMethod toDomain() => CardPaymentMethod(
pan: pan,
firstName: firstName,
lastName: lastName,
expMonth: expMonth,
expYear: expYear,
country: country,
);
}

View File

@@ -0,0 +1,17 @@
import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/models/payment/methods/card_token.dart';
extension CardTokenPaymentMethodMapper on CardTokenPaymentMethod {
CardTokenEndpointDTO toDTO() => CardTokenEndpointDTO(
token: token,
maskedPan: maskedPan,
);
}
extension CardTokenPaymentDataDTOMapper on CardTokenEndpointDTO {
CardTokenPaymentMethod toDomain() => CardTokenPaymentMethod(
token: token,
maskedPan: maskedPan,
);
}

View File

@@ -1,19 +1,20 @@
import 'package:pshared/data/dto/payment/crypto_address.dart';
import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
extension CryptoAddressPaymentMethodMapper on CryptoAddressPaymentMethod {
CryptoAddressPaymentDataDTO toDTO() => CryptoAddressPaymentDataDTO(
ExternalChainEndpointDTO toDTO() => ExternalChainEndpointDTO(
address: address,
network: network,
destinationTag: destinationTag,
asset: asset?.toDTO(),
memo: memo,
);
}
extension CryptoAddressPaymentDataDTOMapper on CryptoAddressPaymentDataDTO {
extension CryptoAddressPaymentDataDTOMapper on ExternalChainEndpointDTO {
CryptoAddressPaymentMethod toDomain() => CryptoAddressPaymentMethod(
address: address,
network: network,
destinationTag: destinationTag,
asset: asset?.toDomain(),
memo: memo,
);
}

View File

@@ -0,0 +1,16 @@
import 'package:pshared/data/dto/payment/currency_pair.dart';
import 'package:pshared/models/payment/currency_pair.dart';
extension CurrencyPairMapper on CurrencyPair {
CurrencyPairDTO toDTO() => CurrencyPairDTO(
base: base,
quote: quote,
);
}
extension CurrencyPairDTOMapper on CurrencyPairDTO {
CurrencyPair toDomain() => CurrencyPair(
base: base,
quote: quote,
);
}

View File

@@ -0,0 +1,183 @@
import 'package:pshared/models/payment/chain_network.dart';
import 'package:pshared/models/payment/fx/side.dart';
import 'package:pshared/models/payment/insufficient_net_policy.dart';
import 'package:pshared/models/payment/kind.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/payment/settlement_mode.dart';
PaymentKind paymentKindFromValue(String? value) {
switch (value) {
case 'payout':
return PaymentKind.payout;
case 'internal_transfer':
return PaymentKind.internalTransfer;
case 'fx_conversion':
return PaymentKind.fxConversion;
case 'unspecified':
return PaymentKind.unspecified;
default:
throw ArgumentError('Unknown PaymentKind value: $value');
}
}
String paymentKindToValue(PaymentKind kind) {
switch (kind) {
case PaymentKind.payout:
return 'payout';
case PaymentKind.internalTransfer:
return 'internal_transfer';
case PaymentKind.fxConversion:
return 'fx_conversion';
case PaymentKind.unspecified:
return 'unspecified';
}
}
SettlementMode settlementModeFromValue(String? value) {
switch (value) {
case 'fix_source':
return SettlementMode.fixSource;
case 'fix_received':
return SettlementMode.fixReceived;
case 'unspecified':
return SettlementMode.unspecified;
default:
throw ArgumentError('Unknown SettlementMode value: $value');
}
}
String settlementModeToValue(SettlementMode mode) {
switch (mode) {
case SettlementMode.fixSource:
return 'fix_source';
case SettlementMode.fixReceived:
return 'fix_received';
case SettlementMode.unspecified:
return 'unspecified';
}
}
FxSide fxSideFromValue(String? value) {
switch (value) {
case 'buy_base_sell_quote':
return FxSide.buyBaseSellQuote;
case 'sell_base_buy_quote':
return FxSide.sellBaseBuyQuote;
case 'unspecified':
return FxSide.unspecified;
default:
throw ArgumentError('Unknown FxSide value: $value');
}
}
String fxSideToValue(FxSide side) {
switch (side) {
case FxSide.buyBaseSellQuote:
return 'buy_base_sell_quote';
case FxSide.sellBaseBuyQuote:
return 'sell_base_buy_quote';
case FxSide.unspecified:
return 'unspecified';
}
}
ChainNetwork chainNetworkFromValue(String? value) {
switch (value) {
case 'ethereum_mainnet':
return ChainNetwork.ethereumMainnet;
case 'arbitrum_one':
return ChainNetwork.arbitrumOne;
case 'other_evm':
return ChainNetwork.otherEvm;
case 'unspecified':
return ChainNetwork.unspecified;
default:
throw ArgumentError('Unknown ChainNetwork value: $value');
}
}
String chainNetworkToValue(ChainNetwork chain) {
switch (chain) {
case ChainNetwork.ethereumMainnet:
return 'ethereum_mainnet';
case ChainNetwork.arbitrumOne:
return 'arbitrum_one';
case ChainNetwork.otherEvm:
return 'other_evm';
case ChainNetwork.unspecified:
return 'unspecified';
}
}
InsufficientNetPolicy insufficientNetPolicyFromValue(String? value) {
switch (value) {
case 'block_posting':
return InsufficientNetPolicy.blockPosting;
case 'sweep_org_cash':
return InsufficientNetPolicy.sweepOrgCash;
case 'invoice_later':
return InsufficientNetPolicy.invoiceLater;
case 'unspecified':
return InsufficientNetPolicy.unspecified;
default:
throw ArgumentError('Unknown InsufficientNetPolicy value: $value');
}
}
String insufficientNetPolicyToValue(InsufficientNetPolicy policy) {
switch (policy) {
case InsufficientNetPolicy.blockPosting:
return 'block_posting';
case InsufficientNetPolicy.sweepOrgCash:
return 'sweep_org_cash';
case InsufficientNetPolicy.invoiceLater:
return 'invoice_later';
case InsufficientNetPolicy.unspecified:
return 'unspecified';
}
}
PaymentType endpointTypeFromValue(String? value) {
switch (value) {
case 'managedWallet':
return PaymentType.managedWallet;
case 'externalChain':
return PaymentType.externalChain;
case 'card':
return PaymentType.card;
case 'cardToken':
return PaymentType.cardToken;
case 'ledger':
return PaymentType.ledger;
case 'bankAccount':
return PaymentType.bankAccount;
case 'iban':
return PaymentType.iban;
case 'wallet':
return PaymentType.wallet;
default:
throw ArgumentError('Unknown PaymentType value: $value');
}
}
String endpointTypeToValue(PaymentType type) {
switch (type) {
case PaymentType.ledger:
return 'ledger';
case PaymentType.managedWallet:
return 'managedWallet';
case PaymentType.externalChain:
return 'externalChain';
case PaymentType.card:
return 'card';
case PaymentType.cardToken:
return 'cardToken';
case PaymentType.bankAccount:
return 'bankAccount';
case PaymentType.iban:
return 'iban';
case PaymentType.wallet:
return 'wallet';
}
}

View File

@@ -0,0 +1,24 @@
import 'package:pshared/data/dto/payment/fee_line.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/fees/line.dart';
extension FeeLineDTOMapper on FeeLineDTO {
FeeLine toDomain() => FeeLine(
ledgerAccountRef: ledgerAccountRef,
amount: amount?.toDomain(),
lineType: lineType,
side: side,
meta: meta,
);
}
extension FeeLineMapper on FeeLine {
FeeLineDTO toDTO() => FeeLineDTO(
ledgerAccountRef: ledgerAccountRef,
amount: amount?.toDTO(),
lineType: lineType,
side: side,
meta: meta,
);
}

View File

@@ -0,0 +1,36 @@
import 'package:pshared/data/dto/payment/fx_quote.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/fx/quote.dart';
extension FxQuoteDTOMapper on FxQuoteDTO {
FxQuote toDomain() => FxQuote(
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDomain(),
quoteAmount: quoteAmount?.toDomain(),
expiresAtUnixMs: expiresAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm ?? false,
);
}
extension FxQuoteMapper on FxQuote {
FxQuoteDTO toDTO() => FxQuoteDTO(
quoteRef: quoteRef,
baseCurrency: baseCurrency,
quoteCurrency: quoteCurrency,
side: side,
price: price,
baseAmount: baseAmount?.toDTO(),
quoteAmount: quoteAmount?.toDTO(),
expiresAtUnixMs: expiresAtUnixMs,
provider: provider,
rateRef: rateRef,
firm: firm,
);
}

View File

@@ -0,0 +1,27 @@
import 'package:pshared/data/dto/payment/intent/fx.dart';
import 'package:pshared/data/mapper/payment/currency_pair.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/models/payment/fx/intent.dart';
extension FxIntentMapper on FxIntent {
FxIntentDTO toDTO() => FxIntentDTO(
pair: pair?.toDTO(),
side: fxSideToValue(side),
firm: firm,
ttlMs: ttlMs,
preferredProvider: preferredProvider,
maxAgeMs: maxAgeMs,
);
}
extension FxIntentDTOMapper on FxIntentDTO {
FxIntent toDomain() => FxIntent(
pair: pair?.toDomain(),
side: fxSideFromValue(side),
firm: firm,
ttlMs: ttlMs,
preferredProvider: preferredProvider,
maxAgeMs: maxAgeMs,
);
}

View File

@@ -0,0 +1,30 @@
import 'package:pshared/data/dto/payment/intent/payment.dart';
import 'package:pshared/data/mapper/payment/payment.dart';
import 'package:pshared/data/mapper/payment/enums.dart';
import 'package:pshared/data/mapper/payment/intent/fx.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/intent.dart';
extension PaymentIntentMapper on PaymentIntent {
PaymentIntentDTO toDTO() => PaymentIntentDTO(
kind: paymentKindToValue(kind),
source: source?.toDTO(),
destination: destination?.toDTO(),
amount: amount?.toDTO(),
fx: fx?.toDTO(),
settlementMode: settlementModeToValue(settlementMode),
attributes: attributes,
);
}
extension PaymentIntentDTOMapper on PaymentIntentDTO {
PaymentIntent toDomain() => PaymentIntent(
kind: paymentKindFromValue(kind),
source: source?.toDomain(),
destination: destination?.toDomain(),
amount: amount?.toDomain(),
fx: fx?.toDomain(),
settlementMode: settlementModeFromValue(settlementMode),
attributes: attributes,
);
}

View File

@@ -0,0 +1,17 @@
import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
extension LedgerPaymentMethodMapper on LedgerPaymentMethod {
LedgerEndpointDTO toDTO() => LedgerEndpointDTO(
ledgerAccountRef: ledgerAccountRef,
contraLedgerAccountRef: contraLedgerAccountRef,
);
}
extension LedgerPaymentDataDTOMapper on LedgerEndpointDTO {
LedgerPaymentMethod toDomain() => LedgerPaymentMethod(
ledgerAccountRef: ledgerAccountRef,
contraLedgerAccountRef: contraLedgerAccountRef,
);
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
extension ManagedWalletPaymentMethodMapper on ManagedWalletPaymentMethod {
ManagedWalletEndpointDTO toDTO() => ManagedWalletEndpointDTO(
managedWalletRef: managedWalletRef,
asset: asset?.toDTO(),
);
}
extension ManagedWalletDataDTOMapper on ManagedWalletEndpointDTO {
ManagedWalletPaymentMethod toDomain() => ManagedWalletPaymentMethod(
managedWalletRef: managedWalletRef,
asset: asset?.toDomain(),
);
}

View File

@@ -1,21 +1,30 @@
import 'package:pshared/data/dto/payment/card.dart';
import 'package:pshared/data/dto/payment/crypto_address.dart';
import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/dto/payment/iban.dart';
import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/dto/payment/method.dart';
import 'package:pshared/data/dto/payment/russian_bank.dart';
import 'package:pshared/data/dto/payment/wallet.dart';
import 'package:pshared/data/mapper/payment/card_token.dart';
import 'package:pshared/data/mapper/payment/crypto_address.dart';
import 'package:pshared/data/mapper/payment/card.dart';
import 'package:pshared/data/mapper/payment/iban.dart';
import 'package:pshared/data/mapper/payment/ledger.dart';
import 'package:pshared/data/mapper/payment/managed_wallet.dart';
import 'package:pshared/data/mapper/payment/russian_bank.dart';
import 'package:pshared/data/mapper/payment/type.dart';
import 'package:pshared/data/mapper/payment/wallet.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/organization/bound.dart';
import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/iban.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/methods/russian_bank.dart';
import 'package:pshared/models/payment/methods/type.dart';
import 'package:pshared/models/payment/methods/wallet.dart';
@@ -40,20 +49,17 @@ extension PaymentMethodMapper on PaymentMethod {
isMain: isMain,
);
Map<String, dynamic> _dataToJson(PaymentMethodData data) {
switch (data.type) {
case PaymentType.card:
return (data as CardPaymentMethod).toDTO().toJson();
case PaymentType.iban:
return (data as IbanPaymentMethod).toDTO().toJson();
case PaymentType.bankAccount:
return (data as RussianBankAccountPaymentMethod).toDTO().toJson();
case PaymentType.wallet:
return (data as WalletPaymentMethod).toDTO().toJson();
case PaymentType.cryptoAddress:
return (data as CryptoAddressPaymentMethod).toDTO().toJson();
}
}
Map<String, dynamic> _dataToJson(PaymentMethodData data) => switch (data) {
CardPaymentMethod card => card.toDTO().toJson(),
CardTokenPaymentMethod cardToken => cardToken.toDTO().toJson(),
IbanPaymentMethod iban => iban.toDTO().toJson(),
RussianBankAccountPaymentMethod bankAccount => bankAccount.toDTO().toJson(),
WalletPaymentMethod wallet => wallet.toDTO().toJson(),
CryptoAddressPaymentMethod crypto => crypto.toDTO().toJson(),
LedgerPaymentMethod ledger => ledger.toDTO().toJson(),
ManagedWalletPaymentMethod managedWallet => managedWallet.toDTO().toJson(),
_ => throw UnsupportedError('Unsupported payment method data: ${data.runtimeType}'),
};
}
extension PaymentMethodDTOMapper on PaymentMethodDTO {
@@ -73,15 +79,21 @@ extension PaymentMethodDTOMapper on PaymentMethodDTO {
PaymentMethodData _dataToDomain(PaymentType paymentType, Map<String, dynamic> payload) {
switch (paymentType) {
case PaymentType.card:
return CardPaymentDataDTO.fromJson(payload).toDomain();
return CardEndpointDTO.fromJson(payload).toDomain();
case PaymentType.cardToken:
return CardTokenEndpointDTO.fromJson(payload).toDomain();
case PaymentType.iban:
return IbanPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.bankAccount:
return RussianBankAccountPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.wallet:
return WalletPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.cryptoAddress:
return CryptoAddressPaymentDataDTO.fromJson(payload).toDomain();
case PaymentType.externalChain:
return ExternalChainEndpointDTO.fromJson(payload).toDomain();
case PaymentType.ledger:
return LedgerEndpointDTO.fromJson(payload).toDomain();
case PaymentType.managedWallet:
return ManagedWalletEndpointDTO.fromJson(payload).toDomain();
}
}
}

View File

@@ -0,0 +1,16 @@
import 'package:pshared/data/dto/payment/money.dart';
import 'package:pshared/models/payment/money.dart';
extension MoneyMapper on Money {
MoneyDTO toDTO() => MoneyDTO(
amount: amount,
currency: currency,
);
}
extension MoneyDTOMapper on MoneyDTO {
Money toDomain() => Money(
amount: amount,
currency: currency,
);
}

View File

@@ -0,0 +1,18 @@
import 'package:pshared/data/dto/payment/network_fee.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/fees/network.dart';
extension NetworkFeeDTOMapper on NetworkFeeDTO {
NetworkFee toDomain() => NetworkFee(
networkFee: networkFee?.toDomain(),
estimationContext: estimationContext,
);
}
extension NetworkFeeMapper on NetworkFee {
NetworkFeeDTO toDTO() => NetworkFeeDTO(
networkFee: networkFee?.toDTO(),
estimationContext: estimationContext,
);
}

View File

@@ -0,0 +1,133 @@
import 'package:pshared/data/dto/payment/card.dart';
import 'package:pshared/data/dto/payment/card_token.dart';
import 'package:pshared/data/dto/payment/endpoint.dart';
import 'package:pshared/data/dto/payment/external_chain.dart';
import 'package:pshared/data/dto/payment/ledger.dart';
import 'package:pshared/data/dto/payment/managed_wallet.dart';
import 'package:pshared/data/mapper/payment/asset.dart';
import 'package:pshared/data/mapper/payment/type.dart';
import 'package:pshared/models/payment/methods/card.dart';
import 'package:pshared/models/payment/methods/card_token.dart';
import 'package:pshared/models/payment/methods/crypto_address.dart';
import 'package:pshared/models/payment/methods/data.dart';
import 'package:pshared/models/payment/methods/ledger.dart';
import 'package:pshared/models/payment/methods/managed_wallet.dart';
import 'package:pshared/models/payment/type.dart';
extension PaymentMethodDataEndpointMapper on PaymentMethodData {
PaymentEndpointDTO toDTO() {
final metadata = this.metadata;
switch (type) {
case PaymentType.ledger:
final payload = this as LedgerPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: LedgerEndpointDTO(
ledgerAccountRef: payload.ledgerAccountRef,
contraLedgerAccountRef: payload.contraLedgerAccountRef,
).toJson(),
metadata: metadata,
);
case PaymentType.managedWallet:
final payload = this as ManagedWalletPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: ManagedWalletEndpointDTO(
managedWalletRef: payload.managedWalletRef,
asset: payload.asset?.toDTO(),
).toJson(),
metadata: metadata,
);
case PaymentType.externalChain:
final payload = this as CryptoAddressPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: ExternalChainEndpointDTO(
asset: payload.asset?.toDTO(),
address: payload.address,
memo: payload.memo,
).toJson(),
metadata: metadata,
);
case PaymentType.card:
final payload = this as CardPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: CardEndpointDTO(
pan: payload.pan,
expMonth: payload.expMonth,
expYear: payload.expYear,
country: payload.country,
firstName: payload.firstName,
lastName: payload.lastName,
).toJson(),
metadata: metadata,
);
case PaymentType.cardToken:
final payload = this as CardTokenPaymentMethod;
return PaymentEndpointDTO(
type: paymentTypeToValue(type),
data: CardTokenEndpointDTO(
token: payload.token,
maskedPan: payload.maskedPan,
).toJson(),
metadata: metadata,
);
default:
throw UnsupportedError('Unsupported payment endpoint type: $type');
}
}
}
extension PaymentEndpointDTOMapper on PaymentEndpointDTO {
PaymentMethodData toDomain() {
final metadata = this.metadata;
switch (paymentTypeFromValue(type)) {
case PaymentType.ledger:
final payload = LedgerEndpointDTO.fromJson(data);
return LedgerPaymentMethod(
ledgerAccountRef: payload.ledgerAccountRef,
contraLedgerAccountRef: payload.contraLedgerAccountRef,
metadata: metadata,
);
case PaymentType.managedWallet:
final payload = ManagedWalletEndpointDTO.fromJson(data);
return ManagedWalletPaymentMethod(
managedWalletRef: payload.managedWalletRef,
asset: payload.asset?.toDomain(),
metadata: metadata,
);
case PaymentType.externalChain:
final payload = ExternalChainEndpointDTO.fromJson(data);
return CryptoAddressPaymentMethod(
asset: payload.asset?.toDomain(),
address: payload.address,
memo: payload.memo,
metadata: metadata,
);
case PaymentType.card:
final payload = CardEndpointDTO.fromJson(data);
return CardPaymentMethod(
pan: payload.pan,
firstName: payload.firstName,
lastName: payload.lastName,
expMonth: payload.expMonth,
expYear: payload.expYear,
country: payload.country,
metadata: metadata,
);
case PaymentType.cardToken:
final payload = CardTokenEndpointDTO.fromJson(data);
return CardTokenPaymentMethod(
token: payload.token,
maskedPan: payload.maskedPan,
metadata: metadata,
);
default:
throw UnsupportedError('Unsupported payment endpoint type: ${paymentTypeFromValue(type)}');
}
}
}

View File

@@ -0,0 +1,31 @@
import 'package:pshared/data/dto/payment/payment_quote.dart';
import 'package:pshared/data/mapper/payment/fee_line.dart';
import 'package:pshared/data/mapper/payment/fx_quote.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/data/mapper/payment/network_fee.dart';
import 'package:pshared/models/payment/quote.dart';
extension PaymentQuoteDTOMapper on PaymentQuoteDTO {
PaymentQuote toDomain() => PaymentQuote(
quoteRef: quoteRef,
debitAmount: debitAmount?.toDomain(),
expectedSettlementAmount: expectedSettlementAmount?.toDomain(),
expectedFeeTotal: expectedFeeTotal?.toDomain(),
feeLines: feeLines?.map((line) => line.toDomain()).toList(),
networkFee: networkFee?.toDomain(),
fxQuote: fxQuote?.toDomain(),
);
}
extension PaymentQuoteMapper on PaymentQuote {
PaymentQuoteDTO toDTO() => PaymentQuoteDTO(
quoteRef: quoteRef,
debitAmount: debitAmount?.toDTO(),
expectedSettlementAmount: expectedSettlementAmount?.toDTO(),
expectedFeeTotal: expectedFeeTotal?.toDTO(),
feeLines: feeLines?.map((line) => line.toDTO()).toList(),
networkFee: networkFee?.toDTO(),
fxQuote: fxQuote?.toDTO(),
);
}

View File

@@ -0,0 +1,26 @@
import 'package:pshared/data/dto/payment/payment.dart';
import 'package:pshared/data/mapper/payment/payment_quote.dart';
import 'package:pshared/models/payment/payment.dart';
extension PaymentDTOMapper on PaymentDTO {
Payment toDomain() => Payment(
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state,
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDomain(),
);
}
extension PaymentMapper on Payment {
PaymentDTO toDTO() => PaymentDTO(
paymentRef: paymentRef,
idempotencyKey: idempotencyKey,
state: state,
failureCode: failureCode,
failureReason: failureReason,
lastQuote: lastQuote?.toDTO(),
);
}

View File

@@ -7,12 +7,18 @@ PaymentType paymentTypeFromValue(String value) {
return PaymentType.iban;
case 'card':
return PaymentType.card;
case 'cardToken':
return PaymentType.cardToken;
case 'bankAccount':
return PaymentType.bankAccount;
case 'ledger':
return PaymentType.ledger;
case 'wallet':
return PaymentType.wallet;
case 'managedWallet':
return PaymentType.managedWallet;
case 'cryptoAddress':
return PaymentType.cryptoAddress;
return PaymentType.externalChain;
default:
return PaymentType.iban;
}
@@ -24,11 +30,17 @@ String paymentTypeToValue(PaymentType type) {
return 'iban';
case PaymentType.card:
return 'card';
case PaymentType.cardToken:
return 'cardToken';
case PaymentType.ledger:
return 'ledger';
case PaymentType.bankAccount:
return 'bankAccount';
case PaymentType.wallet:
return 'wallet';
case PaymentType.cryptoAddress:
case PaymentType.managedWallet:
return 'managedWallet';
case PaymentType.externalChain:
return 'cryptoAddress';
}
}

View File

@@ -29,5 +29,25 @@
"resourceEmpty": "Empty data",
"@resourceEmpty": {
"description": "Default message shown when no data is available"
},
"chainNetworkUnspecified": "Unspecified network",
"@chainNetworkUnspecified": {
"description": "Fallback label when the chain network is not known"
},
"chainNetworkEthereumMainnet": "Ethereum Mainnet",
"@chainNetworkEthereumMainnet": {
"description": "Label for the Ethereum mainnet network"
},
"chainNetworkArbitrumOne": "Arbitrum One",
"@chainNetworkArbitrumOne": {
"description": "Label for the Arbitrum One network"
},
"chainNetworkOtherEvm": "Other EVM chain",
"@chainNetworkOtherEvm": {
"description": "Label for any other EVM-compatible network"
}
}

View File

@@ -29,5 +29,25 @@
"resourceEmpty": "Нет данных",
"@resourceEmpty": {
"description": "Default message shown when no data is available"
},
"chainNetworkUnspecified": "Сеть не указана",
"@chainNetworkUnspecified": {
"description": "Fallback label when the chain network is not known"
},
"chainNetworkEthereumMainnet": "Ethereum Mainnet",
"@chainNetworkEthereumMainnet": {
"description": "Label for the Ethereum mainnet network"
},
"chainNetworkArbitrumOne": "Arbitrum One",
"@chainNetworkArbitrumOne": {
"description": "Label for the Arbitrum One network"
},
"chainNetworkOtherEvm": "Другая EVM сеть",
"@chainNetworkOtherEvm": {
"description": "Label for any other EVM-compatible network"
}
}

View File

@@ -0,0 +1,14 @@
import 'package:pshared/models/payment/chain_network.dart';
class PaymentAsset {
final ChainNetwork chain;
final String tokenSymbol;
final String? contractAddress;
const PaymentAsset({
this.chain = ChainNetwork.unspecified,
required this.tokenSymbol,
this.contractAddress,
});
}

Some files were not shown because too many files have changed in this diff Show More