7 Commits

Author SHA1 Message Date
Arseni
32cccc7895 Added PostHog 2025-12-09 21:02:37 +03:00
81d2db394b Merge pull request 'removed auto-generated code' (#51) from interface-#50 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #51
2025-12-09 17:36:05 +00:00
Stephan D
8d6a302cb8 removed auto-generated code 2025-12-09 18:35:42 +01:00
0e48d2a318 Merge pull request 'double-sided quotation + fixed tests' (#49) from quote-#45 into main
Some checks failed
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #49
2025-12-09 16:46:02 +00:00
Stephan D
32653e11fc double-sided quotation + fixed tests 2025-12-09 17:45:29 +01:00
a24ead2c36 Merge pull request 'quotation bff' (#46) from quote-#45 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
Reviewed-on: #46
2025-12-09 15:30:46 +00:00
Stephan D
ce59cb1b26 quotation bff 2025-12-09 16:29:29 +01:00
84 changed files with 3054 additions and 733 deletions

View File

@@ -18,7 +18,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -44,8 +44,8 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -176,15 +176,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -2,448 +2,16 @@ package fees
import (
"context"
"errors"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
// Implementation lives under internal/service/fees/internal/calculator.
type Calculator interface {
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
}
// CalculationResult contains derived fee lines and audit metadata.
type CalculationResult struct {
Lines []*feesv1.DerivedPostingLine
Applied []*feesv1.AppliedRule
FxUsed *feesv1.FXUsed
}
// quoteCalculator is the default Calculator implementation.
type fxOracle interface {
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}
type quoteCalculator struct {
logger mlogger.Logger
oracle fxOracle
}
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
return &quoteCalculator{
logger: logger.Named("calculator"),
oracle: oracle,
}
}
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
if plan == nil {
return nil, merrors.InvalidArgument("plan is required")
}
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
trigger := convertTrigger(intent.GetTrigger())
if trigger == model.TriggerUnspecified {
return nil, merrors.InvalidArgument("unsupported trigger")
}
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
if err != nil {
return nil, merrors.InvalidArgument("invalid base amount")
}
if baseAmount.Sign() < 0 {
return nil, merrors.InvalidArgument("base amount cannot be negative")
}
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
rules := make([]model.FeeRule, len(plan.Rules))
copy(rules, plan.Rules)
sort.SliceStable(rules, func(i, j int) bool {
if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID
}
return rules[i].Priority < rules[j].Priority
})
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
applied := make([]*feesv1.AppliedRule, 0, len(rules))
planID := ""
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
planID = planRef.Hex()
}
for _, rule := range rules {
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
continue
}
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
if ledgerAccountRef == "" {
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
continue
}
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil {
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
}
continue
}
if amount.Sign() == 0 {
continue
}
currency := intent.GetBaseAmount().GetCurrency()
if override := strings.TrimSpace(rule.Currency); override != "" {
currency = override
}
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
}
meta := map[string]string{
"fee_rule_id": rule.RuleID,
}
if planID != "" {
meta["fee_plan_id"] = planID
}
if rule.Metadata != nil {
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
meta["tax_code"] = taxCode
}
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
meta["tax_rate"] = taxRate
}
}
lines = append(lines, &feesv1.DerivedPostingLine{
LedgerAccountRef: ledgerAccountRef,
Money: &moneyv1.Money{
Amount: dmath.FormatRat(amount, scale),
Currency: currency,
},
LineType: mapLineType(rule.LineType),
Side: entrySide,
Meta: meta,
})
applied = append(applied, &feesv1.AppliedRule{
RuleId: rule.RuleID,
RuleVersion: planID,
Formula: rule.Formula,
Rounding: mapRoundingMode(rule.Rounding),
TaxCode: metadataValue(rule.Metadata, "tax_code"),
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
Parameters: cloneStringMap(rule.Metadata),
})
}
var fxUsed *feesv1.FXUsed
if trigger == model.TriggerFXConversion && c.oracle != nil {
fxUsed = c.buildFxUsed(ctx, intent)
}
return &CalculationResult{
Lines: lines,
Applied: applied,
FxUsed: fxUsed,
}, nil
}
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
scale, err := resolveRuleScale(rule, baseScale)
if err != nil {
return nil, 0, err
}
result := new(big.Rat)
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
percentageRat, perr := dmath.RatFromString(percentage)
if perr != nil {
return nil, 0, merrors.InvalidArgument("invalid percentage")
}
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
}
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
fixedRat, ferr := dmath.RatFromString(fixed)
if ferr != nil {
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
}
result = dmath.AddRat(result, fixedRat)
}
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
minRat, merr := dmath.RatFromString(minStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
}
if dmath.CmpRat(result, minRat) < 0 {
result = new(big.Rat).Set(minRat)
}
}
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
maxRat, merr := dmath.RatFromString(maxStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
result = new(big.Rat).Set(maxRat)
}
}
if result.Sign() < 0 {
result = new(big.Rat).Abs(result)
}
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
if rerr != nil {
return nil, 0, rerr
}
return rounded, scale, nil
}
const (
attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency"
attrFxProvider = "fx_provider"
attrFxSide = "fx_side"
attrFxRateOverride = "fx_rate"
)
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
if intent == nil || c.oracle == nil {
return nil
}
attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" {
return nil
}
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
provider := strings.TrimSpace(attrs[attrFxProvider])
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
Meta: oracleclient.RequestMeta{},
Pair: pair,
Provider: provider,
})
if err != nil {
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
return nil
}
if snapshot == nil {
return nil
}
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
if rateValue == "" {
rateValue = snapshot.Mid
}
if rateValue == "" {
rateValue = snapshot.Ask
}
if rateValue == "" {
rateValue = snapshot.Bid
}
return &feesv1.FXUsed{
Pair: pair,
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
Rate: &moneyv1.Decimal{Value: rateValue},
AsofUnixMs: snapshot.AsOf.UnixMilli(),
Provider: snapshot.Provider,
RateRef: snapshot.RateRef,
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
}
}
func parseFxSide(value string) fxv1.Side {
switch strings.ToLower(value) {
case "buy_base", "buy_base_sell_quote", "buy":
return fxv1.Side_BUY_BASE_SELL_QUOTE
case "sell_base", "sell_base_buy_quote", "sell":
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func inferScale(amount string) uint32 {
value := strings.TrimSpace(amount)
if value == "" {
return 0
}
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
value = value[:idx]
}
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
value = value[1:]
}
if dot := strings.IndexByte(value, '.'); dot >= 0 {
return uint32(len(value[dot+1:]))
}
return 0
}
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
if rule.Trigger != trigger {
return false
}
if rule.EffectiveFrom.After(bookedAt) {
return false
}
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
return false
}
return ruleMatchesAttributes(rule, attributes)
}
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
if rule.Metadata != nil {
for _, field := range []string{"scale", "decimals", "precision"} {
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
return parseScale(field, value)
}
}
}
return fallback, nil
}
func parseScale(field, value string) (uint32, error) {
clean := strings.TrimSpace(value)
if clean == "" {
return 0, merrors.InvalidArgument(field + " is empty")
}
parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil {
return 0, merrors.InvalidArgument("invalid " + field + " value")
}
return uint32(parsed), nil
}
func metadataValue(meta map[string]string, key string) string {
if meta == nil {
return ""
}
return strings.TrimSpace(meta[key])
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
cloned := make(map[string]string, len(src))
for k, v := range src {
cloned[k] = v
}
return cloned
}
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
if len(rule.AppliesTo) == 0 {
return true
}
for key, value := range rule.AppliesTo {
if attributes == nil {
return false
}
if attrValue, ok := attributes[key]; !ok || attrValue != value {
return false
}
}
return true
}
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}
func mapLineType(lineType string) accountingv1.PostingLineType {
switch strings.ToLower(lineType) {
case "tax":
return accountingv1.PostingLineType_POSTING_LINE_TAX
case "spread":
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case "reversal":
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_FEE
}
}
func mapEntrySide(entrySide string) accountingv1.EntrySide {
switch strings.ToLower(entrySide) {
case "debit":
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case "credit":
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func toDecimalRounding(mode string) dmath.RoundingMode {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "half_up":
return dmath.RoundingModeHalfUp
case "down":
return dmath.RoundingModeDown
case "half_even", "bankers":
return dmath.RoundingModeHalfEven
default:
return dmath.RoundingModeHalfEven
}
}
func mapRoundingMode(mode string) moneyv1.RoundingMode {
switch strings.ToLower(mode) {
case "half_up":
return moneyv1.RoundingMode_ROUND_HALF_UP
case "down":
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
}
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
}

View File

@@ -0,0 +1,442 @@
package calculator
import (
"context"
"errors"
"math/big"
"sort"
"strconv"
"strings"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
"go.uber.org/zap"
)
// fxOracle captures the oracle dependency for FX conversions.
type fxOracle interface {
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
}
// New constructs the default calculator implementation.
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
if logger == nil {
logger = zap.NewNop()
}
return &quoteCalculator{
logger: logger.Named("calculator"),
oracle: oracle,
}
}
type quoteCalculator struct {
logger mlogger.Logger
oracle fxOracle
}
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
if plan == nil {
return nil, merrors.InvalidArgument("plan is required")
}
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
trigger := convertTrigger(intent.GetTrigger())
if trigger == model.TriggerUnspecified {
return nil, merrors.InvalidArgument("unsupported trigger")
}
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
if err != nil {
return nil, merrors.InvalidArgument("invalid base amount")
}
if baseAmount.Sign() < 0 {
return nil, merrors.InvalidArgument("base amount cannot be negative")
}
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
rules := make([]model.FeeRule, len(plan.Rules))
copy(rules, plan.Rules)
sort.SliceStable(rules, func(i, j int) bool {
if rules[i].Priority == rules[j].Priority {
return rules[i].RuleID < rules[j].RuleID
}
return rules[i].Priority < rules[j].Priority
})
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
applied := make([]*feesv1.AppliedRule, 0, len(rules))
planID := ""
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
planID = planRef.Hex()
}
for _, rule := range rules {
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
continue
}
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
if ledgerAccountRef == "" {
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
continue
}
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil {
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
}
continue
}
if amount.Sign() == 0 {
continue
}
currency := intent.GetBaseAmount().GetCurrency()
if override := strings.TrimSpace(rule.Currency); override != "" {
currency = override
}
entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
}
meta := map[string]string{
"fee_rule_id": rule.RuleID,
}
if planID != "" {
meta["fee_plan_id"] = planID
}
if rule.Metadata != nil {
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
meta["tax_code"] = taxCode
}
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
meta["tax_rate"] = taxRate
}
}
lines = append(lines, &feesv1.DerivedPostingLine{
LedgerAccountRef: ledgerAccountRef,
Money: &moneyv1.Money{
Amount: dmath.FormatRat(amount, scale),
Currency: currency,
},
LineType: mapLineType(rule.LineType),
Side: entrySide,
Meta: meta,
})
applied = append(applied, &feesv1.AppliedRule{
RuleId: rule.RuleID,
RuleVersion: planID,
Formula: rule.Formula,
Rounding: mapRoundingMode(rule.Rounding),
TaxCode: metadataValue(rule.Metadata, "tax_code"),
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
Parameters: cloneStringMap(rule.Metadata),
})
}
var fxUsed *feesv1.FXUsed
if trigger == model.TriggerFXConversion && c.oracle != nil {
fxUsed = c.buildFxUsed(ctx, intent)
}
return &types.CalculationResult{
Lines: lines,
Applied: applied,
FxUsed: fxUsed,
}, nil
}
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
scale, err := resolveRuleScale(rule, baseScale)
if err != nil {
return nil, 0, err
}
result := new(big.Rat)
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
percentageRat, perr := dmath.RatFromString(percentage)
if perr != nil {
return nil, 0, merrors.InvalidArgument("invalid percentage")
}
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
}
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
fixedRat, ferr := dmath.RatFromString(fixed)
if ferr != nil {
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
}
result = dmath.AddRat(result, fixedRat)
}
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
minRat, merr := dmath.RatFromString(minStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
}
if dmath.CmpRat(result, minRat) < 0 {
result = new(big.Rat).Set(minRat)
}
}
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
maxRat, merr := dmath.RatFromString(maxStr)
if merr != nil {
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
}
if dmath.CmpRat(result, maxRat) > 0 {
result = new(big.Rat).Set(maxRat)
}
}
if result.Sign() < 0 {
result = new(big.Rat).Abs(result)
}
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
if rerr != nil {
return nil, 0, rerr
}
return rounded, scale, nil
}
const (
attrFxBaseCurrency = "fx_base_currency"
attrFxQuoteCurrency = "fx_quote_currency"
attrFxProvider = "fx_provider"
attrFxSide = "fx_side"
attrFxRateOverride = "fx_rate"
)
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
if intent == nil || c.oracle == nil {
return nil
}
attrs := intent.GetAttributes()
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
if base == "" || quote == "" {
return nil
}
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
provider := strings.TrimSpace(attrs[attrFxProvider])
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
Meta: oracleclient.RequestMeta{},
Pair: pair,
Provider: provider,
})
if err != nil {
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
return nil
}
if snapshot == nil {
return nil
}
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
if rateValue == "" {
rateValue = snapshot.Mid
}
if rateValue == "" {
rateValue = snapshot.Ask
}
if rateValue == "" {
rateValue = snapshot.Bid
}
return &feesv1.FXUsed{
Pair: pair,
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
Rate: &moneyv1.Decimal{Value: rateValue},
AsofUnixMs: snapshot.AsOf.UnixMilli(),
Provider: snapshot.Provider,
RateRef: snapshot.RateRef,
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
}
}
func parseFxSide(value string) fxv1.Side {
switch strings.ToLower(value) {
case "buy_base", "buy_base_sell_quote", "buy":
return fxv1.Side_BUY_BASE_SELL_QUOTE
case "sell_base", "sell_base_buy_quote", "sell":
return fxv1.Side_SELL_BASE_BUY_QUOTE
default:
return fxv1.Side_SIDE_UNSPECIFIED
}
}
func inferScale(amount string) uint32 {
value := strings.TrimSpace(amount)
if value == "" {
return 0
}
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
value = value[:idx]
}
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
value = value[1:]
}
if dot := strings.IndexByte(value, '.'); dot >= 0 {
return uint32(len(value[dot+1:]))
}
return 0
}
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
if rule.Trigger != trigger {
return false
}
if rule.EffectiveFrom.After(bookedAt) {
return false
}
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
return false
}
return ruleMatchesAttributes(rule, attributes)
}
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
if rule.Metadata != nil {
for _, field := range []string{"scale", "decimals", "precision"} {
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
return parseScale(field, value)
}
}
}
return fallback, nil
}
func parseScale(field, value string) (uint32, error) {
clean := strings.TrimSpace(value)
if clean == "" {
return 0, merrors.InvalidArgument(field + " is empty")
}
parsed, err := strconv.ParseUint(clean, 10, 32)
if err != nil {
return 0, merrors.InvalidArgument("invalid " + field + " value")
}
return uint32(parsed), nil
}
func metadataValue(meta map[string]string, key string) string {
if meta == nil {
return ""
}
return strings.TrimSpace(meta[key])
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
cloned := make(map[string]string, len(src))
for k, v := range src {
cloned[k] = v
}
return cloned
}
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
if len(rule.AppliesTo) == 0 {
return true
}
for key, value := range rule.AppliesTo {
if attributes == nil {
return false
}
if attrValue, ok := attributes[key]; !ok || attrValue != value {
return false
}
}
return true
}
func mapLineType(lineType string) accountingv1.PostingLineType {
switch strings.ToLower(lineType) {
case "tax":
return accountingv1.PostingLineType_POSTING_LINE_TAX
case "spread":
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
case "reversal":
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
default:
return accountingv1.PostingLineType_POSTING_LINE_FEE
}
}
func mapEntrySide(entrySide string) accountingv1.EntrySide {
switch strings.ToLower(entrySide) {
case "debit":
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
case "credit":
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
default:
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
}
}
func toDecimalRounding(mode string) dmath.RoundingMode {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "half_up":
return dmath.RoundingModeHalfUp
case "down":
return dmath.RoundingModeDown
case "half_even", "bankers":
return dmath.RoundingModeHalfEven
default:
return dmath.RoundingModeHalfEven
}
}
func mapRoundingMode(mode string) moneyv1.RoundingMode {
switch strings.ToLower(mode) {
case "half_up":
return moneyv1.RoundingMode_ROUND_HALF_UP
case "down":
return moneyv1.RoundingMode_ROUND_DOWN
default:
return moneyv1.RoundingMode_ROUND_HALF_EVEN
}
}
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}

View File

@@ -0,0 +1,10 @@
package resolver
import "github.com/tech/sendico/pkg/merrors"
var (
// ErrNoFeeRuleFound indicates that no applicable rule exists for the given context.
ErrNoFeeRuleFound = merrors.ErrNoData
// ErrConflictingFeeRules indicates multiple rules share the same highest priority.
ErrConflictingFeeRules = merrors.ErrDataConflict
)

View File

@@ -0,0 +1,148 @@
package resolver
import (
"context"
"errors"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type planFinder interface {
FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error)
}
type feeResolver struct {
plans storage.PlansStore
finder planFinder
logger *zap.Logger
}
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
var finder planFinder
if pf, ok := plans.(planFinder); ok {
finder = pf
}
if logger == nil {
logger = zap.NewNop()
}
return &feeResolver{
plans: plans,
finder: finder,
logger: logger.Named("resolver"),
}
}
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
}
// Try org-specific first if provided.
if orgID != nil && !orgID.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex()))
return nil, nil, selErr
}
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
return nil, nil, err
}
}
plan, err := r.getGlobalPlan(ctx, at)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
}
r.logger.Warn("failed resolving global fee plan", zap.Error(err))
return nil, nil, err
}
rule, err := selectRule(plan, trigger, at, attrs)
if err != nil {
if !errors.Is(err, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule in global plan", zap.Error(err))
}
return nil, nil, err
}
return plan, rule, nil
}
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if r.finder != nil {
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
}
return r.plans.GetActivePlan(ctx, orgRef, at)
}
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
if r.finder != nil {
return r.finder.FindActiveGlobalPlan(ctx, at)
}
// Treat zero ObjectID as global in legacy path.
return r.plans.GetActivePlan(ctx, primitive.NilObjectID, at)
}
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
if plan == nil {
return nil, merrors.NoData("fees: no applicable fee rule found")
}
var selected *model.FeeRule
var highestPriority int
for _, rule := range plan.Rules {
if rule.Trigger != trigger {
continue
}
if rule.EffectiveFrom.After(at) {
continue
}
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
continue
}
if !matchesAppliesTo(rule.AppliesTo, attrs) {
continue
}
if selected == nil || rule.Priority > highestPriority {
copy := rule
selected = &copy
highestPriority = rule.Priority
continue
}
if rule.Priority == highestPriority {
return nil, merrors.DataConflict("fees: conflicting fee rules")
}
}
if selected == nil {
return nil, merrors.NoData("fees: no applicable fee rule found")
}
return selected, nil
}
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
if len(appliesTo) == 0 {
return true
}
for key, value := range appliesTo {
if attrs == nil {
return false
}
if attrs[key] != value {
return false
}
}
return true
}

View File

@@ -0,0 +1,315 @@
package resolver
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
t.Helper()
now := time.Now()
globalPlan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan}}
resolver := New(store, zap.NewNop())
orgA := primitive.NewObjectID()
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if !plan.GetOrganizationRef().IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex())
}
if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
}
}
func TestResolver_OrgOverridesGlobal(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
globalPlan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
orgPlan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
orgPlan.SetOrganizationRef(org)
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected org plan rule, got error: %v", err)
}
if rule.RuleID != "org_capture" {
t.Fatalf("expected org rule, got %s", rule.RuleID)
}
otherOrg := primitive.NewObjectID()
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected global fallback for other org, got error: %v", err)
}
if rule.RuleID != "global_capture" {
t.Fatalf("expected global rule, got %s", rule.RuleID)
}
}
func TestResolver_SelectsHighestPriority(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "low", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
plan.SetOrganizationRef(org)
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected rule resolution, got error: %v", err)
}
if rule.RuleID != "high" {
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
}
plan.Rules = append(plan.Rules, model.FeeRule{
RuleID: "conflict",
Trigger: model.TriggerCapture,
Priority: 200,
Percentage: "0.02",
EffectiveFrom: now.Add(-time.Hour),
})
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, ErrConflictingFeeRules) {
t.Fatalf("expected conflicting fee rules error, got %v", err)
}
}
func TestResolver_EffectiveDateFiltering(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
past := now.Add(-24 * time.Hour)
future := now.Add(24 * time.Hour)
orgPlan := &model.FeePlan{
Active: true,
EffectiveFrom: past,
Rules: []model.FeeRule{
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
},
}
orgPlan.SetOrganizationRef(org)
globalPlan := &model.FeePlan{
Active: true,
EffectiveFrom: past,
Rules: []model.FeeRule{
{RuleID: "current", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &future},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{orgPlan, globalPlan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err)
}
if rule.RuleID != "current" {
t.Fatalf("expected current global rule, got %s", rule.RuleID)
}
}
func TestResolver_AppliesToFiltering(t *testing.T) {
t.Helper()
now := time.Now()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "card", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", AppliesTo: map[string]string{"paymentMethod": "card"}, EffectiveFrom: now.Add(-time.Hour)},
{RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
_, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "card"})
if err != nil {
t.Fatalf("expected card rule, got error: %v", err)
}
if rule.RuleID != "card" {
t.Fatalf("expected card rule, got %s", rule.RuleID)
}
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "bank"})
if err != nil {
t.Fatalf("expected default rule, got error: %v", err)
}
if rule.RuleID != "default" {
t.Fatalf("expected default rule, got %s", rule.RuleID)
}
}
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
t.Helper()
now := time.Now()
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop())
if _, _, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerRefund, now, nil); !errors.Is(err, ErrNoFeeRuleFound) {
t.Fatalf("expected ErrNoFeeRuleFound, got %v", err)
}
}
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
t.Helper()
now := time.Now()
org := primitive.NewObjectID()
p1 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
},
}
p1.SetOrganizationRef(org)
p2 := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-30 * time.Minute),
Rules: []model.FeeRule{
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
},
}
p2.SetOrganizationRef(org)
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
resolver := New(store, zap.NewNop())
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
t.Fatalf("expected conflicting plans error, got %v", err)
}
}
type memoryPlansStore struct {
plans []*model.FeePlan
}
func (m *memoryPlansStore) Create(context.Context, *model.FeePlan) error { return nil }
func (m *memoryPlansStore) Update(context.Context, *model.FeePlan) error { return nil }
func (m *memoryPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
return nil, storage.ErrFeePlanNotFound
}
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if !orgRef.IsZero() {
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err
}
}
return m.FindActiveGlobalPlan(ctx, at)
}
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || plan.GetOrganizationRef() != orgRef {
continue
}
if !plan.Active {
continue
}
if plan.EffectiveFrom.After(at) {
continue
}
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
continue
}
matches = append(matches, plan)
}
if len(matches) == 0 {
return nil, storage.ErrFeePlanNotFound
}
if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return matches[0], nil
}
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan
for _, plan := range m.plans {
if plan == nil || !plan.GetOrganizationRef().IsZero() {
continue
}
if !plan.Active {
continue
}
if plan.EffectiveFrom.After(at) {
continue
}
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
continue
}
matches = append(matches, plan)
}
if len(matches) == 0 {
return nil, storage.ErrFeePlanNotFound
}
if len(matches) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return matches[0], nil
}
var _ storage.PlansStore = (*memoryPlansStore)(nil)

View File

@@ -1,6 +1,7 @@
package fees
import (
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
oracleclient "github.com/tech/sendico/fx/oracle/client"
clockpkg "github.com/tech/sendico/pkg/clock"
)
@@ -30,8 +31,18 @@ func WithCalculator(calculator Calculator) Option {
func WithOracleClient(oracle oracleclient.Client) Option {
return func(s *Service) {
s.oracle = oracle
if qc, ok := s.calculator.(*quoteCalculator); ok {
qc.oracle = oracle
// Rebuild default calculator if none was injected.
if s.calculator == nil {
s.calculator = internalcalculator.New(s.logger, oracle)
}
}
}
// WithFeeResolver injects a custom fee resolver (useful for tests).
func WithFeeResolver(r FeeResolver) Option {
return func(s *Service) {
if r != nil {
s.resolver = r
}
}
}

View File

@@ -0,0 +1,15 @@
package fees
import (
"context"
"time"
"github.com/tech/sendico/billing/fees/storage/model"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// FeeResolver centralises plan/rule resolution with org override and global fallback.
// Implementations live under the internal/resolver package.
type FeeResolver interface {
ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error)
}

View File

@@ -8,7 +8,10 @@ import (
"strings"
"time"
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/api/routers"
clockpkg "github.com/tech/sendico/pkg/clock"
@@ -32,6 +35,7 @@ type Service struct {
clock clockpkg.Clock
calculator Calculator
oracle oracleclient.Client
resolver FeeResolver
feesv1.UnimplementedFeeEngineServer
}
@@ -52,7 +56,10 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
svc.clock = clockpkg.NewSystem()
}
if svc.calculator == nil {
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
}
if svc.resolver == nil {
svc.resolver = resolver.New(repo.Plans(), svc.logger)
}
return svc
@@ -273,15 +280,34 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
bookedAt = intent.GetBookedAt().AsTime()
}
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
if err != nil {
if errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
}
s.logger.Warn("failed to load active fee plan", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
var orgPtr *primitive.ObjectID
if !orgRef.IsZero() {
orgPtr = &orgRef
}
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
if err != nil {
switch {
case errors.Is(err, merrors.ErrNoData):
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
case errors.Is(err, merrors.ErrDataConflict):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
case errors.Is(err, storage.ErrConflictingFeePlans):
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee plans")
case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
default:
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
}
}
originalRules := plan.Rules
plan.Rules = []model.FeeRule{*rule}
defer func() {
plan.Rules = originalRules
}()
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
if calcErr != nil {
if errors.Is(calcErr, merrors.ErrInvalidArg) {

View File

@@ -2,9 +2,11 @@ package fees
import (
"context"
"errors"
"testing"
"time"
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
oracleclient "github.com/tech/sendico/fx/oracle/client"
@@ -263,11 +265,21 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
plan := &model.FeePlan{
Active: true,
EffectiveFrom: now.Add(-time.Hour),
Rules: []model.FeeRule{
{
RuleID: "stub",
Trigger: model.TriggerCapture,
Priority: 1,
Percentage: "0.01",
LedgerAccountRef: "acct:stub",
EffectiveFrom: now.Add(-time.Hour),
},
},
}
plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef)
result := &CalculationResult{
result := &types.CalculationResult{
Lines: []*feesv1.DerivedPostingLine{
{
LedgerAccountRef: "acct:stub",
@@ -409,7 +421,8 @@ func (s *stubRepository) Plans() storage.PlansStore {
}
type stubPlansStore struct {
plan *model.FeePlan
plan *model.FeePlan
globalPlan *model.FeePlan
}
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
@@ -425,6 +438,17 @@ func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePla
}
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if !orgRef.IsZero() {
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
return plan, nil
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
return nil, err
}
}
return s.FindActiveGlobalPlan(context.Background(), at)
}
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if s.plan == nil {
return nil, storage.ErrFeePlanNotFound
}
@@ -434,15 +458,31 @@ func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.Objec
if !s.plan.Active {
return nil, storage.ErrFeePlanNotFound
}
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
if s.plan.EffectiveFrom.After(at) {
return nil, storage.ErrFeePlanNotFound
}
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
return nil, storage.ErrFeePlanNotFound
}
return s.plan, nil
}
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
if s.globalPlan == nil {
return nil, storage.ErrFeePlanNotFound
}
if !s.globalPlan.Active {
return nil, storage.ErrFeePlanNotFound
}
if s.globalPlan.EffectiveFrom.After(at) {
return nil, storage.ErrFeePlanNotFound
}
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
return nil, storage.ErrFeePlanNotFound
}
return s.globalPlan, nil
}
type noopProducer struct{}
func (noopProducer) SendMessage(me.Envelope) error {
@@ -458,14 +498,14 @@ func (f fixedClock) Now() time.Time {
}
type stubCalculator struct {
result *CalculationResult
result *types.CalculationResult
err error
called bool
gotPlan *model.FeePlan
bookedAt time.Time
}
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
s.called = true
s.gotPlan = plan
s.bookedAt = bookedAt

View File

@@ -0,0 +1,23 @@
package fees
import (
"github.com/tech/sendico/billing/fees/storage/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
)
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
switch trigger {
case feesv1.Trigger_TRIGGER_CAPTURE:
return model.TriggerCapture
case feesv1.Trigger_TRIGGER_REFUND:
return model.TriggerRefund
case feesv1.Trigger_TRIGGER_DISPUTE:
return model.TriggerDispute
case feesv1.Trigger_TRIGGER_PAYOUT:
return model.TriggerPayout
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
return model.TriggerFXConversion
default:
return model.TriggerUnspecified
}
}

View File

@@ -0,0 +1,12 @@
package types
import (
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
)
// CalculationResult contains derived fee lines and audit metadata.
type CalculationResult struct {
Lines []*feesv1.DerivedPostingLine
Applied []*feesv1.AppliedRule
FxUsed *feesv1.FXUsed
}

View File

@@ -42,21 +42,21 @@ func (*FeePlan) Collection() string {
// FeeRule represents a single pricing rule within a plan.
type FeeRule struct {
RuleID string `bson:"ruleId" json:"ruleId"`
Trigger Trigger `bson:"trigger" json:"trigger"`
Priority int `bson:"priority" json:"priority"`
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
RuleID string `bson:"ruleId" json:"ruleId"`
Trigger Trigger `bson:"trigger" json:"trigger"`
Priority int `bson:"priority" json:"priority"`
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
}

View File

@@ -3,10 +3,14 @@ package store
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/tech/sendico/billing/fees/storage"
"github.com/tech/sendico/billing/fees/storage/model"
dmath "github.com/tech/sendico/pkg/decimal"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder"
ri "github.com/tech/sendico/pkg/db/repository/index"
@@ -53,6 +57,19 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
return nil, err
}
// Recommended index to speed up active-plan lookups (org/global + active + dates).
activeIndex := &ri.Definition{
Keys: []ri.Key{
{Field: m.OrganizationRefField, Sort: ri.Asc},
{Field: "active", Sort: ri.Asc},
{Field: "effectiveFrom", Sort: ri.Asc},
{Field: "effectiveTo", Sort: ri.Asc},
},
}
if err := repo.CreateIndex(activeIndex); err != nil {
logger.Warn("failed to ensure fee plan active index", zap.Error(err))
}
return &plansStore{
logger: logger.Named("plans"),
repo: repo,
@@ -60,9 +77,13 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
}
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan")
if err := validatePlan(plan); err != nil {
return err
}
if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err
}
if err := p.repo.Insert(ctx, plan, nil); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateFeePlan
@@ -77,6 +98,13 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
}
if err := validatePlan(plan); err != nil {
return err
}
if err := p.ensureNoOverlap(ctx, plan); err != nil {
return err
}
if err := p.repo.Update(ctx, plan); err != nil {
p.logger.Warn("failed to update fee plan", zap.Error(err))
return err
@@ -99,13 +127,42 @@ func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*mode
}
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
if orgRef.IsZero() {
return p.FindActiveGlobalPlan(ctx, at)
}
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
if err == nil {
return plan, nil
}
if errors.Is(err, storage.ErrFeePlanNotFound) {
return p.FindActiveGlobalPlan(ctx, at)
}
return nil, err
}
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
if orgRef.IsZero() {
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
}
query := repository.Query().Filter(repository.OrgField(), orgRef)
return p.findActivePlan(ctx, query, at)
}
limit := int64(1)
query := repository.Query().
Filter(repository.OrgField(), orgRef).
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
globalQuery := repository.Query().Or(
repository.Exists(repository.OrgField(), false),
repository.Query().Filter(repository.OrgField(), nil),
)
return p.findActivePlan(ctx, globalQuery, at)
}
var _ storage.PlansStore = (*plansStore)(nil)
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
limit := int64(2)
query := orgQuery.
Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
Sort(repository.Field("effectiveFrom"), false).
@@ -118,13 +175,13 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
),
)
var plan *model.FeePlan
var plans []*model.FeePlan
decoder := func(cursor *mongo.Cursor) error {
target := &model.FeePlan{}
if err := cursor.Decode(target); err != nil {
return err
}
plan = target
plans = append(plans, target)
return nil
}
@@ -135,10 +192,127 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
return nil, err
}
if plan == nil {
if len(plans) == 0 {
return nil, storage.ErrFeePlanNotFound
}
return plan, nil
if len(plans) > 1 {
return nil, storage.ErrConflictingFeePlans
}
return plans[0], nil
}
var _ storage.PlansStore = (*plansStore)(nil)
func validatePlan(plan *model.FeePlan) error {
if plan == nil {
return merrors.InvalidArgument("plansStore: nil fee plan")
}
if len(plan.Rules) == 0 {
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
}
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
}
// Ensure unique priority per (trigger, appliesTo) combination.
seen := make(map[string]struct{})
for _, rule := range plan.Rules {
if strings.TrimSpace(rule.Percentage) != "" {
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule percentage")
}
}
if strings.TrimSpace(rule.FixedAmount) != "" {
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
}
}
if strings.TrimSpace(rule.MinimumAmount) != "" {
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
}
}
if strings.TrimSpace(rule.MaximumAmount) != "" {
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
}
}
appliesKey := normalizeAppliesTo(rule.AppliesTo)
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
if _, ok := seen[priorityKey]; ok {
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
}
seen[priorityKey] = struct{}{}
}
return nil
}
func normalizeAppliesTo(applies map[string]string) string {
if len(applies) == 0 {
return ""
}
keys := make([]string, 0, len(applies))
for k := range applies {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+applies[k])
}
return strings.Join(parts, ",")
}
func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error {
if plan == nil || !plan.Active {
return nil
}
orgQuery := repository.Query()
if plan.OrganizationRef.IsZero() {
orgQuery = repository.Query().Or(
repository.Exists(repository.OrgField(), false),
repository.Query().Filter(repository.OrgField(), nil),
)
} else {
orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef)
}
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
newFrom := plan.EffectiveFrom
newTo := maxTime
if plan.EffectiveTo != nil {
newTo = *plan.EffectiveTo
}
query := orgQuery.
Filter(repository.Field("active"), true).
Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo).
And(repository.Query().Or(
repository.Query().Filter(repository.Field("effectiveTo"), nil),
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom),
))
if id := plan.GetID(); id != nil && !id.IsZero() {
query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id))
}
limit := int64(1)
query = query.Limit(&limit)
var overlapFound bool
decoder := func(cursor *mongo.Cursor) error {
overlapFound = true
return nil
}
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil
}
return err
}
if overlapFound {
return storage.ErrConflictingFeePlans
}
return nil
}

View File

@@ -19,6 +19,8 @@ var (
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
// ErrConflictingFeePlans indicates multiple active plans matched a query.
ErrConflictingFeePlans = storageError("billing.fees.storage: conflicting fee plans")
)
// Repository defines the root storage contract for the fees service.
@@ -32,5 +34,6 @@ type PlansStore interface {
Create(ctx context.Context, plan *model.FeePlan) error
Update(ctx context.Context, plan *model.FeePlan) error
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
// Legacy helper that now prefers an org plan and falls back to a global plan.
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
}

View File

@@ -13,14 +13,14 @@ require (
github.com/tech/sendico/fx/storage v0.0.0
github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.47.0
golang.org/x/net v0.48.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -45,7 +45,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -176,15 +176,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -21,7 +21,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -45,8 +45,8 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -176,15 +176,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -12,7 +12,7 @@ require (
require (
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/golang/snappy v1.0.0 // indirect
@@ -25,7 +25,7 @@ require (
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect

View File

@@ -7,8 +7,8 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -138,8 +138,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -154,8 +154,8 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@@ -26,7 +26,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@@ -79,9 +79,9 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -17,8 +17,8 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -320,8 +320,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -329,8 +329,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -19,7 +19,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -45,8 +45,8 @@ require (
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -178,15 +178,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -19,7 +19,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -46,8 +46,8 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -178,15 +178,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -21,7 +21,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -48,8 +48,8 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect

View File

@@ -13,8 +13,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -191,15 +191,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -29,7 +29,7 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -54,8 +54,8 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -179,15 +179,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=

View File

@@ -97,6 +97,7 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
FXQuote: cloneFXQuote(src.GetFxQuote()),
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
}
}
@@ -220,6 +221,7 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
FxQuote: cloneFXQuote(src.FXQuote),
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
FeeQuoteToken: src.FeeQuoteToken,
QuoteRef: strings.TrimSpace(src.QuoteRef),
}
}

View File

@@ -10,6 +10,8 @@ import (
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
@@ -19,35 +21,53 @@ import (
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, error) {
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
intent := req.GetIntent()
amount := intent.GetAmount()
baseAmount := cloneMoney(amount)
feeQuote, err := s.quoteFees(ctx, orgRef, req)
if err != nil {
return nil, err
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), amount.GetCurrency())
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, err
return nil, time.Time{}, err
}
}
var fxQuote *oraclev1.Quote
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, err
}
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
debitAmount, settlementAmount := computeAggregates(baseAmount, feeTotal, networkFee)
return &orchestratorv1.PaymentQuote{
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
@@ -56,17 +76,25 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
FxQuote: fxQuote,
NetworkFee: networkFee,
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
}, nil
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
}
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*feesv1.PrecomputeFeesResponse, error) {
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.fees.available() {
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: cloneMoney(intent.GetAmount()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
@@ -160,7 +188,19 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
}
if amount := intent.GetAmount(); amount != nil {
params.BaseAmount = cloneMoney(amount)
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.oracle.client.GetQuote(ctx, params)
@@ -287,11 +327,14 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
if fq == nil {
return merrors.InvalidArgument("ledger: fx quote missing")
}
fromMoney := cloneMoney(fq.GetBaseAmount())
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.FX != nil {
fxSide = intent.FX.Side
}
fromMoney, toMoney := resolveTradeAmounts(intent.Amount, fq, fxSide)
if fromMoney == nil {
fromMoney = cloneMoney(intent.Amount)
}
toMoney := cloneMoney(fq.GetQuoteAmount())
if toMoney == nil {
toMoney = cloneMoney(quote.GetExpectedSettlementAmount())
}

View File

@@ -2,6 +2,7 @@ package orchestrator
import (
"strings"
"time"
"github.com/shopspring/decimal"
oracleclient "github.com/tech/sendico/fx/oracle/client"
@@ -13,6 +14,7 @@ import (
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
@@ -108,30 +110,91 @@ func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *money
}
}
func computeAggregates(base, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse) (*moneyv1.Money, *moneyv1.Money) {
if base == nil {
func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) {
if fxQuote == nil {
return cloneMoney(intentAmount), cloneMoney(intentAmount)
}
qSide := fxQuote.GetSide()
if qSide == fxv1.Side_SIDE_UNSPECIFIED {
qSide = side
}
switch qSide {
case fxv1.Side_BUY_BASE_SELL_QUOTE:
pay := cloneMoney(fxQuote.GetQuoteAmount())
settle := cloneMoney(fxQuote.GetBaseAmount())
if pay == nil {
pay = cloneMoney(intentAmount)
}
if settle == nil {
settle = cloneMoney(intentAmount)
}
return pay, settle
case fxv1.Side_SELL_BASE_BUY_QUOTE:
pay := cloneMoney(fxQuote.GetBaseAmount())
settle := cloneMoney(fxQuote.GetQuoteAmount())
if pay == nil {
pay = cloneMoney(intentAmount)
}
if settle == nil {
settle = cloneMoney(intentAmount)
}
return pay, settle
default:
return cloneMoney(intentAmount), cloneMoney(intentAmount)
}
}
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote) (*moneyv1.Money, *moneyv1.Money) {
if pay == nil {
return nil, nil
}
baseDecimal, err := decimalFromMoney(base)
debitDecimal, err := decimalFromMoney(pay)
if err != nil {
return cloneMoney(base), cloneMoney(base)
}
debit := baseDecimal
settlement := baseDecimal
if feeDecimal, err := decimalFromMoneyMatching(base, fee); err == nil && feeDecimal != nil {
debit = debit.Add(*feeDecimal)
settlement = settlement.Sub(*feeDecimal)
return cloneMoney(pay), cloneMoney(settlement)
}
if network != nil && network.GetNetworkFee() != nil {
if networkDecimal, err := decimalFromMoneyMatching(base, network.GetNetworkFee()); err == nil && networkDecimal != nil {
debit = debit.Add(*networkDecimal)
settlement = settlement.Sub(*networkDecimal)
settlementCurrency := pay.GetCurrency()
if settlement != nil && strings.TrimSpace(settlement.GetCurrency()) != "" {
settlementCurrency = settlement.GetCurrency()
}
settlementDecimal := debitDecimal
if settlement != nil {
if val, err := decimalFromMoney(settlement); err == nil {
settlementDecimal = val
}
}
return makeMoney(base.GetCurrency(), debit), makeMoney(base.GetCurrency(), settlement)
adjustDebit := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
if err != nil || converted == nil {
return
}
if val, err := decimalFromMoney(converted); err == nil {
debitDecimal = debitDecimal.Add(val)
}
}
adjustSettlement := func(m *moneyv1.Money) {
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
if err != nil || converted == nil {
return
}
if val, err := decimalFromMoney(converted); err == nil {
settlementDecimal = settlementDecimal.Sub(val)
}
}
adjustDebit(fee)
adjustSettlement(fee)
if network != nil && network.GetNetworkFee() != nil {
adjustDebit(network.GetNetworkFee())
adjustSettlement(network.GetNetworkFee())
}
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
}
func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
@@ -162,6 +225,46 @@ func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
}
}
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
if m == nil || strings.TrimSpace(targetCurrency) == "" {
return nil, nil
}
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
return cloneMoney(m), nil
}
return convertWithQuote(m, quote, targetCurrency)
}
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
return nil, nil
}
base := strings.TrimSpace(quote.GetPair().GetBase())
qt := strings.TrimSpace(quote.GetPair().GetQuote())
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
return nil, nil
}
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
if err != nil || price.IsZero() {
return nil, err
}
value, err := decimalFromMoney(m)
if err != nil {
return nil, err
}
switch {
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
return makeMoney(targetCurrency, value.Mul(price)), nil
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
return makeMoney(targetCurrency, value.Div(price)), nil
default:
return nil, nil
}
}
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
if src == nil {
return nil
@@ -219,6 +322,23 @@ func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv
}
}
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
expiry := time.Time{}
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
expiry = feeQuote.GetExpiresAt().AsTime()
}
if expiry.IsZero() {
expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond)
}
if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 {
fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC()
if fxExpiry.Before(expiry) {
expiry = fxExpiry
}
}
return expiry
}
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown {
if quote == nil {
return nil

View File

@@ -0,0 +1,57 @@
package orchestrator
import (
"testing"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
)
func TestResolveTradeAmountsBuyBase(t *testing.T) {
fxQuote := &oraclev1.Quote{
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
BaseAmount: &moneyv1.Money{
Currency: "EUR",
Amount: "100",
},
QuoteAmount: &moneyv1.Money{
Currency: "USD",
Amount: "110",
},
}
pay, settle := resolveTradeAmounts(nil, fxQuote, fxv1.Side_SIDE_UNSPECIFIED)
if pay.GetCurrency() != "USD" || pay.GetAmount() != "110" {
t.Fatalf("expected pay amount in USD 110, got %s %s", pay.GetCurrency(), pay.GetAmount())
}
if settle.GetCurrency() != "EUR" || settle.GetAmount() != "100" {
t.Fatalf("expected settlement in EUR 100, got %s %s", settle.GetCurrency(), settle.GetAmount())
}
}
func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
pay := &moneyv1.Money{Currency: "USD", Amount: "100"}
settle := &moneyv1.Money{Currency: "EUR", Amount: "50"}
fee := &moneyv1.Money{Currency: "USD", Amount: "10"}
network := &chainv1.EstimateTransferFeeResponse{
NetworkFee: &moneyv1.Money{Currency: "USD", Amount: "5"},
}
fxQuote := &oraclev1.Quote{
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
Price: &moneyv1.Decimal{
Value: "2",
},
}
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote)
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
}
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "42.5" {
t.Fatalf("expected settlement 42.5 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
}
}

View File

@@ -3,8 +3,8 @@ package orchestrator
import (
"time"
chainclient "github.com/tech/sendico/gateway/chain/client"
oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
ledgerclient "github.com/tech/sendico/ledger/client"
clockpkg "github.com/tech/sendico/pkg/clock"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"

View File

@@ -0,0 +1,64 @@
package orchestrator
import (
"context"
"testing"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
ctx := context.Background()
var captured oracleclient.GetQuoteParams
svc := &Service{
logger: zap.NewNop(),
clock: testClock{now: time.Now()},
oracle: oracleDependency{
client: &oracleclient.Fake{
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
captured = params
return &oracleclient.Quote{
QuoteRef: "q",
Pair: params.Pair,
Side: params.Side,
Price: "1.1",
BaseAmount: params.BaseAmount,
QuoteAmount: params.QuoteAmount,
ExpiresAt: time.Now(),
}, nil
},
},
},
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
Intent: &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
Fx: &orchestratorv1.FXIntent{
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
},
},
}
if _, err := svc.requestFXQuote(ctx, "org", req); err != nil {
t.Fatalf("requestFXQuote returned error: %v", err)
}
if captured.QuoteAmount == nil {
t.Fatal("expected quote amount to be populated")
}
if captured.BaseAmount != nil {
t.Fatal("expected base amount to be nil when using quote amount input")
}
if captured.QuoteAmount.GetCurrency() != "USD" {
t.Fatalf("expected quote amount currency USD, got %s", captured.QuoteAmount.GetCurrency())
}
}

View File

@@ -16,6 +16,7 @@ import (
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/grpc"
"google.golang.org/protobuf/proto"
)
type serviceError string
@@ -132,6 +133,10 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
@@ -140,11 +145,31 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
quote, err := s.buildPaymentQuote(ctx, orgRef, req)
quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgObjectID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
}
@@ -194,10 +219,34 @@ func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
quote := req.GetFeeQuoteToken()
quoteRef := strings.TrimSpace(req.GetQuoteRef())
quote := strings.TrimSpace(req.GetFeeQuoteToken())
var quoteSnapshot *orchestratorv1.PaymentQuote
if quote == "" {
quoteSnapshot, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
if quoteRef != "" {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef)
if err != nil {
if err == storage.ErrQuoteNotFound {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired"))
}
if !proto.Equal(protoIntentFromModel(record.Intent), intent) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent"))
}
quoteSnapshot = modelQuoteToProto(record.Quote)
if quoteSnapshot == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteSnapshot.QuoteRef = quoteRef
} else if quote == "" {
quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: req.GetIntent(),
@@ -389,7 +438,7 @@ func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestrat
FeePolicy: req.GetFeePolicy(),
}
quote, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,

View File

@@ -12,6 +12,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
mo "github.com/tech/sendico/pkg/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -208,11 +209,43 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
// ----------------------------------------------------------------------
type stubRepository struct {
store *stubPaymentsStore
store *stubPaymentsStore
quotes storage.QuotesStore
}
func (r *stubRepository) Ping(context.Context) error { return nil }
func (r *stubRepository) Payments() storage.PaymentsStore { return r.store }
func (r *stubRepository) Quotes() storage.QuotesStore {
if r.quotes != nil {
return r.quotes
}
return &stubQuotesStore{}
}
type stubQuotesStore struct {
quotes map[string]*model.PaymentQuoteRecord
}
func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
if quote == nil {
return merrors.InvalidArgument("nil quote")
}
if s.quotes == nil {
s.quotes = map[string]*model.PaymentQuoteRecord{}
}
s.quotes[strings.TrimSpace(quote.QuoteRef)] = quote
return nil
}
func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
if s.quotes == nil {
return nil, storage.ErrQuoteNotFound
}
if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok {
return q, nil
}
return nil, storage.ErrQuoteNotFound
}
type stubPaymentsStore struct {
payments map[string]*model.Payment

View File

@@ -119,6 +119,7 @@ type PaymentQuoteSnapshot struct {
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
}
// ExecutionRefs links to downstream systems.

View File

@@ -0,0 +1,24 @@
package model
import (
"time"
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
)
// PaymentQuoteRecord stores a quoted payment snapshot for later execution.
type PaymentQuoteRecord struct {
storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
Intent PaymentIntent `bson:"intent" json:"intent"`
Quote *PaymentQuoteSnapshot `bson:"quote" json:"quote"`
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
}
// Collection implements storable.Storable.
func (*PaymentQuoteRecord) Collection() string {
return "payment_quotes"
}

View File

@@ -18,6 +18,7 @@ type Store struct {
ping func(context.Context) error
payments storage.PaymentsStore
quotes storage.QuotesStore
}
// New constructs a Mongo-backed payments repository from a Mongo connection.
@@ -25,28 +26,37 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
if conn == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
}
repo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
return NewWithRepository(logger, conn.Ping, repo)
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo)
}
// NewWithRepository constructs a payments repository using the provided primitives.
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository) (*Store, error) {
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository) (*Store, error) {
if ping == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
}
if paymentsRepo == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
}
if quotesRepo == nil {
return nil, merrors.InvalidArgument("payments.storage.mongo: quotes repository is nil")
}
childLogger := logger.Named("storage").Named("mongo")
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
if err != nil {
return nil, err
}
quotesStore, err := store.NewQuotes(childLogger, quotesRepo)
if err != nil {
return nil, err
}
result := &Store{
logger: childLogger,
ping: ping,
payments: paymentsStore,
quotes: quotesStore,
}
return result, nil
@@ -65,4 +75,9 @@ func (s *Store) Payments() storage.PaymentsStore {
return s.payments
}
// Quotes returns the quotes store.
func (s *Store) Quotes() storage.QuotesStore {
return s.quotes
}
var _ storage.Repository = (*Store)(nil)

View File

@@ -0,0 +1,117 @@
package store
import (
"context"
"errors"
"strings"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type Quotes struct {
logger mlogger.Logger
repo repository.Repository
}
// NewQuotes constructs a Mongo-backed quotes store.
func NewQuotes(logger mlogger.Logger, repo repository.Repository) (*Quotes, error) {
if repo == nil {
return nil, merrors.InvalidArgument("quotesStore: repository is nil")
}
indexes := []*ri.Definition{
{
Keys: []ri.Key{{Field: "quoteRef", Sort: ri.Asc}},
Unique: true,
},
{
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}},
},
{
Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}},
TTL: int32Ptr(0),
},
}
for _, def := range indexes {
if err := repo.CreateIndex(def); err != nil {
logger.Error("failed to ensure quotes index", zap.Error(err), zap.String("collection", repo.Collection()))
return nil, err
}
}
return &Quotes{
logger: logger.Named("quotes"),
repo: repo,
}, nil
}
func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
if quote == nil {
return merrors.InvalidArgument("quotesStore: nil quote")
}
quote.QuoteRef = strings.TrimSpace(quote.QuoteRef)
if quote.QuoteRef == "" {
return merrors.InvalidArgument("quotesStore: empty quoteRef")
}
if quote.OrganizationRef == primitive.NilObjectID {
return merrors.InvalidArgument("quotesStore: organization_ref is required")
}
if quote.ExpiresAt.IsZero() {
return merrors.InvalidArgument("quotesStore: expires_at is required")
}
if quote.Intent.Attributes != nil {
for k, v := range quote.Intent.Attributes {
quote.Intent.Attributes[k] = strings.TrimSpace(v)
}
}
quote.Update()
filter := repository.OrgFilter(quote.OrganizationRef).And(
repository.Filter("quoteRef", quote.QuoteRef),
)
if err := q.repo.Insert(ctx, quote, filter); err != nil {
if errors.Is(err, merrors.ErrDataConflict) {
return storage.ErrDuplicateQuote
}
return err
}
return nil
}
func (q *Quotes) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
quoteRef = strings.TrimSpace(quoteRef)
if quoteRef == "" {
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
}
if orgRef == primitive.NilObjectID {
return nil, merrors.InvalidArgument("quotesStore: organization_ref is required")
}
entity := &model.PaymentQuoteRecord{}
query := repository.OrgFilter(orgRef).And(repository.Filter("quoteRef", quoteRef))
if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil {
if errors.Is(err, merrors.ErrNoData) {
return nil, storage.ErrQuoteNotFound
}
return nil, err
}
if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) {
return nil, storage.ErrQuoteNotFound
}
return entity, nil
}
var _ storage.QuotesStore = (*Quotes)(nil)
func int32Ptr(v int32) *int32 {
return &v
}

View File

@@ -18,12 +18,17 @@ var (
ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found")
// ErrDuplicatePayment signals that idempotency constraints were violated.
ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment")
// ErrQuoteNotFound signals that a stored quote does not exist or expired.
ErrQuoteNotFound = storageError("payments.orchestrator.storage: quote not found")
// ErrDuplicateQuote signals that a quote reference already exists.
ErrDuplicateQuote = storageError("payments.orchestrator.storage: duplicate quote")
)
// Repository exposes persistence primitives for the orchestrator domain.
type Repository interface {
Ping(ctx context.Context) error
Payments() PaymentsStore
Quotes() QuotesStore
}
// PaymentsStore manages payment lifecycle state.
@@ -35,3 +40,9 @@ type PaymentsStore interface {
GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error)
List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error)
}
// QuotesStore manages temporary stored payment quotes.
type QuotesStore interface {
Create(ctx context.Context, quote *model.PaymentQuoteRecord) error
GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
}

View File

@@ -3,7 +3,7 @@ module github.com/tech/sendico/pkg
go 1.24.0
require (
github.com/casbin/casbin/v2 v2.134.0
github.com/casbin/casbin/v2 v2.135.0
github.com/casbin/mongodb-adapter/v3 v3.7.0
github.com/go-chi/chi/v5 v5.2.3
github.com/google/uuid v1.6.0
@@ -16,7 +16,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.45.0
golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10
)
@@ -88,7 +88,7 @@ require (
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -11,8 +11,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -202,8 +202,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -216,8 +216,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -244,8 +244,8 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=

View File

@@ -99,6 +99,7 @@ message PaymentQuote {
oracle.v1.Quote fx_quote = 6;
chain.gateway.v1.EstimateTransferFeeResponse network_fee = 7;
string fee_quote_token = 8;
string quote_ref = 9;
}
message ExecutionRefs {
@@ -140,6 +141,7 @@ message InitiatePaymentRequest {
string fee_quote_token = 4;
string fx_quote_ref = 5;
map<string, string> metadata = 6;
string quote_ref = 7;
}
message InitiatePaymentResponse {

View File

@@ -90,6 +90,12 @@ api:
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
payment_orchestrator:
address: sendico_payment_orchestrator:50062
address_env: PAYMENT_ORCHESTRATOR_ADDRESS
dial_timeout_seconds: 5
call_timeout_seconds: 5
insecure: true
app:

View File

@@ -6,13 +6,15 @@ replace github.com/tech/sendico/pkg => ../pkg
replace github.com/tech/sendico/ledger => ../ledger
replace github.com/tech/sendico/payments/orchestrator => ../payments/orchestrator
replace github.com/tech/sendico/gateway/chain => ../gateway/chain
require (
github.com/aws/aws-sdk-go-v2 v1.40.1
github.com/aws/aws-sdk-go-v2/config v1.32.3
github.com/aws/aws-sdk-go-v2/credentials v1.19.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.4
github.com/aws/aws-sdk-go-v2/credentials v1.19.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1
github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3
@@ -22,12 +24,13 @@ require (
github.com/stretchr/testify v1.11.1
github.com/tech/sendico/gateway/chain v0.1.0
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0
github.com/testcontainers/testcontainers-go v0.33.0
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0
go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1
golang.org/x/net v0.47.0
golang.org/x/net v0.48.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
moul.io/chizap v1.0.3
@@ -35,7 +38,7 @@ require (
require (
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/casbin/casbin/v2 v2.134.0 // indirect
github.com/casbin/casbin/v2 v2.135.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
)
@@ -44,19 +47,19 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 // indirect
github.com/aws/smithy-go v1.24.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
@@ -131,7 +134,7 @@ require (
go.opentelemetry.io/proto/otlp v1.7.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect

View File

@@ -6,42 +6,42 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/aws/aws-sdk-go-v2 v1.40.1 h1:difXb4maDZkRH0x//Qkwcfpdg1XQVXEAEs2DdXldFFc=
github.com/aws/aws-sdk-go-v2 v1.40.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.3 h1:cpz7H2uMNTDa0h/5CYL5dLUEzPSLo2g0NkbxTRJtSSU=
github.com/aws/aws-sdk-go-v2/config v1.32.3/go.mod h1:srtPKaJJe3McW6T/+GMBZyIPc+SeqJsNPJsd4mOYZ6s=
github.com/aws/aws-sdk-go-v2/credentials v1.19.3 h1:01Ym72hK43hjwDeJUfi1l2oYLXBAOR8gNSZNmXmvuas=
github.com/aws/aws-sdk-go-v2/credentials v1.19.3/go.mod h1:55nWF/Sr9Zvls0bGnWkRxUdhzKqj9uRNlPvgV1vgxKc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15 h1:utxLraaifrSBkeyII9mIbVwXXWrZdlPO7FIKmyLCEcY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.15/go.mod h1:hW6zjYUDQwfz3icf4g2O41PHi77u10oAzJ84iSzR/lo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15 h1:Y5YXgygXwDI5P4RkteB5yF7v35neH7LfJKBG+hzIons=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.15/go.mod h1:K+/1EpG42dFSY7CBj+Fruzm8PsCGWTXJ3jdeJ659oGQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15 h1:AvltKnW9ewxX2hFmQS0FyJH93aSvJVUEFvXfU+HWtSE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.15/go.mod h1:3I4oCdZdmgrREhU74qS1dK9yZ62yumob+58AbFR4cQA=
github.com/aws/aws-sdk-go-v2/config v1.32.4 h1:gl+DxVuadpkYoaDcWllZqLkhGEbvwyqgNVRTmlaf5PI=
github.com/aws/aws-sdk-go-v2/config v1.32.4/go.mod h1:MBUp9Og/bzMmQHjMwace4aJfyvJeadzXjoTcR/SxLV0=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4 h1:KeIZxHVbGWRLhPvhdPbbi/DtFBHNKm6OsVDuiuFefdQ=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4/go.mod h1:Smw5n0nCZE9PeFEguofdXyt8kUC4JNrkDTfBOioPhFA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15 h1:NLYTEyZmVZo0Qh183sC8nC+ydJXOOeIL/qI/sS3PdLY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.15/go.mod h1:Z803iB3B0bc8oJV8zH2PERLRfQUJ2n2BXISpsA4+O1M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6 h1:P1MU/SuhadGvg2jtviDXPEejU3jBNhoeeAlRadHzvHI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.6/go.mod h1:5KYaMG6wmVKMFBSfWoyG/zH8pWwzQFnKgpoSRlXHKdQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15 h1:3/u/4yZOffg5jdNk1sDpOQ4Y+R6Xbh+GzpDrSZjuy3U=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.15/go.mod h1:4Zkjq0FKjE78NKjabuM4tRXKFzUJWXgP0ItEZK8l7JU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15 h1:wsSQ4SVz5YE1crz0Ap7VBZrV4nNqZt4CIBBT8mnwoNc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.15/go.mod h1:I7sditnFGtYMIqPRU1QoHZAUrXkGp4SczmlLwrNPlD0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0 h1:IrbE3B8O9pm3lsg96AXIN5MXX4pECEuExh/A0Du3AuI=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.0/go.mod h1:/sJLzHtiiZvs6C1RbxS/anSAFwZD6oC6M/kotQzOiLw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3 h1:d/6xOGIllc/XW1lzG9a4AUBMmpLA9PXcQnVPTuHHcik=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.3/go.mod h1:fQ7E7Qj9GiW8y0ClD7cUJk3Bz5Iw8wZkWDHsTe8vDKs=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6 h1:8sTTiw+9yuNXcfWeqKF2x01GqCF49CpP4Z9nKrrk/ts=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.6/go.mod h1:8WYg+Y40Sn3X2hioaaWAAIngndR8n1XFdRPPX+7QBaM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11 h1:E+KqWoVsSrj1tJ6I/fjDIu5xoS2Zacuu1zT+H7KtiIk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.11/go.mod h1:qyWHz+4lvkXcr3+PoGlGHEI+3DLLiU6/GdrFfMaAhB0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3 h1:tzMkjh0yTChUqJDgGkcDdxvZDSrJ/WB6R6ymI5ehqJI=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.3/go.mod h1:T270C0R5sZNLbWUe8ueiAF42XSZxxPocTaGSgs5c/60=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 h1:5FhzzN6JmlGQF6c04kDIb5KNGm6KnNdLISNrfivIhHg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 h1:YCu/iAhQer8WZ66lldyKkpvMyv+HkPufMa4dyT6wils=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -50,8 +50,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
@@ -288,8 +288,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -304,8 +304,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -332,8 +332,8 @@ golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=

View File

@@ -6,10 +6,11 @@ import (
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
Storage *fsc.Config `yaml:"storage"`
ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"`
Ledger *LedgerConfig `yaml:"ledger"`
Mw *mwa.Config `yaml:"middleware"`
Storage *fsc.Config `yaml:"storage"`
ChainGateway *ChainGatewayConfig `yaml:"chain_gateway"`
Ledger *LedgerConfig `yaml:"ledger"`
PaymentOrchestrator *PaymentOrchestratorConfig `yaml:"payment_orchestrator"`
}
type ChainGatewayConfig struct {
@@ -34,3 +35,11 @@ type LedgerConfig struct {
CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
Insecure bool `yaml:"insecure"`
}
type PaymentOrchestratorConfig struct {
Address string `yaml:"address"`
AddressEnv string `yaml:"address_env"`
DialTimeoutSeconds int `yaml:"dial_timeout_seconds"`
CallTimeoutSeconds int `yaml:"call_timeout_seconds"`
Insecure bool `yaml:"insecure"`
}

View File

@@ -0,0 +1,17 @@
package srequest
type QuotePayment struct {
IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type InitiatePayment struct {
IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
Metadata map[string]string `json:"metadata,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FxQuoteRef string `json:"fxQuoteRef,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"`
}

View File

@@ -0,0 +1,103 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
type PaymentKind int32
const (
PaymentKindUnspecified PaymentKind = 0
PaymentKindPayout PaymentKind = 1
PaymentKindInternalTransfer PaymentKind = 2
PaymentKindFxConversion PaymentKind = 3
)
// FXSide mirrors the common FX side enum.
type FXSide int32
const (
FXSideUnspecified FXSide = 0
FXSideBuyBaseSellQuote FXSide = 1
FXSideSellBaseBuyQuote FXSide = 2
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork int32
const (
ChainNetworkUnspecified ChainNetwork = 0
ChainNetworkEthereumMainnet ChainNetwork = 1
ChainNetworkArbitrumOne ChainNetwork = 2
ChainNetworkOtherEVM ChainNetwork = 3
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy int32
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = 0
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = 1
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = 2
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = 3
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *PaymentEndpoint `json:"source,omitempty"`
Destination *PaymentEndpoint `json:"destination,omitempty"`
Amount *Money `json:"amount,omitempty"`
RequiresFX bool `json:"requires_fx,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
FeePolicy *PolicyOverrides `json:"fee_policy,omitempty"`
Attributes map[string]string `json:"attributes,omitempty"`
}
type PaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type CurrencyPair struct {
Base string `json:"base"`
Quote string `json:"quote"`
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
Firm bool `json:"firm,omitempty"`
TTLms int64 `json:"ttl_ms,omitempty"`
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}
type PolicyOverrides struct {
InsufficientNet InsufficientNetPolicy `json:"insufficient_net,omitempty"`
}

View File

@@ -0,0 +1,18 @@
package sresponse
import moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
func toMoney(m *moneyv1.Money) *Money {
if m == nil {
return nil
}
return &Money{
Amount: m.GetAmount(),
Currency: m.GetCurrency(),
}
}

View File

@@ -0,0 +1,174 @@
package sresponse
import (
"net/http"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type FeeLine struct {
LedgerAccountRef string `json:"ledgerAccountRef,omitempty"`
Amount *Money `json:"amount,omitempty"`
LineType string `json:"lineType,omitempty"`
Side string `json:"side,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
type NetworkFee struct {
NetworkFee *Money `json:"networkFee,omitempty"`
EstimationContext string `json:"estimationContext,omitempty"`
}
type FxQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
BaseCurrency string `json:"baseCurrency,omitempty"`
QuoteCurrency string `json:"quoteCurrency,omitempty"`
Side string `json:"side,omitempty"`
Price string `json:"price,omitempty"`
BaseAmount *Money `json:"baseAmount,omitempty"`
QuoteAmount *Money `json:"quoteAmount,omitempty"`
ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"`
Provider string `json:"provider,omitempty"`
RateRef string `json:"rateRef,omitempty"`
Firm bool `json:"firm,omitempty"`
}
type PaymentQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
DebitAmount *Money `json:"debitAmount,omitempty"`
ExpectedSettlementAmount *Money `json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *Money `json:"expectedFeeTotal,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FeeLines []FeeLine `json:"feeLines,omitempty"`
NetworkFee *NetworkFee `json:"networkFee,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"`
}
type Payment struct {
PaymentRef string `json:"paymentRef,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
State string `json:"state,omitempty"`
FailureCode string `json:"failureCode,omitempty"`
FailureReason string `json:"failureReason,omitempty"`
LastQuote *PaymentQuote `json:"lastQuote,omitempty"`
}
type paymentQuoteResponse struct {
authResponse `json:",inline"`
Quote *PaymentQuote `json:"quote"`
}
type paymentResponse struct {
authResponse `json:",inline"`
Payment *Payment `json:"payment"`
}
// PaymentQuote wraps a payment quote with refreshed access token.
func PaymentQuoteResponse(logger mlogger.Logger, quote *orchestratorv1.PaymentQuote, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentQuoteResponse{
Quote: toPaymentQuote(quote),
authResponse: authResponse{AccessToken: *token},
})
}
// Payment wraps a payment with refreshed access token.
func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc {
return response.Ok(logger, paymentResponse{
Payment: toPayment(payment),
authResponse: authResponse{AccessToken: *token},
})
}
func toFeeLines(lines []*feesv1.DerivedPostingLine) []FeeLine {
if len(lines) == 0 {
return nil
}
result := make([]FeeLine, 0, len(lines))
for _, line := range lines {
if line == nil {
continue
}
result = append(result, FeeLine{
LedgerAccountRef: line.GetLedgerAccountRef(),
Amount: toMoney(line.GetMoney()),
LineType: line.GetLineType().String(),
Side: line.GetSide().String(),
Meta: line.GetMeta(),
})
}
if len(result) == 0 {
return nil
}
return result
}
func toNetworkFee(n *chainv1.EstimateTransferFeeResponse) *NetworkFee {
if n == nil {
return nil
}
return &NetworkFee{
NetworkFee: toMoney(n.GetNetworkFee()),
EstimationContext: n.GetEstimationContext(),
}
}
func toFxQuote(q *oraclev1.Quote) *FxQuote {
if q == nil {
return nil
}
pair := q.GetPair()
base := ""
quote := ""
if pair != nil {
base = pair.GetBase()
quote = pair.GetQuote()
}
return &FxQuote{
QuoteRef: q.GetQuoteRef(),
BaseCurrency: base,
QuoteCurrency: quote,
Side: q.GetSide().String(),
Price: q.GetPrice().GetValue(),
BaseAmount: toMoney(q.GetBaseAmount()),
QuoteAmount: toMoney(q.GetQuoteAmount()),
ExpiresAtUnixMs: q.GetExpiresAtUnixMs(),
Provider: q.GetProvider(),
RateRef: q.GetRateRef(),
Firm: q.GetFirm(),
}
}
func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote {
if q == nil {
return nil
}
return &PaymentQuote{
QuoteRef: q.GetQuoteRef(),
DebitAmount: toMoney(q.GetDebitAmount()),
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
FeeQuoteToken: q.GetFeeQuoteToken(),
FeeLines: toFeeLines(q.GetFeeLines()),
NetworkFee: toNetworkFee(q.GetNetworkFee()),
FxQuote: toFxQuote(q.GetFxQuote()),
}
}
func toPayment(p *orchestratorv1.Payment) *Payment {
if p == nil {
return nil
}
return &Payment{
PaymentRef: p.GetPaymentRef(),
IdempotencyKey: p.GetIdempotencyKey(),
State: p.GetState().String(),
FailureCode: p.GetFailureCode().String(),
FailureReason: p.GetFailureReason(),
LastQuote: toPaymentQuote(p.GetLastQuote()),
}
}

View File

@@ -6,7 +6,6 @@ import (
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb"
@@ -36,16 +35,11 @@ type walletsResponse struct {
Page *paginationv1.CursorPageResponse `json:"page,omitempty"`
}
type walletMoney struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
type walletBalance struct {
Available *walletMoney `json:"available,omitempty"`
PendingInbound *walletMoney `json:"pendingInbound,omitempty"`
PendingOutbound *walletMoney `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"`
Available *Money `json:"available,omitempty"`
PendingInbound *Money `json:"pendingInbound,omitempty"`
PendingOutbound *Money `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"`
}
type walletBalanceResponse struct {
@@ -114,16 +108,6 @@ func toWalletBalance(b *chainv1.WalletBalance) walletBalance {
}
}
func toMoney(m *moneyv1.Money) *walletMoney {
if m == nil {
return nil
}
return &walletMoney{
Amount: m.GetAmount(),
Currency: m.GetCurrency(),
}
}
func tsToString(ts *timestamppb.Timestamp) string {
if ts == nil {
return ""

View File

@@ -0,0 +1,12 @@
package payment
import (
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/server/interface/api"
"github.com/tech/sendico/server/internal/server/paymentapiimp"
)
// Create wires payment orchestrator BFF API.
func Create(a api.API) (mservice.MicroService, error) {
return paymentapiimp.CreateAPI(a)
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/tech/sendico/server/interface/services/ledger"
"github.com/tech/sendico/server/interface/services/logo"
"github.com/tech/sendico/server/interface/services/organization"
"github.com/tech/sendico/server/interface/services/payment"
"github.com/tech/sendico/server/interface/services/paymethod"
"github.com/tech/sendico/server/interface/services/permission"
"github.com/tech/sendico/server/interface/services/recipient"
@@ -91,6 +92,7 @@ func (a *APIImp) installServices() error {
srvf = append(srvf, ledger.Create)
srvf = append(srvf, recipient.Create)
srvf = append(srvf, paymethod.Create)
srvf = append(srvf, payment.Create)
for _, v := range srvf {
if err := a.addMicroservice(v); err != nil {

View File

@@ -10,6 +10,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
@@ -29,11 +30,11 @@ func (a *LedgerAPI) getBalance(r *http.Request, account *model.Account, token *s
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.balancePerm, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check ledger balance access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex()), zap.String("ledger_account_ref", accountRef))
a.logger.Warn("Failed to check ledger balance access permissions", zap.Error(err), mutil.PLog(a.oph, r), zap.String("ledger_account_ref", accountRef))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when reading ledger balance", zap.String(a.oph.Name(), orgRef.Hex()), zap.String("ledger_account_ref", accountRef))
a.logger.Debug("Access denied when reading ledger balance", mutil.PLog(a.oph, r), zap.String("ledger_account_ref", accountRef))
return response.AccessDenied(a.logger, a.Name(), "ledger balance read permission denied")
}
if a.client == nil {

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
@@ -23,11 +24,11 @@ func (a *LedgerAPI) listAccounts(r *http.Request, account *model.Account, token
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex()))
a.logger.Warn("Failed to check ledger accounts access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when listing ledger accounts", zap.String(a.oph.Name(), orgRef.Hex()))
a.logger.Debug("Access denied when listing ledger accounts", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "ledger accounts read permission denied")
}
if a.client == nil {

View File

@@ -0,0 +1,175 @@
package paymentapiimp
import (
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/tech/sendico/server/interface/api/srequest"
)
func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIntent, error) {
if intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
source, err := mapPaymentEndpoint(intent.Source, "source")
if err != nil {
return nil, err
}
destination, err := mapPaymentEndpoint(intent.Destination, "destination")
if err != nil {
return nil, err
}
fx, err := mapFXIntent(intent.FX)
if err != nil {
return nil, err
}
return &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind(intent.Kind),
Source: source,
Destination: destination,
Amount: mapMoney(intent.Amount),
RequiresFx: intent.RequiresFX,
Fx: fx,
FeePolicy: mapPolicyOverrides(intent.FeePolicy),
Attributes: copyStringMap(intent.Attributes),
}, nil
}
func mapPaymentEndpoint(endpoint *srequest.PaymentEndpoint, field string) (*orchestratorv1.PaymentEndpoint, error) {
if endpoint == nil {
return nil, nil
}
var (
count int
result orchestratorv1.PaymentEndpoint
)
if endpoint.Ledger != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{
Ledger: mapLedgerEndpoint(endpoint.Ledger),
}
}
if endpoint.ManagedWallet != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: mapManagedWalletEndpoint(endpoint.ManagedWallet),
}
}
if endpoint.ExternalChain != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{
ExternalChain: mapExternalChainEndpoint(endpoint.ExternalChain),
}
}
if count > 1 {
return nil, merrors.InvalidArgument(field + " endpoint must set only one of ledger, managed_wallet, external_chain")
}
result.Metadata = copyStringMap(endpoint.Metadata)
return &result, nil
}
func mapLedgerEndpoint(endpoint *srequest.LedgerEndpoint) *orchestratorv1.LedgerEndpoint {
if endpoint == nil {
return nil
}
return &orchestratorv1.LedgerEndpoint{
LedgerAccountRef: endpoint.LedgerAccountRef,
ContraLedgerAccountRef: endpoint.ContraLedgerAccountRef,
}
}
func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) *orchestratorv1.ManagedWalletEndpoint {
if endpoint == nil {
return nil
}
return &orchestratorv1.ManagedWalletEndpoint{
ManagedWalletRef: endpoint.ManagedWalletRef,
Asset: mapAsset(endpoint.Asset),
}
}
func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) *orchestratorv1.ExternalChainEndpoint {
if endpoint == nil {
return nil
}
return &orchestratorv1.ExternalChainEndpoint{
Asset: mapAsset(endpoint.Asset),
Address: endpoint.Address,
Memo: endpoint.Memo,
}
}
func mapAsset(asset *srequest.Asset) *chainv1.Asset {
if asset == nil {
return nil
}
return &chainv1.Asset{
Chain: chainv1.ChainNetwork(asset.Chain),
TokenSymbol: asset.TokenSymbol,
ContractAddress: asset.ContractAddress,
}
}
func mapMoney(m *srequest.Money) *moneyv1.Money {
if m == nil {
return nil
}
return &moneyv1.Money{
Amount: m.Amount,
Currency: m.Currency,
}
}
func mapFXIntent(fx *srequest.FXIntent) (*orchestratorv1.FXIntent, error) {
if fx == nil {
return nil, nil
}
return &orchestratorv1.FXIntent{
Pair: mapCurrencyPair(fx.Pair),
Side: fxv1.Side(fx.Side),
Firm: fx.Firm,
TtlMs: fx.TTLms,
PreferredProvider: fx.PreferredProvider,
MaxAgeMs: fx.MaxAgeMs,
}, nil
}
func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair {
if pair == nil {
return nil
}
return &fxv1.CurrencyPair{
Base: pair.Base,
Quote: pair.Quote,
}
}
func mapPolicyOverrides(policy *srequest.PolicyOverrides) *feesv1.PolicyOverrides {
if policy == nil {
return nil
}
return &feesv1.PolicyOverrides{
InsufficientNet: feesv1.InsufficientNetPolicy(policy.InsufficientNet),
}
}
func copyStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View File

@@ -0,0 +1,90 @@
package paymentapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
// shared initiation pipeline
func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, token *sresponse.TokenData, expectQuote bool) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when initiating payment", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeInitiatePayload(r)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
if expectQuote && strings.TrimSpace(payload.QuoteRef) == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quote_ref is required"))
}
if !expectQuote {
payload.QuoteRef = ""
}
intent, err := mapPaymentIntent(payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{
OrganizationRef: orgRef.Hex(),
},
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
Intent: intent,
FeeQuoteToken: strings.TrimSpace(payload.FeeQuoteToken),
FxQuoteRef: strings.TrimSpace(payload.FxQuoteRef),
QuoteRef: strings.TrimSpace(payload.QuoteRef),
Metadata: payload.Metadata,
}
resp, err := a.client.InitiatePayment(ctx, req)
if err != nil {
a.logger.Warn("Failed to initiate payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.PaymentResponse(a.logger, resp.GetPayment(), token)
}
func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) {
defer r.Body.Close()
payload := &srequest.InitiatePayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotencyKey is required")
}
if payload.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
return payload, nil
}

View File

@@ -0,0 +1,13 @@
package paymentapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
)
// initiateImmediate runs a one-shot payment using a fresh quote.
func (a *PaymentAPI) initiateImmediate(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
return a.initiatePayment(r, account, token, false)
}

View File

@@ -0,0 +1,13 @@
package paymentapiimp
import (
"net/http"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/server/interface/api/sresponse"
)
// initiateByQuote executes a payment using a previously issued quote_ref.
func (a *PaymentAPI) initiateByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
return a.initiatePayment(r, account, token, true)
}

View File

@@ -0,0 +1,80 @@
package paymentapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for quote", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
ctx := r.Context()
allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !allowed {
a.logger.Debug("Access denied when quoting payment", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "payments write permission denied")
}
payload, err := decodeQuotePayload(r)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
intent, err := mapPaymentIntent(payload.Intent)
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: &orchestratorv1.RequestMeta{
OrganizationRef: orgRef.Hex(),
},
IdempotencyKey: payload.IdempotencyKey,
Intent: intent,
PreviewOnly: payload.PreviewOnly,
}
resp, err := a.client.QuotePayment(ctx, req)
if err != nil {
a.logger.Warn("Failed to quote payment", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return response.Auto(a.logger, a.Name(), err)
}
return sresponse.PaymentQuoteResponse(a.logger, resp.GetQuote(), token)
}
func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
defer r.Body.Close()
payload := &srequest.QuotePayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" {
return nil, merrors.InvalidArgument("idempotencyKey is required")
}
if payload.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
}
return payload, nil
}

View File

@@ -0,0 +1,102 @@
package paymentapiimp
import (
"context"
"fmt"
"os"
"strings"
"time"
orchestratorclient "github.com/tech/sendico/payments/orchestrator/client"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
eapi "github.com/tech/sendico/server/interface/api"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type paymentClient interface {
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
Close() error
}
type PaymentAPI struct {
logger mlogger.Logger
client paymentClient
enf auth.Enforcer
oph mutil.ParamHelper
permissionRef primitive.ObjectID
}
func (a *PaymentAPI) Name() mservice.Type { return mservice.PaymentOrchestrator }
func (a *PaymentAPI) Finish(ctx context.Context) error {
if a.client != nil {
if err := a.client.Close(); err != nil {
a.logger.Warn("Failed to close payment orchestrator client", zap.Error(err))
}
}
return nil
}
func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
p := &PaymentAPI{
logger: apiCtx.Logger().Named(mservice.PaymentOrchestrator),
enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations),
}
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.PaymentOrchestrator)
if err != nil {
p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err))
return nil, err
}
p.permissionRef = desc.ID
if err := p.initPaymentClient(apiCtx.Config().PaymentOrchestrator); err != nil {
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
return nil, err
}
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote"), api.Post, p.quotePayment)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote)
return p, nil
}
func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig) error {
if cfg == nil {
return merrors.InvalidArgument("payment orchestrator configuration is not provided")
}
address := strings.TrimSpace(cfg.Address)
if address == "" {
address = strings.TrimSpace(os.Getenv(cfg.AddressEnv))
}
if address == "" {
return merrors.InvalidArgument(fmt.Sprintf("payment orchestrator address is not specified and address env %s is empty", cfg.AddressEnv))
}
clientCfg := orchestratorclient.Config{
Address: address,
DialTimeout: time.Duration(cfg.DialTimeoutSeconds) * time.Second,
CallTimeout: time.Duration(cfg.CallTimeoutSeconds) * time.Second,
Insecure: cfg.Insecure,
}
client, err := orchestratorclient.New(context.Background(), clientCfg)
if err != nil {
return err
}
a.client = client
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
@@ -28,11 +29,11 @@ func (a *WalletAPI) getWalletBalance(r *http.Request, account *model.Account, to
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.balancesPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check wallet balance permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex()), zap.String("wallet_ref", walletRef))
a.logger.Warn("Failed to check wallet balance permissions", zap.Error(err), mutil.PLog(a.oph, r), zap.String("wallet_ref", walletRef))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when reading wallet balance", zap.String(a.oph.Name(), orgRef.Hex()), zap.String("wallet_ref", walletRef))
a.logger.Debug("Access denied when reading wallet balance", mutil.PLog(a.oph, r), zap.String("wallet_ref", walletRef))
return response.AccessDenied(a.logger, a.Name(), "wallet balance read permission denied")
}
if a.chainGateway == nil {

View File

@@ -10,6 +10,7 @@ import (
"github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
@@ -24,11 +25,11 @@ func (a *WalletAPI) listWallets(r *http.Request, account *model.Account, token *
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead)
if err != nil {
a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), zap.String(a.oph.Name(), orgRef.Hex()))
a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when listing organization wallets", zap.String(a.oph.Name(), orgRef.Hex()))
a.logger.Debug("Access denied when listing organization wallets", mutil.PLog(a.oph, r))
return response.AccessDenied(a.logger, a.Name(), "wallets read permission denied")
}
if a.chainGateway == nil {

View File

@@ -11,6 +11,8 @@ class CommonConstants {
static String apiEndpoint = '/api/v1';
static String amplitudeSecret = 'c3d75b3e2520d708440acbb16b923e79';
static String amplitudeServerZone = 'EU';
static String posthogApiKey = '';
static String posthogHost = 'https://eu.i.posthog.com';
static Locale defaultLocale = const Locale('en');
static String defaultCurrency = 'EUR';
static int defaultDimensionLength = 500;
@@ -36,6 +38,8 @@ class CommonConstants {
apiEndpoint = configJson['apiEndpoint'] ?? apiEndpoint;
amplitudeSecret = configJson['amplitudeSecret'] ?? amplitudeSecret;
amplitudeServerZone = configJson['amplitudeServerZone'] ?? amplitudeServerZone;
posthogApiKey = configJson['posthogApiKey'] ?? posthogApiKey;
posthogHost = configJson['posthogHost'] ?? posthogHost;
defaultLocale = Locale(configJson['defaultLocale'] ?? defaultLocale.languageCode);
defaultCurrency = configJson['defaultCurrency'] ?? defaultCurrency;
wsProto = configJson['wsProto'] ?? wsProto;

View File

@@ -15,6 +15,8 @@ class Constants extends CommonConstants {
static String get currentOrgKey => CommonConstants.currentOrgKey;
static String get apiUrl => CommonConstants.apiUrl;
static String get serviceUrl => CommonConstants.serviceUrl;
static String get posthogApiKey => CommonConstants.posthogApiKey;
static String get posthogHost => CommonConstants.posthogHost;
static int get defaultDimensionLength => CommonConstants.defaultDimensionLength;
static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey;
static String get nilObjectRef => CommonConstants.nilObjectRef;

View File

@@ -21,6 +21,8 @@ extension AppConfigExtension on AppConfig {
external String? get apiEndpoint;
external String? get amplitudeSecret;
external String? get amplitudeServerZone;
external String? get posthogApiKey;
external String? get posthogHost;
external String? get defaultLocale;
external String? get wsProto;
external String? get wsEndpoint;
@@ -40,6 +42,8 @@ class Constants extends CommonConstants {
static String get currentOrgKey => CommonConstants.currentOrgKey;
static String get apiUrl => CommonConstants.apiUrl;
static String get serviceUrl => CommonConstants.serviceUrl;
static String get posthogApiKey => CommonConstants.posthogApiKey;
static String get posthogHost => CommonConstants.posthogHost;
static int get defaultDimensionLength => CommonConstants.defaultDimensionLength;
static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey;
static String get nilObjectRef => CommonConstants.nilObjectRef;
@@ -57,6 +61,8 @@ class Constants extends CommonConstants {
'apiEndpoint': config.apiEndpoint,
'amplitudeSecret': config.amplitudeSecret,
'amplitudeServerZone': config.amplitudeServerZone,
'posthogApiKey': config.posthogApiKey,
'posthogHost': config.posthogHost,
'defaultLocale': config.defaultLocale,
'wsProto': config.wsProto,
'wsEndpoint': config.wsEndpoint,

View File

@@ -27,6 +27,7 @@ class AccountProvider extends ChangeNotifier {
Resource<Account?> get resource => _resource;
late LocaleProvider _localeProvider;
PendingLogin? _pendingLogin;
Future<void>? _restoreFuture;
Account? get account => _resource.data;
PendingLogin? get pendingLogin => _pendingLogin;
@@ -34,6 +35,7 @@ class AccountProvider extends ChangeNotifier {
bool get isLoading => _resource.isLoading;
Object? get error => _resource.error;
bool get isReady => (!isLoading) && (account != null);
Future<void>? get restoreFuture => _restoreFuture;
Account? currentUser() {
final acc = account;
@@ -220,4 +222,12 @@ class AccountProvider extends ChangeNotifier {
rethrow;
}
}
Future<void> restoreIfPossible() {
return _restoreFuture ??= () async {
final hasAuth = await AuthorizationService.isAuthorizationStored();
if (!hasAuth) return;
await restore();
}();
}
}

View File

@@ -24,9 +24,9 @@ import 'package:pweb/providers/two_factor.dart';
import 'package:pweb/providers/upload_history.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/providers/wallet_transactions.dart';
// import 'package:pweb/services/amplitude.dart';
import 'package:pweb/services/operations.dart';
import 'package:pweb/services/payments/history.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/services/wallet_transactions.dart';
import 'package:pweb/services/wallets.dart';
@@ -40,11 +40,9 @@ void _setupLogging() {
}
void main() async {
await Constants.initialize();
WidgetsFlutterBinding.ensureInitialized();
// await AmplitudeService.initialize();
await Constants.initialize();
await PosthogService.initialize();
_setupLogging();

View File

@@ -14,7 +14,7 @@ import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pweb/pages/address_book/form/view.dart';
// import 'package:pweb/services/amplitude.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/utils/payment/label.dart';
import 'package:pweb/utils/snackbar.dart';
@@ -105,11 +105,11 @@ class _AdressBookRecipientFormState extends State<AdressBookRecipientForm> {
return;
}
// AmplitudeService.recipientAddCompleted(
// _type,
// _status,
// _methods.keys.toSet(),
// );
await PosthogService.recipientAddCompleted(
_type,
_status,
_methods.keys.toSet(),
);
final recipient = await executeActionWithNotification(
context: context,
action: _doSave,

View File

@@ -6,6 +6,7 @@ import 'package:pshared/provider/account.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -17,15 +18,38 @@ class AccountLoader extends StatelessWidget {
@override
Widget build(BuildContext context) => Consumer<AccountProvider>(builder: (context, provider, _) {
if (provider.isLoading) return const Center(child: CircularProgressIndicator());
if (provider.error != null) {
postNotifyUserOfErrorX(
context: context,
errorSituation: AppLocalizations.of(context)!.errorLogin,
exception: provider.error!,
);
navigateAndReplace(context, Pages.login);
if (provider.account != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final account = provider.account;
if (account != null) {
PosthogService.identify(account);
}
});
return child;
}
if (provider.error != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
postNotifyUserOfErrorX(
context: context,
errorSituation: AppLocalizations.of(context)!.errorLogin,
exception: provider.error!,
);
navigateAndReplace(context, Pages.login);
});
return const Center(child: CircularProgressIndicator());
}
if (provider.restoreFuture == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
provider.restoreIfPossible().catchError((error, stack) {
debugPrint('Account restore failed: $error');
});
});
return const Center(child: CircularProgressIndicator());
}
if (provider.isLoading) return const Center(child: CircularProgressIndicator());
if (provider.account == null) {
WidgetsBinding.instance.addPostFrameCallback((_) => navigateAndReplace(context, Pages.login));
return const Center(child: CircularProgressIndicator());

View File

@@ -14,6 +14,7 @@ import 'package:pweb/widgets/password/password.dart';
import 'package:pweb/widgets/username.dart';
import 'package:pweb/widgets/vspacer.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -43,10 +44,14 @@ class _LoginFormState extends State<LoginForm> {
password: _passwordController.text,
locale: context.read<LocaleProvider>().locale.languageCode,
);
await PosthogService.login(pending: outcome.isPending);
if (outcome.isPending) {
// TODO: fix context usage
navigateAndReplace(context, Pages.sfactor);
} else {
if (outcome.account != null) {
await PosthogService.identify(outcome.account!);
}
onLogin();
}
return 'ok';

View File

@@ -16,6 +16,7 @@ import 'package:pweb/providers/payment_flow.dart';
import 'package:pweb/pages/payment_methods/payment_page/body.dart';
import 'package:pweb/providers/wallets.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/services/posthog.dart';
class PaymentPage extends StatefulWidget {
@@ -109,7 +110,7 @@ class _PaymentPageState extends State<PaymentPage> {
void _handleSendPayment() {
// TODO: Handle Payment logic
// AmplitudeService.paymentInitiated();
PosthogService.paymentInitiated(method: _flowProvider.selectedType);
}
@override

View File

@@ -4,7 +4,7 @@ import 'package:provider/provider.dart';
import 'package:pshared/provider/locale.dart';
// import 'package:pweb/services/amplitude.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -58,7 +58,7 @@ class LocalePicker extends StatelessWidget {
onChanged: (locale) {
if (locale != null) {
localeProvider.setLocale(locale);
// AmplitudeService.localeChanged(locale);
PosthogService.localeChanged(locale);
}
},
decoration: const InputDecoration(

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.dart';
import 'package:posthog_flutter/posthog_flutter.dart';
import 'package:pshared/config/constants.dart';
import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/models/recipient/status.dart';
import 'package:pshared/models/recipient/type.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
class PosthogService {
static final _logger = Logger('service.posthog');
static String? _identifiedUserId;
static bool _initialized = false;
static bool get isEnabled => _initialized;
static Future<void> initialize() async {
final apiKey = Constants.posthogApiKey;
if (apiKey.isEmpty) {
_logger.warning('PostHog API key is not configured, analytics disabled.');
return;
}
try {
final config = PostHogConfig(apiKey)
..host = Constants.posthogHost
..captureApplicationLifecycleEvents = true;
await Posthog().setup(config);
await Posthog().register('client_id', Constants.clientId);
_initialized = true;
_logger.info('PostHog initialized with host ${Constants.posthogHost}');
} catch (e, st) {
_initialized = false;
_logger.warning('Failed to initialize PostHog: $e', e, st);
}
}
static Future<void> identify(Account account) async {
if (!_initialized) return;
if (_identifiedUserId == account.id) return;
await Posthog().identify(
userId: account.id,
userProperties: {
'email': account.login,
'name': account.name,
'locale': account.locale,
'created_at': account.createdAt.toIso8601String(),
},
);
_identifiedUserId = account.id;
}
static Future<void> reset() async {
if (!_initialized) return;
_identifiedUserId = null;
await Posthog().reset();
}
static Future<void> login({required bool pending}) async {
if (!_initialized) return;
await _capture(
'login',
properties: {
'result': pending ? 'pending' : 'success',
},
);
}
static Future<void> pageOpened(PayoutDestination page, {String? path, String? uiSource}) async {
if (!_initialized) return;
return _capture(
'pageOpened',
properties: {
'page': page.name,
if (path != null) 'path': path,
if (uiSource != null) 'uiSource': uiSource,
},
);
}
static Future<void> localeChanged(Locale locale) async {
if (!_initialized) return;
return _capture(
'localeChanged',
properties: {'locale': locale.toLanguageTag()},
);
}
static Future<void> recipientAddCompleted(
RecipientType type,
RecipientStatus status,
Set<PaymentType> methods,
) async {
if (!_initialized) return;
return _capture(
'recipientAddCompleted',
properties: {
'methods': methods.map((m) => m.name).toList(),
'type': type.name,
'status': status.name,
},
);
}
static Future<void> paymentInitiated({PaymentType? method}) async {
if (!_initialized) return;
return _capture(
'paymentInitiated',
properties: {
if (method != null) 'method': method.name,
},
);
}
static Future<void> _capture(
String eventName, {
Map<String, Object?>? properties,
}) async {
if (!_initialized) return;
final filtered = <String, Object>{};
if (properties != null) {
for (final entry in properties.entries) {
final value = entry.value;
if (value != null) filtered[entry.key] = value;
}
}
await Posthog().capture(eventName: eventName, properties: filtered.isEmpty ? null : filtered);
}
}

View File

@@ -6,10 +6,12 @@ import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/app/router/pages.dart';
import 'package:pweb/services/posthog.dart';
void logoutUtil(BuildContext context) {
context.read<AccountProvider>().logout();
context.read<PermissionsProvider>().reset();
PosthogService.reset();
navigateAndReplace(context, Pages.login);
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
// import 'package:pweb/services/amplitude.dart';
import 'package:pweb/services/posthog.dart';
import 'package:pweb/widgets/sidebar/destinations.dart';
@@ -49,7 +49,7 @@ class SideMenuColumn extends StatelessWidget {
child: InkWell(
onTap: () {
onSelected(item);
// AmplitudeService.pageOpened(item, uiSource: 'sidebar');
PosthogService.pageOpened(item, uiSource: 'sidebar');
},
borderRadius: BorderRadius.circular(12),
hoverColor: theme.colorScheme.primaryContainer,

View File

@@ -9,6 +9,7 @@ import amplitude_flutter
import file_selector_macos
import flutter_timezone
import path_provider_foundation
import posthog_flutter
import share_plus
import shared_preferences_foundation
import sqflite_darwin
@@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
PosthogFlutterPlugin.register(with: registry.registrar(forPlugin: "PosthogFlutterPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@@ -35,6 +35,7 @@ dependencies:
sdk: flutter
pshared:
path: ../pshared
posthog_flutter: ^4.0.1
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.