package ledger import ( "context" "crypto/tls" "fmt" "net/url" "strings" "time" "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/types/known/structpb" ) const ledgerConnectorID = "ledger" type Config struct { Endpoint string Timeout time.Duration Insecure bool } type Account struct { AccountID string AccountCode string Currency string OrganizationRef string } type Balance struct { AccountID string Amount string Currency string } type PostRequest struct { AccountID string OrganizationRef string Amount string Currency string Reference string IdempotencyKey string } type OperationResult struct { Reference string } type Client interface { GetAccount(ctx context.Context, accountID string) (*Account, error) GetBalance(ctx context.Context, accountID string) (*Balance, error) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) Close() error } type grpcConnectorClient interface { GetAccount(ctx context.Context, in *connectorv1.GetAccountRequest, opts ...grpc.CallOption) (*connectorv1.GetAccountResponse, error) GetBalance(ctx context.Context, in *connectorv1.GetBalanceRequest, opts ...grpc.CallOption) (*connectorv1.GetBalanceResponse, error) SubmitOperation(ctx context.Context, in *connectorv1.SubmitOperationRequest, opts ...grpc.CallOption) (*connectorv1.SubmitOperationResponse, error) } type connectorClient struct { cfg Config conn *grpc.ClientConn client grpcConnectorClient } func New(cfg Config) (Client, error) { cfg.Endpoint = strings.TrimSpace(cfg.Endpoint) if cfg.Endpoint == "" { return nil, merrors.InvalidArgument("ledger endpoint is required", "ledger.endpoint") } if normalized, insecure := normalizeEndpoint(cfg.Endpoint); normalized != "" { cfg.Endpoint = normalized if insecure { cfg.Insecure = true } } if cfg.Timeout <= 0 { cfg.Timeout = 5 * time.Second } dialOpts := []grpc.DialOption{} if cfg.Insecure { dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) } else { dialOpts = append(dialOpts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{}))) } conn, err := grpc.NewClient(cfg.Endpoint, dialOpts...) if err != nil { return nil, merrors.InternalWrap(err, fmt.Sprintf("ledger: dial %s", cfg.Endpoint)) } return &connectorClient{ cfg: cfg, conn: conn, client: connectorv1.NewConnectorServiceClient(conn), }, nil } func (c *connectorClient) Close() error { if c == nil || c.conn == nil { return nil } return c.conn.Close() } func (c *connectorClient) GetAccount(ctx context.Context, accountID string) (*Account, error) { accountID = strings.TrimSpace(accountID) if accountID == "" { return nil, merrors.InvalidArgument("ledger account_id is required", "account_id") } ctx, cancel := c.callContext(ctx) defer cancel() resp, err := c.client.GetAccount(ctx, &connectorv1.GetAccountRequest{ AccountRef: &connectorv1.AccountRef{ ConnectorId: ledgerConnectorID, AccountId: accountID, }, }) if err != nil { return nil, err } account := resp.GetAccount() if account == nil { return nil, merrors.NoData("ledger account not found") } accountCode := strings.TrimSpace(account.GetLabel()) organizationRef := strings.TrimSpace(account.GetOwnerRef()) if organizationRef == "" && account.GetProviderDetails() != nil { details := account.GetProviderDetails().AsMap() if organizationRef == "" { organizationRef = firstDetailValue(details, "organization_ref", "organizationRef", "org_ref") } if accountCode == "" { accountCode = firstDetailValue(details, "account_code", "accountCode", "code", "ledger_account_code") } } return &Account{ AccountID: accountID, AccountCode: accountCode, Currency: strings.ToUpper(strings.TrimSpace(account.GetAsset())), OrganizationRef: organizationRef, }, nil } func (c *connectorClient) GetBalance(ctx context.Context, accountID string) (*Balance, error) { accountID = strings.TrimSpace(accountID) if accountID == "" { return nil, merrors.InvalidArgument("ledger account_id is required", "account_id") } ctx, cancel := c.callContext(ctx) defer cancel() resp, err := c.client.GetBalance(ctx, &connectorv1.GetBalanceRequest{ AccountRef: &connectorv1.AccountRef{ ConnectorId: ledgerConnectorID, AccountId: accountID, }, }) if err != nil { return nil, err } balance := resp.GetBalance() if balance == nil || balance.GetAvailable() == nil { return nil, merrors.Internal("ledger balance is unavailable") } return &Balance{ AccountID: accountID, Amount: strings.TrimSpace(balance.GetAvailable().GetAmount()), Currency: strings.ToUpper(strings.TrimSpace(balance.GetAvailable().GetCurrency())), }, nil } func (c *connectorClient) ExternalCredit(ctx context.Context, req PostRequest) (*OperationResult, error) { return c.submitExternalOperation(ctx, connectorv1.OperationType_CREDIT, discovery.OperationExternalCredit, req) } func (c *connectorClient) ExternalDebit(ctx context.Context, req PostRequest) (*OperationResult, error) { return c.submitExternalOperation(ctx, connectorv1.OperationType_DEBIT, discovery.OperationExternalDebit, req) } func (c *connectorClient) submitExternalOperation(ctx context.Context, opType connectorv1.OperationType, operation string, req PostRequest) (*OperationResult, error) { req.AccountID = strings.TrimSpace(req.AccountID) req.OrganizationRef = strings.TrimSpace(req.OrganizationRef) req.Amount = strings.TrimSpace(req.Amount) req.Currency = strings.ToUpper(strings.TrimSpace(req.Currency)) req.Reference = strings.TrimSpace(req.Reference) req.IdempotencyKey = strings.TrimSpace(req.IdempotencyKey) if req.AccountID == "" { return nil, merrors.InvalidArgument("ledger account_id is required", "account_id") } if req.OrganizationRef == "" { return nil, merrors.InvalidArgument("ledger organization_ref is required", "organization_ref") } if req.Amount == "" || req.Currency == "" { return nil, merrors.InvalidArgument("ledger amount is required", "amount") } if req.IdempotencyKey == "" { return nil, merrors.InvalidArgument("ledger idempotency_key is required", "idempotency_key") } params := map[string]any{ "organization_ref": req.OrganizationRef, "operation": operation, "description": "tgsettle treasury operation", "metadata": map[string]any{ "reference": req.Reference, }, } operationReq := &connectorv1.Operation{ Type: opType, IdempotencyKey: req.IdempotencyKey, Money: &moneyv1.Money{ Amount: req.Amount, Currency: req.Currency, }, Params: structFromMap(params), } account := &connectorv1.AccountRef{ConnectorId: ledgerConnectorID, AccountId: req.AccountID} switch opType { case connectorv1.OperationType_CREDIT: operationReq.To = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}} case connectorv1.OperationType_DEBIT: operationReq.From = &connectorv1.OperationParty{Ref: &connectorv1.OperationParty_Account{Account: account}} } ctx, cancel := c.callContext(ctx) defer cancel() resp, err := c.client.SubmitOperation(ctx, &connectorv1.SubmitOperationRequest{Operation: operationReq}) if err != nil { return nil, err } if resp.GetReceipt() == nil { return nil, merrors.Internal("ledger receipt is unavailable") } if receiptErr := resp.GetReceipt().GetError(); receiptErr != nil { message := strings.TrimSpace(receiptErr.GetMessage()) if message == "" { message = "ledger operation failed" } return nil, merrors.InvalidArgument(message) } reference := strings.TrimSpace(resp.GetReceipt().GetOperationId()) if reference == "" { reference = req.Reference } return &OperationResult{Reference: reference}, nil } func (c *connectorClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { if ctx == nil { ctx = context.Background() } return context.WithTimeout(ctx, c.cfg.Timeout) } func structFromMap(values map[string]any) *structpb.Struct { if len(values) == 0 { return nil } result, err := structpb.NewStruct(values) if err != nil { return nil } return result } func normalizeEndpoint(raw string) (string, bool) { raw = strings.TrimSpace(raw) if raw == "" { return "", false } parsed, err := url.Parse(raw) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return raw, false } switch strings.ToLower(strings.TrimSpace(parsed.Scheme)) { case "http", "grpc": return parsed.Host, true case "https", "grpcs": return parsed.Host, false default: return raw, false } } func firstDetailValue(values map[string]any, keys ...string) string { if len(values) == 0 || len(keys) == 0 { return "" } for _, key := range keys { key = strings.TrimSpace(key) if key == "" { continue } if value, ok := values[key]; ok { if text := strings.TrimSpace(fmt.Sprint(value)); text != "" { return text } } } return "" }