Files
sendico/api/gateway/tgsettle/internal/service/treasury/ledger/client.go
2026-03-04 20:01:37 +01:00

288 lines
8.5 KiB
Go

package ledger
import (
"context"
"crypto/tls"
"fmt"
"net/url"
"strings"
"time"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/structpb"
)
const ledgerConnectorID = "ledger"
type Config struct {
Endpoint string
Timeout time.Duration
Insecure bool
}
type Account struct {
AccountID string
Currency string
OrganizationRef string
}
type Balance struct {
AccountID string
Amount string
Currency string
}
type PostRequest struct {
AccountID string
OrganizationRef string
Amount string
Currency string
Reference string
IdempotencyKey string
}
type OperationResult struct {
Reference string
}
type Client interface {
GetAccount(ctx context.Context, accountID string) (*Account, error)
GetBalance(ctx context.Context, accountID string) (*Balance, error)
ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error)
ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error)
Close() error
}
type grpcConnectorClient interface {
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, 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)
}
type connectorClient struct {
cfg Config
conn *grpc.ClientConn
client grpcConnectorClient
}
func New(cfg Config) (Client, error) {
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
if cfg.Endpoint == "" {
return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint")
}
if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" {
cfg.Endpoint = normalized
if insecure {
cfg.Insecure = true
}
}
if cfg.Timeout <= 0 {
cfg.Timeout = 5 * time.Second
}
dialOpts := []grpc.DialOption{}
if cfg.Insecure {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
} else {
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
}
conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...)
if err != nil {
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint))
}
return &connectorClient{
cfg: cfg,
conn: conn,
client: connectorv1.NewConnectorServiceClient(conn),
}, nil
}
func (c *connectorClient) Close() error {
if c == nil || c.conn == nil {
return nil
}
return c.conn.Close()
}
func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{
AccountRef: &connectorv1.AccountRef{
ConnectorId: ledgerConnectorID,
AccountId: accountID,
},
})
if err != nil {
return nil, err
}
account := resp.GetAccount()
if account == nil {
return nil, merrors.NoData("ledger account not found")
}
organizationRef := strings.TrimSpace(account.GetOwnerRef())
if organizationRef == "" && account.GetProviderDetails() != nil {
if value, ok := account.GetProviderDetails().AsMap()["organization_ref"]; ok {
organizationRef = strings.TrimSpace(fmt.Sprint(value))
}
}
return &Account{
AccountID: accountID,
Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())),
OrganizationRef: organizationRef,
}, nil
}
func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) {
accountID = strings.TrimSpace(accountID)
if accountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{
AccountRef: &connectorv1.AccountRef{
ConnectorId: ledgerConnectorID,
AccountId: accountID,
},
})
if err != nil {
return nil, err
}
balance := resp.GetBalance()
if balance == nil || balance.GetAvailable() == nil {
return nil, merrors.Internal("ledger balance is unavailable")
}
return &Balance{
AccountID: accountID,
Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()),
Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())),
}, nil
}
func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) {
return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req)
}
func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) {
return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req)
}
func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) {
req.AccountID = strings.TrimSpace(req.AccountID)
req.OrganizationRef = strings.TrimSpace(req.OrganizationRef)
req.Amount = strings.TrimSpace(req.Amount)
req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency))
req.Reference = strings.TrimSpace(req.Reference)
req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey)
if req.AccountID == "" {
return nil, merrors.InvalidArgument("ledger account_id is required", "account_id")
}
if req.OrganizationRef == "" {
return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref")
}
if req.Amount == "" || req.Currency == "" {
return nil, merrors.InvalidArgument("ledger amount is required", "amount")
}
if req.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key")
}
params := map[string]any{
"organization_ref": req.OrganizationRef,
"operation": operation,
"description": "tgsettle treasury operation",
"metadata": map[string]any{
"reference": req.Reference,
},
}
operationReq := &connectorv1.Operation{
Type: opType,
IdempotencyKey: req.IdempotencyKey,
Money: &moneyv1.Money{
Amount: req.Amount,
Currency: req.Currency,
},
Params: structFromMap(params),
}
account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID}
switch opType {
case connectorv1.OperationType_CREDIT:
operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
case connectorv1.OperationType_DEBIT:
operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}}
}
ctx, cancel := c.callContext(ctx)
defer cancel()
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq})
if err != nil {
return nil, err
}
if resp.GetReceipt() == nil {
return nil, merrors.Internal("ledger receipt is unavailable")
}
if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil {
message := strings.TrimSpace(receiptErr.GetMessage())
if message == "" {
message = "ledger operation failed"
}
return nil, merrors.InvalidArgument(message)
}
reference := strings.TrimSpace(resp.GetReceipt().GetOperationId())
if reference == "" {
reference = req.Reference
}
return &OperationResult{Reference: reference}, nil
}
func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
if ctx == nil {
ctx = context.Background()
}
return context.WithTimeout(ctx, c.cfg.Timeout)
}
func structFromMap(values map[string]any) *structpb.Struct {
if len(values) == 0 {
return nil
}
result, err := structpb.NewStruct(values)
if err != nil {
return nil
}
return result
}
func normalizeEndpoint(raw string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return raw, false
}
switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) {
case "http", "grpc":
return parsed.Host, true
case "https", "grpcs":
return parsed.Host, false
default:
return raw, false
}
}