288 lines
8.5 KiB
Go
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
|
|
}
|
|
}
|