Compare commits
1 Commits
c8a97d940c
...
SEND005
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83e3af9a42 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -9,5 +9,4 @@ untranslated.txt
|
||||
generate_protos.sh
|
||||
update_dep.sh
|
||||
.vscode/
|
||||
.gocache/
|
||||
.cache/
|
||||
.gocache/
|
||||
@@ -50,5 +50,5 @@ require (
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
|
||||
@@ -216,8 +216,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -90,6 +90,10 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -109,8 +113,7 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
|
||||
|
||||
entrySide := mapEntrySide(rule.EntrySide)
|
||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||
// Default fees to debit (i.e. charge the customer) when entry side is not specified.
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||
}
|
||||
|
||||
meta := map[string]string{
|
||||
|
||||
@@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||
}
|
||||
}
|
||||
|
||||
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||
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 orgRef != nil && !orgRef.IsZero() {
|
||||
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
|
||||
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", orgRef.Hex()))
|
||||
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", orgRef.Hex()))
|
||||
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", orgRef.Hex()))
|
||||
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex()))
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||
}
|
||||
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
||||
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)
|
||||
@@ -59,7 +59,8 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
orgPlan.OrganizationRef = &org
|
||||
orgPlan.SetOrganizationRef(org)
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
|
||||
@@ -94,7 +95,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
plan.OrganizationRef = &org
|
||||
plan.SetOrganizationRef(org)
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
@@ -135,7 +136,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
|
||||
},
|
||||
}
|
||||
orgPlan.OrganizationRef = &org
|
||||
orgPlan.SetOrganizationRef(org)
|
||||
|
||||
globalPlan := &model.FeePlan{
|
||||
Active: true,
|
||||
@@ -220,7 +221,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p1.OrganizationRef = &org
|
||||
p1.SetOrganizationRef(org)
|
||||
p2 := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||
@@ -228,7 +229,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||
},
|
||||
}
|
||||
p2.OrganizationRef = &org
|
||||
p2.SetOrganizationRef(org)
|
||||
|
||||
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||
resolver := New(store, zap.NewNop())
|
||||
@@ -262,7 +263,7 @@ func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.O
|
||||
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.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||
if plan == nil || plan.GetOrganizationRef() != orgRef {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
@@ -288,7 +289,7 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive
|
||||
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.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||
if plan == nil || !plan.GetOrganizationRef().IsZero() {
|
||||
continue
|
||||
}
|
||||
if !plan.Active {
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func requestLogFields(meta *feesv1.RequestMeta, intent *feesv1.Intent) []zap.Field {
|
||||
fields := logFieldsFromRequestMeta(meta)
|
||||
fields = append(fields, logFieldsFromIntent(intent)...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromRequestMeta(meta *feesv1.RequestMeta) []zap.Field {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 4)
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
fields = append(fields, logFieldsFromTrace(meta.GetTrace())...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromIntent(intent *feesv1.Intent) []zap.Field {
|
||||
if intent == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 5)
|
||||
if trigger := intent.GetTrigger(); trigger != feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
fields = append(fields, zap.String("trigger", trigger.String()))
|
||||
}
|
||||
if base := intent.GetBaseAmount(); base != nil {
|
||||
if amount := strings.TrimSpace(base.GetAmount()); amount != "" {
|
||||
fields = append(fields, zap.String("base_amount", amount))
|
||||
}
|
||||
if currency := strings.TrimSpace(base.GetCurrency()); currency != "" {
|
||||
fields = append(fields, zap.String("base_currency", currency))
|
||||
}
|
||||
}
|
||||
if booked := intent.GetBookedAt(); booked != nil && booked.IsValid() {
|
||||
fields = append(fields, zap.Time("booked_at", booked.AsTime()))
|
||||
}
|
||||
if attrs := intent.GetAttributes(); len(attrs) > 0 {
|
||||
fields = append(fields, zap.Int("attributes_count", len(attrs)))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromTrace(trace *tracev1.TraceContext) []zap.Field {
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 3)
|
||||
if reqRef := strings.TrimSpace(trace.GetRequestRef()); reqRef != "" {
|
||||
fields = append(fields, zap.String("request_ref", reqRef))
|
||||
}
|
||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||
fields = append(fields, zap.String("idempotency_key", idem))
|
||||
}
|
||||
if traceRef := strings.TrimSpace(trace.GetTraceRef()); traceRef != "" {
|
||||
fields = append(fields, zap.String("trace_ref", traceRef))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func logFieldsFromTokenPayload(payload *feeQuoteTokenPayload) []zap.Field {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 6)
|
||||
if org := strings.TrimSpace(payload.OrganizationRef); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if payload.ExpiresAtUnixMs > 0 {
|
||||
fields = append(fields,
|
||||
zap.Int64("expires_at_unix_ms", payload.ExpiresAtUnixMs),
|
||||
zap.Time("expires_at", time.UnixMilli(payload.ExpiresAtUnixMs)))
|
||||
}
|
||||
fields = append(fields, logFieldsFromIntent(payload.Intent)...)
|
||||
fields = append(fields, logFieldsFromTrace(payload.Trace)...)
|
||||
return fields
|
||||
}
|
||||
@@ -72,57 +72,26 @@ func (s *Service) Register(router routers.GRPC) error {
|
||||
}
|
||||
|
||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||
var (
|
||||
meta *feesv1.RequestMeta
|
||||
intent *feesv1.Intent
|
||||
)
|
||||
if req != nil {
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
if req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
}
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
}
|
||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Int("lines", linesCount),
|
||||
zap.Int("applied_rules", appliedCount),
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("QuoteFees finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("QuoteFees finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("QuoteFees request received")
|
||||
|
||||
if err = s.validateQuoteRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
@@ -143,59 +112,20 @@ func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (
|
||||
}
|
||||
|
||||
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
|
||||
var (
|
||||
meta *feesv1.RequestMeta
|
||||
intent *feesv1.Intent
|
||||
)
|
||||
if req != nil {
|
||||
meta = req.GetMeta()
|
||||
intent = req.GetIntent()
|
||||
}
|
||||
logger := s.logger.With(requestLogFields(meta, intent)...)
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if intent != nil {
|
||||
trigger = intent.GetTrigger()
|
||||
if req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
}
|
||||
var (
|
||||
fxUsed bool
|
||||
expiresAt time.Time
|
||||
)
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
linesCount := 0
|
||||
appliedCount := 0
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
linesCount = len(resp.GetLines())
|
||||
appliedCount = len(resp.GetApplied())
|
||||
if ts := resp.GetExpiresAt(); ts != nil {
|
||||
expiresAt = ts.AsTime()
|
||||
}
|
||||
}
|
||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Int("lines", linesCount),
|
||||
zap.Int("applied_rules", appliedCount),
|
||||
}
|
||||
if !expiresAt.IsZero() {
|
||||
logFields = append(logFields, zap.Time("expires_at", expiresAt))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("PrecomputeFees finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("PrecomputeFees finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("PrecomputeFees request received")
|
||||
|
||||
if err = s.validatePrecomputeRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -204,7 +134,6 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
@@ -219,7 +148,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
if ttl <= 0 {
|
||||
ttl = 60000
|
||||
}
|
||||
expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
|
||||
payload := feeQuoteTokenPayload{
|
||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||
@@ -230,7 +159,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
|
||||
var token string
|
||||
if token, err = encodeTokenPayload(payload); err != nil {
|
||||
logger.Warn("failed to encode fee quote token", zap.Error(err))
|
||||
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
|
||||
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
||||
return nil, err
|
||||
}
|
||||
@@ -247,18 +176,9 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
|
||||
}
|
||||
|
||||
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
|
||||
tokenLen := 0
|
||||
if req != nil {
|
||||
tokenLen = len(strings.TrimSpace(req.GetFeeQuoteToken()))
|
||||
}
|
||||
logger := s.logger.With(zap.Int("token_length", tokenLen))
|
||||
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
var (
|
||||
fxUsed bool
|
||||
resultReason string
|
||||
)
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
if err == nil && resp != nil {
|
||||
@@ -271,28 +191,9 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
}
|
||||
}
|
||||
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.String("status", statusLabel),
|
||||
zap.Duration("duration", time.Since(start)),
|
||||
zap.Bool("fx_used", fxUsed),
|
||||
zap.String("trigger", trigger.String()),
|
||||
zap.Bool("valid", resp != nil && resp.GetValid()),
|
||||
}
|
||||
if resultReason != "" {
|
||||
logFields = append(logFields, zap.String("reason", resultReason))
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("ValidateFeeToken finished", append(logFields, zap.Error(err))...)
|
||||
return
|
||||
}
|
||||
logger.Info("ValidateFeeToken finished", logFields...)
|
||||
}()
|
||||
|
||||
logger.Debug("ValidateFeeToken request received")
|
||||
|
||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||
resultReason = "missing_token"
|
||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||
return nil, err
|
||||
}
|
||||
@@ -301,29 +202,21 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
|
||||
|
||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||
if decodeErr != nil {
|
||||
resultReason = "invalid_token"
|
||||
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
|
||||
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
|
||||
|
||||
if payload.Intent != nil {
|
||||
trigger = payload.Intent.GetTrigger()
|
||||
}
|
||||
trigger = payload.Intent.GetTrigger()
|
||||
|
||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||
resultReason = "expired"
|
||||
logger.Info("fee quote token expired")
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
|
||||
if parseErr != nil {
|
||||
resultReason = "invalid_token"
|
||||
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
|
||||
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -387,16 +280,6 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
bookedAt = intent.GetBookedAt().AsTime()
|
||||
}
|
||||
|
||||
logFields := []zap.Field{
|
||||
zap.Time("booked_at_used", bookedAt),
|
||||
}
|
||||
if !orgRef.IsZero() {
|
||||
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
|
||||
}
|
||||
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
||||
logger := s.logger.With(logFields...)
|
||||
|
||||
var orgPtr *primitive.ObjectID
|
||||
if !orgRef.IsZero() {
|
||||
orgPtr = &orgRef
|
||||
@@ -405,7 +288,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
|
||||
@@ -414,7 +297,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||
default:
|
||||
logger.Warn("failed to resolve fee rule", zap.Error(err))
|
||||
s.logger.Warn("failed to resolve fee rule", zap.Error(err))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
|
||||
}
|
||||
}
|
||||
@@ -430,7 +313,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
||||
}
|
||||
logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
result := &types.CalculationResult{
|
||||
Lines: []*feesv1.DerivedPostingLine{
|
||||
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.OrganizationRef = &orgRef
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
fakeOracle := &oracleclient.Fake{
|
||||
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||
@@ -452,7 +452,7 @@ func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.O
|
||||
if s.plan == nil {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
|
||||
if s.plan.GetOrganizationRef() != orgRef {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.plan.Active {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -26,14 +25,14 @@ const (
|
||||
|
||||
// FeePlan describes a collection of fee rules for an organisation.
|
||||
type FeePlan struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
OrganizationRef *primitive.ObjectID `bson:"organizationRef,omitempty" json:"organizationRef,omitempty"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
|
||||
@@ -11,18 +11,12 @@ market:
|
||||
- driver: CBR
|
||||
settings:
|
||||
base_url: "https://www.cbr.ru"
|
||||
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
|
||||
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
||||
pairs:
|
||||
BINANCE:
|
||||
- base: "USDT"
|
||||
quote: "EUR"
|
||||
symbol: "EURUSDT"
|
||||
invert: true
|
||||
- base: "USD"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUSD"
|
||||
invert: true
|
||||
- base: "UAH"
|
||||
quote: "USDT"
|
||||
symbol: "USDTUAH"
|
||||
|
||||
@@ -51,5 +51,5 @@ require (
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
@@ -216,8 +216,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -85,7 +85,6 @@ func (s *Service) executePoll(ctx context.Context) error {
|
||||
|
||||
func (s *Service) pollOnce(ctx context.Context) error {
|
||||
var firstErr error
|
||||
failures := 0
|
||||
for _, pair := range s.pairs {
|
||||
start := time.Now()
|
||||
err := s.upsertPair(ctx, pair)
|
||||
@@ -97,24 +96,14 @@ func (s *Service) pollOnce(ctx context.Context) error {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
failures++
|
||||
s.logger.Warn("Failed to ingest pair",
|
||||
zap.String("symbol", pair.Symbol),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("base", pair.Base),
|
||||
zap.String("quote", pair.Quote),
|
||||
zap.Bool("invert", pair.Invert),
|
||||
zap.Duration("elapsed", elapsed),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
s.logger.Warn("Ingestion poll completed with failures", zap.Int("failures", failures), zap.Int("total", len(s.pairs)))
|
||||
} else {
|
||||
s.logger.Info("Ingestion poll completed", zap.Int("total", len(s.pairs)))
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -126,7 +115,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
|
||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
|
||||
return merrors.InternalWrap(err, "fetch ticker")
|
||||
}
|
||||
|
||||
bid, err := parseDecimal(ticker.BidPrice)
|
||||
@@ -183,14 +172,9 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||
s.logger.Debug("Snapshot ingested",
|
||||
zap.String("pair", pair.Base+"/"+pair.Quote),
|
||||
zap.String("provider", pair.Provider),
|
||||
zap.String("source", pair.Source.String()),
|
||||
zap.String("provider_ref", snapshot.ProviderRef),
|
||||
zap.String("bid", snapshot.Bid),
|
||||
zap.String("ask", snapshot.Ask),
|
||||
zap.String("mid", snapshot.Mid),
|
||||
zap.String("spread_bps", snapshot.SpreadBps),
|
||||
zap.Int64("asof_unix_ms", snapshot.AsOfUnixMs),
|
||||
zap.String("rate_ref", snapshot.RateRef),
|
||||
)
|
||||
|
||||
return nil
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
type cbrConnector struct {
|
||||
id mmodel.Driver
|
||||
provider string
|
||||
http *httpClient
|
||||
client *http.Client
|
||||
base string
|
||||
dailyPath string
|
||||
directoryPath string
|
||||
@@ -60,8 +60,6 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
||||
directoryPath := defaultDirectoryPath
|
||||
dailyPath := defaultDailyPath
|
||||
dynamicPath := defaultDynamicPath
|
||||
userAgent := defaultUserAgent
|
||||
acceptHeader := defaultAccept
|
||||
|
||||
if settings != nil {
|
||||
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
@@ -79,12 +77,6 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
||||
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
dynamicPath = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
userAgent = strings.TrimSpace(value)
|
||||
}
|
||||
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
|
||||
acceptHeader = strings.TrimSpace(value)
|
||||
}
|
||||
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||
@@ -107,24 +99,13 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
||||
transport = customTransport
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
}
|
||||
referer := parsed.String()
|
||||
|
||||
connector := &cbrConnector{
|
||||
id: mmodel.DriverCBR,
|
||||
provider: provider,
|
||||
http: newHTTPClient(
|
||||
logger,
|
||||
client,
|
||||
httpClientOptions{
|
||||
userAgent: userAgent,
|
||||
accept: acceptHeader,
|
||||
referer: referer,
|
||||
},
|
||||
),
|
||||
client: &http.Client{
|
||||
Timeout: requestTimeout,
|
||||
Transport: transport,
|
||||
},
|
||||
base: strings.TrimRight(parsed.String(), "/"),
|
||||
dailyPath: dailyPath,
|
||||
directoryPath: directoryPath,
|
||||
@@ -180,32 +161,20 @@ func (c *cbrConnector) refreshDirectory() error {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return merrors.InternalWrap(err, "cbr: build directory request")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn(
|
||||
"CBR directory request failed",
|
||||
zap.Error(err),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
)
|
||||
c.logger.Warn("CBR directory request failed", zap.Error(err))
|
||||
return merrors.InternalWrap(err, "cbr: directory request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn(
|
||||
"CBR directory returned non-OK status",
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
)
|
||||
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
@@ -214,13 +183,12 @@ func (c *cbrConnector) refreshDirectory() error {
|
||||
|
||||
var directory valuteDirectory
|
||||
if err := decoder.Decode(&directory); err != nil {
|
||||
c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
|
||||
c.logger.Warn("CBR directory decode failed", zap.Error(err))
|
||||
return merrors.InternalWrap(err, "cbr: decode directory")
|
||||
}
|
||||
|
||||
mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
|
||||
mapping, err := buildValuteMapping(directory.Items)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -232,32 +200,23 @@ func (c *cbrConnector) refreshDirectory() error {
|
||||
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
|
||||
endpoint, err := c.buildURL(c.dailyPath, nil)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
c.logger.Warn("Failed to request daily rate", zap.Error(err), zap.String("endpoint", endpoint))
|
||||
return "", merrors.InternalWrap(err, "cbr: build daily request")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn("CBR daily request failed",
|
||||
zap.Error(err), zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("endpoint", endpoint), zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
)
|
||||
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
|
||||
return "", merrors.InternalWrap(err, "cbr: daily request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn(
|
||||
"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
|
||||
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
|
||||
)
|
||||
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
@@ -266,9 +225,7 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
|
||||
|
||||
var payload dailyRates
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
c.logger.Warn("CBR daily decode failed", zap.Error(err),
|
||||
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
|
||||
)
|
||||
c.logger.Warn("CBR daily decode failed", zap.Error(err))
|
||||
return "", merrors.InternalWrap(err, "cbr: decode daily response")
|
||||
}
|
||||
|
||||
@@ -290,40 +247,25 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
|
||||
"date_req2": date.Format("02/01/2006"),
|
||||
"VAL_NM_RQ": valute.ID,
|
||||
}
|
||||
dateStr := date.Format("2006-01-02")
|
||||
endpoint, err := c.buildURL(c.dynamicPath, query)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return "", merrors.InternalWrap(err, "cbr: build historical request")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
c.logger.Warn(
|
||||
"CBR historical request failed",
|
||||
zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("date", dateStr),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.String("referer", c.http.headerValue("Referer")),
|
||||
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err))
|
||||
return "", merrors.InternalWrap(err, "cbr: historical request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.logger.Warn(
|
||||
"CBR historical returned non-OK status",
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("date", dateStr),
|
||||
zap.String("endpoint", endpoint),
|
||||
)
|
||||
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode))
|
||||
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||
}
|
||||
|
||||
@@ -332,13 +274,7 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
|
||||
|
||||
var payload dynamicRates
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
c.logger.Warn(
|
||||
"CBR historical decode failed",
|
||||
zap.String("currency", valute.ISOCharCode),
|
||||
zap.String("date", dateStr),
|
||||
zap.String("endpoint", endpoint),
|
||||
zap.Error(err),
|
||||
)
|
||||
c.logger.Warn("CBR historical decode failed", zap.Error(err))
|
||||
return "", merrors.InternalWrap(err, "cbr: decode historical response")
|
||||
}
|
||||
|
||||
@@ -401,7 +337,7 @@ type valuteMapping struct {
|
||||
byID map[string]valuteInfo
|
||||
}
|
||||
|
||||
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) {
|
||||
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
|
||||
byISO := make(map[string]valuteInfo, len(items))
|
||||
byID := make(map[string]valuteInfo, len(items))
|
||||
byNum := make(map[string]string, len(items))
|
||||
@@ -412,19 +348,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
|
||||
isoNum := strings.TrimSpace(item.ISONum)
|
||||
name := strings.TrimSpace(item.Name)
|
||||
engName := strings.TrimSpace(item.EngName)
|
||||
|
||||
nominal, err := parseNominal(item.NominalStr)
|
||||
if err != nil {
|
||||
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
|
||||
}
|
||||
|
||||
if id == "" || isoChar == "" {
|
||||
logger.Info("Skipping invalid currency entry",
|
||||
zap.String("id", id),
|
||||
zap.String("iso_char", isoChar),
|
||||
zap.String("name", name),
|
||||
)
|
||||
continue
|
||||
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code")
|
||||
}
|
||||
|
||||
info := valuteInfo{
|
||||
@@ -436,76 +365,12 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
|
||||
Nominal: nominal,
|
||||
}
|
||||
|
||||
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
|
||||
if existing, ok := byISO[isoChar]; ok {
|
||||
// Same ISO + same ID: duplicate entry, just ignore.
|
||||
if existing.ID == id {
|
||||
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
|
||||
zap.String("iso_code", isoChar),
|
||||
zap.String("id", id),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Different IDs but same ISO char.
|
||||
// Choose canonical entry:
|
||||
// 1) Prefer nominal == 1
|
||||
// 2) Otherwise prefer smaller nominal
|
||||
keepExisting := true
|
||||
|
||||
if existing.Nominal != 1 && info.Nominal == 1 {
|
||||
keepExisting = false
|
||||
} else if existing.Nominal == 1 && info.Nominal != 1 {
|
||||
keepExisting = true
|
||||
} else if info.Nominal < existing.Nominal {
|
||||
keepExisting = false
|
||||
}
|
||||
|
||||
if keepExisting {
|
||||
logger.Warn("Ignoring duplicate ISO currency entry with less preferred nominal",
|
||||
zap.String("iso_code", isoChar),
|
||||
zap.String("existing_id", existing.ID),
|
||||
zap.Int64("existing_nominal", existing.Nominal),
|
||||
zap.String("new_id", info.ID),
|
||||
zap.Int64("new_nominal", info.Nominal),
|
||||
)
|
||||
// We keep the old one, just skip the new.
|
||||
continue
|
||||
}
|
||||
|
||||
// Replace existing mapping with the new, more canonical one.
|
||||
logger.Warn("Replacing currency mapping due to more canonical nominal",
|
||||
zap.String("iso_code", isoChar),
|
||||
zap.String("old_id", existing.ID),
|
||||
zap.Int64("old_nominal", existing.Nominal),
|
||||
zap.String("new_id", info.ID),
|
||||
zap.Int64("new_nominal", info.Nominal),
|
||||
)
|
||||
|
||||
// Update byID: drop old ID, add new one
|
||||
delete(byID, existing.ID)
|
||||
byID[id] = info
|
||||
|
||||
// Update ISO mapping
|
||||
byISO[isoChar] = info
|
||||
|
||||
// Update numeric-code index if present
|
||||
if isoNum != "" {
|
||||
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
||||
}
|
||||
byNum[isoNum] = id
|
||||
}
|
||||
|
||||
continue
|
||||
if existing, ok := byISO[isoChar]; ok && existing.ID != id {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar)
|
||||
}
|
||||
|
||||
// No existing ISO entry, do normal uniqueness checks.
|
||||
|
||||
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id)
|
||||
}
|
||||
|
||||
if isoNum != "" {
|
||||
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
||||
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
||||
@@ -513,8 +378,6 @@ func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping,
|
||||
byNum[isoNum] = id
|
||||
}
|
||||
|
||||
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
|
||||
|
||||
byISO[isoChar] = info
|
||||
byID[id] = info
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package cbr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUserAgent = "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
|
||||
defaultAccept = "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
||||
)
|
||||
|
||||
// httpClient wraps http.Client to ensure CBR requests always carry required headers.
|
||||
type httpClient struct {
|
||||
client *http.Client
|
||||
headers http.Header
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
type httpClientOptions struct {
|
||||
userAgent string
|
||||
accept string
|
||||
referer string
|
||||
}
|
||||
|
||||
func newHTTPClient(logger *zap.Logger, client *http.Client, opts httpClientOptions) *httpClient {
|
||||
userAgent := opts.userAgent
|
||||
if strings.TrimSpace(userAgent) == "" {
|
||||
userAgent = defaultUserAgent
|
||||
}
|
||||
|
||||
accept := opts.accept
|
||||
if strings.TrimSpace(accept) == "" {
|
||||
accept = defaultAccept
|
||||
}
|
||||
|
||||
referer := opts.referer
|
||||
if strings.TrimSpace(referer) == "" {
|
||||
referer = defaultCBRBaseURL
|
||||
}
|
||||
l := logger.Named("http_client")
|
||||
|
||||
headers := make(http.Header, 3)
|
||||
headers.Set("User-Agent", userAgent)
|
||||
headers.Set("Accept", accept)
|
||||
headers.Set("Referer", referer)
|
||||
|
||||
l.Info("HTTP client initialized", zap.String("user_agent", userAgent),
|
||||
zap.String("accept", accept), zap.String("referrer", referer))
|
||||
|
||||
return &httpClient{
|
||||
client: client,
|
||||
headers: headers,
|
||||
logger: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *httpClient) NewRequest(ctx context.Context, method, endpoint string) (*http.Request, error) {
|
||||
return http.NewRequestWithContext(ctx, method, endpoint, nil)
|
||||
}
|
||||
|
||||
func (h *httpClient) Do(req *http.Request) (*http.Response, error) {
|
||||
enriched := req.Clone(req.Context())
|
||||
for key, values := range h.headers {
|
||||
if enriched.Header.Get(key) != "" {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
enriched.Header.Add(key, value)
|
||||
}
|
||||
}
|
||||
r, err := h.client.Do(enriched)
|
||||
if err != nil {
|
||||
h.logger.Warn("HTTP request failed", zap.Error(err), zap.String("method", req.Method),
|
||||
zap.String("url", req.URL.String()))
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (h *httpClient) headerValue(name string) string {
|
||||
return h.headers.Get(name)
|
||||
}
|
||||
@@ -40,15 +40,15 @@ func main() {
|
||||
|
||||
application, err := app.New(logger, *configFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialise application", zap.Error(err))
|
||||
} else {
|
||||
if err := application.Run(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Info("FX ingestor stopped")
|
||||
return
|
||||
}
|
||||
logger.Error("Ingestor terminated with error", zap.Error(err))
|
||||
logger.Fatal("Failed to initialise application", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := application.Run(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Info("FX ingestor stopped")
|
||||
return
|
||||
}
|
||||
logger.Fatal("Ingestor terminated with error", zap.Error(err))
|
||||
}
|
||||
|
||||
logger.Info("FX ingestor stopped")
|
||||
|
||||
@@ -14,7 +14,7 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
@@ -216,8 +216,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package oracle
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
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"
|
||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func quoteRequestFields(req *oraclev1.GetQuoteRequest) []zap.Field {
|
||||
if req == nil {
|
||||
return nil
|
||||
}
|
||||
fields := requestMetaFields(req.GetMeta())
|
||||
if pair := req.GetPair(); pair != nil {
|
||||
if base := strings.TrimSpace(pair.GetBase()); base != "" {
|
||||
fields = append(fields, zap.String("pair_base", base))
|
||||
}
|
||||
if quote := strings.TrimSpace(pair.GetQuote()); quote != "" {
|
||||
fields = append(fields, zap.String("pair_quote", quote))
|
||||
}
|
||||
}
|
||||
if side := req.GetSide(); side != fxv1.Side_SIDE_UNSPECIFIED {
|
||||
fields = append(fields, zap.String("side", side.String()))
|
||||
}
|
||||
if req.GetFirm() {
|
||||
fields = append(fields, zap.Bool("firm", req.GetFirm()))
|
||||
}
|
||||
if ttl := req.GetTtlMs(); ttl > 0 {
|
||||
fields = append(fields, zap.Int64("ttl_ms", ttl))
|
||||
}
|
||||
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
|
||||
fields = append(fields, zap.Int32("max_age_ms", maxAge))
|
||||
}
|
||||
if provider := strings.TrimSpace(req.GetPreferredProvider()); provider != "" {
|
||||
fields = append(fields, zap.String("preferred_provider", provider))
|
||||
}
|
||||
fields = append(fields, moneyFields("base_amount", req.GetBaseAmount())...)
|
||||
fields = append(fields, moneyFields("quote_amount", req.GetQuoteAmount())...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func requestMetaFields(meta *oraclev1.RequestMeta) []zap.Field {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 4)
|
||||
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
|
||||
fields = append(fields, zap.String("organization_ref", org))
|
||||
}
|
||||
if tenant := strings.TrimSpace(meta.GetTenantRef()); tenant != "" {
|
||||
fields = append(fields, zap.String("tenant_ref", tenant))
|
||||
}
|
||||
fields = append(fields, traceFields(meta.GetTrace())...)
|
||||
return fields
|
||||
}
|
||||
|
||||
func moneyFields(prefix string, money *moneyv1.Money) []zap.Field {
|
||||
if money == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 2)
|
||||
if amt := strings.TrimSpace(money.GetAmount()); amt != "" {
|
||||
fields = append(fields, zap.String(prefix, amt))
|
||||
}
|
||||
if ccy := strings.TrimSpace(money.GetCurrency()); ccy != "" {
|
||||
fields = append(fields, zap.String(prefix+"_currency", ccy))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func traceFields(trace *tracev1.TraceContext) []zap.Field {
|
||||
if trace == nil {
|
||||
return nil
|
||||
}
|
||||
fields := make([]zap.Field, 0, 3)
|
||||
if ref := strings.TrimSpace(trace.GetTraceRef()); ref != "" {
|
||||
fields = append(fields, zap.String("trace_ref", ref))
|
||||
}
|
||||
if idem := strings.TrimSpace(trace.GetIdempotencyKey()); idem != "" {
|
||||
fields = append(fields, zap.String("idempotency_key", idem))
|
||||
}
|
||||
if req := strings.TrimSpace(trace.GetRequestRef()); req != "" {
|
||||
fields = append(fields, zap.String("request_ref", req))
|
||||
}
|
||||
return fields
|
||||
}
|
||||
@@ -101,27 +101,22 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if req == nil {
|
||||
req = &oraclev1.GetQuoteRequest{}
|
||||
}
|
||||
logger := s.logger.With(quoteRequestFields(req)...)
|
||||
logger.Debug("Handling GetQuote")
|
||||
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm()))
|
||||
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
|
||||
logger.Warn("GetQuote invalid: side missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
|
||||
}
|
||||
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil {
|
||||
logger.Warn("GetQuote invalid: both base_amount and quote_amount provided")
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
|
||||
}
|
||||
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
|
||||
logger.Warn("GetQuote invalid: amount missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
|
||||
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairMsg := req.GetPair()
|
||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||
logger.Warn("GetQuote invalid: pair missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
@@ -130,10 +125,8 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("pair not supported", zap.String("pair", pairKey.Base+"/"+pairKey.Quote))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
|
||||
default:
|
||||
logger.Warn("GetQuote failed to load pair", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -150,10 +143,8 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("rate not found", zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
|
||||
default:
|
||||
logger.Warn("GetQuote failed to load rate", zap.Error(err), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -162,31 +153,27 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
|
||||
age := now.UnixMilli() - rate.AsOfUnixMs
|
||||
if age > int64(maxAge) {
|
||||
logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
s.logger.Warn("Rate snapshot stale", zap.Int64("age_ms", age), zap.Int32("max_age_ms", req.GetMaxAgeMs()), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", provider))
|
||||
return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "stale_rate", merrors.InvalidArgument("rate older than allowed window"))
|
||||
}
|
||||
}
|
||||
|
||||
comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
|
||||
if err != nil {
|
||||
logger.Warn("GetQuote invalid input", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
if req.GetBaseAmount() != nil {
|
||||
if err := comp.withBaseInput(req.GetBaseAmount()); err != nil {
|
||||
logger.Warn("GetQuote invalid base input", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
} else if req.GetQuoteAmount() != nil {
|
||||
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != nil {
|
||||
logger.Warn("GetQuote invalid quote input", zap.Error(err))
|
||||
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := comp.compute(); err != nil {
|
||||
logger.Warn("GetQuote computation failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
|
||||
@@ -208,14 +195,12 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
|
||||
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrDataConflict):
|
||||
logger.Warn("GetQuote conflict issuing firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
|
||||
return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("GetQuote failed to issue firm quote", zap.Error(err), zap.String("quote_ref", quoteModel.QuoteRef))
|
||||
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
|
||||
s.logger.Info("Firm quote stored", zap.String("quote_ref", quoteModel.QuoteRef), zap.String("pair", pairKey.Base+"/"+pairKey.Quote), zap.String("provider", quoteModel.Provider), zap.Int64("expires_at_ms", quoteModel.ExpiresAtUnixMs))
|
||||
}
|
||||
|
||||
resp := &oraclev1.GetQuoteResponse{
|
||||
@@ -229,24 +214,18 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
||||
if req == nil {
|
||||
req = &oraclev1.ValidateQuoteRequest{}
|
||||
}
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
|
||||
logger = logger.With(zap.String("quote_ref", ref))
|
||||
}
|
||||
logger.Debug("Handling ValidateQuote")
|
||||
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef()))
|
||||
if req.GetQuoteRef() == "" {
|
||||
logger.Warn("ValidateQuote invalid: quote_ref missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
|
||||
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("ValidateQuote: quote not found", zap.String("quote_ref", req.GetQuoteRef()))
|
||||
resp := &oraclev1.ValidateQuoteResponse{
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Quote: nil,
|
||||
@@ -255,7 +234,6 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
default:
|
||||
logger.Warn("ValidateQuote failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -277,11 +255,6 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
|
||||
Valid: valid,
|
||||
Reason: reason,
|
||||
}
|
||||
if !valid {
|
||||
logger.Info("ValidateQuote invalid", zap.String("reason", reason), zap.Bool("firm", quote.Firm))
|
||||
} else {
|
||||
logger.Debug("ValidateQuote valid", zap.Bool("firm", quote.Firm))
|
||||
}
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -289,43 +262,29 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
|
||||
if req == nil {
|
||||
req = &oraclev1.ConsumeQuoteRequest{}
|
||||
}
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
if ref := strings.TrimSpace(req.GetQuoteRef()); ref != "" {
|
||||
logger = logger.With(zap.String("quote_ref", ref))
|
||||
}
|
||||
if ledger := strings.TrimSpace(req.GetLedgerTxnRef()); ledger != "" {
|
||||
logger = logger.With(zap.String("ledger_txn_ref", ledger))
|
||||
}
|
||||
logger.Debug("Handling ConsumeQuote")
|
||||
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
if req.GetQuoteRef() == "" {
|
||||
logger.Warn("ConsumeQuote invalid: quote_ref missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
|
||||
}
|
||||
if req.GetLedgerTxnRef() == "" {
|
||||
logger.Warn("ConsumeQuote invalid: ledger_txn_ref missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
|
||||
}
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
|
||||
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, storage.ErrQuoteExpired):
|
||||
logger.Warn("ConsumeQuote failed: expired")
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
|
||||
case errors.Is(err, storage.ErrQuoteConsumed):
|
||||
logger.Warn("ConsumeQuote failed: already consumed")
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
|
||||
case errors.Is(err, storage.ErrQuoteNotFirm):
|
||||
logger.Warn("ConsumeQuote failed: quote not firm")
|
||||
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("ConsumeQuote failed: quote not found")
|
||||
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("ConsumeQuote failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -335,7 +294,7 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
|
||||
Consumed: true,
|
||||
Reason: "consumed",
|
||||
}
|
||||
logger.Info("Quote consumed")
|
||||
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef()))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -343,21 +302,13 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
if req == nil {
|
||||
req = &oraclev1.LatestRateRequest{}
|
||||
}
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
if pair := req.GetPair(); pair != nil {
|
||||
logger = logger.With(zap.String("pair_base", strings.TrimSpace(pair.GetBase())), zap.String("pair_quote", strings.TrimSpace(pair.GetQuote())))
|
||||
}
|
||||
if provider := strings.TrimSpace(req.GetProvider()); provider != "" {
|
||||
logger = logger.With(zap.String("provider", provider))
|
||||
}
|
||||
logger.Debug("Handling LatestRate")
|
||||
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()))
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
|
||||
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairMsg := req.GetPair()
|
||||
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" {
|
||||
logger.Warn("LatestRate invalid: pair missing")
|
||||
return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
|
||||
}
|
||||
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
|
||||
@@ -366,10 +317,8 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("LatestRate pair not found")
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("LatestRate failed to load pair", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -386,10 +335,8 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, merrors.ErrNoData):
|
||||
logger.Warn("LatestRate not found", zap.String("provider", provider))
|
||||
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
default:
|
||||
logger.Warn("LatestRate failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
}
|
||||
@@ -398,7 +345,6 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Rate: rateModelToProto(rate),
|
||||
}
|
||||
logger.Debug("LatestRate succeeded", zap.String("provider", provider), zap.Int64("asof_unix_ms", rate.AsOfUnixMs))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
@@ -406,15 +352,13 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
|
||||
if req == nil {
|
||||
req = &oraclev1.ListPairsRequest{}
|
||||
}
|
||||
logger := s.logger.With(requestMetaFields(req.GetMeta())...)
|
||||
logger.Debug("Handling ListPairs")
|
||||
s.logger.Debug("Handling ListPairs")
|
||||
if err := s.pingStorage(ctx); err != nil {
|
||||
logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
|
||||
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
|
||||
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
pairs, err := s.storage.Pairs().ListEnabled(ctx)
|
||||
if err != nil {
|
||||
logger.Warn("ListPairs failed", zap.Error(err))
|
||||
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
|
||||
}
|
||||
result := make([]*oraclev1.PairMeta, 0, len(pairs))
|
||||
@@ -425,7 +369,7 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
|
||||
Meta: buildResponseMeta(req.GetMeta()),
|
||||
Pairs: result,
|
||||
}
|
||||
logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
|
||||
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
|
||||
return gsresponse.Success(resp)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,5 +28,5 @@ require (
|
||||
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.11 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
@@ -169,7 +169,7 @@ golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBn
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -16,13 +16,13 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251211224604-2e727cd2e6fe // indirect
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect
|
||||
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
|
||||
|
||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251211224604-2e727cd2e6fe h1:Z93WiwkZABbBBb0hGVFSF9nofjiYRvdF7PUxB75oeyE=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251211224604-2e727cd2e6fe/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be h1:1LtMLkGIqE5IQZ7Vdh4zv8A6LECInKF86/fTVxKxYLE=
|
||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
|
||||
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -366,8 +366,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -12,7 +12,7 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -54,5 +54,5 @@ require (
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
||||
google.golang.org/grpc v1.77.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
)
|
||||
|
||||
@@ -231,8 +231,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -61,6 +61,3 @@ card_gateways:
|
||||
monetix:
|
||||
funding_address: "wallet_funding_monetix"
|
||||
fee_address: "wallet_fee_monetix"
|
||||
|
||||
fee_ledger_accounts:
|
||||
monetix: "ledger:fees:monetix"
|
||||
|
||||
@@ -25,7 +25,7 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
|
||||
@@ -219,8 +219,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -46,7 +46,6 @@ type config struct {
|
||||
Gateway clientConfig `yaml:"gateway"`
|
||||
Oracle clientConfig `yaml:"oracle"`
|
||||
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
|
||||
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
|
||||
}
|
||||
|
||||
type clientConfig struct {
|
||||
@@ -160,9 +159,6 @@ func (i *Imp) Start() error {
|
||||
if routes := buildCardGatewayRoutes(cfg.CardGateways); len(routes) > 0 {
|
||||
opts = append(opts, orchestrator.WithCardGatewayRoutes(routes))
|
||||
}
|
||||
if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 {
|
||||
opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts))
|
||||
}
|
||||
return orchestrator.NewService(logger, repo, opts...), nil
|
||||
}
|
||||
|
||||
@@ -327,19 +323,3 @@ func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]or
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildFeeLedgerAccounts(src map[string]string) map[string]string {
|
||||
if len(src) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]string, len(src))
|
||||
for key, account := range src {
|
||||
k := strings.ToLower(strings.TrimSpace(key))
|
||||
v := strings.TrimSpace(account)
|
||||
if k == "" || v == "" {
|
||||
continue
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s
|
||||
}
|
||||
}
|
||||
|
||||
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) {
|
||||
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote) (*moneyv1.Money, *moneyv1.Money) {
|
||||
if pay == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -166,7 +166,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
|
||||
}
|
||||
}
|
||||
|
||||
applyChargeToDebit := func(m *moneyv1.Money) {
|
||||
adjustDebit := func(m *moneyv1.Money) {
|
||||
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
|
||||
if err != nil || converted == nil {
|
||||
return
|
||||
@@ -176,7 +176,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
|
||||
}
|
||||
}
|
||||
|
||||
applyChargeToSettlement := func(m *moneyv1.Money) {
|
||||
adjustSettlement := func(m *moneyv1.Money) {
|
||||
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
|
||||
if err != nil || converted == nil {
|
||||
return
|
||||
@@ -186,22 +186,12 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est
|
||||
}
|
||||
}
|
||||
|
||||
switch mode {
|
||||
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
|
||||
// Sender pays the fee: keep settlement fixed, increase debit.
|
||||
applyChargeToDebit(fee)
|
||||
default:
|
||||
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
|
||||
applyChargeToSettlement(fee)
|
||||
}
|
||||
adjustDebit(fee)
|
||||
adjustSettlement(fee)
|
||||
|
||||
if network != nil && network.GetNetworkFee() != nil {
|
||||
switch mode {
|
||||
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
|
||||
applyChargeToDebit(network.GetNetworkFee())
|
||||
default:
|
||||
applyChargeToSettlement(network.GetNetworkFee())
|
||||
}
|
||||
adjustDebit(network.GetNetworkFee())
|
||||
adjustSettlement(network.GetNetworkFee())
|
||||
}
|
||||
|
||||
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
|
||||
@@ -393,22 +383,6 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
|
||||
return breakdown
|
||||
}
|
||||
|
||||
func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) []*feesv1.DerivedPostingLine {
|
||||
if account == "" || len(lines) == 0 {
|
||||
return lines
|
||||
}
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line.GetLedgerAccountRef()) != "" {
|
||||
continue
|
||||
}
|
||||
line.LedgerAccountRef = account
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func moneyEquals(a, b *moneyv1.Money) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
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"
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
)
|
||||
|
||||
func TestResolveTradeAmountsBuyBase(t *testing.T) {
|
||||
@@ -48,32 +47,11 @@ func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED)
|
||||
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() != "50" {
|
||||
t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeAggregatesRecipientPaysFee(t *testing.T) {
|
||||
pay := &moneyv1.Money{Currency: "USDT", Amount: "100"}
|
||||
settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32
|
||||
fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100
|
||||
fxQuote := &oraclev1.Quote{
|
||||
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||
Price: &moneyv1.Decimal{
|
||||
Value: "79.32",
|
||||
},
|
||||
}
|
||||
|
||||
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE)
|
||||
if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" {
|
||||
t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount())
|
||||
}
|
||||
if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" {
|
||||
t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "42.5" {
|
||||
t.Fatalf("expected settlement 42.5 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,24 +113,6 @@ func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithFeeLedgerAccounts maps gateway identifiers to ledger accounts used for fees.
|
||||
func WithFeeLedgerAccounts(routes map[string]string) Option {
|
||||
return func(s *Service) {
|
||||
if len(routes) == 0 {
|
||||
return
|
||||
}
|
||||
s.deps.feeLedgerAccounts = make(map[string]string, len(routes))
|
||||
for k, v := range routes {
|
||||
key := strings.ToLower(strings.TrimSpace(k))
|
||||
val := strings.TrimSpace(v)
|
||||
if key == "" || val == "" {
|
||||
continue
|
||||
}
|
||||
s.deps.feeLedgerAccounts[key] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithClock overrides the default clock.
|
||||
func WithClock(clock clockpkg.Clock) Option {
|
||||
return func(s *Service) {
|
||||
|
||||
@@ -52,9 +52,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
||||
} else if amount != nil {
|
||||
feeCurrency = amount.GetCurrency()
|
||||
}
|
||||
feeLines := cloneFeeLines(feeQuote.GetLines())
|
||||
s.assignFeeLedgerAccounts(intent, feeLines)
|
||||
feeTotal := extractFeeTotal(feeLines, feeCurrency)
|
||||
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
|
||||
|
||||
var networkFee *chainv1.EstimateTransferFeeResponse
|
||||
if shouldEstimateNetworkFee(intent) {
|
||||
@@ -65,13 +63,13 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
||||
s.logger.Debug("network fee estimated", zap.String("org_ref", orgRef))
|
||||
}
|
||||
|
||||
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote, intent.GetSettlementMode())
|
||||
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
|
||||
|
||||
quote := &orchestratorv1.PaymentQuote{
|
||||
DebitAmount: debitAmount,
|
||||
ExpectedSettlementAmount: settlementAmount,
|
||||
ExpectedFeeTotal: feeTotal,
|
||||
FeeLines: feeLines,
|
||||
FeeLines: cloneFeeLines(feeQuote.GetLines()),
|
||||
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
|
||||
FxQuote: fxQuote,
|
||||
NetworkFee: networkFee,
|
||||
@@ -209,53 +207,3 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
|
||||
}
|
||||
return quoteToProto(quote), nil
|
||||
}
|
||||
|
||||
func (s *Service) feeLedgerAccountForIntent(intent *orchestratorv1.PaymentIntent) string {
|
||||
if intent == nil || len(s.deps.feeLedgerAccounts) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
key := s.gatewayKeyFromIntent(intent)
|
||||
if key == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(s.deps.feeLedgerAccounts[key])
|
||||
}
|
||||
|
||||
func (s *Service) assignFeeLedgerAccounts(intent *orchestratorv1.PaymentIntent, lines []*feesv1.DerivedPostingLine) {
|
||||
account := s.feeLedgerAccountForIntent(intent)
|
||||
key := s.gatewayKeyFromIntent(intent)
|
||||
|
||||
missing := 0
|
||||
for _, line := range lines {
|
||||
if line == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(line.GetLedgerAccountRef()) == "" {
|
||||
missing++
|
||||
}
|
||||
}
|
||||
if missing == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if account == "" {
|
||||
s.logger.Debug("no fee ledger account mapping found", zap.String("gateway", key), zap.Int("missing_lines", missing))
|
||||
return
|
||||
}
|
||||
assignLedgerAccounts(lines, account)
|
||||
s.logger.Debug("applied fee ledger account mapping", zap.String("gateway", key), zap.String("ledger_account", account), zap.Int("lines", missing))
|
||||
}
|
||||
|
||||
func (s *Service) gatewayKeyFromIntent(intent *orchestratorv1.PaymentIntent) string {
|
||||
if intent == nil {
|
||||
return ""
|
||||
}
|
||||
key := strings.TrimSpace(intent.GetAttributes()["gateway"])
|
||||
if key == "" {
|
||||
if dest := intent.GetDestination(); dest != nil && dest.GetCard() != nil {
|
||||
key = defaultCardGateway
|
||||
}
|
||||
}
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
|
||||
@@ -41,13 +41,12 @@ type Service struct {
|
||||
}
|
||||
|
||||
type serviceDependencies struct {
|
||||
fees feesDependency
|
||||
ledger ledgerDependency
|
||||
gateway gatewayDependency
|
||||
oracle oracleDependency
|
||||
mntx mntxDependency
|
||||
cardRoutes map[string]CardGatewayRoute
|
||||
feeLedgerAccounts map[string]string
|
||||
fees feesDependency
|
||||
ledger ledgerDependency
|
||||
gateway gatewayDependency
|
||||
oracle oracleDependency
|
||||
mntx mntxDependency
|
||||
cardRoutes map[string]CardGatewayRoute
|
||||
}
|
||||
|
||||
type handlerSet struct {
|
||||
|
||||
@@ -18,7 +18,7 @@ require (
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/crypto v0.46.0
|
||||
google.golang.org/grpc v1.77.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
|
||||
require (
|
||||
|
||||
@@ -273,8 +273,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -8,10 +8,9 @@ import (
|
||||
)
|
||||
|
||||
type CryptoAddressPaymentData struct {
|
||||
Currency Currency `bson:"currency" json:"currency"`
|
||||
Address string `bson:"address" json:"address"`
|
||||
Network string `bson:"network" json:"network"`
|
||||
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
|
||||
Address string `bson:"address" json:"address"`
|
||||
Network string `bson:"network" json:"network"`
|
||||
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`
|
||||
}
|
||||
|
||||
func (m *PaymentMethod) AsCryptoAddress() (*CryptoAddressPaymentData, error) {
|
||||
|
||||
@@ -3,13 +3,12 @@ package model
|
||||
type Currency string
|
||||
|
||||
const (
|
||||
CurrencyEUR Currency = "EUR" // Euro
|
||||
CurrencyUSD Currency = "USD" // US Dollar
|
||||
CurrencyRUB Currency = "RUB" // Russian Ruble
|
||||
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
|
||||
CurrencyPLN Currency = "PLN" // Polish Złoty
|
||||
CurrencyCZK Currency = "CZK" // Czech Koruna
|
||||
CurrencyUSDT Currency = "USDT" // Czech Koruna
|
||||
CurrencyEUR Currency = "EUR" // Euro
|
||||
CurrencyUSD Currency = "USD" // US Dollar
|
||||
CurrencyRUB Currency = "RUB" // Russian Ruble
|
||||
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
|
||||
CurrencyPLN Currency = "PLN" // Polish Złoty
|
||||
CurrencyCZK Currency = "CZK" // Czech Koruna
|
||||
)
|
||||
|
||||
// All supported currencies
|
||||
@@ -20,7 +19,6 @@ var SupportedCurrencies = []Currency{
|
||||
CurrencyUAH,
|
||||
CurrencyPLN,
|
||||
CurrencyCZK,
|
||||
CurrencyUSDT,
|
||||
}
|
||||
|
||||
type Amount struct {
|
||||
|
||||
@@ -90,8 +90,8 @@ api:
|
||||
call_timeout_seconds: 5
|
||||
insecure: true
|
||||
payment_orchestrator:
|
||||
address: sendico_payments_orchestrator:50062
|
||||
address_env: PAYMENTS_ADDRESS
|
||||
address: sendico_payment_orchestrator:50062
|
||||
address_env: PAYMENT_ORCHESTRATOR_ADDRESS
|
||||
dial_timeout_seconds: 5
|
||||
call_timeout_seconds: 5
|
||||
insecure: true
|
||||
|
||||
@@ -32,7 +32,7 @@ require (
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/net v0.48.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
google.golang.org/protobuf v1.36.10
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
moul.io/chizap v1.0.3
|
||||
)
|
||||
|
||||
@@ -365,8 +365,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
|
||||
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
|
||||
@@ -15,12 +15,6 @@ type PaymentIntent struct {
|
||||
Attributes map[string]string `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
type AssetResolverStub struct{}
|
||||
|
||||
func (a *AssetResolverStub) IsSupported(_ string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *PaymentIntent) Validate() error {
|
||||
// Kind must be set (non-zero)
|
||||
var zeroKind PaymentKind
|
||||
@@ -39,8 +33,7 @@ func (p *PaymentIntent) Validate() error {
|
||||
if p.Amount == nil {
|
||||
return merrors.InvalidArgument("amount is required", "intent.amount")
|
||||
}
|
||||
//TODO: collect supported currencies and validate against them
|
||||
if err := ValidateMoney(p.Amount, &AssetResolverStub{}); err != nil {
|
||||
if err := ValidateMoney(p.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +1,30 @@
|
||||
package srequest
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// AssetResolver defines environment-specific supported assets.
|
||||
// Implementations should check:
|
||||
// - fiat assets (ISO-4217)
|
||||
// - crypto assets supported by gateways / FX providers
|
||||
type AssetResolver interface {
|
||||
IsSupported(ticker string) bool
|
||||
}
|
||||
|
||||
// Precompile regex for efficiency.
|
||||
var currencySyntax = regexp.MustCompile(`^[A-Z0-9]{2,10}$`)
|
||||
|
||||
// ValidateCurrency validates currency syntax and checks dictionary via assetResolver.
|
||||
func ValidateCurrency(cur string, assetResolver AssetResolver) error {
|
||||
// Basic presence
|
||||
if strings.TrimSpace(cur) == "" {
|
||||
func ValidateMoney(m *model.Money) error {
|
||||
if m.Amount == "" {
|
||||
return merrors.InvalidArgument("amount is required", "intent.amount")
|
||||
}
|
||||
if m.Currency == "" {
|
||||
return merrors.InvalidArgument("currency is required", "intent.currency")
|
||||
}
|
||||
|
||||
// Normalize
|
||||
cur = strings.ToUpper(strings.TrimSpace(cur))
|
||||
|
||||
// Syntax check
|
||||
if !currencySyntax.MatchString(cur) {
|
||||
return merrors.InvalidArgument(
|
||||
"invalid currency format (must be A–Z0–9, length 2–10)",
|
||||
"intent.currency",
|
||||
)
|
||||
if _, err := decimal.NewFromString(m.Amount); err != nil {
|
||||
return merrors.InvalidArgument("invalid amount decimal", "intent.amount")
|
||||
}
|
||||
|
||||
// Dictionary validation
|
||||
if assetResolver == nil {
|
||||
return merrors.InvalidArgument("asset resolver is not configured", "intent.currency")
|
||||
if len(m.Currency) != 3 {
|
||||
return merrors.InvalidArgument("currency must be 3 letters", "intent.currency")
|
||||
}
|
||||
|
||||
if !assetResolver.IsSupported(cur) {
|
||||
return merrors.InvalidArgument("unsupported currency/asset", "intent.currency")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateMoney(m *model.Money, assetResolver AssetResolver) error {
|
||||
if m == nil {
|
||||
return merrors.InvalidArgument("money is required", "intent.amount")
|
||||
}
|
||||
|
||||
// 1) Basic presence
|
||||
if strings.TrimSpace(m.Amount) == "" {
|
||||
return merrors.InvalidArgument("amount is required", "intent.amount")
|
||||
}
|
||||
|
||||
// 2) Validate decimal amount
|
||||
amount, err := decimal.NewFromString(m.Amount)
|
||||
if err != nil {
|
||||
return merrors.InvalidArgument("invalid decimal amount", "intent.amount")
|
||||
}
|
||||
if amount.IsNegative() {
|
||||
return merrors.InvalidArgument("amount must be >= 0", "intent.amount")
|
||||
}
|
||||
|
||||
// 3) Validate currency via helper
|
||||
if err := ValidateCurrency(m.Currency, assetResolver); err != nil {
|
||||
return err
|
||||
for _, c := range m.Currency {
|
||||
if c < 'A' || c > 'Z' {
|
||||
return merrors.InvalidArgument("currency must be uppercase A-Z", "intent.currency")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -83,15 +36,31 @@ type CurrencyPair struct {
|
||||
}
|
||||
|
||||
func (p *CurrencyPair) Validate() error {
|
||||
if p == nil {
|
||||
return merrors.InvalidArgument("currency pair is required", "currncy_pair")
|
||||
if p.Base == "" {
|
||||
return merrors.InvalidArgument("base currency is required", "intent.fx.pair.base")
|
||||
}
|
||||
if err := ValidateCurrency(p.Base, &AssetResolverStub{}); err != nil {
|
||||
return merrors.InvalidArgument("invalid base currency in pair: "+err.Error(), "currency_pair.base")
|
||||
if p.Quote == "" {
|
||||
return merrors.InvalidArgument("quote currency is required", "intent.fx.pair.quote")
|
||||
}
|
||||
if err := ValidateCurrency(p.Quote, &AssetResolverStub{}); err != nil {
|
||||
return merrors.InvalidArgument("invalid quote currency in pair: "+err.Error(), "currency_pair.quote")
|
||||
|
||||
if len(p.Base) != 3 {
|
||||
return merrors.InvalidArgument("base currency must be 3 letters", "intent.fx.pair.base")
|
||||
}
|
||||
if len(p.Quote) != 3 {
|
||||
return merrors.InvalidArgument("quote currency must be 3 letters", "intent.fx.pair.quote")
|
||||
}
|
||||
|
||||
for _, c := range p.Base {
|
||||
if c < 'A' || c > 'Z' {
|
||||
return merrors.InvalidArgument("base currency must be uppercase A-Z", "intent.fx.pair.base")
|
||||
}
|
||||
}
|
||||
for _, c := range p.Quote {
|
||||
if c < 'A' || c > 'Z' {
|
||||
return merrors.InvalidArgument("quote currency must be uppercase A-Z", "intent.fx.pair.quote")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -248,11 +248,12 @@ func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization,
|
||||
a.logger.Warn("Chain gateway client not configured, skipping wallet creation", mzap.StorableRef(org))
|
||||
return merrors.Internal("chain gateway client is not configured")
|
||||
}
|
||||
asset := *a.chainAsset
|
||||
req := &chainv1.CreateManagedWalletRequest{
|
||||
IdempotencyKey: uuid.NewString(),
|
||||
OrganizationRef: org.ID.Hex(),
|
||||
OwnerRef: org.ID.Hex(),
|
||||
Asset: a.chainAsset,
|
||||
Asset: &asset,
|
||||
Metadata: map[string]string{
|
||||
"source": "signup",
|
||||
"login": sr.Account.Login,
|
||||
|
||||
@@ -31,7 +31,6 @@ services:
|
||||
NATS_PASSWORD: ${NATS_PASSWORD}
|
||||
CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_SERVICE_NAME}:${CHAIN_GATEWAY_GRPC_PORT}
|
||||
LEDGER_ADDRESS: ${LEDGER_SERVICE_NAME}:${LEDGER_GRPC_PORT}
|
||||
PAYMENTS_ADDRESS: ${PAYMENTS_SERVICE_NAME}:${PAYMENTS_GRPC_PORT}
|
||||
MONGO_HOST: ${MONGO_HOST}
|
||||
MONGO_PORT: ${MONGO_PORT}
|
||||
MONGO_DATABASE: ${MONGO_DATABASE}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import 'package:pshared/models/currency.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
|
||||
class Asset {
|
||||
final Currency currency;
|
||||
final double amount;
|
||||
|
||||
const Asset({
|
||||
required this.currency,
|
||||
required this.amount,
|
||||
});
|
||||
}
|
||||
|
||||
Asset createAsset(String currencyCode, String amount) => Asset(
|
||||
currency: currencyStringToCode(currencyCode),
|
||||
amount: double.parse(amount),
|
||||
);
|
||||
@@ -79,12 +79,13 @@ enum ResourceType {
|
||||
@JsonValue('payments')
|
||||
payments,
|
||||
|
||||
@JsonValue('payment_methods')
|
||||
paymentMethods,
|
||||
|
||||
/// Represents payment orchestration service
|
||||
@JsonValue('payment_orchestrator')
|
||||
paymentOrchestrator,
|
||||
|
||||
@JsonValue('payment_methods')
|
||||
paymentMethods,
|
||||
|
||||
/// Represents permissions service
|
||||
@JsonValue('permissions')
|
||||
permissions,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
@@ -20,6 +22,9 @@ import 'package:pshared/utils/exception.dart';
|
||||
|
||||
|
||||
class AccountProvider extends ChangeNotifier {
|
||||
AccountProvider({Future<void> Function(Account?)? onAccountChanged})
|
||||
: _onAccountChanged = onAccountChanged;
|
||||
|
||||
static String get currentUserRef => Constants.nilObjectRef;
|
||||
|
||||
// The resource now wraps our Account? state along with its loading/error state.
|
||||
@@ -27,6 +32,8 @@ class AccountProvider extends ChangeNotifier {
|
||||
Resource<Account?> get resource => _resource;
|
||||
late LocaleProvider _localeProvider;
|
||||
PendingLogin? _pendingLogin;
|
||||
Future<void>? _restoreFuture;
|
||||
Future<void> Function(Account?)? _onAccountChanged;
|
||||
|
||||
Account? get account => _resource.data;
|
||||
PendingLogin? get pendingLogin => _pendingLogin;
|
||||
@@ -53,11 +60,21 @@ class AccountProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Private helper to update the resource and notify listeners.
|
||||
void setAccountChangedListener(Future<void> Function(Account?)? listener) => _onAccountChanged = listener;
|
||||
|
||||
void _setResource(Resource<Account?> newResource) {
|
||||
final previousAccount = _resource.data;
|
||||
_resource = newResource;
|
||||
_notifyAccountChanged(previousAccount, newResource.data);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _notifyAccountChanged(Account? previous, Account? current) {
|
||||
if (previous == current) return;
|
||||
final handler = _onAccountChanged;
|
||||
if (handler != null) unawaited(handler(current));
|
||||
}
|
||||
|
||||
void updateProvider(LocaleProvider localeProvider) => _localeProvider = localeProvider;
|
||||
|
||||
void _pickupLocale(String locale) => _localeProvider.setLocale(Locale(locale));
|
||||
@@ -220,4 +237,11 @@ class AccountProvider extends ChangeNotifier {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> restoreIfPossible() {
|
||||
return _restoreFuture ??= AuthorizationService.isAuthorizationStored().then<void>((hasAuth) async {
|
||||
if (!hasAuth) return;
|
||||
await restore();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
|
||||
class PaymentAmountProvider with ChangeNotifier {
|
||||
double _amount = 10.0;
|
||||
bool _payerCoversFee = true;
|
||||
|
||||
double get amount => _amount;
|
||||
bool get payerCoversFee => _payerCoversFee;
|
||||
|
||||
void setAmount(double value) {
|
||||
_amount = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPayerCoversFee(bool value) {
|
||||
_payerCoversFee = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pshared/models/asset.dart';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'package:pshared/models/payment/currency_pair.dart';
|
||||
import 'package:pshared/models/payment/fx/intent.dart';
|
||||
import 'package:pshared/models/payment/fx/side.dart';
|
||||
import 'package:pshared/models/payment/kind.dart';
|
||||
import 'package:pshared/models/payment/methods/card.dart';
|
||||
import 'package:pshared/models/payment/methods/managed_wallet.dart';
|
||||
import 'package:pshared/models/payment/money.dart';
|
||||
import 'package:pshared/models/payment/settlement_mode.dart';
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/api/requests/payment/quote.dart';
|
||||
import 'package:pshared/data/mapper/payment/intent/payment.dart';
|
||||
import 'package:pshared/models/payment/intent.dart';
|
||||
@@ -19,6 +7,7 @@ import 'package:pshared/models/payment/quote.dart';
|
||||
import 'package:pshared/provider/organizations.dart';
|
||||
import 'package:pshared/provider/resource.dart';
|
||||
import 'package:pshared/service/payment/quotation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
|
||||
class QuotationProvider extends ChangeNotifier {
|
||||
@@ -26,46 +15,14 @@ class QuotationProvider extends ChangeNotifier {
|
||||
late OrganizationsProvider _organizations;
|
||||
bool _isLoaded = false;
|
||||
|
||||
void update(OrganizationsProvider venue, PaymentAmountProvider payment) {
|
||||
void update(OrganizationsProvider venue) {
|
||||
_organizations = venue;
|
||||
getQuotation(PaymentIntent(
|
||||
kind: PaymentKind.payout,
|
||||
amount: Money(
|
||||
amount: payment.amount.toString(),
|
||||
currency: 'USDT',
|
||||
),
|
||||
destination: CardPaymentMethod(
|
||||
pan: '4000000000000077',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
),
|
||||
source: ManagedWalletPaymentMethod(
|
||||
managedWalletRef: '',
|
||||
),
|
||||
fx: FxIntent(
|
||||
pair: CurrencyPair(
|
||||
base: 'USDT',
|
||||
quote: 'RUB',
|
||||
),
|
||||
side: FxSide.sellBaseBuyQuote,
|
||||
),
|
||||
settlementMode: payment.payerCoversFee ? SettlementMode.fixReceived : SettlementMode.fixSource,
|
||||
));
|
||||
}
|
||||
|
||||
PaymentQuote? get quotation => _quotation.data;
|
||||
|
||||
bool get isReady => _isLoaded && !_quotation.isLoading && _quotation.error == null;
|
||||
|
||||
Asset? get fee => quotation == null ? null : createAsset(quotation!.expectedFeeTotal!.currency, quotation!.expectedFeeTotal!.amount);
|
||||
Asset? get total => quotation == null ? null : createAsset(quotation!.debitAmount!.currency, quotation!.debitAmount!.amount);
|
||||
Asset? get recipientGets => quotation == null ? null : createAsset(quotation!.expectedSettlementAmount!.currency, quotation!.expectedSettlementAmount!.amount);
|
||||
|
||||
void _setResource(Resource<PaymentQuote> quotation) {
|
||||
_quotation = quotation;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<PaymentQuote?> getQuotation(PaymentIntent intent) async {
|
||||
if (!_organizations.isOrganizationSet) throw StateError('Organization is not set');
|
||||
try {
|
||||
@@ -78,20 +35,19 @@ class QuotationProvider extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
_isLoaded = true;
|
||||
_setResource(_quotation.copyWith(data: response, isLoading: false, error: null));
|
||||
_quotation = _quotation.copyWith(data: response, isLoading: false);
|
||||
} catch (e) {
|
||||
_setResource(_quotation.copyWith(
|
||||
data: null,
|
||||
_quotation = _quotation.copyWith(
|
||||
error: e is Exception ? e : Exception(e.toString()),
|
||||
isLoading: false,
|
||||
));
|
||||
);
|
||||
}
|
||||
notifyListeners();
|
||||
return _quotation.data;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_setResource(Resource(data: null, isLoading: false, error: null));
|
||||
_quotation = Resource(data: null, isLoading: false, error: null);
|
||||
_isLoaded = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class PermissionsProvider extends ChangeNotifier {
|
||||
Resource<UserAccess> _userAccess = Resource(data: null, isLoading: false, error: null);
|
||||
late OrganizationsProvider _organizations;
|
||||
bool _isLoaded = false;
|
||||
bool _errorHandled = false;
|
||||
|
||||
void update(OrganizationsProvider venue) {
|
||||
_organizations = venue;
|
||||
@@ -44,6 +45,7 @@ class PermissionsProvider extends ChangeNotifier {
|
||||
/// Load the [UserAccess] for the current venue.
|
||||
Future<UserAccess?> load() async {
|
||||
_userAccess = _userAccess.copyWith(isLoading: true, error: null);
|
||||
_errorHandled = false;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@@ -67,6 +69,12 @@ class PermissionsProvider extends ChangeNotifier {
|
||||
return _userAccess.data;
|
||||
}
|
||||
|
||||
bool get hasUnhandledError => error != null && !_errorHandled;
|
||||
|
||||
void markErrorHandled() {
|
||||
_errorHandled = true;
|
||||
}
|
||||
|
||||
Future<UserAccess?> changeRole(String accountRef, String newRoleDescRef) async {
|
||||
final currentRole = roles.firstWhereOrNull((r) => r.accountRef == accountRef);
|
||||
final currentDesc = currentRole != null
|
||||
|
||||
@@ -4,8 +4,8 @@ import 'package:pshared/api/requests/payment/quote.dart';
|
||||
import 'package:pshared/api/responses/payment/quotation.dart';
|
||||
import 'package:pshared/data/mapper/payment/payment_quote.dart';
|
||||
import 'package:pshared/models/payment/quote.dart';
|
||||
import 'package:pshared/service/authorization/service.dart';
|
||||
import 'package:pshared/service/services.dart';
|
||||
import 'package:pshared/utils/http/requests.dart';
|
||||
|
||||
|
||||
class QuotationService {
|
||||
@@ -14,11 +14,7 @@ class QuotationService {
|
||||
|
||||
static Future<PaymentQuote> getQuotation(String organizationRef, QuotePaymentRequest request) async {
|
||||
_logger.fine('Quoting payment for organization $organizationRef');
|
||||
final response = await AuthorizationService.getPOSTResponse(
|
||||
_objectType,
|
||||
'/quote/$organizationRef',
|
||||
request.toJson(),
|
||||
);
|
||||
final response = await getPOSTResponse(_objectType, '/quote/$organizationRef', request.toJson());
|
||||
return PaymentQuoteResponse.fromJson(response).quote.toDomain();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/asset.dart';
|
||||
import 'package:pshared/models/currency.dart';
|
||||
|
||||
|
||||
String currencyCodeToSymbol(Currency currencyCode) {
|
||||
switch (currencyCode) {
|
||||
case Currency.usd:
|
||||
return '\$';
|
||||
case Currency.usdt:
|
||||
return '₮';
|
||||
case Currency.usdc:
|
||||
return '\$';
|
||||
case Currency.rub:
|
||||
return '₽';
|
||||
case Currency.eur:
|
||||
return '€';
|
||||
}
|
||||
}
|
||||
|
||||
String amountToString(double amount) {
|
||||
return amount.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
String currencyToString(Currency currencyCode, double amount) {
|
||||
return '${currencyCodeToSymbol(currencyCode)}\u00A0${amountToString(amount)}';
|
||||
}
|
||||
|
||||
String assetToString(Asset asset) {
|
||||
return currencyToString(asset.currency, asset.amount);
|
||||
}
|
||||
|
||||
Currency currencyStringToCode(String currencyCode) {
|
||||
String currencyCodeToSymbol(String currencyCode) {
|
||||
switch (currencyCode) {
|
||||
case 'USD':
|
||||
return Currency.usd;
|
||||
case 'USDT':
|
||||
return Currency.usdt;
|
||||
case 'USDC':
|
||||
return Currency.usdc;
|
||||
case 'RUB':
|
||||
return Currency.rub;
|
||||
return '\$';
|
||||
case 'PLN':
|
||||
return 'zł';
|
||||
case 'EUR':
|
||||
return Currency.eur;
|
||||
return '€';
|
||||
case 'GBP':
|
||||
return '£';
|
||||
case 'HUF':
|
||||
return 'Ft';
|
||||
case 'RUB':
|
||||
return '₽';
|
||||
default:
|
||||
throw ArgumentError('Unknown currency code: $currencyCode');
|
||||
return currencyCode;
|
||||
}
|
||||
}
|
||||
|
||||
IconData iconForCurrencyType(Currency currencyCode) {
|
||||
switch (currencyCode) {
|
||||
case Currency.usd:
|
||||
return Icons.currency_exchange;
|
||||
case Currency.eur:
|
||||
return Icons.currency_exchange;
|
||||
case Currency.rub:
|
||||
return Icons.currency_ruble;
|
||||
case Currency.usdt:
|
||||
return Icons.currency_exchange;
|
||||
case Currency.usdc:
|
||||
return Icons.money;
|
||||
}
|
||||
String currencyToString(String currencyCode, double amount) {
|
||||
return '${currencyCodeToSymbol(currencyCode)}\u00A0${amount.toStringAsFixed(2)}';
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:pshared/models/wallet/wallet.dart' as domain;
|
||||
|
||||
import 'package:pshared/models/currency.dart';
|
||||
|
||||
import 'package:pweb/models/currency.dart';
|
||||
import 'package:pweb/models/wallet.dart';
|
||||
|
||||
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
"send": "Send Payout",
|
||||
"recipientPaysFee": "Recipient pays the fee",
|
||||
|
||||
"sentAmount": "Sent amount: {amount}",
|
||||
"sentAmount": "Sent amount: ${amount}",
|
||||
"@sentAmount": {
|
||||
"description": "Label showing the amount sent",
|
||||
"placeholders": {
|
||||
@@ -388,7 +388,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"fee": "Fee: {fee}",
|
||||
"fee": "Fee: ${fee}",
|
||||
"@fee": {
|
||||
"description": "Label showing the transaction fee",
|
||||
"placeholders": {
|
||||
@@ -398,7 +398,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"recipientWillReceive": "Recipient will receive: {amount}",
|
||||
"recipientWillReceive": "Recipient will receive: ${amount}",
|
||||
"@recipientWillReceive": {
|
||||
"description": "Label showing how much the recipient will receive",
|
||||
"placeholders": {
|
||||
@@ -408,7 +408,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"total": "Total: {total}",
|
||||
"total": "Total: ${total}",
|
||||
"@total": {
|
||||
"description": "Label showing the total amount of the transaction",
|
||||
"placeholders": {
|
||||
|
||||
@@ -378,7 +378,7 @@
|
||||
"send": "Отправить выплату",
|
||||
"recipientPaysFee": "Получатель оплачивает комиссию",
|
||||
|
||||
"sentAmount": "Отправленная сумма: {amount}",
|
||||
"sentAmount": "Отправленная сумма: ${amount}",
|
||||
"@sentAmount": {
|
||||
"description": "Метка, показывающая отправленную сумму",
|
||||
"placeholders": {
|
||||
@@ -388,7 +388,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"fee": "Комиссия: {fee}",
|
||||
"fee": "Комиссия: ${fee}",
|
||||
"@fee": {
|
||||
"description": "Метка, показывающая комиссию за транзакцию",
|
||||
"placeholders": {
|
||||
@@ -398,7 +398,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"recipientWillReceive": "Получатель получит: {amount}",
|
||||
"recipientWillReceive": "Получатель получит: ${amount}",
|
||||
"@recipientWillReceive": {
|
||||
"description": "Метка, показывающая, сколько получит получатель",
|
||||
"placeholders": {
|
||||
@@ -408,7 +408,7 @@
|
||||
}
|
||||
},
|
||||
|
||||
"total": "Итого: {total}",
|
||||
"total": "Итого: ${total}",
|
||||
"@total": {
|
||||
"description": "Метка, показывающая общую сумму транзакции",
|
||||
"placeholders": {
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:pshared/provider/locale.dart';
|
||||
import 'package:pshared/provider/permissions.dart';
|
||||
import 'package:pshared/provider/account.dart';
|
||||
import 'package:pshared/provider/organizations.dart';
|
||||
import 'package:pshared/provider/payment/quotation.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
|
||||
@@ -26,6 +27,7 @@ import 'package:pweb/providers/wallets.dart';
|
||||
import 'package:pweb/providers/wallet_transactions.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';
|
||||
|
||||
@@ -39,11 +41,9 @@ void _setupLogging() {
|
||||
}
|
||||
|
||||
void main() async {
|
||||
await Constants.initialize();
|
||||
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// await AmplitudeService.initialize();
|
||||
await Constants.initialize();
|
||||
await PosthogService.initialize();
|
||||
|
||||
|
||||
_setupLogging();
|
||||
@@ -56,7 +56,12 @@ void main() async {
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => LocaleProvider(null)),
|
||||
ChangeNotifierProxyProvider<LocaleProvider, AccountProvider>(
|
||||
create: (_) => AccountProvider(),
|
||||
create: (_) => AccountProvider(
|
||||
onAccountChanged: (account) {
|
||||
if (account == null) return Future<void>.value();
|
||||
return PosthogService.identify(account);
|
||||
},
|
||||
),
|
||||
update: (context, localeProvider, provider) => provider!..updateProvider(localeProvider),
|
||||
),
|
||||
ChangeNotifierProxyProvider<AccountProvider, TwoFactorProvider>(
|
||||
@@ -69,6 +74,7 @@ void main() async {
|
||||
update: (context, orgnization, provider) => provider!..update(orgnization),
|
||||
),
|
||||
ChangeNotifierProvider(create: (_) => CarouselIndexProvider()),
|
||||
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => UploadHistoryProvider(service: MockUploadHistoryService())..load(),
|
||||
),
|
||||
@@ -90,9 +96,14 @@ void main() async {
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => MockPaymentProvider(),
|
||||
),
|
||||
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => OperationProvider(OperationService())..loadOperations(),
|
||||
),
|
||||
ChangeNotifierProxyProvider<OrganizationsProvider, QuotationProvider>(
|
||||
create: (_) => QuotationProvider(),
|
||||
update: (context, orgnization, provider) => provider!..update(orgnization),
|
||||
),
|
||||
],
|
||||
child: const PayApp(),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:pshared/models/currency.dart';
|
||||
import 'package:pweb/models/currency.dart';
|
||||
|
||||
|
||||
class Wallet {
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
|
||||
import 'package:pshared/models/currency.dart';
|
||||
import 'package:pweb/models/currency.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
@@ -14,6 +16,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/posthog.dart';
|
||||
import 'package:pweb/utils/error/snackbar.dart';
|
||||
import 'package:pweb/utils/payment/label.dart';
|
||||
import 'package:pweb/utils/snackbar.dart';
|
||||
@@ -106,11 +109,11 @@ class _AdressBookRecipientFormState extends State<AdressBookRecipientForm> {
|
||||
return;
|
||||
}
|
||||
|
||||
// AmplitudeService.recipientAddCompleted(
|
||||
// _type,
|
||||
// _status,
|
||||
// _methods.keys.toSet(),
|
||||
// );
|
||||
unawaited(PosthogService.recipientAddCompleted(
|
||||
_type,
|
||||
_status,
|
||||
_methods.keys.toSet(),
|
||||
));
|
||||
final recipient = await executeActionWithNotification(
|
||||
context: context,
|
||||
action: _doSave,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/models/wallet.dart';
|
||||
import 'package:pweb/utils/currency.dart';
|
||||
|
||||
|
||||
class BalanceAmount extends StatelessWidget {
|
||||
@@ -27,7 +25,7 @@ class BalanceAmount extends StatelessWidget {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
wallet.isHidden ? '•••• $currencyBalance' : '${amountToString(wallet.balance)} $currencyBalance',
|
||||
wallet.isHidden ? '•••• $currencyBalance' : '${wallet.balance.toStringAsFixed(2)} $currencyBalance',
|
||||
style: textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentAmountWidget extends StatefulWidget {
|
||||
const PaymentAmountWidget({super.key});
|
||||
|
||||
@override
|
||||
State<PaymentAmountWidget> createState() => _PaymentAmountWidgetState();
|
||||
}
|
||||
|
||||
class _PaymentAmountWidgetState extends State<PaymentAmountWidget> {
|
||||
late final TextEditingController _controller;
|
||||
bool _isSyncingText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final initialAmount = context.read<PaymentAmountProvider>().amount;
|
||||
_controller = TextEditingController(text: amountToString(initialAmount));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double? _parseAmount(String value) => double.tryParse(value.replaceAll(',', '.'));
|
||||
|
||||
void _syncTextWithAmount(double amount) {
|
||||
final parsedText = _parseAmount(_controller.text);
|
||||
if (parsedText != null && parsedText == amount) return;
|
||||
|
||||
final nextText = amountToString(amount);
|
||||
_isSyncingText = true;
|
||||
_controller.value = TextEditingValue(
|
||||
text: nextText,
|
||||
selection: TextSelection.collapsed(offset: nextText.length),
|
||||
);
|
||||
_isSyncingText = false;
|
||||
}
|
||||
|
||||
void _onChanged(String value) {
|
||||
if (_isSyncingText) return;
|
||||
|
||||
final parsed = _parseAmount(value);
|
||||
if (parsed != null) {
|
||||
context.read<PaymentAmountProvider>().setAmount(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final amount = context.select<PaymentAmountProvider, double>((provider) => provider.amount);
|
||||
_syncTextWithAmount(amount);
|
||||
|
||||
return TextField(
|
||||
controller: _controller,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.amount,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: _onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class FeePayerSwitch extends StatelessWidget {
|
||||
final double spacing;
|
||||
final TextStyle? style;
|
||||
|
||||
const FeePayerSwitch({super.key, required this.spacing, TextStyle? this.style});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<PaymentAmountProvider>(
|
||||
builder: (context, provider, _) => Row(
|
||||
spacing: spacing,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.recipientPaysFee, style: style),
|
||||
Switch(
|
||||
value: !provider.payerCoversFee,
|
||||
onChanged: (val) => provider.setPayerCoversFee(!val),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/amount.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/fee_payer.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/widget.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentFormWidget extends StatelessWidget {
|
||||
const PaymentFormWidget({super.key});
|
||||
|
||||
static const double _smallSpacing = 5;
|
||||
static const double _mediumSpacing = 10;
|
||||
static const double _largeSpacing = 16;
|
||||
static const double _extraSpacing = 15;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(loc.details, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: _smallSpacing),
|
||||
|
||||
const PaymentAmountWidget(),
|
||||
|
||||
const SizedBox(height: _mediumSpacing),
|
||||
|
||||
FeePayerSwitch(spacing: _mediumSpacing, style: theme.textTheme.titleMedium),
|
||||
|
||||
const SizedBox(height: _largeSpacing),
|
||||
|
||||
const PaymentSummary(spacing: _extraSpacing),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
92
frontend/pweb/lib/pages/dashboard/payouts/payment_form.dart
Normal file
92
frontend/pweb/lib/pages/dashboard/payouts/payment_form.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pweb/providers/mock_payment.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentFormWidget extends StatelessWidget {
|
||||
const PaymentFormWidget({super.key});
|
||||
|
||||
static const double _smallSpacing = 5;
|
||||
static const double _mediumSpacing = 10;
|
||||
static const double _largeSpacing = 16;
|
||||
static const double _extraSpacing = 15;
|
||||
|
||||
String _formatAmount(double amount) => amount.toStringAsFixed(2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = Provider.of<MockPaymentProvider>(context);
|
||||
final theme = Theme.of(context);
|
||||
final loc = AppLocalizations.of(context)!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(loc.details, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: _smallSpacing),
|
||||
|
||||
TextField(
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
labelText: loc.amount,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (val) {
|
||||
final parsed = double.tryParse(val.replaceAll(',', '.')) ?? 0.0;
|
||||
provider.setAmount(parsed);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: _mediumSpacing),
|
||||
|
||||
Row(
|
||||
spacing: _mediumSpacing,
|
||||
children: [
|
||||
Text(loc.recipientPaysFee, style: theme.textTheme.titleMedium),
|
||||
Switch(
|
||||
value: !provider.payerCoversFee,
|
||||
onChanged: (val) => provider.setPayerCoversFee(!val),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: _largeSpacing),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SummaryRow(label: loc.sentAmount(_formatAmount(provider.amount)), style: theme.textTheme.titleMedium),
|
||||
_SummaryRow(label: loc.fee(_formatAmount(provider.fee)), style: theme.textTheme.titleMedium),
|
||||
_SummaryRow(label: loc.recipientWillReceive(_formatAmount(provider.recipientGets)), style: theme.textTheme.titleMedium),
|
||||
|
||||
const SizedBox(height: _extraSpacing),
|
||||
|
||||
_SummaryRow(
|
||||
label: loc.total(_formatAmount(provider.total)),
|
||||
style: theme.textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryRow extends StatelessWidget {
|
||||
final String label;
|
||||
final TextStyle? style;
|
||||
|
||||
const _SummaryRow({required this.label, this.style});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(label, style: style);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/quotation.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentFeeRow extends StatelessWidget {
|
||||
const PaymentFeeRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<QuotationProvider>(
|
||||
builder: (context, provider, _) => PaymentSummaryRow(
|
||||
labelFactory: AppLocalizations.of(context)!.fee,
|
||||
asset: provider.fee,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/quotation.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentRecipientReceivesRow extends StatelessWidget {
|
||||
const PaymentRecipientReceivesRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<QuotationProvider>(
|
||||
builder: (context, provider, _) => PaymentSummaryRow(
|
||||
labelFactory: AppLocalizations.of(context)!.recipientWillReceive,
|
||||
asset: provider.recipientGets,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/asset.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
|
||||
class PaymentSummaryRow extends StatelessWidget {
|
||||
final String Function(String) labelFactory;
|
||||
final Asset? asset;
|
||||
final TextStyle? style;
|
||||
|
||||
const PaymentSummaryRow({
|
||||
super.key,
|
||||
required this.labelFactory,
|
||||
required this.asset,
|
||||
this.style,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
labelFactory(asset == null ? 'N/A' : assetToString(asset!)),
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/models/asset.dart';
|
||||
import 'package:pshared/models/currency.dart';
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentSentAmountRow extends StatelessWidget {
|
||||
final Currency currency;
|
||||
const PaymentSentAmountRow({super.key, required this.currency});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<PaymentAmountProvider>(
|
||||
builder: (context, provider, _) => PaymentSummaryRow(
|
||||
labelFactory: AppLocalizations.of(context)!.sentAmount,
|
||||
asset: Asset(currency: currency, amount: provider.amount),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/payment/quotation.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/row.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentTotalRow extends StatelessWidget {
|
||||
const PaymentTotalRow({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<QuotationProvider>(
|
||||
builder: (context, provider, _) => PaymentSummaryRow(
|
||||
labelFactory: AppLocalizations.of(context)!.total,
|
||||
asset: provider.total,
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/fee.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/recipient_receives.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/sent_amount.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/summary/total.dart';
|
||||
import 'package:pweb/providers/wallets.dart';
|
||||
|
||||
|
||||
class PaymentSummary extends StatelessWidget {
|
||||
final double spacing;
|
||||
|
||||
const PaymentSummary({super.key, required this.spacing});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Align(
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
PaymentSentAmountRow(currency: currencyStringToCode(context.read<WalletsProvider>().selectedWallet?.tokenSymbol ?? 'USDT')),
|
||||
const PaymentFeeRow(),
|
||||
const PaymentRecipientReceivesRow(),
|
||||
SizedBox(height: spacing),
|
||||
const PaymentTotalRow(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/provider/organizations.dart';
|
||||
import 'package:pshared/provider/payment/amount.dart';
|
||||
import 'package:pshared/provider/payment/quotation.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/form.dart';
|
||||
|
||||
|
||||
class PaymentFromWrappingWidget extends StatelessWidget {
|
||||
const PaymentFromWrappingWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => PaymentAmountProvider(),
|
||||
),
|
||||
ChangeNotifierProxyProvider2<OrganizationsProvider, PaymentAmountProvider, QuotationProvider>(
|
||||
create: (_) => QuotationProvider(),
|
||||
update: (context, orgnization, payment, provider) => provider!..update(orgnization, payment),
|
||||
),
|
||||
],
|
||||
child: const PaymentFormWidget(),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
@@ -10,26 +11,50 @@ import 'package:pweb/widgets/error/snackbar.dart';
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class AccountLoader extends StatelessWidget {
|
||||
class AccountLoader extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const AccountLoader({super.key, required this.child});
|
||||
|
||||
@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) {
|
||||
State<AccountLoader> createState() => _AccountLoaderState();
|
||||
}
|
||||
|
||||
class _AccountLoaderState extends State<AccountLoader> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final provider = Provider.of<AccountProvider>(context, listen: false);
|
||||
if (provider.account == null) {
|
||||
provider.restoreIfPossible().catchError((error, stack) {
|
||||
Logger('Account restore failed: $error');
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AccountProvider>(builder: (context, provider, _) {
|
||||
if (provider.account != null) {
|
||||
return widget.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.isLoading) return const Center(child: CircularProgressIndicator());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => navigateAndReplace(context, Pages.login));
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return child;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,20 +21,29 @@ class PermissionsLoader extends StatelessWidget {
|
||||
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.hasUnhandledError) {
|
||||
provider.markErrorHandled();
|
||||
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.error == null && !provider.isReady && accountProvider.account != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
provider.load();
|
||||
});
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return child;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -14,6 +16,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,6 +46,7 @@ class _LoginFormState extends State<LoginForm> {
|
||||
password: _passwordController.text,
|
||||
locale: context.read<LocaleProvider>().locale.languageCode,
|
||||
);
|
||||
unawaited(PosthogService.login(pending: outcome.isPending));
|
||||
if (outcome.isPending) {
|
||||
// TODO: fix context usage
|
||||
navigateAndReplace(context, Pages.sfactor);
|
||||
|
||||
@@ -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
|
||||
@@ -195,4 +196,4 @@ class _PaymentPageState extends State<PaymentPage> {
|
||||
(method.description?.contains(wallet.walletUserID) ?? false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/send_button.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/form.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/payment_form.dart';
|
||||
import 'package:pweb/pages/payment_methods/widgets/payment_info_section.dart';
|
||||
import 'package:pweb/pages/payment_methods/widgets/recipient_section.dart';
|
||||
import 'package:pweb/pages/payment_methods/widgets/section_title.dart';
|
||||
|
||||
@@ -2,14 +2,14 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pweb/models/wallet.dart';
|
||||
import 'package:pweb/providers/wallets.dart';
|
||||
import 'package:pshared/models/payment/methods/type.dart';
|
||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
|
||||
import 'package:pweb/utils/payment/dropdown.dart';
|
||||
|
||||
|
||||
class PaymentMethodSelector extends StatelessWidget {
|
||||
final ValueChanged<Wallet> onMethodChanged;
|
||||
final ValueChanged<PaymentMethod> onMethodChanged;
|
||||
|
||||
const PaymentMethodSelector({
|
||||
super.key,
|
||||
@@ -17,9 +17,9 @@ class PaymentMethodSelector extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Consumer<WalletsProvider>(builder:(context, provider, _) => PaymentMethodDropdown(
|
||||
methods: provider.wallets,
|
||||
initialValue: provider.selectedWallet,
|
||||
Widget build(BuildContext context) => Consumer<PaymentMethodsProvider>(builder:(context, provider, _) => PaymentMethodDropdown(
|
||||
methods: provider.methods,
|
||||
initialValue: provider.currentObject,
|
||||
onChanged: onMethodChanged,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:pshared/models/recipient/recipient.dart';
|
||||
import 'package:pshared/provider/recipient/pmethods.dart';
|
||||
import 'package:pshared/provider/recipient/provider.dart';
|
||||
|
||||
import 'package:pweb/pages/dashboard/payouts/widget.dart';
|
||||
import 'package:pweb/pages/dashboard/payouts/payment_form.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/back_button.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/header.dart';
|
||||
import 'package:pweb/pages/payment_methods/payment_page/method_selector.dart';
|
||||
@@ -105,7 +105,7 @@ class PaymentPageContent extends StatelessWidget {
|
||||
availableTypes: availablePaymentTypes,
|
||||
),
|
||||
SizedBox(height: dimensions.paddingLarge),
|
||||
const PaymentFromWrappingWidget(),
|
||||
const PaymentFormWidget(),
|
||||
SizedBox(height: dimensions.paddingXXXLarge),
|
||||
SendButton(onPressed: onSend),
|
||||
SizedBox(height: dimensions.paddingLarge),
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/models/wallet.dart';
|
||||
import 'package:pweb/pages/dashboard/buttons/balance/amount.dart';
|
||||
import 'package:pweb/providers/wallets.dart';
|
||||
import 'package:pweb/utils/currency.dart';
|
||||
|
||||
|
||||
class WalletCard extends StatelessWidget {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/models/wallet_transaction.dart';
|
||||
import 'package:pweb/pages/payout_page/wallet/history/chip.dart';
|
||||
import 'package:pweb/pages/report/table/badge.dart';
|
||||
import 'package:pweb/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
@@ -67,11 +66,11 @@ class WalletTransactionsTable extends StatelessWidget {
|
||||
DataCell(OperationStatusBadge(status: tx.status)),
|
||||
DataCell(TypeChip(type: tx.type)),
|
||||
DataCell(Text(
|
||||
'${tx.type.sign}${amountToString(tx.amount)} ${currencyCodeToSymbol(tx.currency)}')),
|
||||
'${tx.type.sign}${tx.amount.toStringAsFixed(2)} ${currencyCodeToSymbol(tx.currency)}')),
|
||||
DataCell(Text(
|
||||
tx.balanceAfter == null
|
||||
? '-'
|
||||
: '${amountToString(tx.balanceAfter!)} ${currencyCodeToSymbol(tx.currency)}',
|
||||
: '${tx.balanceAfter!.toStringAsFixed(2)} ${currencyCodeToSymbol(tx.currency)}',
|
||||
)),
|
||||
DataCell(Text(tx.counterparty ?? '-')),
|
||||
DataCell(Text(
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
// operation_row.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/operation.dart';
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/pages/report/table/badge.dart';
|
||||
|
||||
|
||||
class OperationRow {
|
||||
static DataRow build(OperationItem op, BuildContext context) {
|
||||
return DataRow(cells: [
|
||||
DataCell(OperationStatusBadge(status: op.status)),
|
||||
DataCell(Text(op.fileName ?? '')),
|
||||
DataCell(Text('${amountToString(op.amount)} ${op.currency}')),
|
||||
DataCell(Text('${amountToString(op.toAmount)} ${op.toCurrency}')),
|
||||
DataCell(Text('${op.amount.toStringAsFixed(2)} ${op.currency}')),
|
||||
DataCell(Text('${op.toAmount.toStringAsFixed(2)} ${op.toCurrency}')),
|
||||
DataCell(Text(op.payId)),
|
||||
DataCell(Text(op.cardNumber ?? '-')),
|
||||
DataCell(Text(op.name)),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/utils/currency.dart';
|
||||
|
||||
import 'package:pweb/models/wallet.dart';
|
||||
import 'package:pweb/pages/wallet_top_up/details.dart';
|
||||
import 'package:pweb/pages/wallet_top_up/header.dart';
|
||||
import 'package:pweb/pages/wallet_top_up/meta.dart';
|
||||
import 'package:pweb/utils/currency.dart';
|
||||
import 'package:pweb/utils/dimensions.dart';
|
||||
|
||||
|
||||
|
||||
136
frontend/pweb/lib/services/posthog.dart
Normal file
136
frontend/pweb/lib/services/posthog.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:pshared/models/payment/status.dart';
|
||||
import 'package:pshared/models/currency.dart';
|
||||
|
||||
import 'package:pweb/models/currency.dart';
|
||||
import 'package:pweb/models/wallet_transaction.dart';
|
||||
|
||||
|
||||
|
||||
38
frontend/pweb/lib/utils/currency.dart
Normal file
38
frontend/pweb/lib/utils/currency.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pweb/models/currency.dart';
|
||||
|
||||
|
||||
String currencyCodeToSymbol(Currency currencyCode) {
|
||||
switch (currencyCode) {
|
||||
case Currency.usd:
|
||||
return '\$';
|
||||
case Currency.eur:
|
||||
return '€';
|
||||
case Currency.rub:
|
||||
return '₽';
|
||||
case Currency.usdt:
|
||||
return 'USDT';
|
||||
case Currency.usdc:
|
||||
return 'USDC';
|
||||
}
|
||||
}
|
||||
|
||||
String currencyToString(Currency currencyCode, double amount) {
|
||||
return '${amount.toStringAsFixed(2)} ${currencyCodeToSymbol(currencyCode)}';
|
||||
}
|
||||
|
||||
IconData iconForCurrencyType(Currency currencyCode) {
|
||||
switch (currencyCode) {
|
||||
case Currency.usd:
|
||||
return Icons.currency_exchange;
|
||||
case Currency.eur:
|
||||
return Icons.currency_exchange;
|
||||
case Currency.rub:
|
||||
return Icons.currency_ruble;
|
||||
case Currency.usdt:
|
||||
return Icons.currency_exchange;
|
||||
case Currency.usdc:
|
||||
return Icons.money;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:pshared/models/payment/type.dart';
|
||||
import 'package:pshared/models/payment/methods/type.dart';
|
||||
|
||||
import 'package:pweb/models/wallet.dart';
|
||||
import 'package:pweb/pages/payment_methods/icon.dart';
|
||||
|
||||
import 'package:pweb/generated/i18n/app_localizations.dart';
|
||||
|
||||
|
||||
class PaymentMethodDropdown extends StatefulWidget {
|
||||
final List<Wallet> methods;
|
||||
final ValueChanged<Wallet> onChanged;
|
||||
final Wallet? initialValue;
|
||||
final List<PaymentMethod> methods;
|
||||
final ValueChanged<PaymentMethod> onChanged;
|
||||
final PaymentMethod? initialValue;
|
||||
|
||||
const PaymentMethodDropdown({
|
||||
super.key,
|
||||
@@ -25,7 +24,7 @@ class PaymentMethodDropdown extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _PaymentMethodDropdownState extends State<PaymentMethodDropdown> {
|
||||
late Wallet _selectedMethod;
|
||||
late PaymentMethod _selectedMethod;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,7 +34,7 @@ class _PaymentMethodDropdownState extends State<PaymentMethodDropdown> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DropdownButtonFormField<Wallet>(
|
||||
return DropdownButtonFormField<PaymentMethod>(
|
||||
dropdownColor: Theme.of(context).colorScheme.onSecondary,
|
||||
initialValue: _selectedMethod,
|
||||
decoration: InputDecoration(
|
||||
@@ -43,13 +42,13 @@ class _PaymentMethodDropdownState extends State<PaymentMethodDropdown> {
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: widget.methods.map((method) {
|
||||
return DropdownMenuItem<Wallet>(
|
||||
return DropdownMenuItem<PaymentMethod>(
|
||||
value: method,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(iconForPaymentType(PaymentType.managedWallet), size: 20),
|
||||
Icon(iconForPaymentType(method.type), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(method.name),
|
||||
Text('${method.name}' + (method.description == null ? '' : ' (${method.description!})')),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
@@ -76,4 +76,4 @@ class SideMenuColumn extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user