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 { clean := discovery.NormalizeRail(value) switch clean { case "RAIL_CRYPTO": return discovery.RailCrypto case "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT": return discovery.RailProviderSettlement case "RAIL_LEDGER": return discovery.RailLedger case "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT": return discovery.RailCardPayout case "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP": return discovery.RailFiatOnRamp case "FIAT_OFFRAMP", "RAIL_OFFRAMP", "RAIL_FIAT_OFFRAMP": return discovery.RailFiatOffRamp default: return clean } } 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 }