Files
sendico/api/ledger/client/client.go
2026-01-04 12:57:40 +01:00

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
}