diff --git a/api/billing/documents/internal/service/documents/service.go b/api/billing/documents/internal/service/documents/service.go index 1510f0bb..e8a3dbdb 100644 --- a/api/billing/documents/internal/service/documents/service.go +++ b/api/billing/documents/internal/service/documents/service.go @@ -392,7 +392,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce := discovery.Announcement{ Service: "BILLING_DOCUMENTS", - Operations: []string{"documents.batch_resolve", "documents.get"}, + Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet}, InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/billing/fees/internal/service/fees/service.go b/api/billing/fees/internal/service/fees/service.go index aac2b5de..2cdec8d7 100644 --- a/api/billing/fees/internal/service/fees/service.go +++ b/api/billing/fees/internal/service/fees/service.go @@ -564,7 +564,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce := discovery.Announcement{ Service: "BILLING_FEES", - Operations: []string{"fee.calc"}, + Operations: []string{discovery.OperationFeeCalc}, InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/discovery/internal/server/internal/discovery.go b/api/discovery/internal/server/internal/discovery.go index 7e5186f3..9d66bc8a 100644 --- a/api/discovery/internal/server/internal/discovery.go +++ b/api/discovery/internal/server/internal/discovery.go @@ -51,7 +51,7 @@ func (i *Imp) startDiscovery(cfg *config) error { announce := discovery.Announcement{ Service: "DISCOVERY", InstanceID: discovery.InstanceID(), - Operations: []string{"discovery.lookup"}, + Operations: []string{discovery.OperationDiscoveryLookup}, Version: appversion.Create().Short(), } i.announcer = discovery.NewAnnouncer(i.logger, producer, mservice.Discovery, announce) diff --git a/api/fx/ingestor/internal/app/app.go b/api/fx/ingestor/internal/app/app.go index 1f599a36..dcdc3eef 100644 --- a/api/fx/ingestor/internal/app/app.go +++ b/api/fx/ingestor/internal/app/app.go @@ -86,7 +86,7 @@ func (a *App) Run(ctx context.Context) error { producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker) announce := discovery.Announcement{ Service: "FX_INGESTOR", - Operations: []string{"fx.ingest"}, + Operations: []string{discovery.OperationFXIngest}, Version: appversion.Create().Short(), } announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce) diff --git a/api/fx/oracle/internal/service/oracle/service.go b/api/fx/oracle/internal/service/oracle/service.go index 195df8aa..12c9cb63 100644 --- a/api/fx/oracle/internal/service/oracle/service.go +++ b/api/fx/oracle/internal/service/oracle/service.go @@ -106,7 +106,7 @@ func (s *Service) startDiscoveryAnnouncer() { } announce := discovery.Announcement{ Service: "FX_ORACLE", - Operations: []string{"fx.quote"}, + Operations: []string{discovery.OperationFXQuote}, InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index 161961e9..b1f26419 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -7,6 +7,7 @@ import ( "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" @@ -23,7 +24,36 @@ import ( "google.golang.org/protobuf/types/known/timestamppb" ) -const ledgerConnectorID = "ledger" +const ( + ledgerConnectorID = "ledger" + ledgerRailName = "LEDGER" + + 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 { @@ -36,6 +66,8 @@ type Client interface { 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) @@ -148,7 +180,7 @@ func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx) metadata := ledgerTxMetadata(tx.Metadata, tx) extraParams := map[string]interface{}{} if op := strings.TrimSpace(tx.Operation); op != "" { - extraParams["operation"] = op + extraParams[opParamOperation] = op } if len(extraParams) == 0 { extraParams = nil @@ -204,13 +236,13 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc return nil, merrors.InvalidArgument("ledger: currency is required") } params := map[string]interface{}{ - "organization_ref": strings.TrimSpace(req.GetOrganizationRef()), - "account_type": req.GetAccountType().String(), - "status": req.GetStatus().String(), - "allow_negative": req.GetAllowNegative(), + 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["role"] = role.String() + params[opParamRole] = role.String() } label := "" if desc := req.GetDescribable(); desc != nil { @@ -218,12 +250,12 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc if desc.Description != nil { trimmed := strings.TrimSpace(desc.GetDescription()) if trimmed != "" { - params["description"] = trimmed + params[opParamDescription] = trimmed } } } if len(req.GetMetadata()) > 0 { - params["metadata"] = mapStringToInterface(req.GetMetadata()) + params[opParamMetadata] = mapStringToInterface(req.GetMetadata()) } resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{ Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, @@ -277,6 +309,30 @@ func (c *ledgerClient) PostDebitWithCharges(ctx context.Context, req *ledgerv1.P 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) } @@ -292,7 +348,7 @@ func (c *ledgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXR } params := ledgerOperationParams(req.GetOrganizationRef(), req.GetDescription(), req.GetMetadata(), req.GetCharges(), req.GetEventTime()) params["rate"] = strings.TrimSpace(req.GetRate()) - params["to_money"] = map[string]interface{}{"amount": req.GetToMoney().GetAmount(), "currency": req.GetToMoney().GetCurrency()} + 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()), @@ -466,7 +522,7 @@ func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opTy params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime) if contraRef != "" { - params["contra_ledger_account_ref"] = strings.TrimSpace(contraRef) + params[opParamContraLedgerAccountRef] = strings.TrimSpace(contraRef) } if len(extraParams) > 0 { for key, value := range extraParams { @@ -534,17 +590,17 @@ func accountRoleFromLedgerProto(role ledgerv1.AccountRole) account_role.AccountR func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} { params := map[string]interface{}{ - "organization_ref": strings.TrimSpace(orgRef), - "description": strings.TrimSpace(description), + opParamOrganizationRef: strings.TrimSpace(orgRef), + opParamDescription: strings.TrimSpace(description), } if len(metadata) > 0 { - params["metadata"] = mapStringToInterface(metadata) + params[opParamMetadata] = mapStringToInterface(metadata) } if len(charges) > 0 { - params["charges"] = chargesToInterface(charges) + params[opParamCharges] = chargesToInterface(charges) } if eventTime != nil { - params["event_time"] = eventTime.AsTime().UTC().Format(time.RFC3339Nano) + params[opParamEventTime] = eventTime.AsTime().UTC().Format(time.RFC3339Nano) } return params } @@ -580,25 +636,25 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc details = account.GetProviderDetails().AsMap() } accountType := ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED - if v := strings.TrimSpace(fmt.Sprint(details["account_type"])); v != "" { + 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["status"])); v != "" { + if v := strings.TrimSpace(fmt.Sprint(details[opParamStatus])); v != "" { status = parseAccountStatus(v) } allowNegative := false - if v, ok := details["allow_negative"].(bool); ok { + if v, ok := details[opParamAllowNegative].(bool); ok { allowNegative = v } role := ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED - if v := strings.TrimSpace(fmt.Sprint(details["role"])); v != "" { + 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["is_settlement"].(type) { + switch v := details[opParamIsSettlement].(type) { case bool: if v { role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT @@ -609,13 +665,13 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc } } } - accountCode := strings.TrimSpace(fmt.Sprint(details["account_code"])) + 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["organization_ref"])); v != "" { + if v := strings.TrimSpace(fmt.Sprint(details[opParamOrganizationRef])); v != "" { organizationRef = v } describable := account.GetDescribable() @@ -674,7 +730,7 @@ func operationDescription(op *connectorv1.Operation) string { if op == nil || op.GetParams() == nil { return "" } - if value, ok := op.GetParams().AsMap()["description"]; ok { + if value, ok := op.GetParams().AsMap()[opParamDescription]; ok { return strings.TrimSpace(fmt.Sprint(value)) } return "" @@ -731,10 +787,10 @@ func chargesToInterface(charges []*ledgerv1.PostingLine) []interface{} { continue } result = append(result, map[string]interface{}{ - "ledger_account_ref": strings.TrimSpace(line.GetLedgerAccountRef()), - "amount": strings.TrimSpace(line.GetMoney().GetAmount()), - "currency": strings.TrimSpace(line.GetMoney().GetCurrency()), - "line_type": line.GetLineType().String(), + 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 { @@ -793,7 +849,7 @@ func (c *ledgerClient) callContext(ctx context.Context) (context.Context, contex } func isLedgerRail(value string) bool { - return strings.EqualFold(strings.TrimSpace(value), "LEDGER") + return strings.EqualFold(strings.TrimSpace(value), ledgerRailName) } func cloneMoney(input *moneyv1.Money) *moneyv1.Money { @@ -823,22 +879,22 @@ func ledgerTxMetadata(base map[string]string, tx rail.LedgerTx) map[string]strin meta = map[string]string{} } if val := strings.TrimSpace(tx.PaymentPlanID); val != "" { - meta["payment_plan_id"] = val + meta[txMetaPaymentPlanID] = val } if val := strings.TrimSpace(tx.FromRail); val != "" { - meta["from_rail"] = val + meta[txMetaFromRail] = val } if val := strings.TrimSpace(tx.ToRail); val != "" { - meta["to_rail"] = val + meta[txMetaToRail] = val } if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" { - meta["external_reference_id"] = val + meta[txMetaExternalReference] = val } if val := strings.TrimSpace(tx.FXRateUsed); val != "" { - meta["fx_rate_used"] = val + meta[txMetaFXRateUsed] = val } if val := strings.TrimSpace(tx.FeeAmount); val != "" { - meta["fee_amount"] = val + meta[txMetaFeeAmount] = val } if len(meta) == 0 { return nil diff --git a/api/ledger/client/client_test.go b/api/ledger/client/client_test.go index afeb6c3a..12f1d127 100644 --- a/api/ledger/client/client_test.go +++ b/api/ledger/client/client_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tech/sendico/pkg/discovery" + accountrolev1 "github.com/tech/sendico/pkg/proto/common/account_role/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" @@ -92,3 +94,65 @@ func TestTransferInternal_SubmitsTransferOperation(t *testing.T) { assert.Equal(t, "op-1", resp.GetJournalEntryRef()) assert.Equal(t, ledgerv1.EntryType_ENTRY_TRANSFER, resp.GetEntryType()) } + +func TestPostExternalCreditWithCharges_SubmitsExternalOperation(t *testing.T) { + ctx := context.Background() + + var captured *connectorv1.Operation + stub := &stubConnector{ + submitFn: func(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + captured = req.GetOperation() + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{OperationId: "op-ext-credit"}}, nil + }, + } + + client := NewWithClient(Config{}, stub) + resp, err := client.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ + IdempotencyKey: "id-ext-credit", + OrganizationRef: "org-1", + Money: &moneyv1.Money{Currency: "USDT", Amount: "1.0"}, + Role: ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, captured) + + assert.Equal(t, connectorv1.OperationType_CREDIT, captured.GetType()) + assert.Equal(t, "", captured.GetTo().GetAccount().GetAccountId()) + assert.Equal(t, accountrolev1.AccountRole_OPERATING, captured.GetToRole()) + assert.Equal(t, discovery.OperationExternalCredit, captured.GetParams().AsMap()["operation"]) + assert.Equal(t, "op-ext-credit", resp.GetJournalEntryRef()) + assert.Equal(t, ledgerv1.EntryType_ENTRY_CREDIT, resp.GetEntryType()) +} + +func TestPostExternalDebitWithCharges_SubmitsExternalOperation(t *testing.T) { + ctx := context.Background() + + var captured *connectorv1.Operation + stub := &stubConnector{ + submitFn: func(ctx context.Context, req *connectorv1.SubmitOperationRequest) (*connectorv1.SubmitOperationResponse, error) { + captured = req.GetOperation() + return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{OperationId: "op-ext-debit"}}, nil + }, + } + + client := NewWithClient(Config{}, stub) + resp, err := client.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ + IdempotencyKey: "id-ext-debit", + OrganizationRef: "org-1", + Money: &moneyv1.Money{Currency: "RUB", Amount: "77.14"}, + Role: ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.NotNil(t, captured) + + assert.Equal(t, connectorv1.OperationType_DEBIT, captured.GetType()) + assert.Equal(t, "", captured.GetFrom().GetAccount().GetAccountId()) + assert.Equal(t, accountrolev1.AccountRole_HOLD, captured.GetFromRole()) + assert.Equal(t, discovery.OperationExternalDebit, captured.GetParams().AsMap()["operation"]) + assert.Equal(t, "op-ext-debit", resp.GetJournalEntryRef()) + assert.Equal(t, ledgerv1.EntryType_ENTRY_DEBIT, resp.GetEntryType()) +} diff --git a/api/ledger/client/fake.go b/api/ledger/client/fake.go index 62d1676b..04451875 100644 --- a/api/ledger/client/fake.go +++ b/api/ledger/client/fake.go @@ -4,29 +4,31 @@ import ( "context" "github.com/tech/sendico/pkg/payments/rail" - connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ) // Fake implements Client for tests. type Fake struct { - ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error) - CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error) - HoldBalanceFn func(ctx context.Context, accountID string, amount string) error - CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) - ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) - ListConnectorAccountsFn func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) - PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) - PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) - TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) - ApplyFXWithChargesFn func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) - BlockAccountFn func(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) - UnblockAccountFn func(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) - GetBalanceFn func(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) - GetJournalEntryFn func(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) - GetStatementFn func(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) - CloseFn func() error + ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error) + CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error) + HoldBalanceFn func(ctx context.Context, accountID string, amount string) error + CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) + ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) + ListConnectorAccountsFn func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) + PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) + PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) + PostExternalCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) + PostExternalDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) + TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) + ApplyFXWithChargesFn func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) + BlockAccountFn func(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) + UnblockAccountFn func(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) + GetBalanceFn func(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) + GetJournalEntryFn func(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) + GetStatementFn func(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) + CloseFn func() error } func (f *Fake) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) { @@ -85,6 +87,20 @@ func (f *Fake) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebit return &ledgerv1.PostResponse{}, nil } +func (f *Fake) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + if f.PostExternalCreditWithChargesFn != nil { + return f.PostExternalCreditWithChargesFn(ctx, req) + } + return &ledgerv1.PostResponse{}, nil +} + +func (f *Fake) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + if f.PostExternalDebitWithChargesFn != nil { + return f.PostExternalDebitWithChargesFn(ctx, req) + } + return &ledgerv1.PostResponse{}, nil +} + func (f *Fake) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { if f.TransferInternalFn != nil { return f.TransferInternalFn(ctx, req) diff --git a/api/ledger/internal/service/ledger/connector.go b/api/ledger/internal/service/ledger/connector.go index 934feecd..68e0b12a 100644 --- a/api/ledger/internal/service/ledger/connector.go +++ b/api/ledger/internal/service/ledger/connector.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/ledger/internal/appversion" "github.com/tech/sendico/pkg/connector/params" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/merrors" accountrolev1 "github.com/tech/sendico/pkg/proto/common/account_role/v1" @@ -16,6 +17,7 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + "go.uber.org/zap" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -222,7 +224,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if err != nil { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, nil } - operation := strings.ToLower(strings.TrimSpace(reader.String("operation"))) + operation := discovery.NormalizeOperation(reader.String("operation")) switch op.GetType() { case connectorv1.OperationType_CREDIT: @@ -230,11 +232,11 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if accountID == "" && op.GetToRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: to.account or to_role is required", op, "")}}, nil } - if operation != "" && operation != "external.credit" { + if operation != "" && operation != discovery.OperationExternalCredit { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: unsupported operation override", op, "")}}, nil } creditFn := c.svc.PostCreditWithCharges - if operation == "external.credit" { + if operation == discovery.OperationExternalCredit { creditFn = c.svc.PostExternalCreditWithCharges } resp, err := creditFn(ctx, &ledgerv1.PostCreditRequest{ @@ -250,6 +252,10 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 Role: accountRoleFromConnectorRole(op.GetToRole()), }) if err != nil { + c.svc.logger.Warn("Operation failed", zap.Error(err), zap.String("operation", operation), + zap.String("idempotency_key", op.IdempotencyKey), zap.String("description", description), + zap.String("organization_ref", orgRef), zap.String("ledger_account_ref", accountID), + ) return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil } return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_OPERATION_SUCCESS)}, nil @@ -258,11 +264,11 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1 if accountID == "" && op.GetFromRole() == accountrolev1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: from.account or from_role is required", op, "")}}, nil } - if operation != "" && operation != "external.debit" { + if operation != "" && operation != discovery.OperationExternalDebit { return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: unsupported operation override", op, "")}}, nil } debitFn := c.svc.PostDebitWithCharges - if operation == "external.debit" { + if operation == discovery.OperationExternalDebit { debitFn = c.svc.PostExternalDebitWithCharges } resp, err := debitFn(ctx, &ledgerv1.PostDebitRequest{ @@ -393,14 +399,14 @@ func ledgerOperationParams() []*connectorv1.OperationParamSpec { Type: connectorv1.ParamType_STRING, Required: false, Description: "Optional ledger operation override (external.credit).", - AllowedValues: []string{"external.credit"}, + AllowedValues: []string{discovery.OperationExternalCredit}, } externalDebit := &connectorv1.ParamSpec{ Key: "operation", Type: connectorv1.ParamType_STRING, Required: false, Description: "Optional ledger operation override (external.debit).", - AllowedValues: []string{"external.debit"}, + AllowedValues: []string{discovery.OperationExternalDebit}, } return []*connectorv1.OperationParamSpec{ {OperationType: connectorv1.OperationType_CREDIT, Params: append(common, externalCredit, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})}, diff --git a/api/ledger/internal/service/ledger/metrics.go b/api/ledger/internal/service/ledger/metrics.go index ad51bab4..8ffca07d 100644 --- a/api/ledger/internal/service/ledger/metrics.go +++ b/api/ledger/internal/service/ledger/metrics.go @@ -7,6 +7,40 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) +type journalEntryType string + +const ( + journalEntryTypeCredit journalEntryType = "credit" + journalEntryTypeDebit journalEntryType = "debit" + journalEntryTypeTransfer journalEntryType = "transfer" + journalEntryTypeFX journalEntryType = "fx" +) + +type journalEntryStatus string + +const ( + journalEntryStatusAttempted journalEntryStatus = "attempted" + journalEntryStatusSuccess journalEntryStatus = "success" + journalEntryStatusError journalEntryStatus = "error" +) + +type journalEntryErrorType string + +const ( + journalEntryErrorNotImplemented journalEntryErrorType = "not_implemented" + journalEntryErrorFailed journalEntryErrorType = "failed" + journalEntryErrorIdempotencyCheck journalEntryErrorType = "idempotency_check_failed" + journalEntryErrorAccountResolve journalEntryErrorType = "account_resolve_failed" + journalEntryErrorAccountInvalid journalEntryErrorType = "account_invalid" + journalEntryErrorContraResolve journalEntryErrorType = "contra_resolve_failed" + journalEntryErrorContraMissingID journalEntryErrorType = "contra_missing_id" + journalEntryErrorSystemAccountResolve journalEntryErrorType = "system_account_resolve_failed" + journalEntryErrorSystemAccountInvalid journalEntryErrorType = "system_account_invalid" + journalEntryErrorSystemAccountMissing journalEntryErrorType = "system_account_missing_id" + journalEntryErrorUnbalancedAfterContra journalEntryErrorType = "unbalanced_after_contra" + journalEntryErrorTransactionFailed journalEntryErrorType = "transaction_failed" +) + var ( metricsOnce sync.Once @@ -110,16 +144,16 @@ func initMetrics() { // Metric recording helpers -func recordJournalEntry(entryType, status string, durationSeconds float64) { +func recordJournalEntry(entryType journalEntryType, status journalEntryStatus, durationSeconds float64) { initMetrics() - journalEntriesTotal.WithLabelValues(entryType, status).Inc() - journalEntryLatency.WithLabelValues(entryType).Observe(durationSeconds) + journalEntriesTotal.WithLabelValues(string(entryType), string(status)).Inc() + journalEntryLatency.WithLabelValues(string(entryType)).Observe(durationSeconds) } -func recordJournalEntryError(entryType, errorType string) { +func recordJournalEntryError(entryType journalEntryType, errorType journalEntryErrorType) { initMetrics() - journalEntryErrors.WithLabelValues(entryType, errorType).Inc() - journalEntriesTotal.WithLabelValues(entryType, "error").Inc() + journalEntryErrors.WithLabelValues(string(entryType), string(errorType)).Inc() + journalEntriesTotal.WithLabelValues(string(entryType), string(journalEntryStatusError)).Inc() } func recordBalanceQuery(status string, durationSeconds float64) { @@ -128,9 +162,9 @@ func recordBalanceQuery(status string, durationSeconds float64) { balanceQueryLatency.WithLabelValues(status).Observe(durationSeconds) } -func recordTransactionAmount(currency, entryType string, amount float64) { +func recordTransactionAmount(currency string, entryType journalEntryType, amount float64) { initMetrics() - transactionAmounts.WithLabelValues(currency, entryType).Observe(amount) + transactionAmounts.WithLabelValues(currency, string(entryType)).Observe(amount) } func recordAccountOperation(operation, status string) { @@ -138,7 +172,7 @@ func recordAccountOperation(operation, status string) { accountOperationsTotal.WithLabelValues(operation, status).Inc() } -func recordDuplicateRequest(entryType string) { +func recordDuplicateRequest(entryType journalEntryType) { initMetrics() - duplicateRequestsTotal.WithLabelValues(entryType).Inc() + duplicateRequestsTotal.WithLabelValues(string(entryType)).Inc() } diff --git a/api/ledger/internal/service/ledger/posting.go b/api/ledger/internal/service/ledger/posting.go index f4969aad..09180631 100644 --- a/api/ledger/internal/service/ledger/posting.go +++ b/api/ledger/internal/service/ledger/posting.go @@ -65,7 +65,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("credit") + recordDuplicateRequest(journalEntryTypeCredit) logger.Info("Duplicate credit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -75,18 +75,18 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - recordJournalEntryError("credit", "idempotency_check_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorIdempotencyCheck) logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("credit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("credit", "account_invalid") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountInvalid) return nil, err } @@ -159,12 +159,12 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef) if err != nil { - recordJournalEntryError("credit", "contra_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraResolve) return nil, err } contraAccountID := contraAccount.GetID() if contraAccountID == nil { - recordJournalEntryError("credit", "contra_missing_id") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraMissingID) return nil, merrors.Internal("contra account missing identifier") } @@ -183,7 +183,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi } if !entryTotal.IsZero() { - recordJournalEntryError("credit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -237,13 +237,13 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi }) if err != nil { - recordJournalEntryError("credit", "transaction_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := creditAmount.Float64() - recordTransactionAmount(req.Money.Currency, "credit", amountFloat) - recordJournalEntry("credit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeCredit, amountFloat) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_debit.go b/api/ledger/internal/service/ledger/posting_debit.go index ccf0bdc4..41e69d31 100644 --- a/api/ledger/internal/service/ledger/posting_debit.go +++ b/api/ledger/internal/service/ledger/posting_debit.go @@ -63,7 +63,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("debit") + recordDuplicateRequest(journalEntryTypeDebit) logger.Info("Duplicate debit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -79,11 +79,11 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("debit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("debit", "account_invalid") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountInvalid) return nil, err } @@ -156,12 +156,12 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef) if err != nil { - recordJournalEntryError("debit", "contra_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorContraResolve) return nil, err } contraAccountID := contraAccount.GetID() if contraAccountID == nil { - recordJournalEntryError("debit", "contra_missing_id") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorContraMissingID) return nil, merrors.Internal("contra account missing identifier") } @@ -180,7 +180,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR } if !entryTotal.IsZero() { - recordJournalEntryError("debit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -234,13 +234,13 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR }) if err != nil { - recordJournalEntryError("debit", "transaction_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := debitAmount.Float64() - recordTransactionAmount(req.Money.Currency, "debit", amountFloat) - recordJournalEntry("debit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeDebit, amountFloat) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_external.go b/api/ledger/internal/service/ledger/posting_external.go index 362eb8b5..ee1beedb 100644 --- a/api/ledger/internal/service/ledger/posting_external.go +++ b/api/ledger/internal/service/ledger/posting_external.go @@ -60,7 +60,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("credit") + recordDuplicateRequest(journalEntryTypeCredit) logger.Info("Duplicate external credit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -70,34 +70,34 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - recordJournalEntryError("credit", "idempotency_check_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorIdempotencyCheck) logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("credit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("credit", "account_invalid") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountInvalid) return nil, err } systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency) if err != nil { - recordJournalEntryError("credit", "system_account_resolve_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountResolve) return nil, err } if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency); err != nil { - recordJournalEntryError("credit", "system_account_invalid") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountInvalid) return nil, err } systemAccountID := systemAccount.GetID() if systemAccountID == nil { - recordJournalEntryError("credit", "system_account_missing_id") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountMissing) return nil, merrors.Internal("system account missing identifier") } @@ -186,7 +186,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P } if !entryTotal.IsZero() { - recordJournalEntryError("credit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -240,13 +240,13 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P }) if err != nil { - recordJournalEntryError("credit", "transaction_failed") + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := creditAmount.Float64() - recordTransactionAmount(req.Money.Currency, "credit", amountFloat) - recordJournalEntry("credit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeCredit, amountFloat) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } @@ -293,7 +293,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("debit") + recordDuplicateRequest(journalEntryTypeDebit) logger.Info("Duplicate external debit request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -303,34 +303,34 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po }, nil } if err != nil && err != storage.ErrJournalEntryNotFound { - recordJournalEntryError("debit", "idempotency_check_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorIdempotencyCheck) logger.Warn("Failed to check idempotency", zap.Error(err)) return nil, merrors.Internal("failed to check idempotency") } account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") if err != nil { - recordJournalEntryError("debit", "account_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountResolve) return nil, err } if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { - recordJournalEntryError("debit", "account_invalid") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountInvalid) return nil, err } systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency) if err != nil { - recordJournalEntryError("debit", "system_account_resolve_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountResolve) return nil, err } if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency); err != nil { - recordJournalEntryError("debit", "system_account_invalid") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountInvalid) return nil, err } systemAccountID := systemAccount.GetID() if systemAccountID == nil { - recordJournalEntryError("debit", "system_account_missing_id") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountMissing) return nil, merrors.Internal("system account missing identifier") } @@ -419,7 +419,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po } if !entryTotal.IsZero() { - recordJournalEntryError("debit", "unbalanced_after_contra") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorUnbalancedAfterContra) return nil, merrors.Internal("failed to balance journal entry") } @@ -473,13 +473,13 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po }) if err != nil { - recordJournalEntryError("debit", "transaction_failed") + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorTransactionFailed) return nil, err } amountFloat, _ := debitAmount.Float64() - recordTransactionAmount(req.Money.Currency, "debit", amountFloat) - recordJournalEntry("debit", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeDebit, amountFloat) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_fx.go b/api/ledger/internal/service/ledger/posting_fx.go index 10a0a227..515fa7d9 100644 --- a/api/ledger/internal/service/ledger/posting_fx.go +++ b/api/ledger/internal/service/ledger/posting_fx.go @@ -76,7 +76,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp // Check for duplicate idempotency key existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("fx") + recordDuplicateRequest(journalEntryTypeFX) logger.Info("Duplicate FX request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -244,15 +244,15 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp }) if err != nil { - recordJournalEntryError("fx", "transaction_failed") + recordJournalEntryError(journalEntryTypeFX, journalEntryErrorTransactionFailed) return nil, err } fromAmountFloat, _ := fromAmount.Float64() toAmountFloat, _ := toAmount.Float64() - recordTransactionAmount(req.FromMoney.Currency, "fx", fromAmountFloat) - recordTransactionAmount(req.ToMoney.Currency, "fx", toAmountFloat) - recordJournalEntry("fx", "success", 0) + recordTransactionAmount(req.FromMoney.Currency, journalEntryTypeFX, fromAmountFloat) + recordTransactionAmount(req.ToMoney.Currency, journalEntryTypeFX, toAmountFloat) + recordJournalEntry(journalEntryTypeFX, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/posting_transfer.go b/api/ledger/internal/service/ledger/posting_transfer.go index 213b9b4e..4cd8d9c1 100644 --- a/api/ledger/internal/service/ledger/posting_transfer.go +++ b/api/ledger/internal/service/ledger/posting_transfer.go @@ -86,7 +86,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq // Check for duplicate idempotency key existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) if err == nil && existingEntry != nil { - recordDuplicateRequest("transfer") + recordDuplicateRequest(journalEntryTypeTransfer) logger.Info("Duplicate transfer request (idempotency)", zap.String("existingEntryID", existingEntry.GetID().Hex())) return &ledgerv1.PostResponse{ @@ -246,13 +246,13 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq }) if err != nil { - recordJournalEntryError("transfer", "failed") + recordJournalEntryError(journalEntryTypeTransfer, journalEntryErrorFailed) return nil, err } amountFloat, _ := transferAmount.Float64() - recordTransactionAmount(req.Money.Currency, "transfer", amountFloat) - recordJournalEntry("transfer", "success", 0) + recordTransactionAmount(req.Money.Currency, journalEntryTypeTransfer, amountFloat) + recordJournalEntry(journalEntryTypeTransfer, journalEntryStatusSuccess, 0) return result.(*ledgerv1.PostResponse), nil } } diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index a46eca22..c30df397 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -77,7 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging. initMetrics() service := &Service{ - logger: logger.Named("ledger"), + logger: logger.Named("service"), storage: repo, producer: prod, msgCfg: msgCfg, @@ -117,17 +117,10 @@ func (s *Service) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccount func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("credit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postCreditResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("credit", "not_implemented") - } - - logger := s.logger.With(zap.String("operation", "credit")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerCredit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -147,7 +140,16 @@ func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostC logger = logger.With(zap.String("contra_ledger_account_ref", contra)) } } - s.logLedgerOperation("credit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerCredit, logger) + + responder := s.postCreditResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorNotImplemented) + } + + s.logLedgerOperation(discovery.OperationLedgerCredit, logger, resp, err, time.Since(start)) return resp, err } @@ -156,17 +158,10 @@ func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostC func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("credit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeCredit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postExternalCreditResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("credit", "failed") - } - - logger := s.logger.With(zap.String("operation", "external_credit")) + logger := s.logger.With(zap.String("operation", discovery.OperationExternalCredit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -183,7 +178,16 @@ func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledger logger = logger.With(zap.String("role", role.String())) } } - s.logLedgerOperation("external_credit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationExternalCredit, logger) + + responder := s.postExternalCreditResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationExternalCredit, logger, resp, err, time.Since(start)) return resp, err } @@ -192,17 +196,10 @@ func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledger func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("debit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postDebitResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("debit", "failed") - } - - logger := s.logger.With(zap.String("operation", "debit")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerDebit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -222,7 +219,16 @@ func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDe logger = logger.With(zap.String("contra_ledger_account_ref", contra)) } } - s.logLedgerOperation("debit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerDebit, logger) + + responder := s.postDebitResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationLedgerDebit, logger, resp, err, time.Since(start)) return resp, err } @@ -231,17 +237,10 @@ func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDe func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("debit", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeDebit, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.postExternalDebitResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("debit", "failed") - } - - logger := s.logger.With(zap.String("operation", "external_debit")) + logger := s.logger.With(zap.String("operation", discovery.OperationExternalDebit)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -258,7 +257,16 @@ func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv logger = logger.With(zap.String("role", role.String())) } } - s.logLedgerOperation("external_debit", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationExternalDebit, logger) + + responder := s.postExternalDebitResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationExternalDebit, logger, resp, err, time.Since(start)) return resp, err } @@ -267,17 +275,10 @@ func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("transfer", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeTransfer, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.transferResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("transfer", "failed") - } - - logger := s.logger.With(zap.String("operation", "transfer")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerTransfer)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -298,7 +299,16 @@ func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRe logger = logger.With(zap.String("to_role", role.String())) } } - s.logLedgerOperation("transfer", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerTransfer, logger) + + responder := s.transferResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeTransfer, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationLedgerTransfer, logger, resp, err, time.Since(start)) return resp, err } @@ -307,17 +317,10 @@ func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRe func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { start := time.Now() defer func() { - recordJournalEntry("fx", "attempted", time.Since(start).Seconds()) + recordJournalEntry(journalEntryTypeFX, journalEntryStatusAttempted, time.Since(start).Seconds()) }() - responder := s.fxResponder(ctx, req) - resp, err := responder(ctx) - - if err != nil { - recordJournalEntryError("fx", "failed") - } - - logger := s.logger.With(zap.String("operation", "fx")) + logger := s.logger.With(zap.String("operation", discovery.OperationLedgerFX)) if req != nil { logger = logger.With( zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), @@ -341,7 +344,16 @@ func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXReques logger = logger.With(zap.String("rate", rate)) } } - s.logLedgerOperation("fx", logger, resp, err) + s.logLedgerOperationStart(discovery.OperationLedgerFX, logger) + + responder := s.fxResponder(ctx, req) + resp, err := responder(ctx) + + if err != nil { + recordJournalEntryError(journalEntryTypeFX, journalEntryErrorFailed) + } + + s.logLedgerOperation(discovery.OperationLedgerFX, logger, resp, err, time.Since(start)) return resp, err } @@ -365,23 +377,42 @@ func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryReq return responder(ctx) } -func (s *Service) logLedgerOperation(op string, logger mlogger.Logger, resp *ledgerv1.PostResponse, err error) { +func (s *Service) logLedgerOperationStart(op string, logger mlogger.Logger) { if logger == nil { return } - if err != nil { - logger.Warn(fmt.Sprintf("ledger %s failed", op), zap.Error(err)) + logger.Debug("Ledger operation execution started", zap.String("operation_name", op)) +} + +func (s *Service) logLedgerOperation(op string, logger mlogger.Logger, resp *ledgerv1.PostResponse, err error, duration time.Duration) { + if logger == nil { return } entryRef := "" if resp != nil { entryRef = strings.TrimSpace(resp.GetJournalEntryRef()) } - if entryRef == "" { - logger.Info(fmt.Sprintf("ledger %s posted", op)) + status := "succeeded" + fields := []zap.Field{ + zap.String("operation_name", op), + zap.String("status", status), + zap.Int64("duration_ms", duration.Milliseconds()), + } + if entryRef != "" { + fields = append(fields, zap.String("journal_entry_ref", entryRef)) + } + if err != nil { + fields[1] = zap.String("status", "failed") + logger.Debug("Ledger operation execution completed", append(fields, zap.Error(err))...) + logger.Warn("Ledger operation failed", zap.String("operation_name", op), zap.Error(err)) return } - logger.Info(fmt.Sprintf("ledger %s posted", op), zap.String("journal_entry_ref", entryRef)) + logger.Debug("Ledger operation execution completed", fields...) + if entryRef == "" { + logger.Info("Ledger operation posted", zap.String("operation_name", op)) + return + } + logger.Info("Ledger operation posted", zap.String("operation_name", op), zap.String("journal_entry_ref", entryRef)) } func (s *Service) Shutdown() { @@ -402,7 +433,7 @@ func (s *Service) startDiscoveryAnnouncer() { } announce := discovery.Announcement{ Service: "LEDGER", - Operations: []string{"balance.read", "ledger.debit", "ledger.credit", "external.credit", "external.debit"}, + Operations: discovery.LedgerServiceOperations(), InvokeURI: s.invokeURI, Version: appversion.Create().Short(), } @@ -428,8 +459,7 @@ func (s *Service) startOutboxReliableProducer() error { } s.outbox.producer = reliableProducer if s.outbox.producer == nil || s.producer == nil { - s.logger.Info("Outbox reliable publisher disabled", - zap.Bool("enabled", settings.Enabled)) + s.logger.Info("Outbox reliable publisher disabled", zap.Bool("enabled", settings.Enabled)) return } s.logger.Info("Outbox reliable publisher configured", diff --git a/api/notification/internal/server/notificationimp/notification.go b/api/notification/internal/server/notificationimp/notification.go index 8439d9b9..7a3664bd 100644 --- a/api/notification/internal/server/notificationimp/notification.go +++ b/api/notification/internal/server/notificationimp/notification.go @@ -120,7 +120,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { announce := discovery.Announcement{ Service: "NOTIFICATIONS", - Operations: []string{"notify.send"}, + Operations: []string{discovery.OperationNotifySend}, Version: appversion.Create().Short(), } p.announcer = discovery.NewAnnouncer(p.logger, a.Register().Producer(), string(mservice.Notifications), announce) diff --git a/api/payments/methods/internal/server/internal/discovery.go b/api/payments/methods/internal/server/internal/discovery.go index 7a0cb157..42931e2e 100644 --- a/api/payments/methods/internal/server/internal/discovery.go +++ b/api/payments/methods/internal/server/internal/discovery.go @@ -53,8 +53,8 @@ func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) { announce := discovery.Announcement{ Service: "PAYMENTS_METHODS", Operations: []string{ - "payment_methods.manage", - "payment_methods.read", + discovery.OperationPaymentMethodsManage, + discovery.OperationPaymentMethodsRead, }, InvokeURI: invokeURI, Version: appversion.Create().Short(), diff --git a/api/payments/orchestrator/internal/server/internal/dependencies.go b/api/payments/orchestrator/internal/server/internal/dependencies.go index ba0b905c..751050af 100644 --- a/api/payments/orchestrator/internal/server/internal/dependencies.go +++ b/api/payments/orchestrator/internal/server/internal/dependencies.go @@ -1,18 +1,14 @@ package serverimp import ( - oracleclient "github.com/tech/sendico/fx/oracle/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" ) type orchestratorDeps struct { - feesClient feesv1.FeeEngineClient ledgerClient ledgerclient.Client mntxClient mntxclient.Client - oracleClient oracleclient.Client gatewayInvokeResolver orchestrator.GatewayInvokeResolver } @@ -26,9 +22,7 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps { } i.discoveryClients = newDiscoveryClientResolver(i.logger, i.discoveryReg) - deps.feesClient = &discoveryFeeClient{resolver: i.discoveryClients} deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients} - deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients} deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients} deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients} return deps @@ -39,9 +33,6 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest return nil } opts := []orchestrator.Option{} - if deps.feesClient != nil { - opts = append(opts, orchestrator.WithFeeEngine(deps.feesClient, cfg.Fees.callTimeout())) - } if deps.ledgerClient != nil { opts = append(opts, orchestrator.WithLedgerClient(deps.ledgerClient)) } @@ -49,16 +40,12 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest opts = append(opts, orchestrator.WithMntxGateway(deps.mntxClient)) } - opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis())) if deps.gatewayInvokeResolver != nil { opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver)) } if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 { opts = append(opts, orchestrator.WithCardGatewayRoutes(routes)) } - if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 { - opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts)) - } if registry := buildGatewayRegistry(i.logger, cfg.GatewayInstances, i.discoveryReg); registry != nil { opts = append(opts, orchestrator.WithGatewayRegistry(registry)) } diff --git a/api/payments/orchestrator/internal/server/internal/discovery.go b/api/payments/orchestrator/internal/server/internal/discovery.go index cf1ddec7..45d84462 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery.go +++ b/api/payments/orchestrator/internal/server/internal/discovery.go @@ -33,7 +33,7 @@ func (i *Imp) initDiscovery(cfg *config) { } announce := discovery.Announcement{ Service: "PAYMENTS_ORCHESTRATOR", - Operations: []string{"payment.initiate"}, + Operations: []string{discovery.OperationPaymentInitiate}, InvokeURI: cfg.GRPC.DiscoveryInvokeURI(), Version: appversion.Create().Short(), } diff --git a/api/payments/orchestrator/internal/server/internal/discovery_clients.go b/api/payments/orchestrator/internal/server/internal/discovery_clients.go index 317ac9dd..3997445f 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_clients.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_clients.go @@ -32,6 +32,11 @@ var ( ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} mntxServiceNames = []string{"CARD_RAIL_GATEWAY", string(mservice.MntxGateway)} + + feesRequiredOps = []string{discovery.OperationFeeCalc} + ledgerRequiredOps = discovery.LedgerServiceOperations() + oracleRequiredOps = []string{discovery.OperationFXQuote} + mntxRequiredOps = discovery.CardPayoutRailGatewayOperations() ) type discoveryEndpoint struct { @@ -109,27 +114,27 @@ func (r *discoveryClientResolver) Close() { } func (r *discoveryClientResolver) FeesAvailable() bool { - _, ok := r.findEntry("fees", feesServiceNames, "", "") + _, ok := r.findEntry("fees", feesServiceNames, "", "", feesRequiredOps) return ok } func (r *discoveryClientResolver) LedgerAvailable() bool { - _, ok := r.findEntry("ledger", ledgerServiceNames, "", "") + _, ok := r.findEntry("ledger", ledgerServiceNames, "", "", ledgerRequiredOps) return ok } func (r *discoveryClientResolver) OracleAvailable() bool { - _, ok := r.findEntry("oracle", oracleServiceNames, "", "") + _, ok := r.findEntry("oracle", oracleServiceNames, "", "", oracleRequiredOps) return ok } func (r *discoveryClientResolver) MntxAvailable() bool { - _, ok := r.findEntry("mntx", mntxServiceNames, "", "") + _, ok := r.findEntry("mntx", mntxServiceNames, "", "", mntxRequiredOps) return ok } func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) { - entry, ok := r.findEntry("fees", feesServiceNames, "", "") + entry, ok := r.findEntry("fees", feesServiceNames, "", "", feesRequiredOps) if !ok { return nil, merrors.NoData("discovery: fees service unavailable") } @@ -160,7 +165,7 @@ func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEng } func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclient.Client, error) { - entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "") + entry, ok := r.findEntry("ledger", ledgerServiceNames, "", "", ledgerRequiredOps) if !ok { return nil, merrors.NoData("discovery: ledger service unavailable") } @@ -194,7 +199,7 @@ func (r *discoveryClientResolver) LedgerClient(ctx context.Context) (ledgerclien } func (r *discoveryClientResolver) OracleClient(ctx context.Context) (oracleclient.Client, error) { - entry, ok := r.findEntry("oracle", oracleServiceNames, "", "") + entry, ok := r.findEntry("oracle", oracleServiceNames, "", "", oracleRequiredOps) if !ok { return nil, merrors.NoData("discovery: oracle service unavailable") } @@ -228,7 +233,7 @@ func (r *discoveryClientResolver) OracleClient(ctx context.Context) (oracleclien } func (r *discoveryClientResolver) MntxClient(ctx context.Context) (mntxclient.Client, error) { - entry, ok := r.findEntry("mntx", mntxServiceNames, "", "") + entry, ok := r.findEntry("mntx", mntxServiceNames, "", "", mntxRequiredOps) if !ok { return nil, merrors.NoData("discovery: mntx service unavailable") } @@ -316,14 +321,19 @@ func (r *discoveryClientResolver) PaymentGatewayClient(ctx context.Context, invo return client, nil } -func (r *discoveryClientResolver) findEntry(key string, services []string, rail string, network string) (*discovery.RegistryEntry, bool) { +func (r *discoveryClientResolver) findEntry(key string, services []string, rail string, network string, requiredOps []string) (*discovery.RegistryEntry, bool) { if r == nil || r.registry == nil { r.logMissing(key, "discovery registry unavailable", "", nil) return nil, false } + type discoveryMatch struct { + entry discovery.RegistryEntry + opMatch bool + } + entries := r.registry.List(time.Now(), true) - matches := make([]discovery.RegistryEntry, 0) + matches := make([]discoveryMatch, 0) for _, entry := range entries { if !matchesService(entry.Service, services) { continue @@ -334,7 +344,10 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail if network != "" && !strings.EqualFold(strings.TrimSpace(entry.Network), network) { continue } - matches = append(matches, entry) + matches = append(matches, discoveryMatch{ + entry: entry, + opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps), + }) } if len(matches) == 0 { @@ -343,16 +356,19 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail } sort.Slice(matches, func(i, j int) bool { - if matches[i].RoutingPriority != matches[j].RoutingPriority { - return matches[i].RoutingPriority > matches[j].RoutingPriority + if matches[i].opMatch != matches[j].opMatch { + return matches[i].opMatch } - if matches[i].ID != matches[j].ID { - return matches[i].ID < matches[j].ID + if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority { + return matches[i].entry.RoutingPriority > matches[j].entry.RoutingPriority } - return matches[i].InstanceID < matches[j].InstanceID + if matches[i].entry.ID != matches[j].entry.ID { + return matches[i].entry.ID < matches[j].entry.ID + } + return matches[i].entry.InstanceID < matches[j].entry.InstanceID }) - entry := matches[0] + entry := matches[0].entry entryKey := discoveryEntryKey(entry) r.logSelection(key, entryKey, entry) return &entry, true diff --git a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go index 625c1b85..d7816328 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_wrappers.go @@ -134,6 +134,22 @@ func (c *discoveryLedgerClient) PostDebitWithCharges(ctx context.Context, req *l return client.PostDebitWithCharges(ctx, req) } +func (c *discoveryLedgerClient) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + client, err := c.resolver.LedgerClient(ctx) + if err != nil { + return nil, err + } + return client.PostExternalCreditWithCharges(ctx, req) +} + +func (c *discoveryLedgerClient) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + client, err := c.resolver.LedgerClient(ctx) + if err != nil { + return nil, err + } + return client.PostExternalDebitWithCharges(ctx, req) +} + func (c *discoveryLedgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { client, err := c.resolver.LedgerClient(ctx) if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go index 6cb6ff77..f511c74e 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor.go @@ -62,17 +62,42 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste return nil, err } - transferReq := &ledgerv1.TransferRequest{ - IdempotencyKey: ledgerStepIdempotencyKey(req.Payment, req.Step), - OrganizationRef: req.Payment.OrganizationRef.Hex(), - Money: amount, - Description: ledgerDescription(req.Step), - Metadata: ledgerTransferMetadata(req.Payment, req.Step, roles), - FromRole: ledgerRoleToProto(roles.from), - ToRole: ledgerRoleToProto(roles.to), - } + idempotencyKey := ledgerStepIdempotencyKey(req.Payment, req.Step) + organizationRef := req.Payment.OrganizationRef.Hex() + description := ledgerDescription(req.Step) + metadata := ledgerTransferMetadata(req.Payment, req.Step, roles) - resp, err := e.ledgerClient.TransferInternal(ctx, transferReq) + var resp *ledgerv1.PostResponse + switch action { + case model.RailOperationExternalCredit: + resp, err = e.ledgerClient.PostExternalCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + Money: amount, + Description: description, + Metadata: metadata, + Role: ledgerRoleToProto(roles.to), + }) + case model.RailOperationExternalDebit: + resp, err = e.ledgerClient.PostExternalDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + Money: amount, + Description: description, + Metadata: metadata, + Role: ledgerRoleToProto(roles.from), + }) + default: + resp, err = e.ledgerClient.TransferInternal(ctx, &ledgerv1.TransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: organizationRef, + Money: amount, + Description: description, + Metadata: metadata, + FromRole: ledgerRoleToProto(roles.from), + ToRole: ledgerRoleToProto(roles.to), + }) + } if err != nil { return nil, err } diff --git a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go index 6eed5339..87d12a35 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/ledger_executor_test.go @@ -80,6 +80,146 @@ func TestGatewayLedgerExecutor_ExecuteLedger_CreditUsesSourceAmountAndDefaultRol } } +func TestGatewayLedgerExecutor_ExecuteLedger_ExternalCreditUsesPostCreditWithCharges(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var ( + postReq *ledgerv1.PostCreditRequest + transferCalled bool + ) + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + PostExternalCreditWithChargesFn: func(_ context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + postReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-ext-credit"}, nil + }, + TransferInternalFn: func(_ context.Context, _ *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferCalled = true + return &ledgerv1.PostResponse{JournalEntryRef: "entry-transfer"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_1_2_ledger_credit", + StepCode: "edge.1_2.ledger.credit", + Action: model.RailOperationExternalCredit, + Rail: model.RailLedger, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_1_2_ledger_credit", + StepCode: "edge.1_2.ledger.credit", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if postReq == nil { + t.Fatal("expected external credit request") + } + if transferCalled { + t.Fatal("expected external credit to skip transfer") + } + if got, want := postReq.GetMoney().GetAmount(), "1.000000"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetMoney().GetCurrency(), "USDT"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_OPERATING; got != want { + t.Fatalf("role mismatch: got=%v want=%v", got, want) + } + if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 1 { + t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs)) + } + if got, want := out.StepExecution.ExternalRefs[0].Ref, "entry-ext-credit"; got != want { + t.Fatalf("external ref value mismatch: got=%q want=%q", got, want) + } +} + +func TestGatewayLedgerExecutor_ExecuteLedger_ExternalDebitUsesPostDebitWithCharges(t *testing.T) { + orgID := bson.NewObjectID() + payment := testLedgerExecutorPayment(orgID) + + var ( + postReq *ledgerv1.PostDebitRequest + transferCalled bool + ) + executor := &gatewayLedgerExecutor{ + ledgerClient: &ledgerclient.Fake{ + PostExternalDebitWithChargesFn: func(_ context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + postReq = req + return &ledgerv1.PostResponse{JournalEntryRef: "entry-ext-debit"}, nil + }, + TransferInternalFn: func(_ context.Context, _ *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) { + transferCalled = true + return &ledgerv1.PostResponse{JournalEntryRef: "entry-transfer"}, nil + }, + }, + } + + out, err := executor.ExecuteLedger(context.Background(), sexec.StepRequest{ + Payment: payment, + Step: xplan.Step{ + StepRef: "edge_3_4_ledger_debit", + StepCode: "edge.3_4.ledger.debit", + Action: model.RailOperationExternalDebit, + Rail: model.RailLedger, + Metadata: map[string]string{ + "mode": "finalize_debit", + }, + }, + StepExecution: agg.StepExecution{ + StepRef: "edge_3_4_ledger_debit", + StepCode: "edge.3_4.ledger.debit", + Attempt: 1, + }, + }) + if err != nil { + t.Fatalf("ExecuteLedger returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if postReq == nil { + t.Fatal("expected external debit request") + } + if transferCalled { + t.Fatal("expected external debit to skip transfer") + } + if got, want := postReq.GetMoney().GetAmount(), "76.5"; got != want { + t.Fatalf("money.amount mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetMoney().GetCurrency(), "RUB"; got != want { + t.Fatalf("money.currency mismatch: got=%q want=%q", got, want) + } + if got, want := postReq.GetRole(), ledgerv1.AccountRole_ACCOUNT_ROLE_HOLD; got != want { + t.Fatalf("role mismatch: got=%v want=%v", got, want) + } + if got, want := postReq.GetMetadata()[ledgerMetadataMode], "finalize_debit"; got != want { + t.Fatalf("mode metadata mismatch: got=%q want=%q", got, want) + } + if got, want := out.StepExecution.State, agg.StepStateCompleted; got != want { + t.Fatalf("state mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 1 { + t.Fatalf("expected one external ref, got=%d", len(out.StepExecution.ExternalRefs)) + } + if got, want := out.StepExecution.ExternalRefs[0].Ref, "entry-ext-debit"; got != want { + t.Fatalf("external ref value mismatch: got=%q want=%q", got, want) + } +} + func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSettlementAmount(t *testing.T) { orgID := bson.NewObjectID() payment := testLedgerExecutorPayment(orgID) diff --git a/api/payments/quotation/internal/server/internal/discovery.go b/api/payments/quotation/internal/server/internal/discovery.go index 7275a0ab..ad80b257 100644 --- a/api/payments/quotation/internal/server/internal/discovery.go +++ b/api/payments/quotation/internal/server/internal/discovery.go @@ -52,7 +52,7 @@ func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) { announce := discovery.Announcement{ Service: "PAYMENTS_QUOTATION", - Operations: []string{"payment.quote", "payment.multiquote"}, + Operations: []string{discovery.OperationPaymentQuote, discovery.OperationPaymentMultiQuote}, InvokeURI: invokeURI, Version: appversion.Create().Short(), } diff --git a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go index 736b9d77..3f992603 100644 --- a/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go +++ b/api/payments/quotation/internal/service/quotation/managed_wallet_network_resolver.go @@ -197,18 +197,6 @@ func (r *managedWalletNetworkResolver) listDiscoveredGatewayCandidates(ctx conte return candidates, nil } -func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) { - asset, err := managedWalletAssetFromResponse(resp) - if err != nil { - return "", err - } - network := strings.ToUpper(strings.TrimSpace(asset.GetChain())) - if network == "" || network == "UNSPECIFIED" { - return "", merrors.NoData("managed wallet network is missing") - } - return network, nil -} - func managedWalletAssetFromResponse(resp *chainv1.GetManagedWalletResponse) (*paymenttypes.Asset, error) { wallet := resp.GetWallet() if wallet == nil || wallet.GetAsset() == nil { diff --git a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go index f7f16fd5..098a60ec 100644 --- a/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go +++ b/api/payments/quotation/internal/service/quotation/plan_builder_adapters.go @@ -8,7 +8,3 @@ import ( func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { return plan.RailFromEndpoint(endpoint, attrs, isSource) } - -func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) { - return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork) -} diff --git a/api/payments/quotation/internal/service/quotation/quote_engine.go b/api/payments/quotation/internal/service/quotation/quote_engine.go index 192823d6..0327c942 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine.go @@ -152,7 +152,8 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *quo func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { - return &feesv1.PrecomputeFeesResponse{}, nil + s.logger.Warn("Fees precompute failed: fee engine unavailable") + return nil, merrors.Internal("fees_precompute_failed") } intent := req.GetIntent() amount := cloneProtoMoney(baseAmount) @@ -188,7 +189,8 @@ func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteReques func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { if !s.deps.fees.available() { - return &feesv1.PrecomputeFeesResponse{}, nil + s.logger.Warn("Conversion fee precompute failed: fee engine unavailable") + return nil, merrors.Internal("fees_precompute_failed") } intent := req.GetIntent() amount := cloneProtoMoney(baseAmount) diff --git a/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go index 020939af..695e7677 100644 --- a/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_engine_conversion_fee_test.go @@ -2,6 +2,7 @@ package quotation import ( "context" + "errors" "strings" "testing" @@ -152,9 +153,89 @@ func TestBuildPaymentQuote_DoesNotRequestConversionFeesForManagedWalletToLedger( } } +func TestBuildPaymentQuote_ReturnsErrorWhenFeeEngineUnavailable(t *testing.T) { + svc := NewService(zap.NewNop(), nil) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToCardIntent(), + } + + _, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got: %v", err) + } + if !strings.Contains(err.Error(), "fees_precompute_failed") { + t.Fatalf("expected fees_precompute_failed error, got: %v", err) + } +} + +func TestBuildPaymentQuote_ReturnsErrorWhenBaseFeePrecomputeFails(t *testing.T) { + feeClient := &stubFeeEngineClient{ + precomputeErrByOrigin: map[string]error{ + "payments.orchestrator.quote": merrors.Internal("billing_fees_unreachable"), + }, + } + + svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0)) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToLedgerIntent(), + } + + _, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got: %v", err) + } + if !strings.Contains(err.Error(), "fees_precompute_failed") { + t.Fatalf("expected fees_precompute_failed error, got: %v", err) + } +} + +func TestBuildPaymentQuote_ReturnsErrorWhenConversionFeePrecomputeFails(t *testing.T) { + feeClient := &stubFeeEngineClient{ + precomputeByOrigin: map[string]*feesv1.PrecomputeFeesResponse{ + "payments.orchestrator.quote": { + Lines: []*feesv1.DerivedPostingLine{ + testFeeLine("1.00", "USDT"), + }, + }, + }, + precomputeErrByOrigin: map[string]error{ + "payments.orchestrator.conversion_quote": merrors.Internal("billing_fees_unreachable"), + }, + } + + svc := NewService(zap.NewNop(), nil, WithFeeEngine(feeClient, 0)) + req := "eRequest{ + Meta: &sharedv1.RequestMeta{OrganizationRef: "org_1"}, + IdempotencyKey: "idem_1", + Intent: testManagedWalletToCardIntent(), + } + + _, _, err := svc.buildPaymentQuote(context.Background(), "org_1", req) + if err == nil { + t.Fatalf("expected error") + } + if !errors.Is(err, merrors.ErrInternal) { + t.Fatalf("expected internal error, got: %v", err) + } + if !strings.Contains(err.Error(), "fees_precompute_failed") { + t.Fatalf("expected fees_precompute_failed error, got: %v", err) + } +} + type stubFeeEngineClient struct { - precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse - precomputeReqs []*feesv1.PrecomputeFeesRequest + precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse + precomputeErrByOrigin map[string]error + precomputeReqs []*feesv1.PrecomputeFeesRequest } func (s *stubFeeEngineClient) QuoteFees(context.Context, *feesv1.QuoteFeesRequest, ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) { @@ -177,6 +258,9 @@ func (s *stubFeeEngineClient) PrecomputeFees(_ context.Context, in *feesv1.Preco } originType := strings.TrimSpace(in.GetIntent().GetOriginType()) + if err := s.precomputeErrByOrigin[originType]; err != nil { + return nil, err + } resp, ok := s.precomputeByOrigin[originType] if !ok || resp == nil { return &feesv1.PrecomputeFeesResponse{}, nil diff --git a/api/pkg/discovery/operations.go b/api/pkg/discovery/operations.go new file mode 100644 index 00000000..44b323b7 --- /dev/null +++ b/api/pkg/discovery/operations.go @@ -0,0 +1,71 @@ +package discovery + +import "strings" + +const ( + OperationDiscoveryLookup = "discovery.lookup" + + OperationDocumentsBatchResolve = "documents.batch_resolve" + OperationDocumentsGet = "documents.get" + OperationFeeCalc = "fee.calc" + OperationNotifySend = "notify.send" + OperationFXQuote = "fx.quote" + OperationFXIngest = "fx.ingest" + + OperationPaymentInitiate = "payment.initiate" + OperationPaymentQuote = "payment.quote" + OperationPaymentMultiQuote = "payment.multiquote" + OperationPaymentMethodsManage = "payment_methods.manage" + OperationPaymentMethodsRead = "payment_methods.read" + + OperationBalanceRead = "balance.read" + + OperationLedgerDebit = "ledger.debit" + OperationLedgerCredit = "ledger.credit" + OperationLedgerTransfer = "ledger.transfer" + OperationLedgerFX = "ledger.fx" + OperationExternalDebit = "external.debit" + OperationExternalCredit = "external.credit" + + OperationSend = "send" + OperationFee = "fee" + OperationObserveConfirm = "observe.confirm" + OperationFXConvert = "fx.convert" +) + +// NormalizeOperation canonicalizes an operation string for comparisons. +func NormalizeOperation(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +// HasAnyOperation reports whether ops contains any of required operations. +func HasAnyOperation(ops []string, required []string) bool { + if len(required) == 0 { + return true + } + for _, op := range ops { + normalized := NormalizeOperation(op) + if normalized == "" { + continue + } + for _, target := range required { + if normalized == NormalizeOperation(target) { + return true + } + } + } + return false +} + +// LedgerServiceOperations returns canonical operations announced by ledger. +func LedgerServiceOperations() []string { + return []string{ + OperationBalanceRead, + OperationLedgerDebit, + OperationLedgerCredit, + OperationLedgerTransfer, + OperationLedgerFX, + OperationExternalCredit, + OperationExternalDebit, + } +} diff --git a/api/pkg/discovery/rail_vocab.go b/api/pkg/discovery/rail_vocab.go index c0249802..22eaf59c 100644 --- a/api/pkg/discovery/rail_vocab.go +++ b/api/pkg/discovery/rail_vocab.go @@ -104,14 +104,20 @@ func ExpandRailOperation(value string) []string { } switch strings.ToLower(strings.TrimSpace(value)) { + case OperationExternalDebit, "external_debit": + return []string{RailOperationExternalDebit} + case OperationExternalCredit, "external_credit": + return []string{RailOperationExternalCredit} case "payin", "payin.crypto", "payin.fiat", "payin.card": return []string{RailOperationExternalDebit} case "payout", "payout.crypto", "payout.fiat", "payout.card": return []string{RailOperationExternalCredit, RailOperationSend} - case "fee.send", "fees.send": + case "fee.send", "fees.send", OperationFee: return []string{RailOperationFee} - case "observe.confirm", "observe_confirm": + case OperationObserveConfirm, "observe_confirm": return []string{RailOperationObserveConfirm} + case OperationFXConvert, "fx_convert": + return []string{RailOperationFXConvert} case "funds.block", "hold.balance", "block": return []string{RailOperationBlock} case "funds.release", "release", "unblock": @@ -147,6 +153,13 @@ func NormalizeRailOperations(values []string) []string { // CryptoRailGatewayOperations returns canonical operations for crypto rail gateways. func CryptoRailGatewayOperations() []string { return []string{ + OperationBalanceRead, + OperationSend, + OperationExternalDebit, + OperationExternalCredit, + OperationFee, + OperationObserveConfirm, + // Legacy rail tokens retained for backward compatibility. RailOperationSend, RailOperationExternalDebit, RailOperationExternalCredit, @@ -158,6 +171,10 @@ func CryptoRailGatewayOperations() []string { // CardPayoutRailGatewayOperations returns canonical operations for card payout gateways. func CardPayoutRailGatewayOperations() []string { return []string{ + OperationSend, + OperationExternalCredit, + OperationObserveConfirm, + // Legacy rail tokens retained for backward compatibility. RailOperationSend, RailOperationExternalCredit, RailOperationObserveConfirm, @@ -167,6 +184,9 @@ func CardPayoutRailGatewayOperations() []string { // ProviderSettlementRailGatewayOperations returns canonical operations for settlement gateways. func ProviderSettlementRailGatewayOperations() []string { return []string{ + OperationFXConvert, + OperationObserveConfirm, + // Legacy rail tokens retained for backward compatibility. RailOperationFXConvert, RailOperationObserveConfirm, } diff --git a/api/pkg/discovery/rail_vocab_test.go b/api/pkg/discovery/rail_vocab_test.go index 7bb0cf4f..96eb1ce9 100644 --- a/api/pkg/discovery/rail_vocab_test.go +++ b/api/pkg/discovery/rail_vocab_test.go @@ -7,14 +7,16 @@ func TestNormalizeRailOperations(t *testing.T) { "send", "payout.crypto", "observe.confirm", + "external.credit", + "fx.convert", "unknown", - "EXTERNAL_CREDIT", }) want := []string{ RailOperationSend, RailOperationExternalCredit, RailOperationObserveConfirm, + RailOperationFXConvert, } if len(got) != len(want) { t.Fatalf("unexpected operations count: got=%d want=%d", len(got), len(want)) diff --git a/api/server/internal/api/discovery_resolver.go b/api/server/internal/api/discovery_resolver.go index bd946e0e..93952478 100644 --- a/api/server/internal/api/discovery_resolver.go +++ b/api/server/internal/api/discovery_resolver.go @@ -19,15 +19,8 @@ import ( const ( discoveryBootstrapTimeout = 3 * time.Second discoveryBootstrapSender = "server_bootstrap" - discoveryGatewayRailCrypto = "CRYPTO" defaultClientDialTimeoutSecs = 5 defaultClientCallTimeoutSecs = 5 - paymentQuoteOperation = "payment.quote" - paymentInitiateOperation = "payment.initiate" - ledgerDebitOperation = "ledger.debit" - ledgerCreditOperation = "ledger.credit" - gatewayReadBalanceOperation = "balance.read" - paymentMethodsReadOperation = "payment_methods.read" ) var ( @@ -123,7 +116,7 @@ func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary) endpoint, selected, ok := selectGatewayEndpoint( gateways, cfg.DefaultAsset.Chain, - []string{gatewayReadBalanceOperation}, + []string{discovery.OperationBalanceRead}, ) if !ok { return @@ -146,7 +139,7 @@ func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) { endpoint, selected, ok := selectServiceEndpoint( services, ledgerDiscoveryServiceNames, - []string{ledgerDebitOperation, ledgerCreditOperation}, + discovery.LedgerServiceOperations(), ) if !ok { return @@ -170,7 +163,7 @@ func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceS endpoint, selected, ok := selectServiceEndpoint( services, paymentOrchestratorDiscoveryServiceNames, - []string{paymentInitiateOperation}, + []string{discovery.OperationPaymentInitiate}, ) if !ok { return false, discoveryEndpoint{} @@ -196,7 +189,7 @@ func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSumm endpoint, selected, ok := selectServiceEndpoint( services, paymentQuotationDiscoveryServiceNames, - []string{paymentQuoteOperation}, + []string{discovery.OperationPaymentQuote}, ) if !ok { cfg := a.config.PaymentQuotation @@ -229,7 +222,7 @@ func (a *APIImp) resolvePaymentMethodsAddress(services []discovery.ServiceSummar endpoint, selected, ok := selectServiceEndpoint( services, paymentMethodsDiscoveryServiceNames, - []string{paymentMethodsReadOperation}, + []string{discovery.OperationPaymentMethodsRead}, ) if !ok { return @@ -269,7 +262,7 @@ func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []s selections = append(selections, serviceSelection{ service: svc, endpoint: endpoint, - opMatch: hasAnyOperation(svc.Ops, requiredOps), + opMatch: discovery.HasAnyOperation(svc.Ops, requiredOps), nameRank: nameRank, }) } @@ -302,7 +295,7 @@ func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork if !gateway.Healthy { continue } - if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discoveryGatewayRailCrypto) { + if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discovery.RailCrypto) { continue } if strings.TrimSpace(gateway.InvokeURI) == "" { @@ -316,7 +309,7 @@ func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork gateway: gateway, endpoint: endpoint, networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork), - opMatch: hasAnyOperation(gateway.Ops, requiredOps), + opMatch: discovery.HasAnyOperation(gateway.Ops, requiredOps), }) } if len(selections) == 0 { @@ -412,24 +405,6 @@ func serviceRank(service string, names []string) (int, bool) { return 0, false } -func hasAnyOperation(ops []string, required []string) bool { - if len(required) == 0 { - return true - } - for _, op := range ops { - normalized := strings.TrimSpace(op) - if normalized == "" { - continue - } - for _, target := range required { - if strings.EqualFold(normalized, strings.TrimSpace(target)) { - return true - } - } - } - return false -} - func ensureLedgerConfig(cfg *eapi.Config) *eapi.LedgerConfig { if cfg == nil { return nil diff --git a/api/server/internal/server/paymentapiimp/documents.go b/api/server/internal/server/paymentapiimp/documents.go index 8c074ad8..909863be 100644 --- a/api/server/internal/server/paymentapiimp/documents.go +++ b/api/server/internal/server/paymentapiimp/documents.go @@ -27,7 +27,7 @@ import ( const ( documentsServiceName = "BILLING_DOCUMENTS" - documentsOperationGet = "documents.get" + documentsOperationGet = discovery.OperationDocumentsGet documentsDialTimeout = 5 * time.Second documentsCallTimeout = 10 * time.Second ) diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart index 7b67dda2..dc5c0550 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/form.dart @@ -7,7 +7,7 @@ import 'package:pshared/models/payment/type.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/asset_type_field.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/description.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/employees_loading_indicator.dart'; -import 'package:pweb/pages/dashboard/buttons/balance/add/ledger_fields.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/add/ledger/fields.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/managed_wallet_fields.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/name.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/owner.dart'; diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/currency_item.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/currency_item.dart new file mode 100644 index 00000000..2c203d97 --- /dev/null +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/currency_item.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'package:pshared/models/currency.dart'; +import 'package:pshared/utils/currency.dart'; + + +DropdownMenuItem currencyItem(Currency currency) => DropdownMenuItem( + value: currency, + child: Text(currencyCodeToString(currency)), +); \ No newline at end of file diff --git a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/fields.dart similarity index 81% rename from frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart rename to frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/fields.dart index 13afdadd..191da740 100644 --- a/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger_fields.dart +++ b/frontend/pweb/lib/pages/dashboard/buttons/balance/add/ledger/fields.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:pshared/models/currency.dart'; -import 'package:pshared/utils/currency.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/constants.dart'; +import 'package:pweb/pages/dashboard/buttons/balance/add/ledger/currency_item.dart'; import 'package:pweb/utils/text_field_styles.dart'; import 'package:pweb/generated/i18n/app_localizations.dart'; @@ -24,10 +24,8 @@ class LedgerFields extends StatelessWidget { initialValue: currency, decoration: getInputDecoration(context, AppLocalizations.of(context)!.currency, true), items: [ - DropdownMenuItem( - value: ledgerCurrencyDefault, - child: Text(currencyCodeToString(ledgerCurrencyDefault)), - ), + currencyItem(ledgerCurrencyDefault), + currencyItem(managedCurrencyDefault), ], onChanged: onCurrencyChanged, );