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" unifiedv1 "github.com/tech/sendico/pkg/proto/gateway/unified/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: unifiedv1.NewUnifiedGatewayServiceClient(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 }