From 7424ef751c3365b85b6b67bec46d561a84766f2a Mon Sep 17 00:00:00 2001 From: Stephan D Date: Mon, 5 Jan 2026 01:22:47 +0100 Subject: [PATCH] interface refactoring --- api/gateway/chain/client/client.go | 698 +++++++++++++++++- .../internal/service/gateway/connector.go | 691 +++++++++++++++++ .../chain/internal/service/gateway/service.go | 8 +- api/gateway/mntx/client/client.go | 258 ++++++- api/gateway/mntx/go.mod | 1 + .../internal/service/gateway/connector.go | 294 ++++++++ .../mntx/internal/service/gateway/service.go | 6 +- .../internal/service/gateway/connector.go | 254 +++++++ .../internal/service/gateway/service.go | 6 +- api/ledger/client/client.go | 466 +++++++++++- .../internal/service/ledger/connector.go | 622 ++++++++++++++++ api/ledger/internal/service/ledger/service.go | 5 +- api/pkg/connector/params/params.go | 138 ++++ api/proto/connector/v1/connector.proto | 256 +++++++ api/proto/gateway/unified/v1/gateway.proto | 45 -- ci/scripts/proto/generate.sh | 8 +- 16 files changed, 3623 insertions(+), 133 deletions(-) create mode 100644 api/gateway/chain/internal/service/gateway/connector.go create mode 100644 api/gateway/mntx/internal/service/gateway/connector.go create mode 100644 api/gateway/tgsettle/internal/service/gateway/connector.go create mode 100644 api/ledger/internal/service/ledger/connector.go create mode 100644 api/pkg/connector/params/params.go create mode 100644 api/proto/connector/v1/connector.proto delete mode 100644 api/proto/gateway/unified/v1/gateway.proto diff --git a/api/gateway/chain/client/client.go b/api/gateway/chain/client/client.go index f7676ed..dd87230 100644 --- a/api/gateway/chain/client/client.go +++ b/api/gateway/chain/client/client.go @@ -8,13 +8,19 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" ) +const chainConnectorID = "chain" + // Client exposes typed helpers around the chain gateway gRPC API. type Client interface { CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) @@ -30,23 +36,21 @@ type Client interface { Close() error } -type grpcGatewayClient interface { - CreateManagedWallet(ctx context.Context, in *chainv1.CreateManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.CreateManagedWalletResponse, error) - GetManagedWallet(ctx context.Context, in *chainv1.GetManagedWalletRequest, opts ...grpc.CallOption) (*chainv1.GetManagedWalletResponse, error) - ListManagedWallets(ctx context.Context, in *chainv1.ListManagedWalletsRequest, opts ...grpc.CallOption) (*chainv1.ListManagedWalletsResponse, error) - GetWalletBalance(ctx context.Context, in *chainv1.GetWalletBalanceRequest, opts ...grpc.CallOption) (*chainv1.GetWalletBalanceResponse, error) - SubmitTransfer(ctx context.Context, in *chainv1.SubmitTransferRequest, opts ...grpc.CallOption) (*chainv1.SubmitTransferResponse, error) - GetTransfer(ctx context.Context, in *chainv1.GetTransferRequest, opts ...grpc.CallOption) (*chainv1.GetTransferResponse, error) - ListTransfers(ctx context.Context, in *chainv1.ListTransfersRequest, opts ...grpc.CallOption) (*chainv1.ListTransfersResponse, error) - EstimateTransferFee(ctx context.Context, in *chainv1.EstimateTransferFeeRequest, opts ...grpc.CallOption) (*chainv1.EstimateTransferFeeResponse, error) - ComputeGasTopUp(ctx context.Context, in *chainv1.ComputeGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.ComputeGasTopUpResponse, error) - EnsureGasTopUp(ctx context.Context, in *chainv1.EnsureGasTopUpRequest, opts ...grpc.CallOption) (*chainv1.EnsureGasTopUpResponse, error) +type grpcConnectorClient interface { + GetCapabilities(ctx context.Context, in *connectorv1.GetCapabilitiesRequest, opts ...grpc.CallOption) (*connectorv1.GetCapabilitiesResponse, error) + OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error) + GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error) + ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error) + GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error) + SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) + GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) + ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error) } type chainGatewayClient struct { cfg Config conn *grpc.ClientConn - client grpcGatewayClient + client grpcConnectorClient } // New dials the chain gateway endpoint and returns a ready client. @@ -76,12 +80,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro return &chainGatewayClient{ cfg: cfg, conn: conn, - client: unifiedv1.NewUnifiedGatewayServiceClient(conn), + client: connectorv1.NewConnectorServiceClient(conn), }, nil } // NewWithClient injects a pre-built gateway client (useful for tests). -func NewWithClient(cfg Config, gc grpcGatewayClient) Client { +func NewWithClient(cfg Config, gc grpcConnectorClient) Client { cfg.setDefaults() return &chainGatewayClient{ cfg: cfg, @@ -99,61 +103,213 @@ func (c *chainGatewayClient) Close() error { func (c *chainGatewayClient) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.CreateManagedWallet(ctx, req) + + params, err := walletParamsFromRequest(req) + if err != nil { + return nil, err + } + + label := "" + if desc := req.GetDescribable(); desc != nil { + label = strings.TrimSpace(desc.GetName()) + } + resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{ + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET, + Asset: assetStringFromChainAsset(req.GetAsset()), + OwnerRef: strings.TrimSpace(req.GetOwnerRef()), + Label: label, + Params: params, + }) + if err != nil { + return nil, err + } + if resp.GetError() != nil { + return nil, connectorError(resp.GetError()) + } + wallet := managedWalletFromAccount(resp.GetAccount()) + return &chainv1.CreateManagedWalletResponse{Wallet: wallet}, nil } func (c *chainGatewayClient) GetManagedWallet(ctx context.Context, req *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.GetManagedWallet(ctx, req) + if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required") + } + resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}}) + if err != nil { + return nil, err + } + return &chainv1.GetManagedWalletResponse{Wallet: managedWalletFromAccount(resp.GetAccount())}, nil } func (c *chainGatewayClient) ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.ListManagedWallets(ctx, req) + assetString := "" + ownerRef := "" + var page *paginationv1.CursorPageRequest + if req != nil { + assetString = assetStringFromChainAsset(req.GetAsset()) + ownerRef = strings.TrimSpace(req.GetOwnerRef()) + page = req.GetPage() + } + resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{ + OwnerRef: ownerRef, + Asset: assetString, + Page: page, + }) + if err != nil { + return nil, err + } + wallets := make([]*chainv1.ManagedWallet, 0, len(resp.GetAccounts())) + for _, account := range resp.GetAccounts() { + wallets = append(wallets, managedWalletFromAccount(account)) + } + return &chainv1.ListManagedWalletsResponse{Wallets: wallets, Page: resp.GetPage()}, nil } func (c *chainGatewayClient) GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.GetWalletBalance(ctx, req) + if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required") + } + resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}}) + if err != nil { + return nil, err + } + balance := resp.GetBalance() + if balance == nil { + return nil, merrors.Internal("chain-gateway: balance response missing") + } + return &chainv1.GetWalletBalanceResponse{Balance: &chainv1.WalletBalance{ + Available: balance.GetAvailable(), + PendingInbound: balance.GetPendingInbound(), + PendingOutbound: balance.GetPendingOutbound(), + CalculatedAt: balance.GetCalculatedAt(), + }}, nil } func (c *chainGatewayClient) SubmitTransfer(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.SubmitTransfer(ctx, req) + if req == nil { + return nil, merrors.InvalidArgument("chain-gateway: request is required") + } + operation, err := operationFromTransfer(req) + if err != nil { + return nil, err + } + resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + transfer := transferFromReceipt(req, resp.GetReceipt()) + return &chainv1.SubmitTransferResponse{Transfer: transfer}, nil } func (c *chainGatewayClient) GetTransfer(ctx context.Context, req *chainv1.GetTransferRequest) (*chainv1.GetTransferResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.GetTransfer(ctx, req) + if req == nil || strings.TrimSpace(req.GetTransferRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: transfer_ref is required") + } + resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetTransferRef())}) + if err != nil { + return nil, err + } + return &chainv1.GetTransferResponse{Transfer: transferFromOperation(resp.GetOperation())}, nil } func (c *chainGatewayClient) ListTransfers(ctx context.Context, req *chainv1.ListTransfersRequest) (*chainv1.ListTransfersResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.ListTransfers(ctx, req) + source := "" + status := chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED + var page *paginationv1.CursorPageRequest + if req != nil { + source = strings.TrimSpace(req.GetSourceWalletRef()) + status = req.GetStatus() + page = req.GetPage() + } + resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{ + AccountRef: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: source}, + Status: operationStatusFromTransfer(status), + Page: page, + }) + if err != nil { + return nil, err + } + transfers := make([]*chainv1.Transfer, 0, len(resp.GetOperations())) + for _, op := range resp.GetOperations() { + transfers = append(transfers, transferFromOperation(op)) + } + return &chainv1.ListTransfersResponse{Transfers: transfers, Page: resp.GetPage()}, nil } func (c *chainGatewayClient) EstimateTransferFee(ctx context.Context, req *chainv1.EstimateTransferFeeRequest) (*chainv1.EstimateTransferFeeResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.EstimateTransferFee(ctx, req) + if req == nil { + return nil, merrors.InvalidArgument("chain-gateway: request is required") + } + operation, err := feeEstimateOperation(req) + if err != nil { + return nil, err + } + resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return estimateFromReceipt(resp.GetReceipt()), nil } func (c *chainGatewayClient) ComputeGasTopUp(ctx context.Context, req *chainv1.ComputeGasTopUpRequest) (*chainv1.ComputeGasTopUpResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.ComputeGasTopUp(ctx, req) + if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required") + } + operation, err := gasTopUpComputeOperation(req) + if err != nil { + return nil, err + } + resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return computeGasTopUpFromReceipt(resp.GetReceipt()), nil } func (c *chainGatewayClient) EnsureGasTopUp(ctx context.Context, req *chainv1.EnsureGasTopUpRequest) (*chainv1.EnsureGasTopUpResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.EnsureGasTopUp(ctx, req) + if req == nil { + return nil, merrors.InvalidArgument("chain-gateway: request is required") + } + operation, err := gasTopUpEnsureOperation(req) + if err != nil { + return nil, err + } + resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return ensureGasTopUpFromReceipt(resp.GetReceipt()), nil } func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { @@ -163,3 +319,495 @@ func (c *chainGatewayClient) callContext(ctx context.Context) (context.Context, } return context.WithTimeout(ctx, timeout) } + +func walletParamsFromRequest(req *chainv1.CreateManagedWalletRequest) (*structpb.Struct, error) { + if req == nil { + return nil, nil + } + params := map[string]interface{}{ + "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), + } + if asset := req.GetAsset(); asset != nil { + params["network"] = asset.GetChain().String() + params["token_symbol"] = strings.TrimSpace(asset.GetTokenSymbol()) + params["contract_address"] = strings.TrimSpace(asset.GetContractAddress()) + } + desc := "" + if describable := req.GetDescribable(); describable != nil { + desc = strings.TrimSpace(describable.GetDescription()) + } + if desc != "" { + params["description"] = desc + } + if len(req.GetMetadata()) > 0 { + params["metadata"] = mapStringToInterface(req.GetMetadata()) + } + return structpb.NewStruct(params) +} + +func managedWalletFromAccount(account *connectorv1.Account) *chainv1.ManagedWallet { + if account == nil { + return nil + } + details := map[string]interface{}{} + if account.GetProviderDetails() != nil { + details = account.GetProviderDetails().AsMap() + } + walletRef := "" + if ref := account.GetRef(); ref != nil { + walletRef = strings.TrimSpace(ref.GetAccountId()) + } + if v := stringFromDetails(details, "wallet_ref"); v != "" { + walletRef = v + } + organizationRef := stringFromDetails(details, "organization_ref") + ownerRef := stringFromDetails(details, "owner_ref") + if ownerRef == "" { + ownerRef = strings.TrimSpace(account.GetOwnerRef()) + } + asset := &chainv1.Asset{ + Chain: chainNetworkFromString(stringFromDetails(details, "network")), + TokenSymbol: strings.TrimSpace(stringFromDetails(details, "token_symbol")), + ContractAddress: strings.TrimSpace(stringFromDetails(details, "contract_address")), + } + if asset.GetTokenSymbol() == "" { + asset.TokenSymbol = strings.TrimSpace(tokenFromAssetString(account.GetAsset())) + } + return &chainv1.ManagedWallet{ + WalletRef: walletRef, + OrganizationRef: organizationRef, + OwnerRef: ownerRef, + Asset: asset, + DepositAddress: stringFromDetails(details, "deposit_address"), + Status: managedWalletStatusFromAccount(account.GetState()), + CreatedAt: account.GetCreatedAt(), + UpdatedAt: account.GetUpdatedAt(), + Describable: &describablev1.Describable{ + Name: strings.TrimSpace(account.GetLabel()), + }, + } +} + +func operationFromTransfer(req *chainv1.SubmitTransferRequest) (*connectorv1.Operation, error) { + if req == nil { + return nil, merrors.InvalidArgument("chain-gateway: request is required") + } + if strings.TrimSpace(req.GetIdempotencyKey()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required") + } + if strings.TrimSpace(req.GetSourceWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required") + } + if req.GetDestination() == nil { + return nil, merrors.InvalidArgument("chain-gateway: destination is required") + } + if req.GetAmount() == nil { + return nil, merrors.InvalidArgument("chain-gateway: amount is required") + } + + params := map[string]interface{}{ + "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), + "client_reference": strings.TrimSpace(req.GetClientReference()), + } + if memo := strings.TrimSpace(req.GetDestination().GetMemo()); memo != "" { + params["destination_memo"] = memo + } + if len(req.GetMetadata()) > 0 { + params["metadata"] = mapStringToInterface(req.GetMetadata()) + } + if len(req.GetFees()) > 0 { + params["fees"] = feesToInterface(req.GetFees()) + } + + op := &connectorv1.Operation{ + Type: connectorv1.OperationType_TRANSFER, + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}}, + Money: req.GetAmount(), + Params: structFromMap(params), + } + to, err := destinationToParty(req.GetDestination()) + if err != nil { + return nil, err + } + op.To = to + return op, nil +} + +func destinationToParty(dest *chainv1.TransferDestination) (*connectorv1.OperationParty, error) { + if dest == nil { + return nil, merrors.InvalidArgument("chain-gateway: destination is required") + } + switch d := dest.GetDestination().(type) { + case *chainv1.TransferDestination_ManagedWalletRef: + return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(d.ManagedWalletRef)}}}, nil + case *chainv1.TransferDestination_ExternalAddress: + return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{ExternalRef: strings.TrimSpace(d.ExternalAddress)}}}, nil + default: + return nil, merrors.InvalidArgument("chain-gateway: destination is required") + } +} + +func transferFromReceipt(req *chainv1.SubmitTransferRequest, receipt *connectorv1.OperationReceipt) *chainv1.Transfer { + transfer := &chainv1.Transfer{} + if req != nil { + transfer.IdempotencyKey = strings.TrimSpace(req.GetIdempotencyKey()) + transfer.OrganizationRef = strings.TrimSpace(req.GetOrganizationRef()) + transfer.SourceWalletRef = strings.TrimSpace(req.GetSourceWalletRef()) + transfer.Destination = req.GetDestination() + transfer.RequestedAmount = req.GetAmount() + transfer.NetAmount = req.GetAmount() + } + if receipt != nil { + transfer.TransferRef = strings.TrimSpace(receipt.GetOperationId()) + transfer.Status = transferStatusFromOperation(receipt.GetStatus()) + transfer.TransactionHash = strings.TrimSpace(receipt.GetProviderRef()) + } + return transfer +} + +func transferFromOperation(op *connectorv1.Operation) *chainv1.Transfer { + if op == nil { + return nil + } + transfer := &chainv1.Transfer{ + TransferRef: strings.TrimSpace(op.GetOperationId()), + IdempotencyKey: strings.TrimSpace(op.GetOperationId()), + RequestedAmount: op.GetMoney(), + NetAmount: op.GetMoney(), + Status: transferStatusFromOperation(op.GetStatus()), + TransactionHash: strings.TrimSpace(op.GetProviderRef()), + CreatedAt: op.GetCreatedAt(), + UpdatedAt: op.GetUpdatedAt(), + } + if from := op.GetFrom(); from != nil && from.GetAccount() != nil { + transfer.SourceWalletRef = strings.TrimSpace(from.GetAccount().GetAccountId()) + } + if to := op.GetTo(); to != nil { + if account := to.GetAccount(); account != nil { + transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}} + } + if external := to.GetExternal(); external != nil { + transfer.Destination = &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(external.GetExternalRef())}} + } + } + return transfer +} + +func feeEstimateOperation(req *chainv1.EstimateTransferFeeRequest) (*connectorv1.Operation, error) { + if req == nil { + return nil, merrors.InvalidArgument("chain-gateway: request is required") + } + if strings.TrimSpace(req.GetSourceWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required") + } + if req.GetDestination() == nil { + return nil, merrors.InvalidArgument("chain-gateway: destination is required") + } + if req.GetAmount() == nil { + return nil, merrors.InvalidArgument("chain-gateway: amount is required") + } + params := map[string]interface{}{} + op := &connectorv1.Operation{ + Type: connectorv1.OperationType_FEE_ESTIMATE, + IdempotencyKey: feeEstimateKey(req), + From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}}, + Money: req.GetAmount(), + Params: structFromMap(params), + } + to, err := destinationToParty(req.GetDestination()) + if err != nil { + return nil, err + } + op.To = to + return op, nil +} + +func estimateFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EstimateTransferFeeResponse { + resp := &chainv1.EstimateTransferFeeResponse{} + if receipt == nil || receipt.GetResult() == nil { + return resp + } + data := receipt.GetResult().AsMap() + if networkFee, ok := data["network_fee"].(map[string]interface{}); ok { + amount := strings.TrimSpace(fmt.Sprint(networkFee["amount"])) + currency := strings.TrimSpace(fmt.Sprint(networkFee["currency"])) + if amount != "" && currency != "" { + resp.NetworkFee = &moneyv1.Money{Amount: amount, Currency: currency} + } + } + if ctx, ok := data["estimation_context"].(string); ok { + resp.EstimationContext = strings.TrimSpace(ctx) + } + return resp +} + +func gasTopUpComputeOperation(req *chainv1.ComputeGasTopUpRequest) (*connectorv1.Operation, error) { + if req == nil || strings.TrimSpace(req.GetWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: wallet_ref is required") + } + fee := req.GetEstimatedTotalFee() + if fee == nil { + return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required") + } + params := map[string]interface{}{ + "mode": "compute", + "estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()}, + } + return &connectorv1.Operation{ + Type: connectorv1.OperationType_GAS_TOPUP, + IdempotencyKey: fmt.Sprintf("gas_topup_compute:%s:%s", strings.TrimSpace(req.GetWalletRef()), strings.TrimSpace(fee.GetAmount())), + From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetWalletRef())}}}, + Params: structFromMap(params), + }, nil +} + +func gasTopUpEnsureOperation(req *chainv1.EnsureGasTopUpRequest) (*connectorv1.Operation, error) { + if req == nil { + return nil, merrors.InvalidArgument("chain-gateway: request is required") + } + if strings.TrimSpace(req.GetIdempotencyKey()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: idempotency_key is required") + } + if strings.TrimSpace(req.GetSourceWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: source_wallet_ref is required") + } + if strings.TrimSpace(req.GetTargetWalletRef()) == "" { + return nil, merrors.InvalidArgument("chain-gateway: target_wallet_ref is required") + } + fee := req.GetEstimatedTotalFee() + if fee == nil { + return nil, merrors.InvalidArgument("chain-gateway: estimated_total_fee is required") + } + params := map[string]interface{}{ + "mode": "ensure", + "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), + "target_wallet_ref": strings.TrimSpace(req.GetTargetWalletRef()), + "client_reference": strings.TrimSpace(req.GetClientReference()), + "estimated_total_fee": map[string]interface{}{"amount": fee.GetAmount(), "currency": fee.GetCurrency()}, + } + if len(req.GetMetadata()) > 0 { + params["metadata"] = mapStringToInterface(req.GetMetadata()) + } + return &connectorv1.Operation{ + Type: connectorv1.OperationType_GAS_TOPUP, + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: chainConnectorID, AccountId: strings.TrimSpace(req.GetSourceWalletRef())}}}, + Params: structFromMap(params), + }, nil +} + +func computeGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.ComputeGasTopUpResponse { + resp := &chainv1.ComputeGasTopUpResponse{} + if receipt == nil || receipt.GetResult() == nil { + return resp + } + data := receipt.GetResult().AsMap() + if amount, ok := data["topup_amount"].(map[string]interface{}); ok { + resp.TopupAmount = &moneyv1.Money{ + Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])), + Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])), + } + } + if capHit, ok := data["cap_hit"].(bool); ok { + resp.CapHit = capHit + } + return resp +} + +func ensureGasTopUpFromReceipt(receipt *connectorv1.OperationReceipt) *chainv1.EnsureGasTopUpResponse { + resp := &chainv1.EnsureGasTopUpResponse{} + if receipt == nil || receipt.GetResult() == nil { + return resp + } + data := receipt.GetResult().AsMap() + if amount, ok := data["topup_amount"].(map[string]interface{}); ok { + resp.TopupAmount = &moneyv1.Money{ + Amount: strings.TrimSpace(fmt.Sprint(amount["amount"])), + Currency: strings.TrimSpace(fmt.Sprint(amount["currency"])), + } + } + if capHit, ok := data["cap_hit"].(bool); ok { + resp.CapHit = capHit + } + if transferRef, ok := data["transfer_ref"].(string); ok { + resp.Transfer = &chainv1.Transfer{TransferRef: strings.TrimSpace(transferRef)} + } + return resp +} + +func feeEstimateKey(req *chainv1.EstimateTransferFeeRequest) string { + if req == nil || req.GetAmount() == nil { + return "fee_estimate" + } + return fmt.Sprintf("fee_estimate:%s:%s:%s", strings.TrimSpace(req.GetSourceWalletRef()), strings.TrimSpace(req.GetAmount().GetCurrency()), strings.TrimSpace(req.GetAmount().GetAmount())) +} + +func connectorError(err *connectorv1.ConnectorError) error { + if err == nil { + return nil + } + msg := strings.TrimSpace(err.GetMessage()) + switch err.GetCode() { + case connectorv1.ErrorCode_INVALID_PARAMS: + return merrors.InvalidArgument(msg) + case connectorv1.ErrorCode_NOT_FOUND: + return merrors.NoData(msg) + case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND: + return merrors.NotImplemented(msg) + case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE: + return merrors.Internal(msg) + default: + return merrors.Internal(msg) + } +} + +func structFromMap(data map[string]interface{}) *structpb.Struct { + if len(data) == 0 { + return nil + } + result, err := structpb.NewStruct(data) + if err != nil { + return nil + } + return result +} + +func mapStringToInterface(input map[string]string) map[string]interface{} { + if len(input) == 0 { + return nil + } + out := make(map[string]interface{}, len(input)) + for k, v := range input { + out[k] = v + } + return out +} + +func feesToInterface(fees []*chainv1.ServiceFeeBreakdown) []interface{} { + if len(fees) == 0 { + return nil + } + result := make([]interface{}, 0, len(fees)) + for _, fee := range fees { + if fee == nil || fee.GetAmount() == nil { + continue + } + result = append(result, map[string]interface{}{ + "fee_code": strings.TrimSpace(fee.GetFeeCode()), + "description": strings.TrimSpace(fee.GetDescription()), + "amount": strings.TrimSpace(fee.GetAmount().GetAmount()), + "currency": strings.TrimSpace(fee.GetAmount().GetCurrency()), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func stringFromDetails(details map[string]interface{}, key string) string { + if details == nil { + return "" + } + if value, ok := details[key]; ok { + return strings.TrimSpace(fmt.Sprint(value)) + } + return "" +} + +func managedWalletStatusFromAccount(state connectorv1.AccountState) chainv1.ManagedWalletStatus { + switch state { + case connectorv1.AccountState_ACCOUNT_ACTIVE: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE + case connectorv1.AccountState_ACCOUNT_SUSPENDED: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED + case connectorv1.AccountState_ACCOUNT_CLOSED: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED + default: + return chainv1.ManagedWalletStatus_MANAGED_WALLET_STATUS_UNSPECIFIED + } +} + +func transferStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { + switch status { + case connectorv1.OperationStatus_CONFIRMED: + return chainv1.TransferStatus_TRANSFER_CONFIRMED + case connectorv1.OperationStatus_FAILED: + return chainv1.TransferStatus_TRANSFER_FAILED + case connectorv1.OperationStatus_CANCELED: + return chainv1.TransferStatus_TRANSFER_CANCELLED + default: + return chainv1.TransferStatus_TRANSFER_PENDING + } +} + +func operationStatusFromTransfer(status chainv1.TransferStatus) connectorv1.OperationStatus { + switch status { + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + return connectorv1.OperationStatus_CONFIRMED + case chainv1.TransferStatus_TRANSFER_FAILED: + return connectorv1.OperationStatus_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return connectorv1.OperationStatus_CANCELED + default: + return connectorv1.OperationStatus_OPERATION_STATUS_UNSPECIFIED + } +} + +func assetStringFromChainAsset(asset *chainv1.Asset) string { + if asset == nil { + return "" + } + symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol())) + if symbol == "" { + return "" + } + suffix := chainAssetSuffix(asset.GetChain()) + if suffix == "" { + return symbol + } + return symbol + "-" + suffix +} + +func chainAssetSuffix(chain chainv1.ChainNetwork) string { + switch chain { + case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET: + return "ETH" + case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE: + return "ARB" + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET: + return "TRC20" + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE: + return "TRC20" + default: + return "" + } +} + +func tokenFromAssetString(asset string) string { + if asset == "" { + return "" + } + if idx := strings.Index(asset, "-"); idx > 0 { + return asset[:idx] + } + return asset +} + +func chainNetworkFromString(value string) chainv1.ChainNetwork { + value = strings.ToUpper(strings.TrimSpace(value)) + if value == "" { + return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED + } + if val, ok := chainv1.ChainNetwork_value[value]; ok { + return chainv1.ChainNetwork(val) + } + if !strings.HasPrefix(value, "CHAIN_NETWORK_") { + value = "CHAIN_NETWORK_" + value + } + if val, ok := chainv1.ChainNetwork_value[value]; ok { + return chainv1.ChainNetwork(val) + } + return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED +} diff --git a/api/gateway/chain/internal/service/gateway/connector.go b/api/gateway/chain/internal/service/gateway/connector.go new file mode 100644 index 0000000..93f2e6a --- /dev/null +++ b/api/gateway/chain/internal/service/gateway/connector.go @@ -0,0 +1,691 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/tech/sendico/gateway/chain/internal/appversion" + "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" + "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/merrors" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "google.golang.org/protobuf/types/known/structpb" +) + +const chainConnectorID = "chain" + +func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { + return &connectorv1.GetCapabilitiesResponse{ + Capabilities: &connectorv1.ConnectorCapabilities{ + ConnectorType: chainConnectorID, + Version: appversion.Create().Short(), + SupportedAccountKinds: []connectorv1.AccountKind{connectorv1.AccountKind_CHAIN_MANAGED_WALLET}, + SupportedOperationTypes: []connectorv1.OperationType{ + connectorv1.OperationType_TRANSFER, + connectorv1.OperationType_FEE_ESTIMATE, + connectorv1.OperationType_GAS_TOPUP, + }, + OpenAccountParams: chainOpenAccountParams(), + OperationParams: chainOperationParams(), + }, + }, nil +} + +func (s *Service) OpenAccount(ctx context.Context, req *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) { + if req == nil { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: request is required", nil, "")}, nil + } + if req.GetKind() != connectorv1.AccountKind_CHAIN_MANAGED_WALLET { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported account kind", nil, "")}, nil + } + reader := params.New(req.GetParams()) + orgRef := strings.TrimSpace(reader.String("organization_ref")) + if orgRef == "" { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref is required", nil, "")}, nil + } + asset, err := parseChainAsset(strings.TrimSpace(req.GetAsset()), reader) + if err != nil { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), nil, "")}, nil + } + + resp, err := s.CreateManagedWallet(ctx, &chainv1.CreateManagedWalletRequest{ + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + OrganizationRef: orgRef, + OwnerRef: strings.TrimSpace(req.GetOwnerRef()), + Asset: asset, + Metadata: shared.CloneMetadata(reader.StringMap("metadata")), + Describable: describableFromLabel(req.GetLabel(), reader.String("description")), + }) + if err != nil { + return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil + } + return &connectorv1.OpenAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil +} + +func (s *Service) GetAccount(ctx context.Context, req *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) { + if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" { + return nil, merrors.InvalidArgument("get_account: account_ref.account_id is required") + } + resp, err := s.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())}) + if err != nil { + return nil, err + } + return &connectorv1.GetAccountResponse{Account: chainWalletToAccount(resp.GetWallet())}, nil +} + +func (s *Service) ListAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { + if req == nil { + return nil, merrors.InvalidArgument("list_accounts: request is required") + } + asset := (*chainv1.Asset)(nil) + if assetString := strings.TrimSpace(req.GetAsset()); assetString != "" { + parsed, err := parseChainAsset(assetString, params.New(nil)) + if err != nil { + return nil, merrors.InvalidArgument(err.Error()) + } + asset = parsed + } + resp, err := s.ListManagedWallets(ctx, &chainv1.ListManagedWalletsRequest{ + OwnerRef: strings.TrimSpace(req.GetOwnerRef()), + Asset: asset, + Page: req.GetPage(), + }) + if err != nil { + return nil, err + } + accounts := make([]*connectorv1.Account, 0, len(resp.GetWallets())) + for _, wallet := range resp.GetWallets() { + accounts = append(accounts, chainWalletToAccount(wallet)) + } + return &connectorv1.ListAccountsResponse{ + Accounts: accounts, + Page: resp.GetPage(), + }, nil +} + +func (s *Service) GetBalance(ctx context.Context, req *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) { + if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" { + return nil, merrors.InvalidArgument("get_balance: account_ref.account_id is required") + } + resp, err := s.GetWalletBalance(ctx, &chainv1.GetWalletBalanceRequest{WalletRef: strings.TrimSpace(req.GetAccountRef().GetAccountId())}) + if err != nil { + return nil, err + } + bal := resp.GetBalance() + return &connectorv1.GetBalanceResponse{ + Balance: &connectorv1.Balance{ + AccountRef: req.GetAccountRef(), + Available: bal.GetAvailable(), + PendingInbound: bal.GetPendingInbound(), + PendingOutbound: bal.GetPendingOutbound(), + CalculatedAt: bal.GetCalculatedAt(), + }, + }, nil +} + +func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + if req == nil || req.GetOperation() == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil + } + op := req.GetOperation() + if strings.TrimSpace(op.GetIdempotencyKey()) == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil + } + reader := params.New(op.GetParams()) + orgRef := strings.TrimSpace(reader.String("organization_ref")) + source := operationAccountID(op.GetFrom()) + if source == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "operation: from.account is required", op, "")}}, nil + } + + switch op.GetType() { + case connectorv1.OperationType_TRANSFER: + dest, err := transferDestinationFromOperation(op) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + amount := op.GetMoney() + if amount == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil + } + amount = normalizeMoneyForChain(amount) + if orgRef == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: organization_ref is required", op, "")}}, nil + } + resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: orgRef, + SourceWalletRef: source, + Destination: dest, + Amount: amount, + Fees: parseChainFees(reader), + Metadata: shared.CloneMetadata(reader.StringMap("metadata")), + ClientReference: strings.TrimSpace(reader.String("client_reference")), + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + transfer := resp.GetTransfer() + return &connectorv1.SubmitOperationResponse{ + Receipt: &connectorv1.OperationReceipt{ + OperationId: strings.TrimSpace(transfer.GetTransferRef()), + Status: chainTransferStatusToOperation(transfer.GetStatus()), + ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), + }, + }, nil + case connectorv1.OperationType_FEE_ESTIMATE: + dest, err := transferDestinationFromOperation(op) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + amount := op.GetMoney() + if amount == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "estimate: money is required", op, "")}}, nil + } + amount = normalizeMoneyForChain(amount) + opID := strings.TrimSpace(op.GetOperationId()) + if opID == "" { + opID = strings.TrimSpace(op.GetIdempotencyKey()) + } + resp, err := s.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{ + SourceWalletRef: source, + Destination: dest, + Amount: amount, + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + result := feeEstimateResult(resp) + return &connectorv1.SubmitOperationResponse{ + Receipt: &connectorv1.OperationReceipt{ + OperationId: opID, + Status: connectorv1.OperationStatus_CONFIRMED, + Result: result, + }, + }, nil + case connectorv1.OperationType_GAS_TOPUP: + fee, err := parseMoneyFromMap(reader.Map("estimated_total_fee")) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + fee = normalizeMoneyForChain(fee) + mode := strings.ToLower(strings.TrimSpace(reader.String("mode"))) + if mode == "" { + mode = "compute" + } + switch mode { + case "compute": + opID := strings.TrimSpace(op.GetOperationId()) + if opID == "" { + opID = strings.TrimSpace(op.GetIdempotencyKey()) + } + resp, err := s.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{ + WalletRef: source, + EstimatedTotalFee: fee, + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + return &connectorv1.SubmitOperationResponse{ + Receipt: &connectorv1.OperationReceipt{ + OperationId: opID, + Status: connectorv1.OperationStatus_CONFIRMED, + Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), ""), + }, + }, nil + case "ensure": + opID := strings.TrimSpace(op.GetOperationId()) + if opID == "" { + opID = strings.TrimSpace(op.GetIdempotencyKey()) + } + if orgRef == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: organization_ref is required", op, "")}}, nil + } + target := strings.TrimSpace(reader.String("target_wallet_ref")) + if target == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: target_wallet_ref is required", op, "")}}, nil + } + resp, err := s.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: orgRef, + SourceWalletRef: source, + TargetWalletRef: target, + EstimatedTotalFee: fee, + Metadata: shared.CloneMetadata(reader.StringMap("metadata")), + ClientReference: strings.TrimSpace(reader.String("client_reference")), + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + transferRef := "" + if transfer := resp.GetTransfer(); transfer != nil { + transferRef = strings.TrimSpace(transfer.GetTransferRef()) + } + return &connectorv1.SubmitOperationResponse{ + Receipt: &connectorv1.OperationReceipt{ + OperationId: opID, + Status: connectorv1.OperationStatus_CONFIRMED, + Result: gasTopUpResult(resp.GetTopupAmount(), resp.GetCapHit(), transferRef), + }, + }, nil + default: + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "gas_topup: invalid mode", op, "")}}, nil + } + default: + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil + } +} + +func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) { + if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { + return nil, merrors.InvalidArgument("get_operation: operation_id is required") + } + resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())}) + if err != nil { + return nil, err + } + return &connectorv1.GetOperationResponse{Operation: chainTransferToOperation(resp.GetTransfer())}, nil +} + +func (s *Service) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { + if req == nil { + return nil, merrors.InvalidArgument("list_operations: request is required") + } + source := "" + if req.GetAccountRef() != nil { + source = strings.TrimSpace(req.GetAccountRef().GetAccountId()) + } + resp, err := s.ListTransfers(ctx, &chainv1.ListTransfersRequest{ + SourceWalletRef: source, + Status: chainStatusFromOperation(req.GetStatus()), + Page: req.GetPage(), + }) + if err != nil { + return nil, err + } + ops := make([]*connectorv1.Operation, 0, len(resp.GetTransfers())) + for _, transfer := range resp.GetTransfers() { + ops = append(ops, chainTransferToOperation(transfer)) + } + return &connectorv1.ListOperationsResponse{Operations: ops, Page: resp.GetPage()}, nil +} + +func chainOpenAccountParams() []*connectorv1.ParamSpec { + return []*connectorv1.ParamSpec{ + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the wallet."}, + {Key: "network", Type: connectorv1.ParamType_STRING, Required: true, Description: "Blockchain network name."}, + {Key: "token_symbol", Type: connectorv1.ParamType_STRING, Required: true, Description: "Token symbol (e.g., USDT)."}, + {Key: "contract_address", Type: connectorv1.ParamType_STRING, Required: false, Description: "Token contract address override."}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."}, + {Key: "description", Type: connectorv1.ParamType_STRING, Required: false, Description: "Wallet description."}, + } +} + +func chainOperationParams() []*connectorv1.OperationParamSpec { + return []*connectorv1.OperationParamSpec{ + {OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{ + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference."}, + {Key: "destination_memo", Type: connectorv1.ParamType_STRING, Required: false, Description: "Destination memo/tag."}, + {Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference id."}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Transfer metadata."}, + {Key: "fees", Type: connectorv1.ParamType_JSON, Required: false, Description: "Service fee breakdowns."}, + }}, + {OperationType: connectorv1.OperationType_FEE_ESTIMATE, Params: []*connectorv1.ParamSpec{ + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference."}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Estimate metadata."}, + }}, + {OperationType: connectorv1.OperationType_GAS_TOPUP, Params: []*connectorv1.ParamSpec{ + {Key: "mode", Type: connectorv1.ParamType_STRING, Required: false, Description: "compute | ensure."}, + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Organization reference (required for ensure)."}, + {Key: "target_wallet_ref", Type: connectorv1.ParamType_STRING, Required: false, Description: "Target wallet ref (ensure)."}, + {Key: "estimated_total_fee", Type: connectorv1.ParamType_JSON, Required: true, Description: "Estimated total fee {amount,currency}."}, + {Key: "client_reference", Type: connectorv1.ParamType_STRING, Required: false, Description: "Client reference."}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Top-up metadata."}, + }}, + } +} + +func chainWalletToAccount(wallet *chainv1.ManagedWallet) *connectorv1.Account { + if wallet == nil { + return nil + } + details, _ := structpb.NewStruct(map[string]interface{}{ + "deposit_address": wallet.GetDepositAddress(), + "organization_ref": wallet.GetOrganizationRef(), + "owner_ref": wallet.GetOwnerRef(), + "network": wallet.GetAsset().GetChain().String(), + "token_symbol": wallet.GetAsset().GetTokenSymbol(), + "contract_address": wallet.GetAsset().GetContractAddress(), + "wallet_ref": wallet.GetWalletRef(), + }) + return &connectorv1.Account{ + Ref: &connectorv1.AccountRef{ + ConnectorId: chainConnectorID, + AccountId: strings.TrimSpace(wallet.GetWalletRef()), + }, + Kind: connectorv1.AccountKind_CHAIN_MANAGED_WALLET, + Asset: assetStringFromChainAsset(wallet.GetAsset()), + State: chainWalletState(wallet.GetStatus()), + Label: strings.TrimSpace(wallet.GetDescribable().GetName()), + OwnerRef: strings.TrimSpace(wallet.GetOwnerRef()), + ProviderDetails: details, + CreatedAt: wallet.GetCreatedAt(), + UpdatedAt: wallet.GetUpdatedAt(), + } +} + +func chainWalletState(status chainv1.ManagedWalletStatus) connectorv1.AccountState { + switch status { + case chainv1.ManagedWalletStatus_MANAGED_WALLET_ACTIVE: + return connectorv1.AccountState_ACCOUNT_ACTIVE + case chainv1.ManagedWalletStatus_MANAGED_WALLET_SUSPENDED: + return connectorv1.AccountState_ACCOUNT_SUSPENDED + case chainv1.ManagedWalletStatus_MANAGED_WALLET_CLOSED: + return connectorv1.AccountState_ACCOUNT_CLOSED + default: + return connectorv1.AccountState_ACCOUNT_STATE_UNSPECIFIED + } +} + +func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) { + if op == nil { + return nil, fmt.Errorf("transfer: operation is required") + } + if to := op.GetTo(); to != nil { + if account := to.GetAccount(); account != nil { + return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil + } + if ext := to.GetExternal(); ext != nil { + return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil + } + } + return nil, fmt.Errorf("transfer: to.account or to.external is required") +} + +func normalizeMoneyForChain(m *moneyv1.Money) *moneyv1.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.GetCurrency()) + if idx := strings.Index(currency, "-"); idx > 0 { + currency = currency[:idx] + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(m.GetAmount()), + Currency: currency, + } +} + +func parseChainFees(reader params.Reader) []*chainv1.ServiceFeeBreakdown { + rawFees := reader.List("fees") + if len(rawFees) == 0 { + return nil + } + result := make([]*chainv1.ServiceFeeBreakdown, 0, len(rawFees)) + for _, item := range rawFees { + raw, ok := item.(map[string]interface{}) + if !ok { + continue + } + amount := strings.TrimSpace(fmt.Sprint(raw["amount"])) + currency := strings.TrimSpace(fmt.Sprint(raw["currency"])) + if amount == "" || currency == "" { + continue + } + result = append(result, &chainv1.ServiceFeeBreakdown{ + FeeCode: strings.TrimSpace(fmt.Sprint(raw["fee_code"])), + Description: strings.TrimSpace(fmt.Sprint(raw["description"])), + Amount: &moneyv1.Money{Amount: amount, Currency: currency}, + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) { + if raw == nil { + return nil, fmt.Errorf("money is required") + } + amount := strings.TrimSpace(fmt.Sprint(raw["amount"])) + currency := strings.TrimSpace(fmt.Sprint(raw["currency"])) + if amount == "" || currency == "" { + return nil, fmt.Errorf("money is required") + } + return &moneyv1.Money{ + Amount: amount, + Currency: currency, + }, nil +} + +func feeEstimateResult(resp *chainv1.EstimateTransferFeeResponse) *structpb.Struct { + if resp == nil { + return nil + } + payload := map[string]interface{}{ + "estimation_context": strings.TrimSpace(resp.GetEstimationContext()), + } + if fee := resp.GetNetworkFee(); fee != nil { + payload["network_fee"] = map[string]interface{}{ + "amount": strings.TrimSpace(fee.GetAmount()), + "currency": strings.TrimSpace(fee.GetCurrency()), + } + } + result, err := structpb.NewStruct(payload) + if err != nil { + return nil + } + return result +} + +func gasTopUpResult(amount *moneyv1.Money, capHit bool, transferRef string) *structpb.Struct { + payload := map[string]interface{}{ + "cap_hit": capHit, + } + if amount != nil { + payload["topup_amount"] = map[string]interface{}{ + "amount": strings.TrimSpace(amount.GetAmount()), + "currency": strings.TrimSpace(amount.GetCurrency()), + } + } + if strings.TrimSpace(transferRef) != "" { + payload["transfer_ref"] = strings.TrimSpace(transferRef) + } + result, err := structpb.NewStruct(payload) + if err != nil { + return nil + } + return result +} + +func chainTransferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation { + if transfer == nil { + return nil + } + op := &connectorv1.Operation{ + OperationId: strings.TrimSpace(transfer.GetTransferRef()), + Type: connectorv1.OperationType_TRANSFER, + Status: chainTransferStatusToOperation(transfer.GetStatus()), + Money: transfer.GetRequestedAmount(), + ProviderRef: strings.TrimSpace(transfer.GetTransactionHash()), + CreatedAt: transfer.GetCreatedAt(), + UpdatedAt: transfer.GetUpdatedAt(), + From: &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: chainConnectorID, + AccountId: strings.TrimSpace(transfer.GetSourceWalletRef()), + }}}, + } + if dest := transfer.GetDestination(); dest != nil { + switch d := dest.GetDestination().(type) { + case *chainv1.TransferDestination_ManagedWalletRef: + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: chainConnectorID, + AccountId: strings.TrimSpace(d.ManagedWalletRef), + }}} + case *chainv1.TransferDestination_ExternalAddress: + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{ + ExternalRef: strings.TrimSpace(d.ExternalAddress), + }}} + } + } + return op +} + +func chainTransferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { + switch status { + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + return connectorv1.OperationStatus_CONFIRMED + case chainv1.TransferStatus_TRANSFER_FAILED: + return connectorv1.OperationStatus_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return connectorv1.OperationStatus_CANCELED + default: + return connectorv1.OperationStatus_PENDING + } +} + +func chainStatusFromOperation(status connectorv1.OperationStatus) chainv1.TransferStatus { + switch status { + case connectorv1.OperationStatus_CONFIRMED: + return chainv1.TransferStatus_TRANSFER_CONFIRMED + case connectorv1.OperationStatus_FAILED: + return chainv1.TransferStatus_TRANSFER_FAILED + case connectorv1.OperationStatus_CANCELED: + return chainv1.TransferStatus_TRANSFER_CANCELLED + default: + return chainv1.TransferStatus_TRANSFER_STATUS_UNSPECIFIED + } +} + +func parseChainAsset(assetString string, reader params.Reader) (*chainv1.Asset, error) { + network := strings.TrimSpace(reader.String("network")) + token := strings.TrimSpace(reader.String("token_symbol")) + contract := strings.TrimSpace(reader.String("contract_address")) + + if token == "" { + token = tokenFromAssetString(assetString) + } + if network == "" { + network = networkFromAssetString(assetString) + } + if token == "" { + return nil, fmt.Errorf("asset: token_symbol is required") + } + chain := shared.ChainEnumFromName(network) + if chain == chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED { + return nil, fmt.Errorf("asset: network is required") + } + return &chainv1.Asset{ + Chain: chain, + TokenSymbol: strings.ToUpper(token), + ContractAddress: strings.ToLower(contract), + }, nil +} + +func tokenFromAssetString(asset string) string { + if asset == "" { + return "" + } + if idx := strings.Index(asset, "-"); idx > 0 { + return asset[:idx] + } + return asset +} + +func networkFromAssetString(asset string) string { + if asset == "" { + return "" + } + idx := strings.Index(asset, "-") + if idx < 0 { + return "" + } + return strings.TrimSpace(asset[idx+1:]) +} + +func assetStringFromChainAsset(asset *chainv1.Asset) string { + if asset == nil { + return "" + } + symbol := strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol())) + if symbol == "" { + return "" + } + suffix := chainAssetSuffix(asset.GetChain()) + if suffix == "" { + return symbol + } + return symbol + "-" + suffix +} + +func chainAssetSuffix(chain chainv1.ChainNetwork) string { + switch chain { + case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET: + return "ETH" + case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE: + return "ARB" + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET: + return "TRC20" + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE: + return "TRC20" + default: + return "" + } +} + +func describableFromLabel(label, desc string) *describablev1.Describable { + label = strings.TrimSpace(label) + desc = strings.TrimSpace(desc) + if label == "" && desc == "" { + return nil + } + return &describablev1.Describable{ + Name: label, + Description: &desc, + } +} + +func operationAccountID(party *connectorv1.OperationParty) string { + if party == nil { + return "" + } + if account := party.GetAccount(); account != nil { + return strings.TrimSpace(account.GetAccountId()) + } + return "" +} + +func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { + err := &connectorv1.ConnectorError{ + Code: code, + Message: strings.TrimSpace(message), + AccountId: strings.TrimSpace(accountID), + } + if op != nil { + err.CorrelationId = strings.TrimSpace(op.GetCorrelationId()) + err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId()) + err.OperationId = strings.TrimSpace(op.GetOperationId()) + } + return err +} + +func mapErrorCode(err error) connectorv1.ErrorCode { + switch { + case errors.Is(err, merrors.ErrInvalidArg): + return connectorv1.ErrorCode_INVALID_PARAMS + case errors.Is(err, merrors.ErrNoData): + return connectorv1.ErrorCode_NOT_FOUND + case errors.Is(err, merrors.ErrNotImplemented): + return connectorv1.ErrorCode_UNSUPPORTED_OPERATION + case errors.Is(err, merrors.ErrInternal): + return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE + default: + return connectorv1.ErrorCode_PROVIDER_ERROR + } +} diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index 2c05d45..67a87bb 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -19,8 +19,8 @@ import ( msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "google.golang.org/grpc" ) @@ -34,7 +34,7 @@ var ( errStorageUnavailable = serviceError("chain_gateway: storage not initialised") ) -// Service implements the UnifiedGatewayService RPC contract for chain operations. +// Service implements the ConnectorService RPC contract for chain operations. type Service struct { logger mlogger.Logger storage storage.Repository @@ -52,7 +52,7 @@ type Service struct { commands commands.Registry announcers []*discovery.Announcer - unifiedv1.UnimplementedUnifiedGatewayServiceServer + connectorv1.UnimplementedConnectorServiceServer } // NewService constructs the chain gateway service skeleton. @@ -95,7 +95,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro // Register wires the service onto the provided gRPC router. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) + connectorv1.RegisterConnectorServiceServer(reg, s) }) } diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index b1373f8..9bc0e29 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -5,12 +5,15 @@ import ( "strings" "time" + "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "go.uber.org/zap" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" ) // Client wraps the Monetix gateway gRPC API. @@ -22,9 +25,14 @@ type Client interface { Close() error } +type grpcConnectorClient interface { + SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) + GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) +} + type gatewayClient struct { conn *grpc.ClientConn - client unifiedv1.UnifiedGatewayServiceClient + client grpcConnectorClient cfg Config logger *zap.Logger } @@ -44,12 +52,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...) if err != nil { - return nil, merrors.Internal("mntx: dial failed: " + err.Error()) + return nil, merrors.Internal("mntx: dial failed: "+err.Error()) } return &gatewayClient{ conn: conn, - client: unifiedv1.NewUnifiedGatewayServiceClient(conn), + client: connectorv1.NewConnectorServiceClient(conn), cfg: cfg, logger: cfg.Logger, }, nil @@ -70,37 +78,253 @@ func (g *gatewayClient) callContext(ctx context.Context, method string) (context if timeout <= 0 { timeout = 5 * time.Second } - fields := []zap.Field{ - zap.String("method", method), - zap.Duration("timeout", timeout), + if g.logger != nil { + fields := []zap.Field{ + zap.String("method", method), + zap.Duration("timeout", timeout), + } + if deadline, ok := ctx.Deadline(); ok { + fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline))) + } + g.logger.Info("Mntx gateway client call timeout applied", fields...) } - if deadline, ok := ctx.Deadline(); ok { - fields = append(fields, zap.Time("parent_deadline", deadline), zap.Duration("parent_deadline_in", time.Until(deadline))) - } - g.logger.Info("Mntx gateway client call timeout applied", fields...) return context.WithTimeout(ctx, timeout) } func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { ctx, cancel := g.callContext(ctx, "CreateCardPayout") defer cancel() - return g.client.CreateCardPayout(ctx, req) + operation, err := operationFromCardPayout(req) + if err != nil { + return nil, err + } + resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return &mntxv1.CardPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil } func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) { ctx, cancel := g.callContext(ctx, "CreateCardTokenPayout") defer cancel() - return g.client.CreateCardTokenPayout(ctx, req) + operation, err := operationFromTokenPayout(req) + if err != nil { + return nil, err + } + resp, err := g.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return &mntxv1.CardTokenPayoutResponse{Payout: payoutFromReceipt(req.GetPayoutId(), resp.GetReceipt())}, nil } func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) { ctx, cancel := g.callContext(ctx, "GetCardPayoutStatus") defer cancel() - return g.client.GetCardPayoutStatus(ctx, req) + if req == nil || strings.TrimSpace(req.GetPayoutId()) == "" { + return nil, merrors.InvalidArgument("mntx: payout_id is required") + } + resp, err := g.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetPayoutId())}) + if err != nil { + return nil, err + } + return &mntxv1.GetCardPayoutStatusResponse{Payout: payoutFromOperation(resp.GetOperation())}, nil } func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { - ctx, cancel := g.callContext(ctx, "ListGatewayInstances") - defer cancel() - return g.client.ListGatewayInstances(ctx, req) + return nil, merrors.NotImplemented("mntx: ListGatewayInstances not supported via connector") +} + +func operationFromCardPayout(req *mntxv1.CardPayoutRequest) (*connectorv1.Operation, error) { + if req == nil { + return nil, merrors.InvalidArgument("mntx: request is required") + } + params := payoutParamsFromCard(req) + money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) + return &connectorv1.Operation{ + Type: connectorv1.OperationType_PAYOUT, + IdempotencyKey: strings.TrimSpace(req.GetPayoutId()), + Money: money, + Params: structFromMap(params), + }, nil +} + +func operationFromTokenPayout(req *mntxv1.CardTokenPayoutRequest) (*connectorv1.Operation, error) { + if req == nil { + return nil, merrors.InvalidArgument("mntx: request is required") + } + params := payoutParamsFromToken(req) + money := moneyFromMinor(req.GetAmountMinor(), req.GetCurrency()) + return &connectorv1.Operation{ + Type: connectorv1.OperationType_PAYOUT, + IdempotencyKey: strings.TrimSpace(req.GetPayoutId()), + Money: money, + Params: structFromMap(params), + }, nil +} + +func payoutParamsFromCard(req *mntxv1.CardPayoutRequest) map[string]interface{} { + params := map[string]interface{}{ + "payout_id": strings.TrimSpace(req.GetPayoutId()), + "project_id": req.GetProjectId(), + "customer_id": strings.TrimSpace(req.GetCustomerId()), + "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), + "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), + "customer_last_name": strings.TrimSpace(req.GetCustomerLastName()), + "customer_ip": strings.TrimSpace(req.GetCustomerIp()), + "customer_zip": strings.TrimSpace(req.GetCustomerZip()), + "customer_country": strings.TrimSpace(req.GetCustomerCountry()), + "customer_state": strings.TrimSpace(req.GetCustomerState()), + "customer_city": strings.TrimSpace(req.GetCustomerCity()), + "customer_address": strings.TrimSpace(req.GetCustomerAddress()), + "amount_minor": req.GetAmountMinor(), + "currency": strings.TrimSpace(req.GetCurrency()), + "card_pan": strings.TrimSpace(req.GetCardPan()), + "card_exp_year": req.GetCardExpYear(), + "card_exp_month": req.GetCardExpMonth(), + "card_holder": strings.TrimSpace(req.GetCardHolder()), + } + if len(req.GetMetadata()) > 0 { + params["metadata"] = mapStringToInterface(req.GetMetadata()) + } + return params +} + +func payoutParamsFromToken(req *mntxv1.CardTokenPayoutRequest) map[string]interface{} { + params := map[string]interface{}{ + "payout_id": strings.TrimSpace(req.GetPayoutId()), + "project_id": req.GetProjectId(), + "customer_id": strings.TrimSpace(req.GetCustomerId()), + "customer_first_name": strings.TrimSpace(req.GetCustomerFirstName()), + "customer_middle_name": strings.TrimSpace(req.GetCustomerMiddleName()), + "customer_last_name": strings.TrimSpace(req.GetCustomerLastName()), + "customer_ip": strings.TrimSpace(req.GetCustomerIp()), + "customer_zip": strings.TrimSpace(req.GetCustomerZip()), + "customer_country": strings.TrimSpace(req.GetCustomerCountry()), + "customer_state": strings.TrimSpace(req.GetCustomerState()), + "customer_city": strings.TrimSpace(req.GetCustomerCity()), + "customer_address": strings.TrimSpace(req.GetCustomerAddress()), + "amount_minor": req.GetAmountMinor(), + "currency": strings.TrimSpace(req.GetCurrency()), + "card_token": strings.TrimSpace(req.GetCardToken()), + "card_holder": strings.TrimSpace(req.GetCardHolder()), + "masked_pan": strings.TrimSpace(req.GetMaskedPan()), + } + if len(req.GetMetadata()) > 0 { + params["metadata"] = mapStringToInterface(req.GetMetadata()) + } + return params +} + +func moneyFromMinor(amount int64, currency string) *moneyv1.Money { + if amount <= 0 { + return nil + } + dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100)) + return &moneyv1.Money{ + Amount: dec.StringFixed(2), + Currency: strings.ToUpper(strings.TrimSpace(currency)), + } +} + +func payoutFromReceipt(payoutID string, receipt *connectorv1.OperationReceipt) *mntxv1.CardPayoutState { + state := &mntxv1.CardPayoutState{PayoutId: strings.TrimSpace(payoutID)} + if receipt == nil { + return state + } + state.Status = payoutStatusFromOperation(receipt.GetStatus()) + state.ProviderPaymentId = strings.TrimSpace(receipt.GetProviderRef()) + return state +} + +func payoutFromOperation(op *connectorv1.Operation) *mntxv1.CardPayoutState { + if op == nil { + return nil + } + state := &mntxv1.CardPayoutState{ + PayoutId: strings.TrimSpace(op.GetOperationId()), + Status: payoutStatusFromOperation(op.GetStatus()), + ProviderPaymentId: strings.TrimSpace(op.GetProviderRef()), + } + if money := op.GetMoney(); money != nil { + state.Currency = strings.TrimSpace(money.GetCurrency()) + state.AmountMinor = minorFromMoney(money) + } + return state +} + +func minorFromMoney(m *moneyv1.Money) int64 { + if m == nil { + return 0 + } + amount := strings.TrimSpace(m.GetAmount()) + if amount == "" { + return 0 + } + dec, err := decimal.NewFromString(amount) + if err != nil { + return 0 + } + return dec.Mul(decimal.NewFromInt(100)).IntPart() +} + +func payoutStatusFromOperation(status connectorv1.OperationStatus) mntxv1.PayoutStatus { + switch status { + case connectorv1.OperationStatus_CONFIRMED: + return mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED + case connectorv1.OperationStatus_FAILED: + return mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED + case connectorv1.OperationStatus_PENDING, connectorv1.OperationStatus_SUBMITTED: + return mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING + default: + return mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED + } +} + +func connectorError(err *connectorv1.ConnectorError) error { + if err == nil { + return nil + } + msg := strings.TrimSpace(err.GetMessage()) + switch err.GetCode() { + case connectorv1.ErrorCode_INVALID_PARAMS: + return merrors.InvalidArgument(msg) + case connectorv1.ErrorCode_NOT_FOUND: + return merrors.NoData(msg) + case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND: + return merrors.NotImplemented(msg) + case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE: + return merrors.Internal(msg) + default: + return merrors.Internal(msg) + } +} + +func structFromMap(data map[string]interface{}) *structpb.Struct { + if len(data) == 0 { + return nil + } + result, err := structpb.NewStruct(data) + if err != nil { + return nil + } + return result +} + +func mapStringToInterface(input map[string]string) map[string]interface{} { + if len(input) == 0 { + return nil + } + out := make(map[string]interface{}, len(input)) + for k, v := range input { + out[k] = v + } + return out } diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index f026a89..1a401f8 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -7,6 +7,7 @@ replace github.com/tech/sendico/pkg => ../../pkg require ( github.com/go-chi/chi/v5 v5.2.3 github.com/prometheus/client_golang v1.23.2 + github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/pkg v0.1.0 go.uber.org/zap v1.27.1 google.golang.org/grpc v1.78.0 diff --git a/api/gateway/mntx/internal/service/gateway/connector.go b/api/gateway/mntx/internal/service/gateway/connector.go new file mode 100644 index 0000000..b79c881 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/connector.go @@ -0,0 +1,294 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/gateway/mntx/internal/appversion" + "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/merrors" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +const mntxConnectorID = "mntx" + +func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { + return &connectorv1.GetCapabilitiesResponse{ + Capabilities: &connectorv1.ConnectorCapabilities{ + ConnectorType: mntxConnectorID, + Version: appversion.Create().Short(), + SupportedAccountKinds: nil, + SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_PAYOUT}, + OperationParams: mntxOperationParams(), + }, + }, nil +} + +func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil +} + +func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) { + return nil, merrors.NotImplemented("get_account: unsupported") +} + +func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { + return nil, merrors.NotImplemented("list_accounts: unsupported") +} + +func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) { + return nil, merrors.NotImplemented("get_balance: unsupported") +} + +func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + if req == nil || req.GetOperation() == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil + } + op := req.GetOperation() + if strings.TrimSpace(op.GetIdempotencyKey()) == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil + } + if op.GetType() != connectorv1.OperationType_PAYOUT { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil + } + reader := params.New(op.GetParams()) + amountMinor, currency, err := payoutAmount(op, reader) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + + payoutID := strings.TrimSpace(reader.String("payout_id")) + if payoutID == "" { + payoutID = strings.TrimSpace(op.GetIdempotencyKey()) + } + + if strings.TrimSpace(reader.String("card_token")) != "" { + resp, err := s.CreateCardTokenPayout(ctx, buildCardTokenPayoutRequest(reader, payoutID, amountMinor, currency)) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil + } + resp, err := s.CreateCardPayout(ctx, buildCardPayoutRequest(reader, payoutID, amountMinor, currency)) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: payoutReceipt(resp.GetPayout())}, nil +} + +func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) { + if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { + return nil, merrors.InvalidArgument("get_operation: operation_id is required") + } + resp, err := s.GetCardPayoutStatus(ctx, &mntxv1.GetCardPayoutStatusRequest{PayoutId: strings.TrimSpace(req.GetOperationId())}) + if err != nil { + return nil, err + } + return &connectorv1.GetOperationResponse{Operation: payoutToOperation(resp.GetPayout())}, nil +} + +func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { + return nil, merrors.NotImplemented("list_operations: unsupported") +} + +func mntxOperationParams() []*connectorv1.OperationParamSpec { + return []*connectorv1.OperationParamSpec{ + {OperationType: connectorv1.OperationType_PAYOUT, Params: []*connectorv1.ParamSpec{ + {Key: "customer_id", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "customer_first_name", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "customer_last_name", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "customer_ip", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "card_token", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "card_pan", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "card_exp_year", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "card_exp_month", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "card_holder", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "amount_minor", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "project_id", Type: connectorv1.ParamType_INT, Required: false}, + {Key: "customer_middle_name", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_country", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_state", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_city", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_address", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "customer_zip", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "masked_pan", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false}, + }}, + } +} + +func payoutAmount(op *connectorv1.Operation, reader params.Reader) (int64, string, error) { + if op == nil { + return 0, "", fmt.Errorf("payout: operation is required") + } + currency := currencyFromOperation(op) + if currency == "" { + return 0, "", fmt.Errorf("payout: currency is required") + } + if minor, ok := reader.Int64("amount_minor"); ok && minor > 0 { + return minor, currency, nil + } + money := op.GetMoney() + if money == nil { + return 0, "", fmt.Errorf("payout: money is required") + } + amount := strings.TrimSpace(money.GetAmount()) + if amount == "" { + return 0, "", fmt.Errorf("payout: amount is required") + } + dec, err := decimal.NewFromString(amount) + if err != nil { + return 0, "", fmt.Errorf("payout: invalid amount") + } + minor := dec.Mul(decimal.NewFromInt(100)).IntPart() + return minor, currency, nil +} + +func currencyFromOperation(op *connectorv1.Operation) string { + if op == nil || op.GetMoney() == nil { + return "" + } + currency := strings.TrimSpace(op.GetMoney().GetCurrency()) + if idx := strings.Index(currency, "-"); idx > 0 { + currency = currency[:idx] + } + return strings.ToUpper(currency) +} + +func buildCardTokenPayoutRequest(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardTokenPayoutRequest { + req := &mntxv1.CardTokenPayoutRequest{ + PayoutId: payoutID, + ProjectId: readerInt64(reader, "project_id"), + CustomerId: strings.TrimSpace(reader.String("customer_id")), + CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), + CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")), + CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")), + CustomerIp: strings.TrimSpace(reader.String("customer_ip")), + CustomerZip: strings.TrimSpace(reader.String("customer_zip")), + CustomerCountry: strings.TrimSpace(reader.String("customer_country")), + CustomerState: strings.TrimSpace(reader.String("customer_state")), + CustomerCity: strings.TrimSpace(reader.String("customer_city")), + CustomerAddress: strings.TrimSpace(reader.String("customer_address")), + AmountMinor: amountMinor, + Currency: currency, + CardToken: strings.TrimSpace(reader.String("card_token")), + CardHolder: strings.TrimSpace(reader.String("card_holder")), + MaskedPan: strings.TrimSpace(reader.String("masked_pan")), + Metadata: reader.StringMap("metadata"), + } + return req +} + +func buildCardPayoutRequest(reader params.Reader, payoutID string, amountMinor int64, currency string) *mntxv1.CardPayoutRequest { + return &mntxv1.CardPayoutRequest{ + PayoutId: payoutID, + ProjectId: readerInt64(reader, "project_id"), + CustomerId: strings.TrimSpace(reader.String("customer_id")), + CustomerFirstName: strings.TrimSpace(reader.String("customer_first_name")), + CustomerMiddleName: strings.TrimSpace(reader.String("customer_middle_name")), + CustomerLastName: strings.TrimSpace(reader.String("customer_last_name")), + CustomerIp: strings.TrimSpace(reader.String("customer_ip")), + CustomerZip: strings.TrimSpace(reader.String("customer_zip")), + CustomerCountry: strings.TrimSpace(reader.String("customer_country")), + CustomerState: strings.TrimSpace(reader.String("customer_state")), + CustomerCity: strings.TrimSpace(reader.String("customer_city")), + CustomerAddress: strings.TrimSpace(reader.String("customer_address")), + AmountMinor: amountMinor, + Currency: currency, + CardPan: strings.TrimSpace(reader.String("card_pan")), + CardExpYear: uint32(readerInt64(reader, "card_exp_year")), + CardExpMonth: uint32(readerInt64(reader, "card_exp_month")), + CardHolder: strings.TrimSpace(reader.String("card_holder")), + Metadata: reader.StringMap("metadata"), + } +} + +func readerInt64(reader params.Reader, key string) int64 { + if v, ok := reader.Int64(key); ok { + return v + } + return 0 +} + +func payoutReceipt(state *mntxv1.CardPayoutState) *connectorv1.OperationReceipt { + if state == nil { + return &connectorv1.OperationReceipt{ + Status: connectorv1.OperationStatus_PENDING, + } + } + return &connectorv1.OperationReceipt{ + OperationId: strings.TrimSpace(state.GetPayoutId()), + Status: payoutStatusToOperation(state.GetStatus()), + ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), + } +} + +func payoutToOperation(state *mntxv1.CardPayoutState) *connectorv1.Operation { + if state == nil { + return nil + } + return &connectorv1.Operation{ + OperationId: strings.TrimSpace(state.GetPayoutId()), + Type: connectorv1.OperationType_PAYOUT, + Status: payoutStatusToOperation(state.GetStatus()), + Money: &moneyv1.Money{ + Amount: minorToDecimal(state.GetAmountMinor()), + Currency: strings.ToUpper(strings.TrimSpace(state.GetCurrency())), + }, + ProviderRef: strings.TrimSpace(state.GetProviderPaymentId()), + CreatedAt: state.GetCreatedAt(), + UpdatedAt: state.GetUpdatedAt(), + } +} + +func minorToDecimal(amount int64) string { + dec := decimal.NewFromInt(amount).Div(decimal.NewFromInt(100)) + return dec.StringFixed(2) +} + +func payoutStatusToOperation(status mntxv1.PayoutStatus) connectorv1.OperationStatus { + switch status { + case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + return connectorv1.OperationStatus_CONFIRMED + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + return connectorv1.OperationStatus_FAILED + case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING: + return connectorv1.OperationStatus_PENDING + default: + return connectorv1.OperationStatus_PENDING + } +} + +func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { + err := &connectorv1.ConnectorError{ + Code: code, + Message: strings.TrimSpace(message), + AccountId: strings.TrimSpace(accountID), + } + if op != nil { + err.CorrelationId = strings.TrimSpace(op.GetCorrelationId()) + err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId()) + err.OperationId = strings.TrimSpace(op.GetOperationId()) + } + return err +} + +func mapErrorCode(err error) connectorv1.ErrorCode { + switch { + case errors.Is(err, merrors.ErrInvalidArg): + return connectorv1.ErrorCode_INVALID_PARAMS + case errors.Is(err, merrors.ErrNoData): + return connectorv1.ErrorCode_NOT_FOUND + case errors.Is(err, merrors.ErrNotImplemented): + return connectorv1.ErrorCode_UNSUPPORTED_OPERATION + case errors.Is(err, merrors.ErrInternal): + return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE + default: + return connectorv1.ErrorCode_PROVIDER_ERROR + } +} diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index dad98e8..a73a3c1 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -15,7 +15,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" - unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" "go.uber.org/zap" "google.golang.org/grpc" ) @@ -31,7 +31,7 @@ type Service struct { gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor announcer *discovery.Announcer - unifiedv1.UnimplementedUnifiedGatewayServiceServer + connectorv1.UnimplementedConnectorServiceServer } type payoutFailure interface { @@ -96,7 +96,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service { // Register wires the service onto the provided gRPC router. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) + connectorv1.RegisterConnectorServiceServer(reg, s) }) } diff --git a/api/gateway/tgsettle/internal/service/gateway/connector.go b/api/gateway/tgsettle/internal/service/gateway/connector.go new file mode 100644 index 0000000..287f521 --- /dev/null +++ b/api/gateway/tgsettle/internal/service/gateway/connector.go @@ -0,0 +1,254 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/merrors" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +const tgsettleConnectorID = "tgsettle" + +func (s *Service) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { + return &connectorv1.GetCapabilitiesResponse{ + Capabilities: &connectorv1.ConnectorCapabilities{ + ConnectorType: tgsettleConnectorID, + Version: "", + SupportedAccountKinds: nil, + SupportedOperationTypes: []connectorv1.OperationType{connectorv1.OperationType_TRANSFER}, + OperationParams: tgsettleOperationParams(), + }, + }, nil +} + +func (s *Service) OpenAccount(_ context.Context, _ *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported", nil, "")}, nil +} + +func (s *Service) GetAccount(_ context.Context, _ *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) { + return nil, merrors.NotImplemented("get_account: unsupported") +} + +func (s *Service) ListAccounts(_ context.Context, _ *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { + return nil, merrors.NotImplemented("list_accounts: unsupported") +} + +func (s *Service) GetBalance(_ context.Context, _ *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) { + return nil, merrors.NotImplemented("get_balance: unsupported") +} + +func (s *Service) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + if req == nil || req.GetOperation() == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil + } + op := req.GetOperation() + if strings.TrimSpace(op.GetIdempotencyKey()) == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil + } + if op.GetType() != connectorv1.OperationType_TRANSFER { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil + } + reader := params.New(op.GetParams()) + paymentIntentID := strings.TrimSpace(reader.String("payment_intent_id")) + if paymentIntentID == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: payment_intent_id is required", op, "")}}, nil + } + source := operationAccountID(op.GetFrom()) + if source == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account is required", op, "")}}, nil + } + dest, err := transferDestinationFromOperation(op) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + amount := op.GetMoney() + if amount == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: money is required", op, "")}}, nil + } + + metadata := reader.StringMap("metadata") + if metadata == nil { + metadata = map[string]string{} + } + metadata[metadataPaymentIntentID] = paymentIntentID + if quoteRef := strings.TrimSpace(reader.String("quote_ref")); quoteRef != "" { + metadata[metadataQuoteRef] = quoteRef + } + if targetChatID := strings.TrimSpace(reader.String("target_chat_id")); targetChatID != "" { + metadata[metadataTargetChatID] = targetChatID + } + if outgoingLeg := strings.TrimSpace(reader.String("outgoing_leg")); outgoingLeg != "" { + metadata[metadataOutgoingLeg] = outgoingLeg + } + + resp, err := s.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: strings.TrimSpace(reader.String("organization_ref")), + SourceWalletRef: source, + Destination: dest, + Amount: normalizeMoneyForTransfer(amount), + Metadata: metadata, + ClientReference: paymentIntentID, + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + transfer := resp.GetTransfer() + return &connectorv1.SubmitOperationResponse{ + Receipt: &connectorv1.OperationReceipt{ + OperationId: strings.TrimSpace(transfer.GetTransferRef()), + Status: transferStatusToOperation(transfer.GetStatus()), + ProviderRef: strings.TrimSpace(transfer.GetTransferRef()), + }, + }, nil +} + +func (s *Service) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) { + if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { + return nil, merrors.InvalidArgument("get_operation: operation_id is required") + } + resp, err := s.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: strings.TrimSpace(req.GetOperationId())}) + if err != nil { + return nil, err + } + return &connectorv1.GetOperationResponse{Operation: transferToOperation(resp.GetTransfer())}, nil +} + +func (s *Service) ListOperations(_ context.Context, _ *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { + return nil, merrors.NotImplemented("list_operations: unsupported") +} + +func tgsettleOperationParams() []*connectorv1.OperationParamSpec { + return []*connectorv1.OperationParamSpec{ + {OperationType: connectorv1.OperationType_TRANSFER, Params: []*connectorv1.ParamSpec{ + {Key: "payment_intent_id", Type: connectorv1.ParamType_STRING, Required: true}, + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "quote_ref", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "target_chat_id", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "outgoing_leg", Type: connectorv1.ParamType_STRING, Required: false}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false}, + }}, + } +} + +func transferDestinationFromOperation(op *connectorv1.Operation) (*chainv1.TransferDestination, error) { + if op == nil { + return nil, fmt.Errorf("transfer: operation is required") + } + if to := op.GetTo(); to != nil { + if account := to.GetAccount(); account != nil { + return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(account.GetAccountId())}}, nil + } + if ext := to.GetExternal(); ext != nil { + return &chainv1.TransferDestination{Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(ext.GetExternalRef())}}, nil + } + } + return nil, fmt.Errorf("transfer: to.account or to.external is required") +} + +func normalizeMoneyForTransfer(m *moneyv1.Money) *moneyv1.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.GetCurrency()) + if idx := strings.Index(currency, "-"); idx > 0 { + currency = currency[:idx] + } + return &moneyv1.Money{ + Amount: strings.TrimSpace(m.GetAmount()), + Currency: currency, + } +} + +func transferToOperation(transfer *chainv1.Transfer) *connectorv1.Operation { + if transfer == nil { + return nil + } + op := &connectorv1.Operation{ + OperationId: strings.TrimSpace(transfer.GetTransferRef()), + Type: connectorv1.OperationType_TRANSFER, + Status: transferStatusToOperation(transfer.GetStatus()), + Money: transfer.GetRequestedAmount(), + ProviderRef: strings.TrimSpace(transfer.GetTransferRef()), + CreatedAt: transfer.GetCreatedAt(), + UpdatedAt: transfer.GetUpdatedAt(), + } + if source := strings.TrimSpace(transfer.GetSourceWalletRef()); source != "" { + op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: tgsettleConnectorID, + AccountId: source, + }}} + } + if dest := transfer.GetDestination(); dest != nil { + switch d := dest.GetDestination().(type) { + case *chainv1.TransferDestination_ManagedWalletRef: + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ + ConnectorId: tgsettleConnectorID, + AccountId: strings.TrimSpace(d.ManagedWalletRef), + }}} + case *chainv1.TransferDestination_ExternalAddress: + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_External{External: &connectorv1.ExternalRef{ + ExternalRef: strings.TrimSpace(d.ExternalAddress), + }}} + } + } + return op +} + +func transferStatusToOperation(status chainv1.TransferStatus) connectorv1.OperationStatus { + switch status { + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + return connectorv1.OperationStatus_CONFIRMED + case chainv1.TransferStatus_TRANSFER_FAILED: + return connectorv1.OperationStatus_FAILED + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return connectorv1.OperationStatus_CANCELED + default: + return connectorv1.OperationStatus_PENDING + } +} + +func operationAccountID(party *connectorv1.OperationParty) string { + if party == nil { + return "" + } + if account := party.GetAccount(); account != nil { + return strings.TrimSpace(account.GetAccountId()) + } + return "" +} + +func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { + err := &connectorv1.ConnectorError{ + Code: code, + Message: strings.TrimSpace(message), + AccountId: strings.TrimSpace(accountID), + } + if op != nil { + err.CorrelationId = strings.TrimSpace(op.GetCorrelationId()) + err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId()) + err.OperationId = strings.TrimSpace(op.GetOperationId()) + } + return err +} + +func mapErrorCode(err error) connectorv1.ErrorCode { + switch { + case errors.Is(err, merrors.ErrInvalidArg): + return connectorv1.ErrorCode_INVALID_PARAMS + case errors.Is(err, merrors.ErrNoData): + return connectorv1.ErrorCode_NOT_FOUND + case errors.Is(err, merrors.ErrNotImplemented): + return connectorv1.ErrorCode_UNSUPPORTED_OPERATION + case errors.Is(err, merrors.ErrInternal): + return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE + default: + return connectorv1.ErrorCode_PROVIDER_ERROR + } +} diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index 1a7a7fd..6a180b8 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -20,10 +20,10 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" "github.com/tech/sendico/pkg/server/grpcapp" "go.uber.org/zap" "google.golang.org/grpc" @@ -65,7 +65,7 @@ type Service struct { pending map[string]*model.PaymentGatewayIntent consumers []msg.Consumer - unifiedv1.UnimplementedUnifiedGatewayServiceServer + connectorv1.UnimplementedConnectorServiceServer } func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, broker mb.Broker, cfg Config) *Service { @@ -89,7 +89,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) + connectorv1.RegisterConnectorServiceServer(reg, s) }) } diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index cc74b51..18bc390 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -9,14 +9,19 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/payments/rail" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" ) +const ledgerConnectorID = "ledger" + // Client exposes typed helpers around the ledger gRPC API. type Client interface { ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) @@ -37,22 +42,20 @@ type Client interface { Close() error } -type grpcLedgerClient interface { - CreateAccount(ctx context.Context, in *ledgerv1.CreateAccountRequest, opts ...grpc.CallOption) (*ledgerv1.CreateAccountResponse, error) - ListAccounts(ctx context.Context, in *ledgerv1.ListAccountsRequest, opts ...grpc.CallOption) (*ledgerv1.ListAccountsResponse, error) - PostCreditWithCharges(ctx context.Context, in *ledgerv1.PostCreditRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error) - PostDebitWithCharges(ctx context.Context, in *ledgerv1.PostDebitRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error) - TransferInternal(ctx context.Context, in *ledgerv1.TransferRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error) - ApplyFXWithCharges(ctx context.Context, in *ledgerv1.FXRequest, opts ...grpc.CallOption) (*ledgerv1.PostResponse, error) - GetBalance(ctx context.Context, in *ledgerv1.GetBalanceRequest, opts ...grpc.CallOption) (*ledgerv1.BalanceResponse, error) - GetJournalEntry(ctx context.Context, in *ledgerv1.GetEntryRequest, opts ...grpc.CallOption) (*ledgerv1.JournalEntryResponse, error) - GetStatement(ctx context.Context, in *ledgerv1.GetStatementRequest, opts ...grpc.CallOption) (*ledgerv1.StatementResponse, error) +type grpcConnectorClient interface { + OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error) + GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error) + ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error) + GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error) + SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) + GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error) + ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error) } type ledgerClient struct { cfg Config conn *grpc.ClientConn - client grpcLedgerClient + client grpcConnectorClient } // New dials the ledger endpoint and returns a ready client. @@ -82,12 +85,12 @@ func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, erro return &ledgerClient{ cfg: cfg, conn: conn, - client: unifiedv1.NewUnifiedGatewayServiceClient(conn), + client: connectorv1.NewConnectorServiceClient(conn), }, nil } // NewWithClient injects a pre-built ledger client (useful for tests). -func NewWithClient(cfg Config, lc grpcLedgerClient) Client { +func NewWithClient(cfg Config, lc grpcConnectorClient) Client { cfg.setDefaults() return &ledgerClient{ cfg: cfg, @@ -179,55 +182,460 @@ func (c *ledgerClient) HoldBalance(ctx context.Context, accountID string, amount func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.CreateAccount(ctx, req) + if req == nil { + return nil, merrors.InvalidArgument("ledger: request is required") + } + if strings.TrimSpace(req.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("ledger: currency is required") + } + params := map[string]interface{}{ + "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), + "account_code": strings.TrimSpace(req.GetAccountCode()), + "account_type": req.GetAccountType().String(), + "status": req.GetStatus().String(), + "allow_negative": req.GetAllowNegative(), + "is_settlement": req.GetIsSettlement(), + } + if len(req.GetMetadata()) > 0 { + params["metadata"] = mapStringToInterface(req.GetMetadata()) + } + resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{ + Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, + Asset: strings.TrimSpace(req.GetCurrency()), + Params: structFromMap(params), + }) + if err != nil { + return nil, err + } + if resp.GetError() != nil { + return nil, connectorError(resp.GetError()) + } + return &ledgerv1.CreateAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil } func (c *ledgerClient) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.ListAccounts(ctx, req) + if req == nil || strings.TrimSpace(req.GetOrganizationRef()) == "" { + return nil, merrors.InvalidArgument("ledger: organization_ref is required") + } + resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{OwnerRef: strings.TrimSpace(req.GetOrganizationRef())}) + if err != nil { + return nil, err + } + accounts := make([]*ledgerv1.LedgerAccount, 0, len(resp.GetAccounts())) + for _, account := range resp.GetAccounts() { + accounts = append(accounts, ledgerAccountFromConnector(account)) + } + return &ledgerv1.ListAccountsResponse{Accounts: accounts}, nil } func (c *ledgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.PostCreditWithCharges(ctx, req) + return c.submitLedgerOperation(ctx, connectorv1.OperationType_CREDIT, "", req.GetLedgerAccountRef(), req.GetMoney(), req) } func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.PostDebitWithCharges(ctx, req) + return c.submitLedgerOperation(ctx, connectorv1.OperationType_DEBIT, req.GetLedgerAccountRef(), "", req.GetMoney(), req) } func (c *ledgerClient) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { - ctx, cancel := c.callContext(ctx) - defer cancel() - return c.client.TransferInternal(ctx, req) + return c.submitLedgerOperation(ctx, connectorv1.OperationType_TRANSFER, req.GetFromLedgerAccountRef(), req.GetToLedgerAccountRef(), req.GetMoney(), req) } func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.ApplyFXWithCharges(ctx, req) + if req == nil { + return nil, merrors.InvalidArgument("ledger: request is required") + } + if req.GetFromMoney() == nil || req.GetToMoney() == nil { + return nil, merrors.InvalidArgument("ledger: from_money and to_money are required") + } + params := ledgerOperationParams(req.GetOrganizationRef(), req.GetDescription(), req.GetMetadata(), req.GetCharges(), req.GetEventTime()) + params["rate"] = strings.TrimSpace(req.GetRate()) + params["to_money"] = map[string]interface{}{"amount": req.GetToMoney().GetAmount(), "currency": req.GetToMoney().GetCurrency()} + operation := &connectorv1.Operation{ + Type: connectorv1.OperationType_FX, + IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), + From: accountParty(req.GetFromLedgerAccountRef()), + To: accountParty(req.GetToLedgerAccountRef()), + Money: req.GetFromMoney(), + Params: structFromMap(params), + } + resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: ledgerv1.EntryType_ENTRY_FX}, nil } func (c *ledgerClient) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.GetBalance(ctx, req) + if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" { + return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required") + } + resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())}}) + if err != nil { + return nil, err + } + balance := resp.GetBalance() + if balance == nil { + return nil, merrors.Internal("ledger: balance response missing") + } + return &ledgerv1.BalanceResponse{ + LedgerAccountRef: strings.TrimSpace(req.GetLedgerAccountRef()), + Balance: balance.GetAvailable(), + LastUpdated: balance.GetCalculatedAt(), + }, nil } func (c *ledgerClient) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.GetJournalEntry(ctx, req) + if req == nil || strings.TrimSpace(req.GetEntryRef()) == "" { + return nil, merrors.InvalidArgument("ledger: entry_ref is required") + } + resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetEntryRef())}) + if err != nil { + return nil, err + } + return journalEntryFromOperation(resp.GetOperation()), nil } func (c *ledgerClient) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() - return c.client.GetStatement(ctx, req) + if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" { + return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required") + } + resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{ + AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())}, + Page: pageFromStatement(req), + }) + if err != nil { + return nil, err + } + entries := make([]*ledgerv1.JournalEntryResponse, 0, len(resp.GetOperations())) + for _, op := range resp.GetOperations() { + entries = append(entries, journalEntryFromOperation(op)) + } + nextCursor := "" + if resp.GetPage() != nil { + nextCursor = resp.GetPage().GetNextCursor() + } + return &ledgerv1.StatementResponse{Entries: entries, NextCursor: nextCursor}, nil +} + +func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}) (*ledgerv1.PostResponse, error) { + ctx, cancel := c.callContext(ctx) + defer cancel() + if money == nil { + return nil, merrors.InvalidArgument("ledger: money is required") + } + + var ( + idempotencyKey string + orgRef string + description string + metadata map[string]string + charges []*ledgerv1.PostingLine + eventTime *timestamppb.Timestamp + contraRef string + ) + + switch r := req.(type) { + case *ledgerv1.PostCreditRequest: + idempotencyKey = r.GetIdempotencyKey() + orgRef = r.GetOrganizationRef() + description = r.GetDescription() + metadata = r.GetMetadata() + charges = r.GetCharges() + eventTime = r.GetEventTime() + contraRef = r.GetContraLedgerAccountRef() + case *ledgerv1.PostDebitRequest: + idempotencyKey = r.GetIdempotencyKey() + orgRef = r.GetOrganizationRef() + description = r.GetDescription() + metadata = r.GetMetadata() + charges = r.GetCharges() + eventTime = r.GetEventTime() + contraRef = r.GetContraLedgerAccountRef() + case *ledgerv1.TransferRequest: + idempotencyKey = r.GetIdempotencyKey() + orgRef = r.GetOrganizationRef() + description = r.GetDescription() + metadata = r.GetMetadata() + charges = r.GetCharges() + eventTime = r.GetEventTime() + } + + params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime) + if contraRef != "" { + params["contra_ledger_account_ref"] = strings.TrimSpace(contraRef) + } + + op := &connectorv1.Operation{ + Type: opType, + IdempotencyKey: strings.TrimSpace(idempotencyKey), + Money: money, + Params: structFromMap(params), + } + if fromRef != "" { + op.From = accountParty(fromRef) + } + if toRef != "" { + op.To = accountParty(toRef) + } + + resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: op}) + if err != nil { + return nil, err + } + if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil { + return nil, connectorError(resp.GetReceipt().GetError()) + } + return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: entryTypeFromOperation(opType)}, nil +} + +func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} { + params := map[string]interface{}{ + "organization_ref": strings.TrimSpace(orgRef), + "description": strings.TrimSpace(description), + } + if len(metadata) > 0 { + params["metadata"] = mapStringToInterface(metadata) + } + if len(charges) > 0 { + params["charges"] = chargesToInterface(charges) + } + if eventTime != nil { + params["event_time"] = eventTime.AsTime().UTC().Format(time.RFC3339Nano) + } + return params +} + +func accountParty(accountRef string) *connectorv1.OperationParty { + if strings.TrimSpace(accountRef) == "" { + return nil + } + return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(accountRef)}}} +} + +func entryTypeFromOperation(opType connectorv1.OperationType) ledgerv1.EntryType { + switch opType { + case connectorv1.OperationType_CREDIT: + return ledgerv1.EntryType_ENTRY_CREDIT + case connectorv1.OperationType_DEBIT: + return ledgerv1.EntryType_ENTRY_DEBIT + case connectorv1.OperationType_TRANSFER: + return ledgerv1.EntryType_ENTRY_TRANSFER + case connectorv1.OperationType_FX: + return ledgerv1.EntryType_ENTRY_FX + default: + return ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED + } +} + +func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAccount { + if account == nil { + return nil + } + details := map[string]interface{}{} + if account.GetProviderDetails() != nil { + details = account.GetProviderDetails().AsMap() + } + accountType := ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED + if v := strings.TrimSpace(fmt.Sprint(details["account_type"])); v != "" { + accountType = parseAccountType(v) + } + status := ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED + if v := strings.TrimSpace(fmt.Sprint(details["status"])); v != "" { + status = parseAccountStatus(v) + } + allowNegative := false + if v, ok := details["allow_negative"].(bool); ok { + allowNegative = v + } + isSettlement := false + if v, ok := details["is_settlement"].(bool); ok { + isSettlement = v + } + accountCode := strings.TrimSpace(fmt.Sprint(details["account_code"])) + accountID := "" + if ref := account.GetRef(); ref != nil { + accountID = strings.TrimSpace(ref.GetAccountId()) + } + return &ledgerv1.LedgerAccount{ + LedgerAccountRef: accountID, + OrganizationRef: strings.TrimSpace(account.GetOwnerRef()), + AccountCode: accountCode, + AccountType: accountType, + Currency: strings.TrimSpace(account.GetAsset()), + Status: status, + AllowNegative: allowNegative, + IsSettlement: isSettlement, + CreatedAt: account.GetCreatedAt(), + UpdatedAt: account.GetUpdatedAt(), + } +} + +func parseAccountType(value string) ledgerv1.AccountType { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "ACCOUNT_TYPE_ASSET", "ASSET": + return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET + case "ACCOUNT_TYPE_LIABILITY", "LIABILITY": + return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY + case "ACCOUNT_TYPE_REVENUE", "REVENUE": + return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE + case "ACCOUNT_TYPE_EXPENSE", "EXPENSE": + return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE + default: + return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED + } +} + +func parseAccountStatus(value string) ledgerv1.AccountStatus { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "ACCOUNT_STATUS_ACTIVE", "ACTIVE": + return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE + case "ACCOUNT_STATUS_FROZEN", "FROZEN": + return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN + default: + return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED + } +} + +func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntryResponse { + if op == nil { + return nil + } + entry := &ledgerv1.JournalEntryResponse{ + EntryRef: strings.TrimSpace(op.GetOperationId()), + EntryType: entryTypeFromOperation(op.GetType()), + Description: operationDescription(op), + EventTime: op.GetCreatedAt(), + Lines: postingLinesFromOperation(op), + LedgerAccountRefs: ledgerAccountRefsFromOperation(op), + } + return entry +} + +func operationDescription(op *connectorv1.Operation) string { + if op == nil || op.GetParams() == nil { + return "" + } + if value, ok := op.GetParams().AsMap()["description"]; ok { + return strings.TrimSpace(fmt.Sprint(value)) + } + return "" +} + +func postingLinesFromOperation(op *connectorv1.Operation) []*ledgerv1.PostingLine { + if op == nil || op.GetMoney() == nil { + return nil + } + lines := []*ledgerv1.PostingLine{} + if from := op.GetFrom(); from != nil && from.GetAccount() != nil { + lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(from.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN}) + } + if to := op.GetTo(); to != nil && to.GetAccount() != nil { + lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(to.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN}) + } + if len(lines) == 0 { + lines = append(lines, &ledgerv1.PostingLine{Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN}) + } + return lines +} + +func ledgerAccountRefsFromOperation(op *connectorv1.Operation) []string { + refs := []string{} + if op == nil { + return refs + } + if from := op.GetFrom(); from != nil && from.GetAccount() != nil { + refs = append(refs, strings.TrimSpace(from.GetAccount().GetAccountId())) + } + if to := op.GetTo(); to != nil && to.GetAccount() != nil { + refs = append(refs, strings.TrimSpace(to.GetAccount().GetAccountId())) + } + return refs +} + +func pageFromStatement(req *ledgerv1.GetStatementRequest) *paginationv1.CursorPageRequest { + if req == nil { + return nil + } + return &paginationv1.CursorPageRequest{ + Cursor: strings.TrimSpace(req.GetCursor()), + Limit: req.GetLimit(), + } +} + +func chargesToInterface(charges []*ledgerv1.PostingLine) []interface{} { + if len(charges) == 0 { + return nil + } + result := make([]interface{}, 0, len(charges)) + for _, line := range charges { + if line == nil || line.GetMoney() == nil { + continue + } + result = append(result, map[string]interface{}{ + "ledger_account_ref": strings.TrimSpace(line.GetLedgerAccountRef()), + "amount": strings.TrimSpace(line.GetMoney().GetAmount()), + "currency": strings.TrimSpace(line.GetMoney().GetCurrency()), + "line_type": line.GetLineType().String(), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func connectorError(err *connectorv1.ConnectorError) error { + if err == nil { + return nil + } + msg := strings.TrimSpace(err.GetMessage()) + switch err.GetCode() { + case connectorv1.ErrorCode_INVALID_PARAMS: + return merrors.InvalidArgument(msg) + case connectorv1.ErrorCode_NOT_FOUND: + return merrors.NoData(msg) + case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND: + return merrors.NotImplemented(msg) + case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE: + return merrors.Internal(msg) + default: + return merrors.Internal(msg) + } +} + +func structFromMap(data map[string]interface{}) *structpb.Struct { + if len(data) == 0 { + return nil + } + result, err := structpb.NewStruct(data) + if err != nil { + return nil + } + return result +} + +func mapStringToInterface(input map[string]string) map[string]interface{} { + if len(input) == 0 { + return nil + } + out := make(map[string]interface{}, len(input)) + for k, v := range input { + out[k] = v + } + return out } func (c *ledgerClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { diff --git a/api/ledger/internal/service/ledger/connector.go b/api/ledger/internal/service/ledger/connector.go new file mode 100644 index 0000000..3f6f2bc --- /dev/null +++ b/api/ledger/internal/service/ledger/connector.go @@ -0,0 +1,622 @@ +package ledger + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/tech/sendico/ledger/internal/appversion" + "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/merrors" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + "google.golang.org/protobuf/types/known/structpb" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const ledgerConnectorID = "ledger" + +type connectorAdapter struct { + svc *Service +} + +func newConnectorAdapter(svc *Service) *connectorAdapter { + return &connectorAdapter{svc: svc} +} + +func (c *connectorAdapter) GetCapabilities(_ context.Context, _ *connectorv1.GetCapabilitiesRequest) (*connectorv1.GetCapabilitiesResponse, error) { + return &connectorv1.GetCapabilitiesResponse{ + Capabilities: &connectorv1.ConnectorCapabilities{ + ConnectorType: ledgerConnectorID, + Version: appversion.Create().Short(), + SupportedAccountKinds: []connectorv1.AccountKind{connectorv1.AccountKind_LEDGER_ACCOUNT}, + SupportedOperationTypes: []connectorv1.OperationType{ + connectorv1.OperationType_CREDIT, + connectorv1.OperationType_DEBIT, + connectorv1.OperationType_TRANSFER, + connectorv1.OperationType_FX, + }, + OpenAccountParams: ledgerOpenAccountParams(), + OperationParams: ledgerOperationParams(), + }, + }, nil +} + +func (c *connectorAdapter) OpenAccount(ctx context.Context, req *connectorv1.OpenAccountRequest) (*connectorv1.OpenAccountResponse, error) { + if req == nil { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: request is required", nil, "")}, nil + } + if req.GetKind() != connectorv1.AccountKind_LEDGER_ACCOUNT { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND, "open_account: unsupported account kind", nil, "")}, nil + } + + reader := params.New(req.GetParams()) + orgRef := strings.TrimSpace(reader.String("organization_ref")) + accountCode := strings.TrimSpace(reader.String("account_code")) + if orgRef == "" || accountCode == "" { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: organization_ref and account_code are required", nil, "")}, nil + } + + accountType, err := parseLedgerAccountType(reader, "account_type") + if err != nil { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), nil, "")}, nil + } + + currency := strings.TrimSpace(req.GetAsset()) + if currency == "" { + currency = strings.TrimSpace(reader.String("currency")) + } + if currency == "" { + return &connectorv1.OpenAccountResponse{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "open_account: asset is required", nil, "")}, nil + } + + status := parseLedgerAccountStatus(reader, "status") + metadata := mergeMetadata(reader.StringMap("metadata"), req.GetLabel(), req.GetOwnerRef(), req.GetCorrelationId(), req.GetParentIntentId()) + + resp, err := c.svc.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{ + OrganizationRef: orgRef, + AccountCode: accountCode, + AccountType: accountType, + Currency: currency, + Status: status, + AllowNegative: reader.Bool("allow_negative"), + IsSettlement: reader.Bool("is_settlement"), + Metadata: metadata, + }) + if err != nil { + return &connectorv1.OpenAccountResponse{Error: connectorError(mapErrorCode(err), err.Error(), nil, "")}, nil + } + + return &connectorv1.OpenAccountResponse{ + Account: ledgerAccountToConnector(resp.GetAccount()), + }, nil +} + +func (c *connectorAdapter) GetAccount(ctx context.Context, req *connectorv1.GetAccountRequest) (*connectorv1.GetAccountResponse, error) { + if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" { + return nil, merrors.InvalidArgument("get_account: account_ref.account_id is required") + } + accountRef, err := parseObjectID(strings.TrimSpace(req.GetAccountRef().GetAccountId())) + if err != nil { + return nil, err + } + if c.svc.storage == nil || c.svc.storage.Accounts() == nil { + return nil, merrors.Internal("get_account: storage unavailable") + } + account, err := c.svc.storage.Accounts().Get(ctx, accountRef) + if err != nil { + return nil, err + } + return &connectorv1.GetAccountResponse{ + Account: ledgerAccountToConnector(toProtoAccount(account)), + }, nil +} + +func (c *connectorAdapter) ListAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) { + if req == nil || strings.TrimSpace(req.GetOwnerRef()) == "" { + return nil, merrors.InvalidArgument("list_accounts: owner_ref is required") + } + resp, err := c.svc.ListAccounts(ctx, &ledgerv1.ListAccountsRequest{OrganizationRef: strings.TrimSpace(req.GetOwnerRef())}) + if err != nil { + return nil, err + } + accounts := make([]*connectorv1.Account, 0, len(resp.GetAccounts())) + for _, account := range resp.GetAccounts() { + accounts = append(accounts, ledgerAccountToConnector(account)) + } + return &connectorv1.ListAccountsResponse{Accounts: accounts}, nil +} + +func (c *connectorAdapter) GetBalance(ctx context.Context, req *connectorv1.GetBalanceRequest) (*connectorv1.GetBalanceResponse, error) { + if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" { + return nil, merrors.InvalidArgument("get_balance: account_ref.account_id is required") + } + resp, err := c.svc.GetBalance(ctx, &ledgerv1.GetBalanceRequest{ + LedgerAccountRef: strings.TrimSpace(req.GetAccountRef().GetAccountId()), + }) + if err != nil { + return nil, err + } + return &connectorv1.GetBalanceResponse{ + Balance: &connectorv1.Balance{ + AccountRef: req.GetAccountRef(), + Available: resp.GetBalance(), + CalculatedAt: resp.GetLastUpdated(), + PendingInbound: nil, + PendingOutbound: nil, + }, + }, nil +} + +func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + if req == nil || req.GetOperation() == nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: operation is required", nil, "")}}, nil + } + op := req.GetOperation() + if strings.TrimSpace(op.GetIdempotencyKey()) == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: idempotency_key is required", op, "")}}, nil + } + reader := params.New(op.GetParams()) + orgRef := strings.TrimSpace(reader.String("organization_ref")) + if orgRef == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "submit_operation: organization_ref is required", op, "")}}, nil + } + + metadata := mergeMetadata(reader.StringMap("metadata"), "", "", op.GetCorrelationId(), op.GetParentIntentId()) + description := strings.TrimSpace(reader.String("description")) + eventTime := parseEventTime(reader) + charges, err := parseLedgerCharges(reader) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + + switch op.GetType() { + case connectorv1.OperationType_CREDIT: + accountID := operationAccountID(op.GetTo()) + if accountID == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: to.account is required", op, "")}}, nil + } + resp, err := c.svc.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: orgRef, + LedgerAccountRef: accountID, + Money: op.GetMoney(), + Description: description, + Charges: charges, + Metadata: metadata, + EventTime: eventTime, + ContraLedgerAccountRef: strings.TrimSpace(reader.String("contra_ledger_account_ref")), + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + case connectorv1.OperationType_DEBIT: + accountID := operationAccountID(op.GetFrom()) + if accountID == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: from.account is required", op, "")}}, nil + } + resp, err := c.svc.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: orgRef, + LedgerAccountRef: accountID, + Money: op.GetMoney(), + Description: description, + Charges: charges, + Metadata: metadata, + EventTime: eventTime, + ContraLedgerAccountRef: strings.TrimSpace(reader.String("contra_ledger_account_ref")), + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + case connectorv1.OperationType_TRANSFER: + fromID := operationAccountID(op.GetFrom()) + toID := operationAccountID(op.GetTo()) + if fromID == "" || toID == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "transfer: from.account and to.account are required", op, "")}}, nil + } + resp, err := c.svc.TransferInternal(ctx, &ledgerv1.TransferRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: orgRef, + FromLedgerAccountRef: fromID, + ToLedgerAccountRef: toID, + Money: op.GetMoney(), + Description: description, + Charges: charges, + Metadata: metadata, + EventTime: eventTime, + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + case connectorv1.OperationType_FX: + fromID := operationAccountID(op.GetFrom()) + toID := operationAccountID(op.GetTo()) + if fromID == "" || toID == "" { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "fx: from.account and to.account are required", op, "")}}, nil + } + toMoney, err := parseMoneyFromMap(reader.Map("to_money")) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil + } + resp, err := c.svc.ApplyFXWithCharges(ctx, &ledgerv1.FXRequest{ + IdempotencyKey: strings.TrimSpace(op.GetIdempotencyKey()), + OrganizationRef: orgRef, + FromLedgerAccountRef: fromID, + ToLedgerAccountRef: toID, + FromMoney: op.GetMoney(), + ToMoney: toMoney, + Rate: strings.TrimSpace(reader.String("rate")), + Description: description, + Charges: charges, + Metadata: metadata, + EventTime: eventTime, + }) + if err != nil { + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, "")}}, nil + } + return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_CONFIRMED)}, nil + default: + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_UNSUPPORTED_OPERATION, "submit_operation: unsupported operation type", op, "")}}, nil + } +} + +func (c *connectorAdapter) GetOperation(ctx context.Context, req *connectorv1.GetOperationRequest) (*connectorv1.GetOperationResponse, error) { + if req == nil || strings.TrimSpace(req.GetOperationId()) == "" { + return nil, merrors.InvalidArgument("get_operation: operation_id is required") + } + entry, err := c.svc.GetJournalEntry(ctx, &ledgerv1.GetEntryRequest{EntryRef: strings.TrimSpace(req.GetOperationId())}) + if err != nil { + return nil, err + } + return &connectorv1.GetOperationResponse{Operation: ledgerEntryToOperation(entry)}, nil +} + +func (c *connectorAdapter) ListOperations(ctx context.Context, req *connectorv1.ListOperationsRequest) (*connectorv1.ListOperationsResponse, error) { + if req == nil || req.GetAccountRef() == nil || strings.TrimSpace(req.GetAccountRef().GetAccountId()) == "" { + return nil, merrors.InvalidArgument("list_operations: account_ref.account_id is required") + } + resp, err := c.svc.GetStatement(ctx, &ledgerv1.GetStatementRequest{ + LedgerAccountRef: strings.TrimSpace(req.GetAccountRef().GetAccountId()), + Cursor: "", + Limit: 0, + }) + if err != nil { + return nil, err + } + ops := make([]*connectorv1.Operation, 0, len(resp.GetEntries())) + for _, entry := range resp.GetEntries() { + ops = append(ops, ledgerEntryToOperation(entry)) + } + return &connectorv1.ListOperationsResponse{Operations: ops}, nil +} + +func ledgerOpenAccountParams() []*connectorv1.ParamSpec { + return []*connectorv1.ParamSpec{ + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference for the ledger account."}, + {Key: "account_code", Type: connectorv1.ParamType_STRING, Required: true, Description: "Ledger account code."}, + {Key: "account_type", Type: connectorv1.ParamType_STRING, Required: true, Description: "ASSET | LIABILITY | REVENUE | EXPENSE."}, + {Key: "status", Type: connectorv1.ParamType_STRING, Required: false, Description: "ACTIVE | FROZEN."}, + {Key: "allow_negative", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Allow negative balance."}, + {Key: "is_settlement", Type: connectorv1.ParamType_BOOL, Required: false, Description: "Mark account as settlement."}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Additional metadata map."}, + } +} + +func ledgerOperationParams() []*connectorv1.OperationParamSpec { + common := []*connectorv1.ParamSpec{ + {Key: "organization_ref", Type: connectorv1.ParamType_STRING, Required: true, Description: "Organization reference."}, + {Key: "description", Type: connectorv1.ParamType_STRING, Required: false, Description: "Ledger entry description."}, + {Key: "metadata", Type: connectorv1.ParamType_JSON, Required: false, Description: "Entry metadata map."}, + {Key: "charges", Type: connectorv1.ParamType_JSON, Required: false, Description: "Posting line charges."}, + {Key: "event_time", Type: connectorv1.ParamType_STRING, Required: false, Description: "RFC3339 timestamp."}, + } + return []*connectorv1.OperationParamSpec{ + {OperationType: connectorv1.OperationType_CREDIT, Params: append(common, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})}, + {OperationType: connectorv1.OperationType_DEBIT, Params: append(common, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})}, + {OperationType: connectorv1.OperationType_TRANSFER, Params: common}, + {OperationType: connectorv1.OperationType_FX, Params: append(common, + &connectorv1.ParamSpec{Key: "to_money", Type: connectorv1.ParamType_JSON, Required: true, Description: "Target amount {amount,currency}."}, + &connectorv1.ParamSpec{Key: "rate", Type: connectorv1.ParamType_STRING, Required: false, Description: "FX rate snapshot."}, + )}, + } +} + +func ledgerAccountToConnector(account *ledgerv1.LedgerAccount) *connectorv1.Account { + if account == nil { + return nil + } + details, _ := structpb.NewStruct(map[string]interface{}{ + "account_code": account.GetAccountCode(), + "account_type": account.GetAccountType().String(), + "status": account.GetStatus().String(), + "allow_negative": account.GetAllowNegative(), + "is_settlement": account.GetIsSettlement(), + }) + return &connectorv1.Account{ + Ref: &connectorv1.AccountRef{ + ConnectorId: ledgerConnectorID, + AccountId: strings.TrimSpace(account.GetLedgerAccountRef()), + }, + Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, + Asset: strings.TrimSpace(account.GetCurrency()), + State: ledgerAccountState(account.GetStatus()), + Label: strings.TrimSpace(account.GetAccountCode()), + OwnerRef: strings.TrimSpace(account.GetOrganizationRef()), + ProviderDetails: details, + CreatedAt: account.GetCreatedAt(), + UpdatedAt: account.GetUpdatedAt(), + } +} + +func ledgerAccountState(status ledgerv1.AccountStatus) connectorv1.AccountState { + switch status { + case ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE: + return connectorv1.AccountState_ACCOUNT_ACTIVE + case ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN: + return connectorv1.AccountState_ACCOUNT_SUSPENDED + default: + return connectorv1.AccountState_ACCOUNT_STATE_UNSPECIFIED + } +} + +func ledgerReceipt(ref string, status connectorv1.OperationStatus) *connectorv1.OperationReceipt { + return &connectorv1.OperationReceipt{ + OperationId: strings.TrimSpace(ref), + Status: status, + ProviderRef: strings.TrimSpace(ref), + } +} + +func ledgerEntryToOperation(entry *ledgerv1.JournalEntryResponse) *connectorv1.Operation { + if entry == nil { + return nil + } + op := &connectorv1.Operation{ + OperationId: strings.TrimSpace(entry.GetEntryRef()), + Type: ledgerEntryType(entry.GetEntryType()), + Status: connectorv1.OperationStatus_CONFIRMED, + CreatedAt: entry.GetEventTime(), + UpdatedAt: entry.GetEventTime(), + } + mainLines := ledgerMainLines(entry.GetLines()) + if len(mainLines) > 0 { + op.Money = mainLines[0].GetMoney() + } + switch op.Type { + case connectorv1.OperationType_CREDIT: + if len(mainLines) > 0 { + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[0].GetLedgerAccountRef()}}} + } + case connectorv1.OperationType_DEBIT: + if len(mainLines) > 0 { + op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[0].GetLedgerAccountRef()}}} + } + case connectorv1.OperationType_TRANSFER, connectorv1.OperationType_FX: + if len(mainLines) > 0 { + op.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[0].GetLedgerAccountRef()}}} + } + if len(mainLines) > 1 { + op.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: mainLines[1].GetLedgerAccountRef()}}} + } + } + return op +} + +func ledgerMainLines(lines []*ledgerv1.PostingLine) []*ledgerv1.PostingLine { + if len(lines) == 0 { + return nil + } + result := make([]*ledgerv1.PostingLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + if line.GetLineType() == ledgerv1.LineType_LINE_MAIN { + result = append(result, line) + } + } + return result +} + +func ledgerEntryType(entryType ledgerv1.EntryType) connectorv1.OperationType { + switch entryType { + case ledgerv1.EntryType_ENTRY_CREDIT: + return connectorv1.OperationType_CREDIT + case ledgerv1.EntryType_ENTRY_DEBIT: + return connectorv1.OperationType_DEBIT + case ledgerv1.EntryType_ENTRY_TRANSFER: + return connectorv1.OperationType_TRANSFER + case ledgerv1.EntryType_ENTRY_FX: + return connectorv1.OperationType_FX + default: + return connectorv1.OperationType_OPERATION_TYPE_UNSPECIFIED + } +} + +func operationAccountID(party *connectorv1.OperationParty) string { + if party == nil { + return "" + } + if account := party.GetAccount(); account != nil { + return strings.TrimSpace(account.GetAccountId()) + } + return "" +} + +func parseLedgerAccountType(reader params.Reader, key string) (ledgerv1.AccountType, error) { + value, ok := reader.Value(key) + if !ok { + return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, fmt.Errorf("open_account: account_type is required") + } + switch v := value.(type) { + case string: + return parseLedgerAccountTypeString(v) + case float64: + return ledgerv1.AccountType(int32(v)), nil + case int: + return ledgerv1.AccountType(v), nil + case int64: + return ledgerv1.AccountType(v), nil + default: + return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, fmt.Errorf("open_account: account_type is required") + } +} + +func parseLedgerAccountTypeString(value string) (ledgerv1.AccountType, error) { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "ACCOUNT_TYPE_ASSET", "ASSET": + return ledgerv1.AccountType_ACCOUNT_TYPE_ASSET, nil + case "ACCOUNT_TYPE_LIABILITY", "LIABILITY": + return ledgerv1.AccountType_ACCOUNT_TYPE_LIABILITY, nil + case "ACCOUNT_TYPE_REVENUE", "REVENUE": + return ledgerv1.AccountType_ACCOUNT_TYPE_REVENUE, nil + case "ACCOUNT_TYPE_EXPENSE", "EXPENSE": + return ledgerv1.AccountType_ACCOUNT_TYPE_EXPENSE, nil + default: + return ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED, fmt.Errorf("open_account: invalid account_type") + } +} + +func parseLedgerAccountStatus(reader params.Reader, key string) ledgerv1.AccountStatus { + value := strings.ToUpper(strings.TrimSpace(reader.String(key))) + switch value { + case "ACCOUNT_STATUS_ACTIVE", "ACTIVE": + return ledgerv1.AccountStatus_ACCOUNT_STATUS_ACTIVE + case "ACCOUNT_STATUS_FROZEN", "FROZEN": + return ledgerv1.AccountStatus_ACCOUNT_STATUS_FROZEN + default: + return ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED + } +} + +func parseEventTime(reader params.Reader) *timestamppb.Timestamp { + raw := strings.TrimSpace(reader.String("event_time")) + if raw == "" { + return nil + } + parsed, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + return nil + } + return timestamppb.New(parsed) +} + +func parseLedgerCharges(reader params.Reader) ([]*ledgerv1.PostingLine, error) { + items := reader.List("charges") + if len(items) == 0 { + return nil, nil + } + result := make([]*ledgerv1.PostingLine, 0, len(items)) + for i, item := range items { + raw, ok := item.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("charges[%d]: invalid charge entry", i) + } + accountRef := strings.TrimSpace(fmt.Sprint(raw["ledger_account_ref"])) + if accountRef == "" { + return nil, fmt.Errorf("charges[%d]: ledger_account_ref is required", i) + } + money, err := parseMoneyFromMap(raw) + if err != nil { + return nil, fmt.Errorf("charges[%d]: %w", i, err) + } + lineType := parseLedgerLineType(fmt.Sprint(raw["line_type"])) + result = append(result, &ledgerv1.PostingLine{ + LedgerAccountRef: accountRef, + Money: money, + LineType: lineType, + }) + } + return result, nil +} + +func parseLedgerLineType(value string) ledgerv1.LineType { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "LINE_TYPE_FEE", "FEE": + return ledgerv1.LineType_LINE_FEE + case "LINE_TYPE_SPREAD", "SPREAD": + return ledgerv1.LineType_LINE_SPREAD + case "LINE_TYPE_REVERSAL", "REVERSAL": + return ledgerv1.LineType_LINE_REVERSAL + default: + return ledgerv1.LineType_LINE_FEE + } +} + +func parseMoneyFromMap(raw map[string]interface{}) (*moneyv1.Money, error) { + if raw == nil { + return nil, fmt.Errorf("money is required") + } + amount := strings.TrimSpace(fmt.Sprint(raw["amount"])) + currency := strings.TrimSpace(fmt.Sprint(raw["currency"])) + if amount == "" || currency == "" { + return nil, fmt.Errorf("money is required") + } + return &moneyv1.Money{ + Amount: amount, + Currency: currency, + }, nil +} + +func mergeMetadata(base map[string]string, label, ownerRef, correlationID, parentIntentID string) map[string]string { + metadata := map[string]string{} + for k, v := range base { + metadata[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + if label != "" { + if _, ok := metadata["label"]; !ok { + metadata["label"] = label + } + } + if ownerRef != "" { + if _, ok := metadata["owner_ref"]; !ok { + metadata["owner_ref"] = ownerRef + } + } + if correlationID != "" { + metadata["correlation_id"] = correlationID + } + if parentIntentID != "" { + metadata["parent_intent_id"] = parentIntentID + } + if len(metadata) == 0 { + return nil + } + return metadata +} + +func connectorError(code connectorv1.ErrorCode, message string, op *connectorv1.Operation, accountID string) *connectorv1.ConnectorError { + err := &connectorv1.ConnectorError{ + Code: code, + Message: strings.TrimSpace(message), + AccountId: strings.TrimSpace(accountID), + } + if op != nil { + err.CorrelationId = strings.TrimSpace(op.GetCorrelationId()) + err.ParentIntentId = strings.TrimSpace(op.GetParentIntentId()) + err.OperationId = strings.TrimSpace(op.GetOperationId()) + } + return err +} + +func mapErrorCode(err error) connectorv1.ErrorCode { + switch { + case errors.Is(err, merrors.ErrInvalidArg): + return connectorv1.ErrorCode_INVALID_PARAMS + case errors.Is(err, merrors.ErrNoData): + return connectorv1.ErrorCode_NOT_FOUND + case errors.Is(err, merrors.ErrNotImplemented): + return connectorv1.ErrorCode_UNSUPPORTED_OPERATION + case errors.Is(err, merrors.ErrInternal): + return connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE + default: + return connectorv1.ErrorCode_PROVIDER_ERROR + } +} diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index 3959b2c..1332269 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -24,7 +24,7 @@ import ( pmessaging "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" - unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ) @@ -50,7 +50,6 @@ type Service struct { cancel context.CancelFunc publisher *outboxPublisher } - unifiedv1.UnimplementedUnifiedGatewayServiceServer } type feesDependency struct { @@ -83,7 +82,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging. func (s *Service) Register(router routers.GRPC) error { return router.Register(func(reg grpc.ServiceRegistrar) { - unifiedv1.RegisterUnifiedGatewayServiceServer(reg, s) + connectorv1.RegisterConnectorServiceServer(reg, newConnectorAdapter(s)) }) } diff --git a/api/pkg/connector/params/params.go b/api/pkg/connector/params/params.go new file mode 100644 index 0000000..cf9297b --- /dev/null +++ b/api/pkg/connector/params/params.go @@ -0,0 +1,138 @@ +package params + +import ( + "fmt" + + "google.golang.org/protobuf/types/known/structpb" +) + +// Reader provides typed helpers around a Struct param payload. +type Reader struct { + raw map[string]interface{} +} + +// New builds a Reader for the provided struct, tolerating nil inputs. +func New(params *structpb.Struct) Reader { + if params == nil { + return Reader{} + } + return Reader{raw: params.AsMap()} +} + +// Value returns the raw value and whether it was present. +func (r Reader) Value(key string) (interface{}, bool) { + if r.raw == nil { + return nil, false + } + value, ok := r.raw[key] + return value, ok +} + +// String returns the string value for a key, or "" if missing/not a string. +func (r Reader) String(key string) string { + value, ok := r.Value(key) + if !ok { + return "" + } + switch v := value.(type) { + case string: + return v + case fmt.Stringer: + return v.String() + default: + return "" + } +} + +// Bool returns the bool value for a key, or false when missing/not a bool. +func (r Reader) Bool(key string) bool { + value, ok := r.Value(key) + if !ok { + return false + } + switch v := value.(type) { + case bool: + return v + default: + return false + } +} + +// Int64 returns the int64 value for a key with a presence flag. +func (r Reader) Int64(key string) (int64, bool) { + value, ok := r.Value(key) + if !ok { + return 0, false + } + switch v := value.(type) { + case int64: + return v, true + case int32: + return int64(v), true + case int: + return int64(v), true + case float64: + return int64(v), true + case float32: + return int64(v), true + default: + return 0, false + } +} + +// Float64 returns the float64 value for a key with a presence flag. +func (r Reader) Float64(key string) (float64, bool) { + value, ok := r.Value(key) + if !ok { + return 0, false + } + switch v := value.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int64: + return float64(v), true + default: + return 0, false + } +} + +// Map returns a nested object value as a map. +func (r Reader) Map(key string) map[string]interface{} { + value, ok := r.Value(key) + if !ok { + return nil + } + if m, ok := value.(map[string]interface{}); ok { + return m + } + return nil +} + +// List returns a list value as a slice. +func (r Reader) List(key string) []interface{} { + value, ok := r.Value(key) + if !ok { + return nil + } + if list, ok := value.([]interface{}); ok { + return list + } + return nil +} + +// StringMap converts a nested map into a string map. +func (r Reader) StringMap(key string) map[string]string { + raw := r.Map(key) + if len(raw) == 0 { + return nil + } + out := make(map[string]string, len(raw)) + for k, v := range raw { + out[k] = fmt.Sprint(v) + } + return out +} diff --git a/api/proto/connector/v1/connector.proto b/api/proto/connector/v1/connector.proto new file mode 100644 index 0000000..7248f70 --- /dev/null +++ b/api/proto/connector/v1/connector.proto @@ -0,0 +1,256 @@ +syntax = "proto3"; + +package connector.v1; + +option go_package = "github.com/tech/sendico/pkg/proto/connector/v1;connectorv1"; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; +import "common/money/v1/money.proto"; +import "common/pagination/v1/cursor.proto"; + +// ConnectorService exposes capability-driven account and operation primitives. +service ConnectorService { + rpc GetCapabilities(GetCapabilitiesRequest) returns (GetCapabilitiesResponse); + + rpc OpenAccount(OpenAccountRequest) returns (OpenAccountResponse); + rpc GetAccount(GetAccountRequest) returns (GetAccountResponse); + rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse); + rpc GetBalance(GetBalanceRequest) returns (GetBalanceResponse); + + rpc SubmitOperation(SubmitOperationRequest) returns (SubmitOperationResponse); + rpc GetOperation(GetOperationRequest) returns (GetOperationResponse); + rpc ListOperations(ListOperationsRequest) returns (ListOperationsResponse); +} + +enum AccountKind { + ACCOUNT_KIND_UNSPECIFIED = 0; + LEDGER_ACCOUNT = 1; + CHAIN_MANAGED_WALLET = 2; + EXTERNAL_REF = 3; +} + +enum AccountState { + ACCOUNT_STATE_UNSPECIFIED = 0; + ACCOUNT_ACTIVE = 1; + ACCOUNT_SUSPENDED = 2; + ACCOUNT_CLOSED = 3; +} + +enum OperationType { + OPERATION_TYPE_UNSPECIFIED = 0; + CREDIT = 1; + DEBIT = 2; + TRANSFER = 3; + PAYOUT = 4; + FEE_ESTIMATE = 5; + FX = 6; + GAS_TOPUP = 7; +} + +enum OperationStatus { + OPERATION_STATUS_UNSPECIFIED = 0; + SUBMITTED = 1; + PENDING = 2; + CONFIRMED = 3; + FAILED = 4; + CANCELED = 5; +} + +enum ParamType { + PARAM_TYPE_UNSPECIFIED = 0; + STRING = 1; + INT = 2; + BOOL = 3; + DECIMAL_STRING = 4; + JSON = 5; +} + +enum ErrorCode { + ERROR_CODE_UNSPECIFIED = 0; + UNSUPPORTED_OPERATION = 1; + UNSUPPORTED_ACCOUNT_KIND = 2; + INVALID_PARAMS = 3; + INSUFFICIENT_FUNDS = 4; + NOT_FOUND = 5; + TEMPORARY_UNAVAILABLE = 6; + RATE_LIMITED = 7; + PROVIDER_ERROR = 8; +} + +message ParamSpec { + string key = 1; + ParamType type = 2; + bool required = 3; + string description = 4; + repeated string allowed_values = 5; + google.protobuf.Struct example = 6; +} + +message OperationParamSpec { + OperationType operation_type = 1; + repeated ParamSpec params = 2; +} + +message ConnectorCapabilities { + string connector_type = 1; + string version = 2; + repeated AccountKind supported_account_kinds = 3; + repeated OperationType supported_operation_types = 4; + repeated string supported_assets = 5; // canonical asset string (USD, ETH, USDT-TRC20) + repeated string supported_networks = 6; // optional, connector-defined names + repeated ParamSpec open_account_params = 7; + repeated OperationParamSpec operation_params = 8; + map metadata = 9; +} + +message AccountRef { + string connector_id = 1; + string account_id = 2; +} + +message ExternalRef { + string external_ref = 1; + google.protobuf.Struct details = 2; +} + +message OperationParty { + oneof ref { + AccountRef account = 1; + ExternalRef external = 2; + } +} + +message Account { + AccountRef ref = 1; + AccountKind kind = 2; + string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20) + AccountState state = 4; + string label = 5; + string owner_ref = 6; + google.protobuf.Struct provider_details = 7; + google.protobuf.Timestamp created_at = 8; + google.protobuf.Timestamp updated_at = 9; +} + +message Balance { + AccountRef account_ref = 1; + common.money.v1.Money available = 2; + common.money.v1.Money pending_inbound = 3; + common.money.v1.Money pending_outbound = 4; + google.protobuf.Timestamp calculated_at = 5; +} + +message ConnectorError { + ErrorCode code = 1; + string message = 2; + google.protobuf.Struct details = 3; + string correlation_id = 4; + string parent_intent_id = 5; + string operation_id = 6; + string account_id = 7; +} + +message Operation { + string operation_id = 1; + OperationType type = 2; + OperationParty from = 3; + OperationParty to = 4; + common.money.v1.Money money = 5; + string idempotency_key = 6; + google.protobuf.Struct params = 7; + string correlation_id = 8; + string parent_intent_id = 9; + OperationStatus status = 10; + string provider_ref = 11; + google.protobuf.Timestamp created_at = 12; + google.protobuf.Timestamp updated_at = 13; +} + +message OperationReceipt { + string operation_id = 1; + OperationStatus status = 2; + string provider_ref = 3; + ConnectorError error = 4; + google.protobuf.Struct result = 5; // connector-specific output payload +} + +message GetCapabilitiesRequest {} + +message GetCapabilitiesResponse { + ConnectorCapabilities capabilities = 1; +} + +message OpenAccountRequest { + string idempotency_key = 1; + AccountKind kind = 2; + string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20) + string label = 4; + string owner_ref = 5; + google.protobuf.Struct params = 6; + string correlation_id = 7; + string parent_intent_id = 8; +} + +message OpenAccountResponse { + Account account = 1; + ConnectorError error = 2; +} + +message GetAccountRequest { + AccountRef account_ref = 1; +} + +message GetAccountResponse { + Account account = 1; +} + +message ListAccountsRequest { + string owner_ref = 1; + AccountKind kind = 2; + string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20) + common.pagination.v1.CursorPageRequest page = 4; +} + +message ListAccountsResponse { + repeated Account accounts = 1; + common.pagination.v1.CursorPageResponse page = 2; +} + +message GetBalanceRequest { + AccountRef account_ref = 1; +} + +message GetBalanceResponse { + Balance balance = 1; +} + +message SubmitOperationRequest { + Operation operation = 1; +} + +message SubmitOperationResponse { + OperationReceipt receipt = 1; +} + +message GetOperationRequest { + string operation_id = 1; +} + +message GetOperationResponse { + Operation operation = 1; +} + +message ListOperationsRequest { + AccountRef account_ref = 1; + OperationType type = 2; + OperationStatus status = 3; + string correlation_id = 4; + string parent_intent_id = 5; + common.pagination.v1.CursorPageRequest page = 6; +} + +message ListOperationsResponse { + repeated Operation operations = 1; + common.pagination.v1.CursorPageResponse page = 2; +} diff --git a/api/proto/gateway/unified/v1/gateway.proto b/api/proto/gateway/unified/v1/gateway.proto deleted file mode 100644 index 8943a8c..0000000 --- a/api/proto/gateway/unified/v1/gateway.proto +++ /dev/null @@ -1,45 +0,0 @@ -syntax = "proto3"; - -package gateway.unified.v1; - -option go_package = "github.com/tech/sendico/pkg/proto/gateway/unified/v1;unifiedv1"; - -import "gateway/chain/v1/chain.proto"; -import "gateway/mntx/v1/mntx.proto"; -import "ledger/v1/ledger.proto"; - -// UnifiedGatewayService exposes gateway and ledger operations via a single interface. -service UnifiedGatewayService { - // Chain gateway operations. - rpc CreateManagedWallet(chain.gateway.v1.CreateManagedWalletRequest) returns (chain.gateway.v1.CreateManagedWalletResponse); - rpc GetManagedWallet(chain.gateway.v1.GetManagedWalletRequest) returns (chain.gateway.v1.GetManagedWalletResponse); - rpc ListManagedWallets(chain.gateway.v1.ListManagedWalletsRequest) returns (chain.gateway.v1.ListManagedWalletsResponse); - rpc GetWalletBalance(chain.gateway.v1.GetWalletBalanceRequest) returns (chain.gateway.v1.GetWalletBalanceResponse); - - rpc SubmitTransfer(chain.gateway.v1.SubmitTransferRequest) returns (chain.gateway.v1.SubmitTransferResponse); - rpc GetTransfer(chain.gateway.v1.GetTransferRequest) returns (chain.gateway.v1.GetTransferResponse); - rpc ListTransfers(chain.gateway.v1.ListTransfersRequest) returns (chain.gateway.v1.ListTransfersResponse); - - rpc EstimateTransferFee(chain.gateway.v1.EstimateTransferFeeRequest) returns (chain.gateway.v1.EstimateTransferFeeResponse); - rpc ComputeGasTopUp(chain.gateway.v1.ComputeGasTopUpRequest) returns (chain.gateway.v1.ComputeGasTopUpResponse); - rpc EnsureGasTopUp(chain.gateway.v1.EnsureGasTopUpRequest) returns (chain.gateway.v1.EnsureGasTopUpResponse); - - // Card payout gateway operations. - rpc CreateCardPayout(mntx.gateway.v1.CardPayoutRequest) returns (mntx.gateway.v1.CardPayoutResponse); - rpc GetCardPayoutStatus(mntx.gateway.v1.GetCardPayoutStatusRequest) returns (mntx.gateway.v1.GetCardPayoutStatusResponse); - rpc CreateCardTokenPayout(mntx.gateway.v1.CardTokenPayoutRequest) returns (mntx.gateway.v1.CardTokenPayoutResponse); - rpc CreateCardToken(mntx.gateway.v1.CardTokenizeRequest) returns (mntx.gateway.v1.CardTokenizeResponse); - rpc ListGatewayInstances(mntx.gateway.v1.ListGatewayInstancesRequest) returns (mntx.gateway.v1.ListGatewayInstancesResponse); - - // Ledger operations. - rpc CreateAccount(ledger.v1.CreateAccountRequest) returns (ledger.v1.CreateAccountResponse); - rpc ListAccounts(ledger.v1.ListAccountsRequest) returns (ledger.v1.ListAccountsResponse); - rpc PostCreditWithCharges(ledger.v1.PostCreditRequest) returns (ledger.v1.PostResponse); - rpc PostDebitWithCharges(ledger.v1.PostDebitRequest) returns (ledger.v1.PostResponse); - rpc TransferInternal(ledger.v1.TransferRequest) returns (ledger.v1.PostResponse); - rpc ApplyFXWithCharges(ledger.v1.FXRequest) returns (ledger.v1.PostResponse); - - rpc GetBalance(ledger.v1.GetBalanceRequest) returns (ledger.v1.BalanceResponse); - rpc GetJournalEntry(ledger.v1.GetEntryRequest) returns (ledger.v1.JournalEntryResponse); - rpc GetStatement(ledger.v1.GetStatementRequest) returns (ledger.v1.StatementResponse); -} diff --git a/ci/scripts/proto/generate.sh b/ci/scripts/proto/generate.sh index 25c0b51..f01b049 100755 --- a/ci/scripts/proto/generate.sh +++ b/ci/scripts/proto/generate.sh @@ -116,10 +116,10 @@ if [ -f "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" ]; then generate_go_with_grpc "${PROTO_DIR}/gateway/mntx/v1/mntx.proto" fi -if [ -f "${PROTO_DIR}/gateway/unified/v1/gateway.proto" ]; then - info "Compiling unified gateway protos" - clean_pb_files "./pkg/proto/gateway/unified" - generate_go_with_grpc "${PROTO_DIR}/gateway/unified/v1/gateway.proto" +if [ -f "${PROTO_DIR}/connector/v1/connector.proto" ]; then + info "Compiling connector protos" + clean_pb_files "./pkg/proto/connector" + generate_go_with_grpc "${PROTO_DIR}/connector/v1/connector.proto" fi if [ -f "${PROTO_DIR}/payments/orchestrator/v1/orchestrator.proto" ]; then