Fixed billing fees unreachable error propagation. Added USDT ledger creation. Fixed ledger boundaries operation types

This commit is contained in:
Stephan D
2026-02-26 16:25:52 +01:00
parent 54e5c799e8
commit 336f352858
37 changed files with 838 additions and 302 deletions

View File

@@ -392,7 +392,7 @@ func (s *Service) startDiscoveryAnnouncer() {
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "BILLING_DOCUMENTS", Service: "BILLING_DOCUMENTS",
Operations: []string{"documents.batch_resolve", "documents.get"}, Operations: []string{discovery.OperationDocumentsBatchResolve, discovery.OperationDocumentsGet},
InvokeURI: s.invokeURI, InvokeURI: s.invokeURI,
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }

View File

@@ -564,7 +564,7 @@ func (s *Service) startDiscoveryAnnouncer() {
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "BILLING_FEES", Service: "BILLING_FEES",
Operations: []string{"fee.calc"}, Operations: []string{discovery.OperationFeeCalc},
InvokeURI: s.invokeURI, InvokeURI: s.invokeURI,
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }

View File

@@ -51,7 +51,7 @@ func (i *Imp) startDiscovery(cfg *config) error {
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "DISCOVERY", Service: "DISCOVERY",
InstanceID: discovery.InstanceID(), InstanceID: discovery.InstanceID(),
Operations: []string{"discovery.lookup"}, Operations: []string{discovery.OperationDiscoveryLookup},
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }
i.announcer = discovery.NewAnnouncer(i.logger, producer, mservice.Discovery, announce) i.announcer = discovery.NewAnnouncer(i.logger, producer, mservice.Discovery, announce)

View File

@@ -86,7 +86,7 @@ func (a *App) Run(ctx context.Context) error {
producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker) producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker)
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "FX_INGESTOR", Service: "FX_INGESTOR",
Operations: []string{"fx.ingest"}, Operations: []string{discovery.OperationFXIngest},
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }
announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce) announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce)

View File

@@ -106,7 +106,7 @@ func (s *Service) startDiscoveryAnnouncer() {
} }
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "FX_ORACLE", Service: "FX_ORACLE",
Operations: []string{"fx.quote"}, Operations: []string{discovery.OperationFXQuote},
InvokeURI: s.invokeURI, InvokeURI: s.invokeURI,
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }

View File

@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model/account_role" "github.com/tech/sendico/pkg/model/account_role"
@@ -23,7 +24,36 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "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. // Client exposes typed helpers around the ledger gRPC API.
type Client interface { type Client interface {
@@ -36,6 +66,8 @@ type Client interface {
ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) ListConnectorAccounts(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error)
PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*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) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*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) metadata := ledgerTxMetadata(tx.Metadata, tx)
extraParams := map[string]interface{}{} extraParams := map[string]interface{}{}
if op := strings.TrimSpace(tx.Operation); op != "" { if op := strings.TrimSpace(tx.Operation); op != "" {
extraParams["operation"] = op extraParams[opParamOperation] = op
} }
if len(extraParams) == 0 { if len(extraParams) == 0 {
extraParams = nil extraParams = nil
@@ -204,13 +236,13 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc
return nil, merrors.InvalidArgument("ledger: currency is required") return nil, merrors.InvalidArgument("ledger: currency is required")
} }
params := map[string]interface{}{ params := map[string]interface{}{
"organization_ref": strings.TrimSpace(req.GetOrganizationRef()), opParamOrganizationRef: strings.TrimSpace(req.GetOrganizationRef()),
"account_type": req.GetAccountType().String(), opParamAccountType: req.GetAccountType().String(),
"status": req.GetStatus().String(), opParamStatus: req.GetStatus().String(),
"allow_negative": req.GetAllowNegative(), opParamAllowNegative: req.GetAllowNegative(),
} }
if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { if role := req.GetRole(); role != ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
params["role"] = role.String() params[opParamRole] = role.String()
} }
label := "" label := ""
if desc := req.GetDescribable(); desc != nil { if desc := req.GetDescribable(); desc != nil {
@@ -218,12 +250,12 @@ func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAc
if desc.Description != nil { if desc.Description != nil {
trimmed := strings.TrimSpace(desc.GetDescription()) trimmed := strings.TrimSpace(desc.GetDescription())
if trimmed != "" { if trimmed != "" {
params["description"] = trimmed params[opParamDescription] = trimmed
} }
} }
} }
if len(req.GetMetadata()) > 0 { if len(req.GetMetadata()) > 0 {
params["metadata"] = mapStringToInterface(req.GetMetadata()) params[opParamMetadata] = mapStringToInterface(req.GetMetadata())
} }
resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{ resp, err := c.client.OpenAccount(ctx, &connectorv1.OpenAccountRequest{
Kind: connectorv1.AccountKind_LEDGER_ACCOUNT, 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) 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) { 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) 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 := ledgerOperationParams(req.GetOrganizationRef(), req.GetDescription(), req.GetMetadata(), req.GetCharges(), req.GetEventTime())
params["rate"] = strings.TrimSpace(req.GetRate()) 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{ operation := &connectorv1.Operation{
Type: connectorv1.OperationType_FX, Type: connectorv1.OperationType_FX,
IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()), IdempotencyKey: strings.TrimSpace(req.GetIdempotencyKey()),
@@ -466,7 +522,7 @@ func (c *ledgerClient) submitLedgerOperationWithExtras(ctx context.Context, opTy
params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime) params := ledgerOperationParams(orgRef, description, metadata, charges, eventTime)
if contraRef != "" { if contraRef != "" {
params["contra_ledger_account_ref"] = strings.TrimSpace(contraRef) params[opParamContraLedgerAccountRef] = strings.TrimSpace(contraRef)
} }
if len(extraParams) > 0 { if len(extraParams) > 0 {
for key, value := range extraParams { 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{} { func ledgerOperationParams(orgRef, description string, metadata map[string]string, charges []*ledgerv1.PostingLine, eventTime *timestamppb.Timestamp) map[string]interface{} {
params := map[string]interface{}{ params := map[string]interface{}{
"organization_ref": strings.TrimSpace(orgRef), opParamOrganizationRef: strings.TrimSpace(orgRef),
"description": strings.TrimSpace(description), opParamDescription: strings.TrimSpace(description),
} }
if len(metadata) > 0 { if len(metadata) > 0 {
params["metadata"] = mapStringToInterface(metadata) params[opParamMetadata] = mapStringToInterface(metadata)
} }
if len(charges) > 0 { if len(charges) > 0 {
params["charges"] = chargesToInterface(charges) params[opParamCharges] = chargesToInterface(charges)
} }
if eventTime != nil { if eventTime != nil {
params["event_time"] = eventTime.AsTime().UTC().Format(time.RFC3339Nano) params[opParamEventTime] = eventTime.AsTime().UTC().Format(time.RFC3339Nano)
} }
return params return params
} }
@@ -580,25 +636,25 @@ func ledgerAccountFromConnector(account *connectorv1.Account) *ledgerv1.LedgerAc
details = account.GetProviderDetails().AsMap() details = account.GetProviderDetails().AsMap()
} }
accountType := ledgerv1.AccountType_ACCOUNT_TYPE_UNSPECIFIED 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) accountType = parseAccountType(v)
} }
status := ledgerv1.AccountStatus_ACCOUNT_STATUS_UNSPECIFIED 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) status = parseAccountStatus(v)
} }
allowNegative := false allowNegative := false
if v, ok := details["allow_negative"].(bool); ok { if v, ok := details[opParamAllowNegative].(bool); ok {
allowNegative = v allowNegative = v
} }
role := ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED 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 { if parsed, ok := ledgerconv.ParseAccountRole(v); ok {
role = parsed role = parsed
} }
} }
if role == ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED { if role == ledgerv1.AccountRole_ACCOUNT_ROLE_UNSPECIFIED {
switch v := details["is_settlement"].(type) { switch v := details[opParamIsSettlement].(type) {
case bool: case bool:
if v { if v {
role = ledgerv1.AccountRole_ACCOUNT_ROLE_SETTLEMENT 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 := "" accountID := ""
if ref := account.GetRef(); ref != nil { if ref := account.GetRef(); ref != nil {
accountID = strings.TrimSpace(ref.GetAccountId()) accountID = strings.TrimSpace(ref.GetAccountId())
} }
organizationRef := strings.TrimSpace(account.GetOwnerRef()) 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 organizationRef = v
} }
describable := account.GetDescribable() describable := account.GetDescribable()
@@ -674,7 +730,7 @@ func operationDescription(op *connectorv1.Operation) string {
if op == nil || op.GetParams() == nil { if op == nil || op.GetParams() == nil {
return "" return ""
} }
if value, ok := op.GetParams().AsMap()["description"]; ok { if value, ok := op.GetParams().AsMap()[opParamDescription]; ok {
return strings.TrimSpace(fmt.Sprint(value)) return strings.TrimSpace(fmt.Sprint(value))
} }
return "" return ""
@@ -731,10 +787,10 @@ func chargesToInterface(charges []*ledgerv1.PostingLine) []interface{} {
continue continue
} }
result = append(result, map[string]interface{}{ result = append(result, map[string]interface{}{
"ledger_account_ref": strings.TrimSpace(line.GetLedgerAccountRef()), opParamLedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()),
"amount": strings.TrimSpace(line.GetMoney().GetAmount()), opParamAmount: strings.TrimSpace(line.GetMoney().GetAmount()),
"currency": strings.TrimSpace(line.GetMoney().GetCurrency()), opParamCurrency: strings.TrimSpace(line.GetMoney().GetCurrency()),
"line_type": line.GetLineType().String(), opParamLineType: line.GetLineType().String(),
}) })
} }
if len(result) == 0 { if len(result) == 0 {
@@ -793,7 +849,7 @@ func (c *ledgerClient) callContext(ctx context.Context) (context.Context, contex
} }
func isLedgerRail(value string) bool { 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 { 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{} meta = map[string]string{}
} }
if val := strings.TrimSpace(tx.PaymentPlanID); val != "" { if val := strings.TrimSpace(tx.PaymentPlanID); val != "" {
meta["payment_plan_id"] = val meta[txMetaPaymentPlanID] = val
} }
if val := strings.TrimSpace(tx.FromRail); val != "" { if val := strings.TrimSpace(tx.FromRail); val != "" {
meta["from_rail"] = val meta[txMetaFromRail] = val
} }
if val := strings.TrimSpace(tx.ToRail); val != "" { if val := strings.TrimSpace(tx.ToRail); val != "" {
meta["to_rail"] = val meta[txMetaToRail] = val
} }
if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" { if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" {
meta["external_reference_id"] = val meta[txMetaExternalReference] = val
} }
if val := strings.TrimSpace(tx.FXRateUsed); val != "" { if val := strings.TrimSpace(tx.FXRateUsed); val != "" {
meta["fx_rate_used"] = val meta[txMetaFXRateUsed] = val
} }
if val := strings.TrimSpace(tx.FeeAmount); val != "" { if val := strings.TrimSpace(tx.FeeAmount); val != "" {
meta["fee_amount"] = val meta[txMetaFeeAmount] = val
} }
if len(meta) == 0 { if len(meta) == 0 {
return nil return nil

View File

@@ -6,6 +6,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "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" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/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, "op-1", resp.GetJournalEntryRef())
assert.Equal(t, ledgerv1.EntryType_ENTRY_TRANSFER, resp.GetEntryType()) 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())
}

View File

@@ -4,29 +4,31 @@ import (
"context" "context"
"github.com/tech/sendico/pkg/payments/rail" "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" 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" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
) )
// Fake implements Client for tests. // Fake implements Client for tests.
type Fake struct { type Fake struct {
ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error) ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error)
CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error) CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error)
HoldBalanceFn func(ctx context.Context, accountID string, amount string) error HoldBalanceFn func(ctx context.Context, accountID string, amount string) error
CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error)
ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error)
ListConnectorAccountsFn func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error) ListConnectorAccountsFn func(ctx context.Context, req *connectorv1.ListAccountsRequest) (*connectorv1.ListAccountsResponse, error)
PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*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) PostExternalCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error)
ApplyFXWithChargesFn func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) PostExternalDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error)
BlockAccountFn func(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error) TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error)
UnblockAccountFn func(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error) ApplyFXWithChargesFn func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error)
GetBalanceFn func(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error) BlockAccountFn func(ctx context.Context, req *ledgerv1.BlockAccountRequest) (*ledgerv1.BlockAccountResponse, error)
GetJournalEntryFn func(ctx context.Context, req *ledgerv1.GetEntryRequest) (*ledgerv1.JournalEntryResponse, error) UnblockAccountFn func(ctx context.Context, req *ledgerv1.UnblockAccountRequest) (*ledgerv1.UnblockAccountResponse, error)
GetStatementFn func(ctx context.Context, req *ledgerv1.GetStatementRequest) (*ledgerv1.StatementResponse, error) GetBalanceFn func(ctx context.Context, req *ledgerv1.GetBalanceRequest) (*ledgerv1.BalanceResponse, error)
CloseFn func() 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) { 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 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) { func (f *Fake) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
if f.TransferInternalFn != nil { if f.TransferInternalFn != nil {
return f.TransferInternalFn(ctx, req) return f.TransferInternalFn(ctx, req)

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/ledger/internal/appversion" "github.com/tech/sendico/ledger/internal/appversion"
"github.com/tech/sendico/pkg/connector/params" "github.com/tech/sendico/pkg/connector/params"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/ledgerconv" "github.com/tech/sendico/pkg/ledgerconv"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
accountrolev1 "github.com/tech/sendico/pkg/proto/common/account_role/v1" 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" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1" connectorv1 "github.com/tech/sendico/pkg/proto/connector/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/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/structpb"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -222,7 +224,7 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
if err != nil { if err != nil {
return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, err.Error(), op, "")}}, 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() { switch op.GetType() {
case connectorv1.OperationType_CREDIT: 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 { 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 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 return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "credit: unsupported operation override", op, "")}}, nil
} }
creditFn := c.svc.PostCreditWithCharges creditFn := c.svc.PostCreditWithCharges
if operation == "external.credit" { if operation == discovery.OperationExternalCredit {
creditFn = c.svc.PostExternalCreditWithCharges creditFn = c.svc.PostExternalCreditWithCharges
} }
resp, err := creditFn(ctx, &ledgerv1.PostCreditRequest{ resp, err := creditFn(ctx, &ledgerv1.PostCreditRequest{
@@ -250,6 +252,10 @@ func (c *connectorAdapter) SubmitOperation(ctx context.Context, req *connectorv1
Role: accountRoleFromConnectorRole(op.GetToRole()), Role: accountRoleFromConnectorRole(op.GetToRole()),
}) })
if err != nil { 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: &connectorv1.OperationReceipt{Error: connectorError(mapErrorCode(err), err.Error(), op, accountID)}}, nil
} }
return &connectorv1.SubmitOperationResponse{Receipt: ledgerReceipt(resp.GetJournalEntryRef(), connectorv1.OperationStatus_OPERATION_SUCCESS)}, 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 { 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 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 return &connectorv1.SubmitOperationResponse{Receipt: &connectorv1.OperationReceipt{Error: connectorError(connectorv1.ErrorCode_INVALID_PARAMS, "debit: unsupported operation override", op, "")}}, nil
} }
debitFn := c.svc.PostDebitWithCharges debitFn := c.svc.PostDebitWithCharges
if operation == "external.debit" { if operation == discovery.OperationExternalDebit {
debitFn = c.svc.PostExternalDebitWithCharges debitFn = c.svc.PostExternalDebitWithCharges
} }
resp, err := debitFn(ctx, &ledgerv1.PostDebitRequest{ resp, err := debitFn(ctx, &ledgerv1.PostDebitRequest{
@@ -393,14 +399,14 @@ func ledgerOperationParams() []*connectorv1.OperationParamSpec {
Type: connectorv1.ParamType_STRING, Type: connectorv1.ParamType_STRING,
Required: false, Required: false,
Description: "Optional ledger operation override (external.credit).", Description: "Optional ledger operation override (external.credit).",
AllowedValues: []string{"external.credit"}, AllowedValues: []string{discovery.OperationExternalCredit},
} }
externalDebit := &connectorv1.ParamSpec{ externalDebit := &connectorv1.ParamSpec{
Key: "operation", Key: "operation",
Type: connectorv1.ParamType_STRING, Type: connectorv1.ParamType_STRING,
Required: false, Required: false,
Description: "Optional ledger operation override (external.debit).", Description: "Optional ledger operation override (external.debit).",
AllowedValues: []string{"external.debit"}, AllowedValues: []string{discovery.OperationExternalDebit},
} }
return []*connectorv1.OperationParamSpec{ return []*connectorv1.OperationParamSpec{
{OperationType: connectorv1.OperationType_CREDIT, Params: append(common, externalCredit, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})}, {OperationType: connectorv1.OperationType_CREDIT, Params: append(common, externalCredit, &connectorv1.ParamSpec{Key: "contra_ledger_account_ref", Type: connectorv1.ParamType_STRING, Required: false})},

View File

@@ -7,6 +7,40 @@ import (
"github.com/prometheus/client_golang/prometheus/promauto" "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 ( var (
metricsOnce sync.Once metricsOnce sync.Once
@@ -110,16 +144,16 @@ func initMetrics() {
// Metric recording helpers // Metric recording helpers
func recordJournalEntry(entryType, status string, durationSeconds float64) { func recordJournalEntry(entryType journalEntryType, status journalEntryStatus, durationSeconds float64) {
initMetrics() initMetrics()
journalEntriesTotal.WithLabelValues(entryType, status).Inc() journalEntriesTotal.WithLabelValues(string(entryType), string(status)).Inc()
journalEntryLatency.WithLabelValues(entryType).Observe(durationSeconds) journalEntryLatency.WithLabelValues(string(entryType)).Observe(durationSeconds)
} }
func recordJournalEntryError(entryType, errorType string) { func recordJournalEntryError(entryType journalEntryType, errorType journalEntryErrorType) {
initMetrics() initMetrics()
journalEntryErrors.WithLabelValues(entryType, errorType).Inc() journalEntryErrors.WithLabelValues(string(entryType), string(errorType)).Inc()
journalEntriesTotal.WithLabelValues(entryType, "error").Inc() journalEntriesTotal.WithLabelValues(string(entryType), string(journalEntryStatusError)).Inc()
} }
func recordBalanceQuery(status string, durationSeconds float64) { func recordBalanceQuery(status string, durationSeconds float64) {
@@ -128,9 +162,9 @@ func recordBalanceQuery(status string, durationSeconds float64) {
balanceQueryLatency.WithLabelValues(status).Observe(durationSeconds) balanceQueryLatency.WithLabelValues(status).Observe(durationSeconds)
} }
func recordTransactionAmount(currency, entryType string, amount float64) { func recordTransactionAmount(currency string, entryType journalEntryType, amount float64) {
initMetrics() initMetrics()
transactionAmounts.WithLabelValues(currency, entryType).Observe(amount) transactionAmounts.WithLabelValues(currency, string(entryType)).Observe(amount)
} }
func recordAccountOperation(operation, status string) { func recordAccountOperation(operation, status string) {
@@ -138,7 +172,7 @@ func recordAccountOperation(operation, status string) {
accountOperationsTotal.WithLabelValues(operation, status).Inc() accountOperationsTotal.WithLabelValues(operation, status).Inc()
} }
func recordDuplicateRequest(entryType string) { func recordDuplicateRequest(entryType journalEntryType) {
initMetrics() initMetrics()
duplicateRequestsTotal.WithLabelValues(entryType).Inc() duplicateRequestsTotal.WithLabelValues(string(entryType)).Inc()
} }

View File

@@ -65,7 +65,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil { if err == nil && existingEntry != nil {
recordDuplicateRequest("credit") recordDuplicateRequest(journalEntryTypeCredit)
logger.Info("Duplicate credit request (idempotency)", logger.Info("Duplicate credit request (idempotency)",
zap.String("existingEntryID", existingEntry.GetID().Hex())) zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{ return &ledgerv1.PostResponse{
@@ -75,18 +75,18 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
}, nil }, nil
} }
if err != nil && err != storage.ErrJournalEntryNotFound { if err != nil && err != storage.ErrJournalEntryNotFound {
recordJournalEntryError("credit", "idempotency_check_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorIdempotencyCheck)
logger.Warn("Failed to check idempotency", zap.Error(err)) logger.Warn("Failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency") return nil, merrors.Internal("failed to check idempotency")
} }
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
if err != nil { if err != nil {
recordJournalEntryError("credit", "account_resolve_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountResolve)
return nil, err return nil, err
} }
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
recordJournalEntryError("credit", "account_invalid") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountInvalid)
return nil, err 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) contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef)
if err != nil { if err != nil {
recordJournalEntryError("credit", "contra_resolve_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraResolve)
return nil, err return nil, err
} }
contraAccountID := contraAccount.GetID() contraAccountID := contraAccount.GetID()
if contraAccountID == nil { if contraAccountID == nil {
recordJournalEntryError("credit", "contra_missing_id") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorContraMissingID)
return nil, merrors.Internal("contra account missing identifier") return nil, merrors.Internal("contra account missing identifier")
} }
@@ -183,7 +183,7 @@ func (s *Service) postCreditResponder(_ context.Context, req *ledgerv1.PostCredi
} }
if !entryTotal.IsZero() { if !entryTotal.IsZero() {
recordJournalEntryError("credit", "unbalanced_after_contra") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorUnbalancedAfterContra)
return nil, merrors.Internal("failed to balance journal entry") 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 { if err != nil {
recordJournalEntryError("credit", "transaction_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorTransactionFailed)
return nil, err return nil, err
} }
amountFloat, _ := creditAmount.Float64() amountFloat, _ := creditAmount.Float64()
recordTransactionAmount(req.Money.Currency, "credit", amountFloat) recordTransactionAmount(req.Money.Currency, journalEntryTypeCredit, amountFloat)
recordJournalEntry("credit", "success", 0) recordJournalEntry(journalEntryTypeCredit, journalEntryStatusSuccess, 0)
return result.(*ledgerv1.PostResponse), nil return result.(*ledgerv1.PostResponse), nil
} }
} }

View File

@@ -63,7 +63,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil { if err == nil && existingEntry != nil {
recordDuplicateRequest("debit") recordDuplicateRequest(journalEntryTypeDebit)
logger.Info("Duplicate debit request (idempotency)", logger.Info("Duplicate debit request (idempotency)",
zap.String("existingEntryID", existingEntry.GetID().Hex())) zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{ 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") account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
if err != nil { if err != nil {
recordJournalEntryError("debit", "account_resolve_failed") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountResolve)
return nil, err return nil, err
} }
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
recordJournalEntryError("debit", "account_invalid") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountInvalid)
return nil, err 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) contraAccount, err := s.resolveSettlementAccount(ctx, orgRef, req.Money.Currency, req.ContraLedgerAccountRef, accountsByRef)
if err != nil { if err != nil {
recordJournalEntryError("debit", "contra_resolve_failed") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorContraResolve)
return nil, err return nil, err
} }
contraAccountID := contraAccount.GetID() contraAccountID := contraAccount.GetID()
if contraAccountID == nil { if contraAccountID == nil {
recordJournalEntryError("debit", "contra_missing_id") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorContraMissingID)
return nil, merrors.Internal("contra account missing identifier") return nil, merrors.Internal("contra account missing identifier")
} }
@@ -180,7 +180,7 @@ func (s *Service) postDebitResponder(_ context.Context, req *ledgerv1.PostDebitR
} }
if !entryTotal.IsZero() { if !entryTotal.IsZero() {
recordJournalEntryError("debit", "unbalanced_after_contra") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorUnbalancedAfterContra)
return nil, merrors.Internal("failed to balance journal entry") 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 { if err != nil {
recordJournalEntryError("debit", "transaction_failed") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorTransactionFailed)
return nil, err return nil, err
} }
amountFloat, _ := debitAmount.Float64() amountFloat, _ := debitAmount.Float64()
recordTransactionAmount(req.Money.Currency, "debit", amountFloat) recordTransactionAmount(req.Money.Currency, journalEntryTypeDebit, amountFloat)
recordJournalEntry("debit", "success", 0) recordJournalEntry(journalEntryTypeDebit, journalEntryStatusSuccess, 0)
return result.(*ledgerv1.PostResponse), nil return result.(*ledgerv1.PostResponse), nil
} }
} }

View File

@@ -60,7 +60,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil { if err == nil && existingEntry != nil {
recordDuplicateRequest("credit") recordDuplicateRequest(journalEntryTypeCredit)
logger.Info("Duplicate external credit request (idempotency)", logger.Info("Duplicate external credit request (idempotency)",
zap.String("existingEntryID", existingEntry.GetID().Hex())) zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{ return &ledgerv1.PostResponse{
@@ -70,34 +70,34 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P
}, nil }, nil
} }
if err != nil && err != storage.ErrJournalEntryNotFound { if err != nil && err != storage.ErrJournalEntryNotFound {
recordJournalEntryError("credit", "idempotency_check_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorIdempotencyCheck)
logger.Warn("Failed to check idempotency", zap.Error(err)) logger.Warn("Failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency") return nil, merrors.Internal("failed to check idempotency")
} }
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
if err != nil { if err != nil {
recordJournalEntryError("credit", "account_resolve_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountResolve)
return nil, err return nil, err
} }
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
recordJournalEntryError("credit", "account_invalid") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorAccountInvalid)
return nil, err return nil, err
} }
systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency) systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency)
if err != nil { if err != nil {
recordJournalEntryError("credit", "system_account_resolve_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountResolve)
return nil, err return nil, err
} }
if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency); err != nil { if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSource, req.Money.Currency); err != nil {
recordJournalEntryError("credit", "system_account_invalid") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountInvalid)
return nil, err return nil, err
} }
systemAccountID := systemAccount.GetID() systemAccountID := systemAccount.GetID()
if systemAccountID == nil { if systemAccountID == nil {
recordJournalEntryError("credit", "system_account_missing_id") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorSystemAccountMissing)
return nil, merrors.Internal("system account missing identifier") return nil, merrors.Internal("system account missing identifier")
} }
@@ -186,7 +186,7 @@ func (s *Service) postExternalCreditResponder(_ context.Context, req *ledgerv1.P
} }
if !entryTotal.IsZero() { if !entryTotal.IsZero() {
recordJournalEntryError("credit", "unbalanced_after_contra") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorUnbalancedAfterContra)
return nil, merrors.Internal("failed to balance journal entry") 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 { if err != nil {
recordJournalEntryError("credit", "transaction_failed") recordJournalEntryError(journalEntryTypeCredit, journalEntryErrorTransactionFailed)
return nil, err return nil, err
} }
amountFloat, _ := creditAmount.Float64() amountFloat, _ := creditAmount.Float64()
recordTransactionAmount(req.Money.Currency, "credit", amountFloat) recordTransactionAmount(req.Money.Currency, journalEntryTypeCredit, amountFloat)
recordJournalEntry("credit", "success", 0) recordJournalEntry(journalEntryTypeCredit, journalEntryStatusSuccess, 0)
return result.(*ledgerv1.PostResponse), nil 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) existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil { if err == nil && existingEntry != nil {
recordDuplicateRequest("debit") recordDuplicateRequest(journalEntryTypeDebit)
logger.Info("Duplicate external debit request (idempotency)", logger.Info("Duplicate external debit request (idempotency)",
zap.String("existingEntryID", existingEntry.GetID().Hex())) zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{ return &ledgerv1.PostResponse{
@@ -303,34 +303,34 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po
}, nil }, nil
} }
if err != nil && err != storage.ErrJournalEntryNotFound { if err != nil && err != storage.ErrJournalEntryNotFound {
recordJournalEntryError("debit", "idempotency_check_failed") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorIdempotencyCheck)
logger.Warn("Failed to check idempotency", zap.Error(err)) logger.Warn("Failed to check idempotency", zap.Error(err))
return nil, merrors.Internal("failed to check idempotency") return nil, merrors.Internal("failed to check idempotency")
} }
account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account") account, accountRef, err := s.resolveAccount(ctx, strings.TrimSpace(req.LedgerAccountRef), roleModel, orgRef, req.Money.Currency, "account")
if err != nil { if err != nil {
recordJournalEntryError("debit", "account_resolve_failed") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountResolve)
return nil, err return nil, err
} }
if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil { if err := validateAccountForOrg(account, orgRef, req.Money.Currency); err != nil {
recordJournalEntryError("debit", "account_invalid") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorAccountInvalid)
return nil, err return nil, err
} }
systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency) systemAccount, err := s.systemAccount(ctx, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency)
if err != nil { if err != nil {
recordJournalEntryError("debit", "system_account_resolve_failed") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountResolve)
return nil, err return nil, err
} }
if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency); err != nil { if err := validateSystemAccount(systemAccount, pmodel.SystemAccountPurposeExternalSink, req.Money.Currency); err != nil {
recordJournalEntryError("debit", "system_account_invalid") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountInvalid)
return nil, err return nil, err
} }
systemAccountID := systemAccount.GetID() systemAccountID := systemAccount.GetID()
if systemAccountID == nil { if systemAccountID == nil {
recordJournalEntryError("debit", "system_account_missing_id") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorSystemAccountMissing)
return nil, merrors.Internal("system account missing identifier") return nil, merrors.Internal("system account missing identifier")
} }
@@ -419,7 +419,7 @@ func (s *Service) postExternalDebitResponder(_ context.Context, req *ledgerv1.Po
} }
if !entryTotal.IsZero() { if !entryTotal.IsZero() {
recordJournalEntryError("debit", "unbalanced_after_contra") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorUnbalancedAfterContra)
return nil, merrors.Internal("failed to balance journal entry") 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 { if err != nil {
recordJournalEntryError("debit", "transaction_failed") recordJournalEntryError(journalEntryTypeDebit, journalEntryErrorTransactionFailed)
return nil, err return nil, err
} }
amountFloat, _ := debitAmount.Float64() amountFloat, _ := debitAmount.Float64()
recordTransactionAmount(req.Money.Currency, "debit", amountFloat) recordTransactionAmount(req.Money.Currency, journalEntryTypeDebit, amountFloat)
recordJournalEntry("debit", "success", 0) recordJournalEntry(journalEntryTypeDebit, journalEntryStatusSuccess, 0)
return result.(*ledgerv1.PostResponse), nil return result.(*ledgerv1.PostResponse), nil
} }
} }

View File

@@ -76,7 +76,7 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp
// Check for duplicate idempotency key // Check for duplicate idempotency key
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil { if err == nil && existingEntry != nil {
recordDuplicateRequest("fx") recordDuplicateRequest(journalEntryTypeFX)
logger.Info("Duplicate FX request (idempotency)", logger.Info("Duplicate FX request (idempotency)",
zap.String("existingEntryID", existingEntry.GetID().Hex())) zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{ return &ledgerv1.PostResponse{
@@ -244,15 +244,15 @@ func (s *Service) fxResponder(_ context.Context, req *ledgerv1.FXRequest) gsresp
}) })
if err != nil { if err != nil {
recordJournalEntryError("fx", "transaction_failed") recordJournalEntryError(journalEntryTypeFX, journalEntryErrorTransactionFailed)
return nil, err return nil, err
} }
fromAmountFloat, _ := fromAmount.Float64() fromAmountFloat, _ := fromAmount.Float64()
toAmountFloat, _ := toAmount.Float64() toAmountFloat, _ := toAmount.Float64()
recordTransactionAmount(req.FromMoney.Currency, "fx", fromAmountFloat) recordTransactionAmount(req.FromMoney.Currency, journalEntryTypeFX, fromAmountFloat)
recordTransactionAmount(req.ToMoney.Currency, "fx", toAmountFloat) recordTransactionAmount(req.ToMoney.Currency, journalEntryTypeFX, toAmountFloat)
recordJournalEntry("fx", "success", 0) recordJournalEntry(journalEntryTypeFX, journalEntryStatusSuccess, 0)
return result.(*ledgerv1.PostResponse), nil return result.(*ledgerv1.PostResponse), nil
} }
} }

View File

@@ -86,7 +86,7 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq
// Check for duplicate idempotency key // Check for duplicate idempotency key
existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey) existingEntry, err := s.storage.JournalEntries().GetByIdempotencyKey(ctx, orgRef, req.IdempotencyKey)
if err == nil && existingEntry != nil { if err == nil && existingEntry != nil {
recordDuplicateRequest("transfer") recordDuplicateRequest(journalEntryTypeTransfer)
logger.Info("Duplicate transfer request (idempotency)", logger.Info("Duplicate transfer request (idempotency)",
zap.String("existingEntryID", existingEntry.GetID().Hex())) zap.String("existingEntryID", existingEntry.GetID().Hex()))
return &ledgerv1.PostResponse{ return &ledgerv1.PostResponse{
@@ -246,13 +246,13 @@ func (s *Service) transferResponder(_ context.Context, req *ledgerv1.TransferReq
}) })
if err != nil { if err != nil {
recordJournalEntryError("transfer", "failed") recordJournalEntryError(journalEntryTypeTransfer, journalEntryErrorFailed)
return nil, err return nil, err
} }
amountFloat, _ := transferAmount.Float64() amountFloat, _ := transferAmount.Float64()
recordTransactionAmount(req.Money.Currency, "transfer", amountFloat) recordTransactionAmount(req.Money.Currency, journalEntryTypeTransfer, amountFloat)
recordJournalEntry("transfer", "success", 0) recordJournalEntry(journalEntryTypeTransfer, journalEntryStatusSuccess, 0)
return result.(*ledgerv1.PostResponse), nil return result.(*ledgerv1.PostResponse), nil
} }
} }

View File

@@ -77,7 +77,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.
initMetrics() initMetrics()
service := &Service{ service := &Service{
logger: logger.Named("ledger"), logger: logger.Named("service"),
storage: repo, storage: repo,
producer: prod, producer: prod,
msgCfg: msgCfg, 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) { func (s *Service) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
start := time.Now() start := time.Now()
defer func() { defer func() {
recordJournalEntry("credit", "attempted", time.Since(start).Seconds()) recordJournalEntry(journalEntryTypeCredit, journalEntryStatusAttempted, time.Since(start).Seconds())
}() }()
responder := s.postCreditResponder(ctx, req) logger := s.logger.With(zap.String("operation", discovery.OperationLedgerCredit))
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("credit", "not_implemented")
}
logger := s.logger.With(zap.String("operation", "credit"))
if req != nil { if req != nil {
logger = logger.With( logger = logger.With(
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), 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)) 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 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) { func (s *Service) PostExternalCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) {
start := time.Now() start := time.Now()
defer func() { defer func() {
recordJournalEntry("credit", "attempted", time.Since(start).Seconds()) recordJournalEntry(journalEntryTypeCredit, journalEntryStatusAttempted, time.Since(start).Seconds())
}() }()
responder := s.postExternalCreditResponder(ctx, req) logger := s.logger.With(zap.String("operation", discovery.OperationExternalCredit))
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("credit", "failed")
}
logger := s.logger.With(zap.String("operation", "external_credit"))
if req != nil { if req != nil {
logger = logger.With( logger = logger.With(
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), 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())) 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 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) { func (s *Service) PostDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
start := time.Now() start := time.Now()
defer func() { defer func() {
recordJournalEntry("debit", "attempted", time.Since(start).Seconds()) recordJournalEntry(journalEntryTypeDebit, journalEntryStatusAttempted, time.Since(start).Seconds())
}() }()
responder := s.postDebitResponder(ctx, req) logger := s.logger.With(zap.String("operation", discovery.OperationLedgerDebit))
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("debit", "failed")
}
logger := s.logger.With(zap.String("operation", "debit"))
if req != nil { if req != nil {
logger = logger.With( logger = logger.With(
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), 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)) 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 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) { func (s *Service) PostExternalDebitWithCharges(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) {
start := time.Now() start := time.Now()
defer func() { defer func() {
recordJournalEntry("debit", "attempted", time.Since(start).Seconds()) recordJournalEntry(journalEntryTypeDebit, journalEntryStatusAttempted, time.Since(start).Seconds())
}() }()
responder := s.postExternalDebitResponder(ctx, req) logger := s.logger.With(zap.String("operation", discovery.OperationExternalDebit))
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("debit", "failed")
}
logger := s.logger.With(zap.String("operation", "external_debit"))
if req != nil { if req != nil {
logger = logger.With( logger = logger.With(
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), 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())) 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 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) { func (s *Service) TransferInternal(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) {
start := time.Now() start := time.Now()
defer func() { defer func() {
recordJournalEntry("transfer", "attempted", time.Since(start).Seconds()) recordJournalEntry(journalEntryTypeTransfer, journalEntryStatusAttempted, time.Since(start).Seconds())
}() }()
responder := s.transferResponder(ctx, req) logger := s.logger.With(zap.String("operation", discovery.OperationLedgerTransfer))
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("transfer", "failed")
}
logger := s.logger.With(zap.String("operation", "transfer"))
if req != nil { if req != nil {
logger = logger.With( logger = logger.With(
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), 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())) 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 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) { func (s *Service) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
start := time.Now() start := time.Now()
defer func() { defer func() {
recordJournalEntry("fx", "attempted", time.Since(start).Seconds()) recordJournalEntry(journalEntryTypeFX, journalEntryStatusAttempted, time.Since(start).Seconds())
}() }()
responder := s.fxResponder(ctx, req) logger := s.logger.With(zap.String("operation", discovery.OperationLedgerFX))
resp, err := responder(ctx)
if err != nil {
recordJournalEntryError("fx", "failed")
}
logger := s.logger.With(zap.String("operation", "fx"))
if req != nil { if req != nil {
logger = logger.With( logger = logger.With(
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), 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)) 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 return resp, err
} }
@@ -365,23 +377,42 @@ func (s *Service) GetJournalEntry(ctx context.Context, req *ledgerv1.GetEntryReq
return responder(ctx) 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 { if logger == nil {
return return
} }
if err != nil { logger.Debug("Ledger operation execution started", zap.String("operation_name", op))
logger.Warn(fmt.Sprintf("ledger %s failed", op), zap.Error(err)) }
func (s *Service) logLedgerOperation(op string, logger mlogger.Logger, resp *ledgerv1.PostResponse, err error, duration time.Duration) {
if logger == nil {
return return
} }
entryRef := "" entryRef := ""
if resp != nil { if resp != nil {
entryRef = strings.TrimSpace(resp.GetJournalEntryRef()) entryRef = strings.TrimSpace(resp.GetJournalEntryRef())
} }
if entryRef == "" { status := "succeeded"
logger.Info(fmt.Sprintf("ledger %s posted", op)) 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 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() { func (s *Service) Shutdown() {
@@ -402,7 +433,7 @@ func (s *Service) startDiscoveryAnnouncer() {
} }
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "LEDGER", Service: "LEDGER",
Operations: []string{"balance.read", "ledger.debit", "ledger.credit", "external.credit", "external.debit"}, Operations: discovery.LedgerServiceOperations(),
InvokeURI: s.invokeURI, InvokeURI: s.invokeURI,
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }
@@ -428,8 +459,7 @@ func (s *Service) startOutboxReliableProducer() error {
} }
s.outbox.producer = reliableProducer s.outbox.producer = reliableProducer
if s.outbox.producer == nil || s.producer == nil { if s.outbox.producer == nil || s.producer == nil {
s.logger.Info("Outbox reliable publisher disabled", s.logger.Info("Outbox reliable publisher disabled", zap.Bool("enabled", settings.Enabled))
zap.Bool("enabled", settings.Enabled))
return return
} }
s.logger.Info("Outbox reliable publisher configured", s.logger.Info("Outbox reliable publisher configured",

View File

@@ -120,7 +120,7 @@ func CreateAPI(a api.API) (*NotificationAPI, error) {
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "NOTIFICATIONS", Service: "NOTIFICATIONS",
Operations: []string{"notify.send"}, Operations: []string{discovery.OperationNotifySend},
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }
p.announcer = discovery.NewAnnouncer(p.logger, a.Register().Producer(), string(mservice.Notifications), announce) p.announcer = discovery.NewAnnouncer(p.logger, a.Register().Producer(), string(mservice.Notifications), announce)

View File

@@ -53,8 +53,8 @@ func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) {
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "PAYMENTS_METHODS", Service: "PAYMENTS_METHODS",
Operations: []string{ Operations: []string{
"payment_methods.manage", discovery.OperationPaymentMethodsManage,
"payment_methods.read", discovery.OperationPaymentMethodsRead,
}, },
InvokeURI: invokeURI, InvokeURI: invokeURI,
Version: appversion.Create().Short(), Version: appversion.Create().Short(),

View File

@@ -1,18 +1,14 @@
package serverimp package serverimp
import ( import (
oracleclient "github.com/tech/sendico/fx/oracle/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client" mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
) )
type orchestratorDeps struct { type orchestratorDeps struct {
feesClient feesv1.FeeEngineClient
ledgerClient ledgerclient.Client ledgerClient ledgerclient.Client
mntxClient mntxclient.Client mntxClient mntxclient.Client
oracleClient oracleclient.Client
gatewayInvokeResolver orchestrator.GatewayInvokeResolver gatewayInvokeResolver orchestrator.GatewayInvokeResolver
} }
@@ -26,9 +22,7 @@ func (i *Imp) initDependencies(_ *config) *orchestratorDeps {
} }
i.discoveryClients = newDiscoveryClientResolver(i.logger, i.discoveryReg) i.discoveryClients = newDiscoveryClientResolver(i.logger, i.discoveryReg)
deps.feesClient = &discoveryFeeClient{resolver: i.discoveryClients}
deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients} deps.ledgerClient = &discoveryLedgerClient{resolver: i.discoveryClients}
deps.oracleClient = &discoveryOracleClient{resolver: i.discoveryClients}
deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients} deps.mntxClient = &discoveryMntxClient{resolver: i.discoveryClients}
deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients} deps.gatewayInvokeResolver = discoveryGatewayInvokeResolver{resolver: i.discoveryClients}
return deps return deps
@@ -39,9 +33,6 @@ func (i *Imp) buildServiceOptions(cfg *config, deps *orchestratorDeps) []orchest
return nil return nil
} }
opts := []orchestrator.Option{} opts := []orchestrator.Option{}
if deps.feesClient != nil {
opts = append(opts, orchestrator.WithFeeEngine(deps.feesClient, cfg.Fees.callTimeout()))
}
if deps.ledgerClient != nil { if deps.ledgerClient != nil {
opts = append(opts, orchestrator.WithLedgerClient(deps.ledgerClient)) 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.WithMntxGateway(deps.mntxClient))
} }
opts = append(opts, orchestrator.WithMaxFXQuoteTTLMillis(cfg.maxFXQuoteTTLMillis()))
if deps.gatewayInvokeResolver != nil { if deps.gatewayInvokeResolver != nil {
opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver)) opts = append(opts, orchestrator.WithGatewayInvokeResolver(deps.gatewayInvokeResolver))
} }
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 { if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes)) 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 { if registry := buildGatewayRegistry(i.logger, cfg.GatewayInstances, i.discoveryReg); registry != nil {
opts = append(opts, orchestrator.WithGatewayRegistry(registry)) opts = append(opts, orchestrator.WithGatewayRegistry(registry))
} }

View File

@@ -33,7 +33,7 @@ func (i *Imp) initDiscovery(cfg *config) {
} }
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "PAYMENTS_ORCHESTRATOR", Service: "PAYMENTS_ORCHESTRATOR",
Operations: []string{"payment.initiate"}, Operations: []string{discovery.OperationPaymentInitiate},
InvokeURI: cfg.GRPC.DiscoveryInvokeURI(), InvokeURI: cfg.GRPC.DiscoveryInvokeURI(),
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }

View File

@@ -32,6 +32,11 @@ var (
ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)}
oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)}
mntxServiceNames = []string{"CARD_RAIL_GATEWAY", string(mservice.MntxGateway)} 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 { type discoveryEndpoint struct {
@@ -109,27 +114,27 @@ func (r *discoveryClientResolver) Close() {
} }
func (r *discoveryClientResolver) FeesAvailable() bool { func (r *discoveryClientResolver) FeesAvailable() bool {
_, ok := r.findEntry("fees", feesServiceNames, "", "") _, ok := r.findEntry("fees", feesServiceNames, "", "", feesRequiredOps)
return ok return ok
} }
func (r *discoveryClientResolver) LedgerAvailable() bool { func (r *discoveryClientResolver) LedgerAvailable() bool {
_, ok := r.findEntry("ledger", ledgerServiceNames, "", "") _, ok := r.findEntry("ledger", ledgerServiceNames, "", "", ledgerRequiredOps)
return ok return ok
} }
func (r *discoveryClientResolver) OracleAvailable() bool { func (r *discoveryClientResolver) OracleAvailable() bool {
_, ok := r.findEntry("oracle", oracleServiceNames, "", "") _, ok := r.findEntry("oracle", oracleServiceNames, "", "", oracleRequiredOps)
return ok return ok
} }
func (r *discoveryClientResolver) MntxAvailable() bool { func (r *discoveryClientResolver) MntxAvailable() bool {
_, ok := r.findEntry("mntx", mntxServiceNames, "", "") _, ok := r.findEntry("mntx", mntxServiceNames, "", "", mntxRequiredOps)
return ok return ok
} }
func (r *discoveryClientResolver) FeesClient(ctx context.Context) (feesv1.FeeEngineClient, error) { 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 { if !ok {
return nil, merrors.NoData("discovery: fees service unavailable") 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) { 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 { if !ok {
return nil, merrors.NoData("discovery: ledger service unavailable") 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) { 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 { if !ok {
return nil, merrors.NoData("discovery: oracle service unavailable") 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) { 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 { if !ok {
return nil, merrors.NoData("discovery: mntx service unavailable") return nil, merrors.NoData("discovery: mntx service unavailable")
} }
@@ -316,14 +321,19 @@ func (r *discoveryClientResolver) PaymentGatewayClient(ctx context.Context, invo
return client, nil 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 { if r == nil || r.registry == nil {
r.logMissing(key, "discovery registry unavailable", "", nil) r.logMissing(key, "discovery registry unavailable", "", nil)
return nil, false return nil, false
} }
type discoveryMatch struct {
entry discovery.RegistryEntry
opMatch bool
}
entries := r.registry.List(time.Now(), true) entries := r.registry.List(time.Now(), true)
matches := make([]discovery.RegistryEntry, 0) matches := make([]discoveryMatch, 0)
for _, entry := range entries { for _, entry := range entries {
if !matchesService(entry.Service, services) { if !matchesService(entry.Service, services) {
continue continue
@@ -334,7 +344,10 @@ func (r *discoveryClientResolver) findEntry(key string, services []string, rail
if network != "" && !strings.EqualFold(strings.TrimSpace(entry.Network), network) { if network != "" && !strings.EqualFold(strings.TrimSpace(entry.Network), network) {
continue continue
} }
matches = append(matches, entry) matches = append(matches, discoveryMatch{
entry: entry,
opMatch: discovery.HasAnyOperation(entry.Operations, requiredOps),
})
} }
if len(matches) == 0 { 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 { sort.Slice(matches, func(i, j int) bool {
if matches[i].RoutingPriority != matches[j].RoutingPriority { if matches[i].opMatch != matches[j].opMatch {
return matches[i].RoutingPriority > matches[j].RoutingPriority return matches[i].opMatch
} }
if matches[i].ID != matches[j].ID { if matches[i].entry.RoutingPriority != matches[j].entry.RoutingPriority {
return matches[i].ID < matches[j].ID 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) entryKey := discoveryEntryKey(entry)
r.logSelection(key, entryKey, entry) r.logSelection(key, entryKey, entry)
return &entry, true return &entry, true

View File

@@ -134,6 +134,22 @@ func (c *discoveryLedgerClient) PostDebitWithCharges(ctx context.Context, req *l
return client.PostDebitWithCharges(ctx, req) 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) { func (c *discoveryLedgerClient) ApplyFXWithCharges(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
client, err := c.resolver.LedgerClient(ctx) client, err := c.resolver.LedgerClient(ctx)
if err != nil { if err != nil {

View File

@@ -62,17 +62,42 @@ func (e *gatewayLedgerExecutor) ExecuteLedger(ctx context.Context, req sexec.Ste
return nil, err return nil, err
} }
transferReq := &ledgerv1.TransferRequest{ idempotencyKey := ledgerStepIdempotencyKey(req.Payment, req.Step)
IdempotencyKey: ledgerStepIdempotencyKey(req.Payment, req.Step), organizationRef := req.Payment.OrganizationRef.Hex()
OrganizationRef: req.Payment.OrganizationRef.Hex(), description := ledgerDescription(req.Step)
Money: amount, metadata := ledgerTransferMetadata(req.Payment, req.Step, roles)
Description: ledgerDescription(req.Step),
Metadata: ledgerTransferMetadata(req.Payment, req.Step, roles),
FromRole: ledgerRoleToProto(roles.from),
ToRole: ledgerRoleToProto(roles.to),
}
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 { if err != nil {
return nil, err return nil, err
} }

View File

@@ -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) { func TestGatewayLedgerExecutor_ExecuteLedger_FinalizeDebitUsesHoldToTransitAndSettlementAmount(t *testing.T) {
orgID := bson.NewObjectID() orgID := bson.NewObjectID()
payment := testLedgerExecutorPayment(orgID) payment := testLedgerExecutorPayment(orgID)

View File

@@ -52,7 +52,7 @@ func (i *Imp) startDiscoveryAnnouncer(cfg *config, producer msg.Producer) {
announce := discovery.Announcement{ announce := discovery.Announcement{
Service: "PAYMENTS_QUOTATION", Service: "PAYMENTS_QUOTATION",
Operations: []string{"payment.quote", "payment.multiquote"}, Operations: []string{discovery.OperationPaymentQuote, discovery.OperationPaymentMultiQuote},
InvokeURI: invokeURI, InvokeURI: invokeURI,
Version: appversion.Create().Short(), Version: appversion.Create().Short(),
} }

View File

@@ -197,18 +197,6 @@ func (r *managedWalletNetworkResolver) listDiscoveredGatewayCandidates(ctx conte
return candidates, nil 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) { func managedWalletAssetFromResponse(resp *chainv1.GetManagedWalletResponse) (*paymenttypes.Asset, error) {
wallet := resp.GetWallet() wallet := resp.GetWallet()
if wallet == nil || wallet.GetAsset() == nil { if wallet == nil || wallet.GetAsset() == nil {

View File

@@ -8,7 +8,3 @@ import (
func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) {
return plan.RailFromEndpoint(endpoint, attrs, isSource) return plan.RailFromEndpoint(endpoint, attrs, isSource)
} }
func resolveRouteNetwork(attrs map[string]string, sourceNetwork, destNetwork string) (string, error) {
return plan.ResolveRouteNetwork(attrs, sourceNetwork, destNetwork)
}

View File

@@ -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) { func (s *Service) quoteFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.deps.fees.available() { 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() intent := req.GetIntent()
amount := cloneProtoMoney(baseAmount) 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) { func (s *Service) quoteConversionFees(ctx context.Context, orgRef string, req *quoteRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.deps.fees.available() { 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() intent := req.GetIntent()
amount := cloneProtoMoney(baseAmount) amount := cloneProtoMoney(baseAmount)

View File

@@ -2,6 +2,7 @@ package quotation
import ( import (
"context" "context"
"errors"
"strings" "strings"
"testing" "testing"
@@ -152,9 +153,89 @@ func TestBuildPaymentQuote_DoesNotRequestConversionFeesForManagedWalletToLedger(
} }
} }
func TestBuildPaymentQuote_ReturnsErrorWhenFeeEngineUnavailable(t *testing.T) {
svc := NewService(zap.NewNop(), nil)
req := &quoteRequest{
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 := &quoteRequest{
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 := &quoteRequest{
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 { type stubFeeEngineClient struct {
precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse precomputeByOrigin map[string]*feesv1.PrecomputeFeesResponse
precomputeReqs []*feesv1.PrecomputeFeesRequest precomputeErrByOrigin map[string]error
precomputeReqs []*feesv1.PrecomputeFeesRequest
} }
func (s *stubFeeEngineClient) QuoteFees(context.Context, *feesv1.QuoteFeesRequest, ...grpc.CallOption) (*feesv1.QuoteFeesResponse, error) { 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()) originType := strings.TrimSpace(in.GetIntent().GetOriginType())
if err := s.precomputeErrByOrigin[originType]; err != nil {
return nil, err
}
resp, ok := s.precomputeByOrigin[originType] resp, ok := s.precomputeByOrigin[originType]
if !ok || resp == nil { if !ok || resp == nil {
return &feesv1.PrecomputeFeesResponse{}, nil return &feesv1.PrecomputeFeesResponse{}, nil

View File

@@ -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,
}
}

View File

@@ -104,14 +104,20 @@ func ExpandRailOperation(value string) []string {
} }
switch strings.ToLower(strings.TrimSpace(value)) { 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": case "payin", "payin.crypto", "payin.fiat", "payin.card":
return []string{RailOperationExternalDebit} return []string{RailOperationExternalDebit}
case "payout", "payout.crypto", "payout.fiat", "payout.card": case "payout", "payout.crypto", "payout.fiat", "payout.card":
return []string{RailOperationExternalCredit, RailOperationSend} return []string{RailOperationExternalCredit, RailOperationSend}
case "fee.send", "fees.send": case "fee.send", "fees.send", OperationFee:
return []string{RailOperationFee} return []string{RailOperationFee}
case "observe.confirm", "observe_confirm": case OperationObserveConfirm, "observe_confirm":
return []string{RailOperationObserveConfirm} return []string{RailOperationObserveConfirm}
case OperationFXConvert, "fx_convert":
return []string{RailOperationFXConvert}
case "funds.block", "hold.balance", "block": case "funds.block", "hold.balance", "block":
return []string{RailOperationBlock} return []string{RailOperationBlock}
case "funds.release", "release", "unblock": case "funds.release", "release", "unblock":
@@ -147,6 +153,13 @@ func NormalizeRailOperations(values []string) []string {
// CryptoRailGatewayOperations returns canonical operations for crypto rail gateways. // CryptoRailGatewayOperations returns canonical operations for crypto rail gateways.
func CryptoRailGatewayOperations() []string { func CryptoRailGatewayOperations() []string {
return []string{ return []string{
OperationBalanceRead,
OperationSend,
OperationExternalDebit,
OperationExternalCredit,
OperationFee,
OperationObserveConfirm,
// Legacy rail tokens retained for backward compatibility.
RailOperationSend, RailOperationSend,
RailOperationExternalDebit, RailOperationExternalDebit,
RailOperationExternalCredit, RailOperationExternalCredit,
@@ -158,6 +171,10 @@ func CryptoRailGatewayOperations() []string {
// CardPayoutRailGatewayOperations returns canonical operations for card payout gateways. // CardPayoutRailGatewayOperations returns canonical operations for card payout gateways.
func CardPayoutRailGatewayOperations() []string { func CardPayoutRailGatewayOperations() []string {
return []string{ return []string{
OperationSend,
OperationExternalCredit,
OperationObserveConfirm,
// Legacy rail tokens retained for backward compatibility.
RailOperationSend, RailOperationSend,
RailOperationExternalCredit, RailOperationExternalCredit,
RailOperationObserveConfirm, RailOperationObserveConfirm,
@@ -167,6 +184,9 @@ func CardPayoutRailGatewayOperations() []string {
// ProviderSettlementRailGatewayOperations returns canonical operations for settlement gateways. // ProviderSettlementRailGatewayOperations returns canonical operations for settlement gateways.
func ProviderSettlementRailGatewayOperations() []string { func ProviderSettlementRailGatewayOperations() []string {
return []string{ return []string{
OperationFXConvert,
OperationObserveConfirm,
// Legacy rail tokens retained for backward compatibility.
RailOperationFXConvert, RailOperationFXConvert,
RailOperationObserveConfirm, RailOperationObserveConfirm,
} }

View File

@@ -7,14 +7,16 @@ func TestNormalizeRailOperations(t *testing.T) {
"send", "send",
"payout.crypto", "payout.crypto",
"observe.confirm", "observe.confirm",
"external.credit",
"fx.convert",
"unknown", "unknown",
"EXTERNAL_CREDIT",
}) })
want := []string{ want := []string{
RailOperationSend, RailOperationSend,
RailOperationExternalCredit, RailOperationExternalCredit,
RailOperationObserveConfirm, RailOperationObserveConfirm,
RailOperationFXConvert,
} }
if len(got) != len(want) { if len(got) != len(want) {
t.Fatalf("unexpected operations count: got=%d want=%d", len(got), len(want)) t.Fatalf("unexpected operations count: got=%d want=%d", len(got), len(want))

View File

@@ -19,15 +19,8 @@ import (
const ( const (
discoveryBootstrapTimeout = 3 * time.Second discoveryBootstrapTimeout = 3 * time.Second
discoveryBootstrapSender = "server_bootstrap" discoveryBootstrapSender = "server_bootstrap"
discoveryGatewayRailCrypto = "CRYPTO"
defaultClientDialTimeoutSecs = 5 defaultClientDialTimeoutSecs = 5
defaultClientCallTimeoutSecs = 5 defaultClientCallTimeoutSecs = 5
paymentQuoteOperation = "payment.quote"
paymentInitiateOperation = "payment.initiate"
ledgerDebitOperation = "ledger.debit"
ledgerCreditOperation = "ledger.credit"
gatewayReadBalanceOperation = "balance.read"
paymentMethodsReadOperation = "payment_methods.read"
) )
var ( var (
@@ -123,7 +116,7 @@ func (a *APIImp) resolveChainGatewayAddress(gateways []discovery.GatewaySummary)
endpoint, selected, ok := selectGatewayEndpoint( endpoint, selected, ok := selectGatewayEndpoint(
gateways, gateways,
cfg.DefaultAsset.Chain, cfg.DefaultAsset.Chain,
[]string{gatewayReadBalanceOperation}, []string{discovery.OperationBalanceRead},
) )
if !ok { if !ok {
return return
@@ -146,7 +139,7 @@ func (a *APIImp) resolveLedgerAddress(services []discovery.ServiceSummary) {
endpoint, selected, ok := selectServiceEndpoint( endpoint, selected, ok := selectServiceEndpoint(
services, services,
ledgerDiscoveryServiceNames, ledgerDiscoveryServiceNames,
[]string{ledgerDebitOperation, ledgerCreditOperation}, discovery.LedgerServiceOperations(),
) )
if !ok { if !ok {
return return
@@ -170,7 +163,7 @@ func (a *APIImp) resolvePaymentOrchestratorAddress(services []discovery.ServiceS
endpoint, selected, ok := selectServiceEndpoint( endpoint, selected, ok := selectServiceEndpoint(
services, services,
paymentOrchestratorDiscoveryServiceNames, paymentOrchestratorDiscoveryServiceNames,
[]string{paymentInitiateOperation}, []string{discovery.OperationPaymentInitiate},
) )
if !ok { if !ok {
return false, discoveryEndpoint{} return false, discoveryEndpoint{}
@@ -196,7 +189,7 @@ func (a *APIImp) resolvePaymentQuotationAddress(services []discovery.ServiceSumm
endpoint, selected, ok := selectServiceEndpoint( endpoint, selected, ok := selectServiceEndpoint(
services, services,
paymentQuotationDiscoveryServiceNames, paymentQuotationDiscoveryServiceNames,
[]string{paymentQuoteOperation}, []string{discovery.OperationPaymentQuote},
) )
if !ok { if !ok {
cfg := a.config.PaymentQuotation cfg := a.config.PaymentQuotation
@@ -229,7 +222,7 @@ func (a *APIImp) resolvePaymentMethodsAddress(services []discovery.ServiceSummar
endpoint, selected, ok := selectServiceEndpoint( endpoint, selected, ok := selectServiceEndpoint(
services, services,
paymentMethodsDiscoveryServiceNames, paymentMethodsDiscoveryServiceNames,
[]string{paymentMethodsReadOperation}, []string{discovery.OperationPaymentMethodsRead},
) )
if !ok { if !ok {
return return
@@ -269,7 +262,7 @@ func selectServiceEndpoint(services []discovery.ServiceSummary, serviceNames []s
selections = append(selections, serviceSelection{ selections = append(selections, serviceSelection{
service: svc, service: svc,
endpoint: endpoint, endpoint: endpoint,
opMatch: hasAnyOperation(svc.Ops, requiredOps), opMatch: discovery.HasAnyOperation(svc.Ops, requiredOps),
nameRank: nameRank, nameRank: nameRank,
}) })
} }
@@ -302,7 +295,7 @@ func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork
if !gateway.Healthy { if !gateway.Healthy {
continue continue
} }
if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discoveryGatewayRailCrypto) { if !strings.EqualFold(strings.TrimSpace(gateway.Rail), discovery.RailCrypto) {
continue continue
} }
if strings.TrimSpace(gateway.InvokeURI) == "" { if strings.TrimSpace(gateway.InvokeURI) == "" {
@@ -316,7 +309,7 @@ func selectGatewayEndpoint(gateways []discovery.GatewaySummary, preferredNetwork
gateway: gateway, gateway: gateway,
endpoint: endpoint, endpoint: endpoint,
networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork), networkMatch: preferredNetwork != "" && strings.EqualFold(strings.TrimSpace(gateway.Network), preferredNetwork),
opMatch: hasAnyOperation(gateway.Ops, requiredOps), opMatch: discovery.HasAnyOperation(gateway.Ops, requiredOps),
}) })
} }
if len(selections) == 0 { if len(selections) == 0 {
@@ -412,24 +405,6 @@ func serviceRank(service string, names []string) (int, bool) {
return 0, false 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 { func ensureLedgerConfig(cfg *eapi.Config) *eapi.LedgerConfig {
if cfg == nil { if cfg == nil {
return nil return nil

View File

@@ -27,7 +27,7 @@ import (
const ( const (
documentsServiceName = "BILLING_DOCUMENTS" documentsServiceName = "BILLING_DOCUMENTS"
documentsOperationGet = "documents.get" documentsOperationGet = discovery.OperationDocumentsGet
documentsDialTimeout = 5 * time.Second documentsDialTimeout = 5 * time.Second
documentsCallTimeout = 10 * time.Second documentsCallTimeout = 10 * time.Second
) )

View File

@@ -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/asset_type_field.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/description.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/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/managed_wallet_fields.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/name.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/name.dart';
import 'package:pweb/pages/dashboard/buttons/balance/add/owner.dart'; import 'package:pweb/pages/dashboard/buttons/balance/add/owner.dart';

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/currency.dart';
import 'package:pshared/utils/currency.dart';
DropdownMenuItem<Currency> currencyItem(Currency currency) => DropdownMenuItem(
value: currency,
child: Text(currencyCodeToString(currency)),
);

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pshared/models/currency.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/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/utils/text_field_styles.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -24,10 +24,8 @@ class LedgerFields extends StatelessWidget {
initialValue: currency, initialValue: currency,
decoration: getInputDecoration(context, AppLocalizations.of(context)!.currency, true), decoration: getInputDecoration(context, AppLocalizations.of(context)!.currency, true),
items: [ items: [
DropdownMenuItem( currencyItem(ledgerCurrencyDefault),
value: ledgerCurrencyDefault, currencyItem(managedCurrencyDefault),
child: Text(currencyCodeToString(ledgerCurrencyDefault)),
),
], ],
onChanged: onCurrencyChanged, onChanged: onCurrencyChanged,
); );