ledger autoresolution #327

Merged
tech merged 1 commits from ledger-326 into main 2026-01-26 03:49:20 +00:00
3 changed files with 90 additions and 13 deletions
Showing only changes of commit 81ba682d18 - Show all commits

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model" "github.com/tech/sendico/billing/fees/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -49,12 +50,19 @@ func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.Obje
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) { } else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex())) r.logger.Warn("Failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
return nil, nil, selErr return nil, nil, selErr
} }
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex())) r.logger.Debug(
"No matching rule in org plan; falling back to global",
mzap.ObjRef("org_ref", *orgRef),
zap.String("trigger", string(trigger)),
zap.Time("booked_at", at),
zap.Any("attributes", attrs),
zapFieldsForPlan(plan)...,
)
} else if !errors.Is(err, storage.ErrFeePlanNotFound) { } else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex())) r.logger.Warn("Failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
return nil, nil, err return nil, nil, err
} }
} }
@@ -62,16 +70,23 @@ func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.Obje
plan, err := r.getGlobalPlan(ctx, at) plan, err := r.getGlobalPlan(ctx, at)
if err != nil { if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) { if errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Debug("No applicable global fee plan found", zap.String("trigger", string(trigger)),
zap.Time("booked_at", at), zap.Any("attributes", attrs),
)
return nil, nil, merrors.NoData("fees: no applicable fee rule found") return nil, nil, merrors.NoData("fees: no applicable fee rule found")
} }
r.logger.Warn("failed resolving global fee plan", zap.Error(err)) r.logger.Warn("Failed resolving global fee plan", zap.Error(err))
return nil, nil, err return nil, nil, err
} }
rule, err := selectRule(plan, trigger, at, attrs) rule, err := selectRule(plan, trigger, at, attrs)
if err != nil { if err != nil {
if !errors.Is(err, ErrNoFeeRuleFound) { if !errors.Is(err, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule in global plan", zap.Error(err)) r.logger.Warn("Failed selecting rule in global plan", zap.Error(err))
} else {
r.logger.Debug("No matching rule in global plan", zap.String("trigger", string(trigger)),
zap.Time("booked_at", at), zap.Any("attributes", attrs), zapFieldsForPlan(plan)...,
)
} }
return nil, nil, err return nil, nil, err
} }
@@ -146,3 +161,29 @@ func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool
} }
return true return true
} }
func zapFieldsForPlan(plan *model.FeePlan) []zap.Field {
if plan == nil {
return []zap.Field{zap.Bool("plan_present", false)}
}
fields := []zap.Field{
zap.Bool("plan_present", true),
zap.Bool("plan_active", plan.Active),
zap.Time("plan_effective_from", plan.EffectiveFrom),
zap.Int("plan_rules_count", len(plan.Rules)),
}
if plan.EffectiveTo != nil {
fields = append(fields, zap.Time("plan_effective_to", *plan.EffectiveTo))
} else {
fields = append(fields, zap.Bool("plan_effective_to_set", false))
}
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
fields = append(fields, zap.String("plan_org_ref", plan.OrganizationRef.Hex()))
} else {
fields = append(fields, zap.Bool("plan_org_ref_set", false))
}
if plan.GetID() != nil && !plan.GetID().IsZero() {
fields = append(fields, zap.String("plan_id", plan.GetID().Hex()))
}
return fields
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@@ -434,18 +435,18 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes()) plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil { if err != nil {
s.logger.Warn("Failed to resolve fee rule", zap.Error(err))
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found") return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee rule not found: %s", err.Error()))
case errors.Is(err, merrors.ErrDataConflict): case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules") return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee rules: %s", err.Error()))
case errors.Is(err, storage.ErrConflictingFeePlans): case errors.Is(err, storage.ErrConflictingFeePlans):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee plans") return nil, nil, nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("conflicting fee plans: %s", err.Error()))
case errors.Is(err, storage.ErrFeePlanNotFound): case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found") return nil, nil, nil, status.Error(codes.NotFound, fmt.Sprintf("fee plan not found: %s", err.Error()))
default: default:
logger.Warn("failed to resolve fee rule", zap.Error(err)) return nil, nil, nil, status.Error(codes.Internal, fmt.Sprintf("failed to resolve fee rule: %s", err.Error()))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
} }
} }

View File

@@ -81,7 +81,7 @@ func (p *paymentExecutor) postLedgerBlock(ctx context.Context, payment *model.Pa
if err != nil { if err != nil {
return "", err return "", err
} }
blockAccount, err := ledgerBlockAccount(payment) blockAccount, err := p.resolveLedgerBlockAccount(ctx, payment, amount)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -139,7 +139,7 @@ func (p *paymentExecutor) postLedgerRelease(ctx context.Context, payment *model.
if err != nil { if err != nil {
return "", err return "", err
} }
blockAccount, err := ledgerBlockAccount(payment) blockAccount, err := p.resolveLedgerBlockAccount(ctx, payment, amount)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -441,6 +441,23 @@ func setLedgerAccountAttributes(payment *model.Payment, accountRef string) {
} }
} }
func setLedgerBlockAccountAttributes(payment *model.Payment, accountRef string) {
if payment == nil || strings.TrimSpace(accountRef) == "" {
return
}
if payment.Intent.Attributes == nil {
payment.Intent.Attributes = map[string]string{}
}
if attributeLookup(payment.Intent.Attributes,
"ledger_block_account_ref",
"ledgerBlockAccountRef",
"ledger_hold_account_ref",
"ledgerHoldAccountRef",
) == "" {
payment.Intent.Attributes["ledger_block_account_ref"] = accountRef
}
}
func ledgerDebitAccount(payment *model.Payment) (string, string, error) { func ledgerDebitAccount(payment *model.Payment) (string, string, error) {
if payment == nil { if payment == nil {
return "", "", merrors.InvalidArgument("ledger: payment is required") return "", "", merrors.InvalidArgument("ledger: payment is required")
@@ -483,6 +500,24 @@ func ledgerBlockAccount(payment *model.Payment) (string, error) {
return "", merrors.InvalidArgument("ledger: block account is required") return "", merrors.InvalidArgument("ledger: block account is required")
} }
func (p *paymentExecutor) resolveLedgerBlockAccount(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) {
if payment == nil {
return "", merrors.InvalidArgument("ledger: payment is required")
}
if amount == nil || strings.TrimSpace(amount.GetCurrency()) == "" {
return "", merrors.InvalidArgument("ledger: amount is required")
}
if ref, err := ledgerBlockAccount(payment); err == nil && strings.TrimSpace(ref) != "" {
return ref, nil
}
account, err := p.resolveOrgOwnedLedgerAccount(ctx, payment, amount)
if err != nil {
return "", err
}
setLedgerBlockAccountAttributes(payment, account)
return account, nil
}
func ledgerBlockAccountIfConfirmed(payment *model.Payment) string { func ledgerBlockAccountIfConfirmed(payment *model.Payment) string {
if payment == nil { if payment == nil {
return "" return ""