Files
sendico/api/ledger/client/client.go
2026-02-27 02:33:40 +01:00

915 lines
33 KiB
Go

package client
import (
"context"
"crypto/tls"
"fmt"
"strings"
"time"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role"
"github.com/tech/sendico/pkg/payments/rail"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/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"
"google.golang.org/protobuf/types/known/structpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
ledgerConnectorID = "ledger"
ledgerRailName = discovery.RailLedger
opParamOperation = "operation"
opParamToMoney = "to_money"
opParamAmount = "amount"
opParamCurrency = "currency"
opParamOrganizationRef = "organization_ref"
opParamAccountType = "account_type"
opParamStatus = "status"
opParamAllowNegative = "allow_negative"
opParamRole = "role"
opParamDescription = "description"
opParamMetadata = "metadata"
opParamCharges = "charges"
opParamEventTime = "event_time"
opParamContraLedgerAccountRef = "contra_ledger_account_ref"
opParamLedgerAccountRef = "ledger_account_ref"
opParamLineType = "line_type"
opParamAccountCode = "account_code"
opParamIsSettlement = "is_settlement"
txMetaPaymentPlanID = "payment_plan_id"
txMetaFromRail = "from_rail"
txMetaToRail = "to_rail"
txMetaExternalReference = "external_reference_id"
txMetaFXRateUsed = "fx_rate_used"
txMetaFeeAmount = "fee_amount"
)
// 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)
ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error)
PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
PostExternalDebitWithCharges(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)
BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error)
UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, 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 grpcConnectorClient interface {
OpenAccount(ctx context.Context, in *connectorv1.OpenAccountRequest, opts ...grpc.CallOption) (*connectorv1.OpenAccountResponse, error)
GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error)
ListAccounts(ctx context.Context, in *connectorv1.ListAccountsRequest, opts ...grpc.CallOption) (*connectorv1.ListAccountsResponse, error)
GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error)
UpdateAccountState(ctx context.Context, in *connectorv1.UpdateAccountStateRequest, opts ...grpc.CallOption) (*connectorv1.UpdateAccountStateResponse, error)
SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error)
GetOperation(ctx context.Context, in *connectorv1.GetOperationRequest, opts ...grpc.CallOption) (*connectorv1.GetOperationResponse, error)
ListOperations(ctx context.Context, in *connectorv1.ListOperationsRequest, opts ...grpc.CallOption) (*connectorv1.ListOperationsResponse, error)
}
type ledgerClient struct {
cfg Config
conn *grpc.ClientConn
client grpcConnectorClient
}
// 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: connectorv1.NewConnectorServiceClient(conn),
}, nil
}
// NewWithClient injects a pre-built ledger client (useful for tests).
func NewWithClient(cfg Config, lc grpcConnectorClient) 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")
}
tx = normalizeLedgerTxRails(tx)
description := strings.TrimSpace(tx.Description)
metadata := ledgerTxMetadata(tx.Metadata, tx)
extraParams := map[string]interface{}{}
if op := strings.TrimSpace(tx.Operation); op != "" {
extraParams[opParamOperation] = op
}
if len(extraParams) == 0 {
extraParams = nil
}
switch {
case isLedgerRail(tx.FromRail) && !isLedgerRail(tx.ToRail):
resp, err := c.submitLedgerOperationWithExtras(ctx, connectorv1.OperationType_DEBIT, accountRef, "", money, &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),
}, extraParams)
if err != nil {
return "", err
}
return strings.TrimSpace(resp.GetJournalEntryRef()), nil
case isLedgerRail(tx.ToRail) && !isLedgerRail(tx.FromRail):
resp, err := c.submitLedgerOperationWithExtras(ctx, connectorv1.OperationType_CREDIT, "", accountRef, money, &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),
}, extraParams)
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()
if req == nil {
return nil, merrors.InvalidArgument("ledger: request is required")
}
if strings.TrimSpace(req.GetCurrency()) == "" {
return nil, merrors.InvalidArgument("ledger: currency is required")
}
params := map[string]interface{}{
opParamOrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
opParamAccountType: req.GetAccountType().String(),
opParamStatus: req.GetStatus().String(),
opParamAllowNegative: req.GetAllowNegative(),
}
if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
params[opParamRole] = role.String()
}
label := ""
if desc := req.GetDescribable(); desc != nil {
label = strings.TrimSpace(desc.GetName())
if desc.Description != nil {
trimmed := strings.TrimSpace(desc.GetDescription())
if trimmed != "" {
params[opParamDescription] = trimmed
}
}
}
if len(req.GetMetadata()) > 0 {
params[opParamMetadata] = mapStringToInterface(req.GetMetadata())
}
resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT,
Asset: strings.TrimSpace(req.GetCurrency()),
Label: label,
Params: structFromMap(params),
})
if err != nil {
return nil, err
}
if resp.GetError() != nil {
return nil, connectorError(resp.GetError())
}
return &ledgerv1.CreateAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
}
func (c *ledgerClient) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil || strings.TrimSpace(req.GetOrganizationRef()) == "" {
return nil, merrors.InvalidArgument("ledger: organization_ref is required")
}
resp, err := c.client.ListAccounts(ctx, &connectorv1.ListAccountsRequest{
OrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
OwnerRefFilter: req.GetOwnerRefFilter(),
})
if err != nil {
return nil, err
}
accounts := make([]*ledgerv1.LedgerAccount, 0, len(resp.GetAccounts()))
for _, account := range resp.GetAccounts() {
accounts = append(accounts, ledgerAccountFromConnector(account))
}
return &ledgerv1.ListAccountsResponse{Accounts: accounts}, nil
}
func (c *ledgerClient) ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil {
return nil, merrors.InvalidArgument("ledger: request is required")
}
return c.client.ListAccounts(ctx, req)
}
func (c *ledgerClient) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
return c.submitLedgerOperation(ctx, connectorv1.OperationType_CREDIT, "", req.GetLedgerAccountRef(), req.GetMoney(), req)
}
func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
return c.submitLedgerOperation(ctx, connectorv1.OperationType_DEBIT, req.GetLedgerAccountRef(), "", req.GetMoney(), req)
}
func (c *ledgerClient) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
return c.submitLedgerOperationWithExtras(
ctx,
connectorv1.OperationType_CREDIT,
"",
req.GetLedgerAccountRef(),
req.GetMoney(),
req,
map[string]interface{}{opParamOperation: discovery.OperationExternalCredit},
)
}
func (c *ledgerClient) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
return c.submitLedgerOperationWithExtras(
ctx,
connectorv1.OperationType_DEBIT,
req.GetLedgerAccountRef(),
"",
req.GetMoney(),
req,
map[string]interface{}{opParamOperation: discovery.OperationExternalDebit},
)
}
func (c *ledgerClient) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
return c.submitLedgerOperation(ctx, connectorv1.OperationType_TRANSFER, req.GetFromLedgerAccountRef(), req.GetToLedgerAccountRef(), req.GetMoney(), req)
}
func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil {
return nil, merrors.InvalidArgument("ledger: request is required")
}
if req.GetFromMoney() == nil || req.GetToMoney() == nil {
return nil, merrors.InvalidArgument("ledger: from_money and to_money are required")
}
params := ledgerOperationParams(req.GetOrganizationRef(), req.GetDescription(), req.GetMetadata(), req.GetCharges(), req.GetEventTime())
params["rate"] = strings.TrimSpace(req.GetRate())
params[opParamToMoney] = map[string]interface{}{opParamAmount: req.GetToMoney().GetAmount(), opParamCurrency: req.GetToMoney().GetCurrency()}
operation := &connectorv1.Operation{
Type: connectorv1.OperationType_FX,
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
From: accountParty(req.GetFromLedgerAccountRef()),
To: accountParty(req.GetToLedgerAccountRef()),
Money: req.GetFromMoney(),
Params: structFromMap(params),
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operation})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: ledgerv1.EntryType_ENTRY_FX}, nil
}
func (c *ledgerClient) BlockAccount(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
}
sourceRole := account_role.ToProto(accountRoleFromLedgerProto(req.GetRole()))
resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{
AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
TargetState: connectorv1.AccountState_ACCOUNT_SUSPENDED,
SourceRole: sourceRole,
})
if err != nil {
return nil, err
}
if resp.GetError() != nil {
return nil, connectorError(resp.GetError())
}
return &ledgerv1.BlockAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
}
func (c *ledgerClient) UnblockAccount(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
}
sourceRole := account_role.ToProto(accountRoleFromLedgerProto(req.GetRole()))
resp, err := c.client.UpdateAccountState(ctx, &connectorv1.UpdateAccountStateRequest{
AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
TargetState: connectorv1.AccountState_ACCOUNT_ACTIVE,
SourceRole: sourceRole,
})
if err != nil {
return nil, err
}
if resp.GetError() != nil {
return nil, connectorError(resp.GetError())
}
return &ledgerv1.UnblockAccountResponse{Account: ledgerAccountFromConnector(resp.GetAccount())}, nil
}
func (c *ledgerClient) GetBalance(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
}
resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())}})
if err != nil {
return nil, err
}
balance := resp.GetBalance()
if balance == nil {
return nil, merrors.Internal("ledger: balance response missing")
}
return &ledgerv1.BalanceResponse{
LedgerAccountRef: strings.TrimSpace(req.GetLedgerAccountRef()),
Balance: balance.GetAvailable(),
LastUpdated: balance.GetCalculatedAt(),
}, nil
}
func (c *ledgerClient) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil || strings.TrimSpace(req.GetEntryRef()) == "" {
return nil, merrors.InvalidArgument("ledger: entry_ref is required")
}
resp, err := c.client.GetOperation(ctx, &connectorv1.GetOperationRequest{OperationId: strings.TrimSpace(req.GetEntryRef())})
if err != nil {
return nil, err
}
return journalEntryFromOperation(resp.GetOperation()), nil
}
func (c *ledgerClient) GetStatement(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if req == nil || strings.TrimSpace(req.GetLedgerAccountRef()) == "" {
return nil, merrors.InvalidArgument("ledger: ledger_account_ref is required")
}
resp, err := c.client.ListOperations(ctx, &connectorv1.ListOperationsRequest{
AccountRef: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(req.GetLedgerAccountRef())},
Page: pageFromStatement(req),
})
if err != nil {
return nil, err
}
entries := make([]*ledgerv1.JournalEntryResponse, 0, len(resp.GetOperations()))
for _, op := range resp.GetOperations() {
entries = append(entries, journalEntryFromOperation(op))
}
nextCursor := ""
if resp.GetPage() != nil {
nextCursor = resp.GetPage().GetNextCursor()
}
return &ledgerv1.StatementResponse{Entries: entries, NextCursor: nextCursor}, nil
}
func (c *ledgerClient) submitLedgerOperation(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}) (*ledgerv1.PostResponse, error) {
return c.submitLedgerOperationWithExtras(ctx, opType, fromRef, toRef, money, req, nil)
}
func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opType connectorv1.OperationType, fromRef, toRef string, money *moneyv1.Money, req interface{}, extraParams map[string]interface{}) (*ledgerv1.PostResponse, error) {
ctx, cancel := c.callContext(ctx)
defer cancel()
if money == nil {
return nil, merrors.InvalidArgument("ledger: money is required")
}
var (
idempotencyKey string
orgRef string
description string
metadata map[string]string
charges []*ledgerv1.PostingLine
eventTime *timestamppb.Timestamp
contraRef string
fromRole account_role.AccountRole
toRole account_role.AccountRole
)
switch r := req.(type) {
case *ledgerv1.PostCreditRequest:
idempotencyKey = r.GetIdempotencyKey()
orgRef = r.GetOrganizationRef()
description = r.GetDescription()
metadata = r.GetMetadata()
charges = r.GetCharges()
eventTime = r.GetEventTime()
contraRef = r.GetContraLedgerAccountRef()
toRole = accountRoleFromLedgerProto(r.GetRole())
case *ledgerv1.PostDebitRequest:
idempotencyKey = r.GetIdempotencyKey()
orgRef = r.GetOrganizationRef()
description = r.GetDescription()
metadata = r.GetMetadata()
charges = r.GetCharges()
eventTime = r.GetEventTime()
contraRef = r.GetContraLedgerAccountRef()
fromRole = accountRoleFromLedgerProto(r.GetRole())
case *ledgerv1.TransferRequest:
idempotencyKey = r.GetIdempotencyKey()
orgRef = r.GetOrganizationRef()
description = r.GetDescription()
metadata = r.GetMetadata()
charges = r.GetCharges()
eventTime = r.GetEventTime()
fromRole = accountRoleFromLedgerProto(r.GetFromRole())
toRole = accountRoleFromLedgerProto(r.GetToRole())
}
params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime)
if contraRef != "" {
params[opParamContraLedgerAccountRef] = strings.TrimSpace(contraRef)
}
if len(extraParams) > 0 {
for key, value := range extraParams {
params[key] = value
}
}
op := &connectorv1.Operation{
Type: opType,
IdempotencyKey: strings.TrimSpace(idempotencyKey),
Money: money,
Params: structFromMap(params),
}
if fromRef != "" {
op.From = accountParty(fromRef)
}
if toRef != "" {
op.To = accountParty(toRef)
}
if fromRole != "" {
op.FromRole = account_role.ToProto(fromRole)
}
if toRole != "" {
op.ToRole = account_role.ToProto(toRole)
}
resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: op})
if err != nil {
return nil, err
}
if resp.GetReceipt() != nil && resp.GetReceipt().GetError() != nil {
return nil, connectorError(resp.GetReceipt().GetError())
}
return &ledgerv1.PostResponse{JournalEntryRef: resp.GetReceipt().GetOperationId(), EntryType: entryTypeFromOperation(opType)}, nil
}
func accountRoleFromLedgerProto(role ledgerv1.AccountRole) account_role.AccountRole {
switch role {
case ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING:
return account_role.AccountRoleOperating
case ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD:
return account_role.AccountRoleHold
case ledgerv1.AccountRole_ACCOUNT_ROLE_TRANSIT:
return account_role.AccountRoleTransit
case ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT:
return account_role.AccountRoleSettlement
case ledgerv1.AccountRole_ACCOUNT_ROLE_CLEARING:
return account_role.AccountRoleClearing
case ledgerv1.AccountRole_ACCOUNT_ROLE_PENDING:
return account_role.AccountRolePending
case ledgerv1.AccountRole_ACCOUNT_ROLE_RESERVE:
return account_role.AccountRoleReserve
case ledgerv1.AccountRole_ACCOUNT_ROLE_LIQUIDITY:
return account_role.AccountRoleLiquidity
case ledgerv1.AccountRole_ACCOUNT_ROLE_FEE:
return account_role.AccountRoleFee
case ledgerv1.AccountRole_ACCOUNT_ROLE_CHARGEBACK:
return account_role.AccountRoleChargeback
case ledgerv1.AccountRole_ACCOUNT_ROLE_ADJUSTMENT:
return account_role.AccountRoleAdjustment
default:
return ""
}
}
func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} {
params := map[string]interface{}{
opParamOrganizationRef: strings.TrimSpace(orgRef),
opParamDescription: strings.TrimSpace(description),
}
if len(metadata) > 0 {
params[opParamMetadata] = mapStringToInterface(metadata)
}
if len(charges) > 0 {
params[opParamCharges] = chargesToInterface(charges)
}
if eventTime != nil {
params[opParamEventTime] = eventTime.AsTime().UTC().Format(time.RFC3339Nano)
}
return params
}
func accountParty(accountRef string) *connectorv1.OperationParty {
if strings.TrimSpace(accountRef) == "" {
return nil
}
return &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: strings.TrimSpace(accountRef)}}}
}
func entryTypeFromOperation(opType connectorv1.OperationType) ledgerv1.EntryType {
switch opType {
case connectorv1.OperationType_CREDIT:
return ledgerv1.EntryType_ENTRY_CREDIT
case connectorv1.OperationType_DEBIT:
return ledgerv1.EntryType_ENTRY_DEBIT
case connectorv1.OperationType_TRANSFER:
return ledgerv1.EntryType_ENTRY_TRANSFER
case connectorv1.OperationType_FX:
return ledgerv1.EntryType_ENTRY_FX
default:
return ledgerv1.EntryType_ENTRY_TYPE_UNSPECIFIED
}
}
func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAccount {
if account == nil {
return nil
}
details := map[string]interface{}{}
if account.GetProviderDetails() != nil {
details = account.GetProviderDetails().AsMap()
}
accountType := ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED
if v := strings.TrimSpace(fmt.Sprint(details[opParamAccountType])); v != "" {
accountType = parseAccountType(v)
}
status := ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED
if v := strings.TrimSpace(fmt.Sprint(details[opParamStatus])); v != "" {
status = parseAccountStatus(v)
}
allowNegative := false
if v, ok := details[opParamAllowNegative].(bool); ok {
allowNegative = v
}
role := ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED
if v := strings.TrimSpace(fmt.Sprint(details[opParamRole])); v != "" {
if parsed, ok := ledgerconv.ParseAccountRole(v); ok {
role = parsed
}
}
if role == ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
switch v := details[opParamIsSettlement].(type) {
case bool:
if v {
role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
}
case string:
if strings.EqualFold(strings.TrimSpace(v), "true") {
role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT
}
}
}
accountCode := strings.TrimSpace(fmt.Sprint(details[opParamAccountCode]))
accountID := ""
if ref := account.GetRef(); ref != nil {
accountID = strings.TrimSpace(ref.GetAccountId())
}
organizationRef := strings.TrimSpace(account.GetOwnerRef())
if v := strings.TrimSpace(fmt.Sprint(details[opParamOrganizationRef])); v != "" {
organizationRef = v
}
describable := account.GetDescribable()
label := strings.TrimSpace(account.GetLabel())
if describable == nil && label != "" {
describable = &describablev1.Describable{Name: label}
} else if describable != nil && strings.TrimSpace(describable.GetName()) == "" && label != "" {
desc := strings.TrimSpace(describable.GetDescription())
if desc == "" {
describable = &describablev1.Describable{Name: label}
} else {
describable = &describablev1.Describable{Name: label, Description: &desc}
}
}
return &ledgerv1.LedgerAccount{
LedgerAccountRef: accountID,
OrganizationRef: organizationRef,
AccountCode: accountCode,
AccountType: accountType,
Currency: strings.TrimSpace(account.GetAsset()),
Status: status,
AllowNegative: allowNegative,
Role: role,
CreatedAt: account.GetCreatedAt(),
UpdatedAt: account.GetUpdatedAt(),
Describable: describable,
}
}
func parseAccountType(value string) ledgerv1.AccountType {
accountType, _ := ledgerconv.ParseAccountType(value)
return accountType
}
func parseAccountStatus(value string) ledgerv1.AccountStatus {
status, _ := ledgerconv.ParseAccountStatus(value)
return status
}
func journalEntryFromOperation(op *connectorv1.Operation) *ledgerv1.JournalEntryResponse {
if op == nil {
return nil
}
entry := &ledgerv1.JournalEntryResponse{
EntryRef: strings.TrimSpace(op.GetOperationId()),
EntryType: entryTypeFromOperation(op.GetType()),
Description: operationDescription(op),
EventTime: op.GetCreatedAt(),
Lines: postingLinesFromOperation(op),
LedgerAccountRefs: ledgerAccountRefsFromOperation(op),
}
return entry
}
func operationDescription(op *connectorv1.Operation) string {
if op == nil || op.GetParams() == nil {
return ""
}
if value, ok := op.GetParams().AsMap()[opParamDescription]; ok {
return strings.TrimSpace(fmt.Sprint(value))
}
return ""
}
func postingLinesFromOperation(op *connectorv1.Operation) []*ledgerv1.PostingLine {
if op == nil || op.GetMoney() == nil {
return nil
}
lines := []*ledgerv1.PostingLine{}
if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(from.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
}
if to := op.GetTo(); to != nil && to.GetAccount() != nil {
lines = append(lines, &ledgerv1.PostingLine{LedgerAccountRef: strings.TrimSpace(to.GetAccount().GetAccountId()), Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
}
if len(lines) == 0 {
lines = append(lines, &ledgerv1.PostingLine{Money: op.GetMoney(), LineType: ledgerv1.LineType_LINE_MAIN})
}
return lines
}
func ledgerAccountRefsFromOperation(op *connectorv1.Operation) []string {
refs := []string{}
if op == nil {
return refs
}
if from := op.GetFrom(); from != nil && from.GetAccount() != nil {
refs = append(refs, strings.TrimSpace(from.GetAccount().GetAccountId()))
}
if to := op.GetTo(); to != nil && to.GetAccount() != nil {
refs = append(refs, strings.TrimSpace(to.GetAccount().GetAccountId()))
}
return refs
}
func pageFromStatement(req *ledgerv1.GetStatementRequest) *paginationv1.CursorPageRequest {
if req == nil {
return nil
}
return &paginationv1.CursorPageRequest{
Cursor: strings.TrimSpace(req.GetCursor()),
Limit: req.GetLimit(),
}
}
func chargesToInterface(charges []*ledgerv1.PostingLine) []interface{} {
if len(charges) == 0 {
return nil
}
result := make([]interface{}, 0, len(charges))
for _, line := range charges {
if line == nil || line.GetMoney() == nil {
continue
}
result = append(result, map[string]interface{}{
opParamLedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
opParamAmount: strings.TrimSpace(line.GetMoney().GetAmount()),
opParamCurrency: strings.TrimSpace(line.GetMoney().GetCurrency()),
opParamLineType: line.GetLineType().String(),
})
}
if len(result) == 0 {
return nil
}
return result
}
func connectorError(err *connectorv1.ConnectorError) error {
if err == nil {
return nil
}
msg := strings.TrimSpace(err.GetMessage())
switch err.GetCode() {
case connectorv1.ErrorCode_INVALID_PARAMS:
return merrors.InvalidArgument(msg)
case connectorv1.ErrorCode_NOT_FOUND:
return merrors.NoData(msg)
case connectorv1.ErrorCode_UNSUPPORTED_OPERATION, connectorv1.ErrorCode_UNSUPPORTED_ACCOUNT_KIND:
return merrors.NotImplemented(msg)
case connectorv1.ErrorCode_RATE_LIMITED, connectorv1.ErrorCode_TEMPORARY_UNAVAILABLE:
return merrors.Internal(msg)
default:
return merrors.Internal(msg)
}
}
func structFromMap(data map[string]interface{}) *structpb.Struct {
if len(data) == 0 {
return nil
}
result, err := structpb.NewStruct(data)
if err != nil {
return nil
}
return result
}
func mapStringToInterface(input map[string]string) map[string]interface{} {
if len(input) == 0 {
return nil
}
out := make(map[string]interface{}, len(input))
for k, v := range input {
out[k] = v
}
return out
}
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 normalizeRail(value) == ledgerRailName
}
func normalizeLedgerTxRails(tx rail.LedgerTx) rail.LedgerTx {
tx.FromRail = normalizeRail(tx.FromRail)
tx.ToRail = normalizeRail(tx.ToRail)
return tx
}
func normalizeRail(value string) string {
return discovery.NormalizeRail(value)
}
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[txMetaPaymentPlanID] = val
}
if val := normalizeRail(tx.FromRail); val != "" {
meta[txMetaFromRail] = val
}
if val := normalizeRail(tx.ToRail); val != "" {
meta[txMetaToRail] = val
}
if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" {
meta[txMetaExternalReference] = val
}
if val := strings.TrimSpace(tx.FXRateUsed); val != "" {
meta[txMetaFXRateUsed] = val
}
if val := strings.TrimSpace(tx.FeeAmount); val != "" {
meta[txMetaFeeAmount] = val
}
if len(meta) == 0 {
return nil
}
return meta
}