293 lines
10 KiB
Go
293 lines
10 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
"github.com/tech/sendico/pkg/payments/rail"
|
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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"
|
|
)
|
|
|
|
// Client exposes typed helpers around the ledger gRPC API.
|
|
type Client interface {
|
|
ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error)
|
|
CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error)
|
|
HoldBalance(ctx context.Context, accountID string, amount string) error
|
|
|
|
CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
|
|
ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
|
|
PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
|
|
PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
|
|
TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
|
|
ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error)
|
|
|
|
GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
|
|
GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error)
|
|
GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error)
|
|
|
|
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 ledgerClient struct {
|
|
cfg Config
|
|
conn *grpc.ClientConn
|
|
client grpcLedgerClient
|
|
}
|
|
|
|
// New dials the ledger endpoint and returns a ready client.
|
|
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
|
|
cfg.setDefaults()
|
|
if strings.TrimSpace(cfg.Address) == "" {
|
|
return nil, merrors.InvalidArgument("ledger: address is required")
|
|
}
|
|
|
|
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
|
|
defer cancel()
|
|
|
|
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
|
|
dialOpts = append(dialOpts, opts...)
|
|
|
|
if cfg.Insecure {
|
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
|
} else {
|
|
dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{})))
|
|
}
|
|
|
|
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
|
|
if err != nil {
|
|
return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Address))
|
|
}
|
|
|
|
return &ledgerClient{
|
|
cfg: cfg,
|
|
conn: conn,
|
|
client: ledgerv1.NewLedgerServiceClient(conn),
|
|
}, nil
|
|
}
|
|
|
|
// NewWithClient injects a pre-built ledger client (useful for tests).
|
|
func NewWithClient(cfg Config, lc grpcLedgerClient) Client {
|
|
cfg.setDefaults()
|
|
return &ledgerClient{
|
|
cfg: cfg,
|
|
client: lc,
|
|
}
|
|
}
|
|
|
|
func (c *ledgerClient) Close() error {
|
|
if c.conn != nil {
|
|
return c.conn.Close()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *ledgerClient) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) {
|
|
if strings.TrimSpace(accountID) == "" {
|
|
return nil, merrors.InvalidArgument("ledger: account_id is required")
|
|
}
|
|
resp, err := c.GetBalance(ctx, &ledgerv1.GetBalanceRequest{LedgerAccountRef: strings.TrimSpace(accountID)})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp == nil || resp.GetBalance() == nil {
|
|
return nil, merrors.Internal("ledger: balance response missing")
|
|
}
|
|
return cloneMoney(resp.GetBalance()), nil
|
|
}
|
|
|
|
func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) {
|
|
orgRef := strings.TrimSpace(tx.OrganizationRef)
|
|
if orgRef == "" {
|
|
return "", merrors.InvalidArgument("ledger: organization_ref is required")
|
|
}
|
|
accountRef := strings.TrimSpace(tx.LedgerAccountRef)
|
|
if accountRef == "" {
|
|
return "", merrors.InvalidArgument("ledger: ledger_account_ref is required")
|
|
}
|
|
money := &moneyv1.Money{
|
|
Currency: strings.TrimSpace(tx.Currency),
|
|
Amount: strings.TrimSpace(tx.Amount),
|
|
}
|
|
if money.GetCurrency() == "" || money.GetAmount() == "" {
|
|
return "", merrors.InvalidArgument("ledger: amount is required")
|
|
}
|
|
|
|
description := strings.TrimSpace(tx.Description)
|
|
metadata := ledgerTxMetadata(tx.Metadata, tx)
|
|
|
|
switch {
|
|
case isLedgerRail(tx.FromRail) && !isLedgerRail(tx.ToRail):
|
|
resp, err := c.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{
|
|
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
|
|
OrganizationRef: orgRef,
|
|
LedgerAccountRef: accountRef,
|
|
Money: money,
|
|
Description: description,
|
|
Charges: tx.Charges,
|
|
Metadata: metadata,
|
|
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
|
|
case isLedgerRail(tx.ToRail) && !isLedgerRail(tx.FromRail):
|
|
resp, err := c.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{
|
|
IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey),
|
|
OrganizationRef: orgRef,
|
|
LedgerAccountRef: accountRef,
|
|
Money: money,
|
|
Description: description,
|
|
Charges: tx.Charges,
|
|
Metadata: metadata,
|
|
ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef),
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
|
|
default:
|
|
return "", merrors.InvalidArgument("ledger: unsupported transaction direction")
|
|
}
|
|
}
|
|
|
|
func (c *ledgerClient) HoldBalance(ctx context.Context, accountID string, amount string) error {
|
|
return merrors.NotImplemented("ledger: hold balance not supported")
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (c *ledgerClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
timeout := c.cfg.CallTimeout
|
|
if timeout <= 0 {
|
|
timeout = 3 * time.Second
|
|
}
|
|
return context.WithTimeout(ctx, timeout)
|
|
}
|
|
|
|
func isLedgerRail(value string) bool {
|
|
return strings.EqualFold(strings.TrimSpace(value), "LEDGER")
|
|
}
|
|
|
|
func cloneMoney(input *moneyv1.Money) *moneyv1.Money {
|
|
if input == nil {
|
|
return nil
|
|
}
|
|
return &moneyv1.Money{
|
|
Currency: input.GetCurrency(),
|
|
Amount: input.GetAmount(),
|
|
}
|
|
}
|
|
|
|
func cloneMetadata(input map[string]string) map[string]string {
|
|
if len(input) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(input))
|
|
for k, v := range input {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func ledgerTxMetadata(base map[string]string, tx rail.LedgerTx) map[string]string {
|
|
meta := cloneMetadata(base)
|
|
if meta == nil {
|
|
meta = map[string]string{}
|
|
}
|
|
if val := strings.TrimSpace(tx.PaymentPlanID); val != "" {
|
|
meta["payment_plan_id"] = val
|
|
}
|
|
if val := strings.TrimSpace(tx.FromRail); val != "" {
|
|
meta["from_rail"] = val
|
|
}
|
|
if val := strings.TrimSpace(tx.ToRail); val != "" {
|
|
meta["to_rail"] = val
|
|
}
|
|
if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" {
|
|
meta["external_reference_id"] = val
|
|
}
|
|
if val := strings.TrimSpace(tx.FXRateUsed); val != "" {
|
|
meta["fx_rate_used"] = val
|
|
}
|
|
if val := strings.TrimSpace(tx.FeeAmount); val != "" {
|
|
meta["fee_amount"] = val
|
|
}
|
|
if len(meta) == 0 {
|
|
return nil
|
|
}
|
|
return meta
|
|
}
|