47 Commits

Author SHA1 Message Date
3f8399d647 Merge pull request 'fixed fee polarity' (#101) from currency-100 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #101
2025-12-12 12:21:19 +00:00
Stephan D
028b29fe08 fixed fee polarity 2025-12-12 13:21:00 +01:00
cb3f59a9d5 Merge pull request 'fixed duplicated ISO codes parsing' (#99) from currency-99 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #99
2025-12-12 10:42:07 +00:00
Stephan D
2b8d02d95c fixed duplicated ISO codes parsing 2025-12-12 11:41:45 +01:00
d90d8cda11 Merge pull request 'fx/ingestor currencies map fixed' (#98) from currencies-97 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/notification Pipeline failed
Reviewed-on: #98
2025-12-12 10:05:52 +00:00
Stephan D
17333df7af fx/ingestor currencies map fixed 2025-12-12 11:05:12 +01:00
681d53e856 Merge pull request 'Fixed http client' (#96) from client-95 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #96
2025-12-12 00:53:59 +00:00
Stephan D
dc608fd257 Fixed http client 2025-12-12 01:53:34 +01:00
cd2efdc2f3 Merge pull request 'improved fx/ingestor logging' (#93) from logging-92 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #93
2025-12-12 00:07:25 +00:00
Stephan D
fd47867101 improved fx/ingestor logging 2025-12-12 01:07:03 +01:00
2ca1a6956c Merge pull request 'fx/oracle logging' (#92) from logging-90 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline failed
Reviewed-on: #92
2025-12-12 00:05:13 +00:00
Stephan D
a5ad4f4c3c fx/oracle logging 2025-12-12 00:44:04 +01:00
8b202e0c60 Merge pull request 'fixed currency validation logic' (#89) from currency-88 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #89
2025-12-11 22:55:50 +00:00
Stephan D
4626d0a1a7 fixed currency validation logic 2025-12-11 23:55:04 +01:00
da72121109 Merge pull request 'ledger account reference removed' (#87) from fees-86 into main
Some checks failed
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #87
2025-12-11 22:32:26 +00:00
Stephan D
5bebadf17c ledger account reference removed 2025-12-11 23:30:42 +01:00
1bab0b14ef Merge pull request 'fixed currency pair validation' (#85) from currency-84 into main
Some checks failed
ci/woodpecker/push/chain_gateway Pipeline is pending
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #85
2025-12-11 22:27:42 +00:00
Stephan D
39f323d050 fixed currency pair validation 2025-12-11 23:27:15 +01:00
7cd9e14618 Merge pull request 'logging-84' (#83) from logging-84 into main
All checks were successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #83
2025-12-11 21:37:04 +00:00
Stephan D
b77d2c16ab improved logging 2025-12-11 22:25:51 +01:00
Stephan D
324f150950 improved logging 2025-12-11 22:25:04 +01:00
dd6bcf843c Merge pull request 'fixed payment orchestrator address' (#81) from connectivity-81 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #81
2025-12-11 20:53:04 +00:00
Stephan D
874cc4971b fixed payment orchestrator address 2025-12-11 21:52:37 +01:00
bfe4695b2d Merge pull request 'config fix' (#80) from discovery-79 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #80
2025-12-11 20:39:07 +00:00
Stephan D
99161c8e7d config fix 2025-12-11 21:38:32 +01:00
6901791dd2 Merge pull request 'default currency resolver' (#78) from currency-76 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #78
2025-12-11 20:24:10 +00:00
Stephan D
acb3d14b47 default currency resolver 2025-12-11 21:23:35 +01:00
aa5f7e271e Merge pull request 'fix currencies validation' (#76) from currencies-75 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #76
2025-12-11 20:06:22 +00:00
Stephan D
0a01995f53 fix currencies validation 2025-12-11 21:05:43 +01:00
97f71d125e Merge pull request 'removed deprecation warnings' (#71) from deprecation-70 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
Reviewed-on: #71
2025-12-11 10:23:12 +00:00
Stephan D
8db2f3926c deprecation fixed 2025-12-11 11:22:51 +01:00
Stephan D
2b68b59eca removed deprecation warnings 2025-12-11 11:11:54 +01:00
d07e64fc4f Merge pull request 'fix fx/oracle compilation' (#68) from bug-66 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #68
2025-12-11 09:36:50 +00:00
Stephan D
8e40e6247b fix fx/oracle compilation 2025-12-11 10:36:31 +01:00
779cb0ead9 Merge pull request 'fix' (#65) from bug-64 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #65
2025-12-11 00:45:07 +00:00
Stephan D
2e0057f839 fix 2025-12-11 01:44:40 +01:00
25080ae168 Merge pull request 'fix' (#63) from bug-62 into main
Some checks failed
ci/woodpecker/push/bff Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #63
2025-12-11 00:31:19 +00:00
Stephan D
e6b001dc61 fix 2025-12-11 01:30:28 +01:00
97d1470515 Merge pull request '+ quotation provider' (#60) from quote-front-59 into main
Some checks failed
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/frontend Pipeline failed
Reviewed-on: #60
2025-12-11 00:13:35 +00:00
Stephan D
a4481fb63d + quotation provider 2025-12-11 01:13:13 +01:00
bdf766075e Merge pull request 'payment rails' (#58) from payment-service-52 into main
Some checks failed
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/mntx_gateway Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
Reviewed-on: #58
2025-12-10 17:50:38 +00:00
Stephan D
47899e25d4 payment rails 2025-12-10 18:40:55 +01:00
4ec934c96b Merge pull request 'fixed CORS wildcard' (#55) from CORS-#54 into main
Some checks failed
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/frontend Pipeline failed
ci/woodpecker/push/fx_ingestor Pipeline failed
ci/woodpecker/push/payments_orchestrator Pipeline failed
ci/woodpecker/push/fx_oracle Pipeline failed
ci/woodpecker/push/ledger Pipeline failed
ci/woodpecker/push/mntx_gateway Pipeline failed
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/notification Pipeline failed
ci/woodpecker/push/chain_gateway Pipeline was successful
Reviewed-on: #55
2025-12-09 19:00:01 +00:00
Stephan D
19df740550 fixed CORS wildcard 2025-12-09 19:59:33 +01:00
1079ad7d0a Merge pull request 'Organizations now load only once' (#38) from SEND002 into main
Some checks failed
ci/woodpecker/push/db Pipeline is pending
ci/woodpecker/push/frontend Pipeline is pending
ci/woodpecker/push/fx_ingestor Pipeline is pending
ci/woodpecker/push/fx_oracle Pipeline is pending
ci/woodpecker/push/ledger Pipeline is pending
ci/woodpecker/push/mntx_gateway Pipeline is pending
ci/woodpecker/push/nats Pipeline is pending
ci/woodpecker/push/notification Pipeline is pending
ci/woodpecker/push/payments_orchestrator Pipeline is pending
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/chain_gateway Pipeline failed
ci/woodpecker/push/bff Pipeline failed
Reviewed-on: #38
2025-12-09 18:53:13 +00:00
Arseni
660f689a7a Current org now sets after list gets to the state of the provider 2025-12-09 16:15:36 +03:00
Arseni
8115abb569 Organizations now load only once 2025-12-08 19:10:33 +03:00
190 changed files with 6143 additions and 1522 deletions

1
.gitignore vendored
View File

@@ -9,3 +9,4 @@ untranslated.txt
generate_protos.sh generate_protos.sh
update_dep.sh update_dep.sh
.vscode/ .vscode/
.gocache/

View File

@@ -50,5 +50,5 @@ require (
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.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/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
) )

View File

@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -90,10 +90,6 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
} }
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef) 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) amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
if calcErr != nil { if calcErr != nil {
@@ -113,7 +109,8 @@ func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, inte
entrySide := mapEntrySide(rule.EntrySide) entrySide := mapEntrySide(rule.EntrySide)
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED { if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT // Default fees to debit (i.e. charge the customer) when entry side is not specified.
entrySide = accountingv1.EntrySide_ENTRY_SIDE_DEBIT
} }
meta := map[string]string{ meta := map[string]string{

View File

@@ -38,23 +38,23 @@ func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
} }
} }
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) { 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) {
if r.plans == nil { if r.plans == nil {
return nil, nil, merrors.InvalidArgument("fees: plans store is required") return nil, nil, merrors.InvalidArgument("fees: plans store is required")
} }
// Try org-specific first if provided. // Try org-specific first if provided.
if orgID != nil && !orgID.IsZero() { if orgRef != nil && !orgRef.IsZero() {
if plan, err := r.getOrgPlan(ctx, *orgID, at); err == nil { if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil { if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
return plan, rule, nil return plan, rule, nil
} else if !errors.Is(selErr, ErrNoFeeRuleFound) { } else if !errors.Is(selErr, ErrNoFeeRuleFound) {
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgID.Hex())) r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
return nil, nil, selErr return nil, nil, selErr
} }
r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgID.Hex())) r.logger.Debug("no matching rule in org plan; falling back to global", zap.String("org_ref", orgRef.Hex()))
} else if !errors.Is(err, storage.ErrFeePlanNotFound) { } else if !errors.Is(err, storage.ErrFeePlanNotFound) {
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgID.Hex())) r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
return nil, nil, err return nil, nil, err
} }
} }

View File

@@ -32,8 +32,8 @@ func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("expected fallback to global, got error: %v", err) t.Fatalf("expected fallback to global, got error: %v", err)
} }
if !plan.GetOrganizationRef().IsZero() { if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
t.Fatalf("expected global plan, got orgRef %s", plan.GetOrganizationRef().Hex()) t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
} }
if rule.RuleID != "global_capture" { if rule.RuleID != "global_capture" {
t.Fatalf("unexpected rule selected: %s", rule.RuleID) t.Fatalf("unexpected rule selected: %s", rule.RuleID)
@@ -59,8 +59,7 @@ func TestResolver_OrgOverridesGlobal(t *testing.T) {
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
orgPlan.SetOrganizationRef(org) orgPlan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}} store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
resolver := New(store, zap.NewNop()) resolver := New(store, zap.NewNop())
@@ -95,7 +94,7 @@ func TestResolver_SelectsHighestPriority(t *testing.T) {
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
plan.SetOrganizationRef(org) plan.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{plan}} store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
resolver := New(store, zap.NewNop()) resolver := New(store, zap.NewNop())
@@ -136,7 +135,7 @@ func TestResolver_EffectiveDateFiltering(t *testing.T) {
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past}, {RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
}, },
} }
orgPlan.SetOrganizationRef(org) orgPlan.OrganizationRef = &org
globalPlan := &model.FeePlan{ globalPlan := &model.FeePlan{
Active: true, Active: true,
@@ -221,7 +220,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
p1.SetOrganizationRef(org) p1.OrganizationRef = &org
p2 := &model.FeePlan{ p2 := &model.FeePlan{
Active: true, Active: true,
EffectiveFrom: now.Add(-30 * time.Minute), EffectiveFrom: now.Add(-30 * time.Minute),
@@ -229,7 +228,7 @@ func TestResolver_MultipleActivePlansConflict(t *testing.T) {
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)}, {RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
}, },
} }
p2.SetOrganizationRef(org) p2.OrganizationRef = &org
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}} store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
resolver := New(store, zap.NewNop()) resolver := New(store, zap.NewNop())
@@ -263,7 +262,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) { func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan var matches []*model.FeePlan
for _, plan := range m.plans { for _, plan := range m.plans {
if plan == nil || plan.GetOrganizationRef() != orgRef { if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
continue continue
} }
if !plan.Active { if !plan.Active {
@@ -289,7 +288,7 @@ func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) { func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
var matches []*model.FeePlan var matches []*model.FeePlan
for _, plan := range m.plans { for _, plan := range m.plans {
if plan == nil || !plan.GetOrganizationRef().IsZero() { if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
continue continue
} }
if !plan.Active { if !plan.Active {

View File

@@ -0,0 +1,88 @@
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
}

View File

@@ -72,26 +72,57 @@ func (s *Service) Register(router routers.GRPC) error {
} }
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err 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() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil { if intent != nil {
trigger = req.GetIntent().GetTrigger() trigger = intent.GetTrigger()
} }
var fxUsed bool var fxUsed bool
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil { if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != nil fxUsed = resp.GetFxUsed() != nil
linesCount = len(resp.GetLines())
appliedCount = len(resp.GetApplied())
} }
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start)) 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 { if err = s.validateQuoteRequest(req); err != nil {
return nil, err return nil, err
} }
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef()) orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil { if parseErr != nil {
logger.Warn("QuoteFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err return nil, err
} }
@@ -112,20 +143,59 @@ 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) { 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() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
if req != nil && req.GetIntent() != nil { if intent != nil {
trigger = req.GetIntent().GetTrigger() trigger = intent.GetTrigger()
} }
var fxUsed bool var (
fxUsed bool
expiresAt time.Time
)
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
linesCount := 0
appliedCount := 0
if err == nil && resp != nil { if err == nil && resp != nil {
fxUsed = resp.GetFxUsed() != 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)) 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 { if err = s.validatePrecomputeRequest(req); err != nil {
return nil, err return nil, err
} }
@@ -134,6 +204,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef()) orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
if parseErr != nil { if parseErr != nil {
logger.Warn("PrecomputeFees invalid organization_ref", zap.Error(parseErr))
err = status.Error(codes.InvalidArgument, "invalid organization_ref") err = status.Error(codes.InvalidArgument, "invalid organization_ref")
return nil, err return nil, err
} }
@@ -148,7 +219,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
if ttl <= 0 { if ttl <= 0 {
ttl = 60000 ttl = 60000
} }
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond) expiresAt = now.Add(time.Duration(ttl) * time.Millisecond)
payload := feeQuoteTokenPayload{ payload := feeQuoteTokenPayload{
OrganizationRef: req.GetMeta().GetOrganizationRef(), OrganizationRef: req.GetMeta().GetOrganizationRef(),
@@ -159,7 +230,7 @@ func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFees
var token string var token string
if token, err = encodeTokenPayload(payload); err != nil { if token, err = encodeTokenPayload(payload); err != nil {
s.logger.Warn("failed to encode fee quote token", zap.Error(err)) logger.Warn("failed to encode fee quote token", zap.Error(err))
err = status.Error(codes.Internal, "failed to encode fee quote token") err = status.Error(codes.Internal, "failed to encode fee quote token")
return nil, err return nil, err
} }
@@ -176,9 +247,18 @@ 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) { 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() start := s.clock.Now()
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
var fxUsed bool var (
fxUsed bool
resultReason string
)
defer func() { defer func() {
statusLabel := statusFromError(err) statusLabel := statusFromError(err)
if err == nil && resp != nil { if err == nil && resp != nil {
@@ -191,9 +271,28 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
} }
} }
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start)) 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()) == "" { if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
resultReason = "missing_token"
err = status.Error(codes.InvalidArgument, "fee_quote_token is required") err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
return nil, err return nil, err
} }
@@ -202,21 +301,29 @@ func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeT
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken()) payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
if decodeErr != nil { if decodeErr != nil {
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr)) resultReason = "invalid_token"
logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil 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 { if now.UnixMilli() > payload.ExpiresAtUnixMs {
resultReason = "expired"
logger.Info("fee quote token expired")
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
return resp, nil return resp, nil
} }
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef) orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
if parseErr != nil { if parseErr != nil {
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr)) resultReason = "invalid_token"
logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"} resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
return resp, nil return resp, nil
} }
@@ -280,6 +387,16 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
bookedAt = intent.GetBookedAt().AsTime() 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 var orgPtr *primitive.ObjectID
if !orgRef.IsZero() { if !orgRef.IsZero() {
orgPtr = &orgRef orgPtr = &orgRef
@@ -297,7 +414,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
case errors.Is(err, storage.ErrFeePlanNotFound): case errors.Is(err, storage.ErrFeePlanNotFound):
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found") return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
default: default:
s.logger.Warn("failed to resolve fee rule", zap.Error(err)) logger.Warn("failed to resolve fee rule", zap.Error(err))
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule") return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
} }
} }
@@ -313,7 +430,7 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
if errors.Is(calcErr, merrors.ErrInvalidArg) { if errors.Is(calcErr, merrors.ErrInvalidArg) {
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error()) return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
} }
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr)) logger.Warn("failed to compute fee quote", zap.Error(calcErr))
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote") return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
} }

View File

@@ -49,7 +49,7 @@ func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -163,7 +163,7 @@ func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -224,7 +224,7 @@ func TestQuoteFees_RoundingDown(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
service := NewService( service := NewService(
zap.NewNop(), zap.NewNop(),
@@ -277,7 +277,7 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
result := &types.CalculationResult{ result := &types.CalculationResult{
Lines: []*feesv1.DerivedPostingLine{ Lines: []*feesv1.DerivedPostingLine{
@@ -353,7 +353,7 @@ func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
}, },
} }
plan.SetID(primitive.NewObjectID()) plan.SetID(primitive.NewObjectID())
plan.SetOrganizationRef(orgRef) plan.OrganizationRef = &orgRef
fakeOracle := &oracleclient.Fake{ fakeOracle := &oracleclient.Fake{
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) { 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 { if s.plan == nil {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if s.plan.GetOrganizationRef() != orgRef { if (s.plan.OrganizationRef != nil) && (*s.plan.OrganizationRef != orgRef) {
return nil, storage.ErrFeePlanNotFound return nil, storage.ErrFeePlanNotFound
} }
if !s.plan.Active { if !s.plan.Active {

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/bson/primitive"
) )
const ( const (
@@ -26,8 +27,8 @@ const (
// FeePlan describes a collection of fee rules for an organisation. // FeePlan describes a collection of fee rules for an organisation.
type FeePlan struct { type FeePlan struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
model.OrganizationBoundBase `bson:",inline" json:",inline"`
model.Describable `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"` Active bool `bson:"active" json:"active"`
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"` EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"` EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`

View File

@@ -11,12 +11,18 @@ market:
- driver: CBR - driver: CBR
settings: settings:
base_url: "https://www.cbr.ru" 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: pairs:
BINANCE: BINANCE:
- base: "USDT" - base: "USDT"
quote: "EUR" quote: "EUR"
symbol: "EURUSDT" symbol: "EURUSDT"
invert: true invert: true
- base: "USD"
quote: "USDT"
symbol: "USDTUSD"
invert: true
- base: "UAH" - base: "UAH"
quote: "USDT" quote: "USDT"
symbol: "USDTUAH" symbol: "USDTUAH"

View File

@@ -51,5 +51,5 @@ require (
golang.org/x/text v0.32.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/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -85,6 +85,7 @@ func (s *Service) executePoll(ctx context.Context) error {
func (s *Service) pollOnce(ctx context.Context) error { func (s *Service) pollOnce(ctx context.Context) error {
var firstErr error var firstErr error
failures := 0
for _, pair := range s.pairs { for _, pair := range s.pairs {
start := time.Now() start := time.Now()
err := s.upsertPair(ctx, pair) err := s.upsertPair(ctx, pair)
@@ -96,14 +97,24 @@ func (s *Service) pollOnce(ctx context.Context) error {
if firstErr == nil { if firstErr == nil {
firstErr = err firstErr = err
} }
failures++
s.logger.Warn("Failed to ingest pair", s.logger.Warn("Failed to ingest pair",
zap.String("symbol", pair.Symbol), zap.String("symbol", pair.Symbol),
zap.String("source", pair.Source.String()), 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.Duration("elapsed", elapsed),
zap.Error(err), 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 return firstErr
} }
@@ -115,7 +126,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
ticker, err := connector.FetchTicker(ctx, pair.Symbol) ticker, err := connector.FetchTicker(ctx, pair.Symbol)
if err != nil { if err != nil {
return merrors.InternalWrap(err, "fetch ticker") return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
} }
bid, err := parseDecimal(ticker.BidPrice) bid, err := parseDecimal(ticker.BidPrice)
@@ -172,9 +183,14 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
s.logger.Debug("Snapshot ingested", s.logger.Debug("Snapshot ingested",
zap.String("pair", pair.Base+"/"+pair.Quote), zap.String("pair", pair.Base+"/"+pair.Quote),
zap.String("provider", pair.Provider), zap.String("provider", pair.Provider),
zap.String("source", pair.Source.String()),
zap.String("provider_ref", snapshot.ProviderRef),
zap.String("bid", snapshot.Bid), zap.String("bid", snapshot.Bid),
zap.String("ask", snapshot.Ask), zap.String("ask", snapshot.Ask),
zap.String("mid", snapshot.Mid), 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 return nil

View File

@@ -23,7 +23,7 @@ import (
type cbrConnector struct { type cbrConnector struct {
id mmodel.Driver id mmodel.Driver
provider string provider string
client *http.Client http *httpClient
base string base string
dailyPath string dailyPath string
directoryPath string directoryPath string
@@ -60,6 +60,8 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
directoryPath := defaultDirectoryPath directoryPath := defaultDirectoryPath
dailyPath := defaultDailyPath dailyPath := defaultDailyPath
dynamicPath := defaultDynamicPath dynamicPath := defaultDynamicPath
userAgent := defaultUserAgent
acceptHeader := defaultAccept
if settings != nil { if settings != nil {
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
@@ -77,6 +79,12 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" { if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
dynamicPath = 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) dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive) dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout) tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
@@ -99,13 +107,24 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
transport = customTransport transport = customTransport
} }
client := &http.Client{
Timeout: requestTimeout,
Transport: transport,
}
referer := parsed.String()
connector := &cbrConnector{ connector := &cbrConnector{
id: mmodel.DriverCBR, id: mmodel.DriverCBR,
provider: provider, provider: provider,
client: &http.Client{ http: newHTTPClient(
Timeout: requestTimeout, logger,
Transport: transport, client,
httpClientOptions{
userAgent: userAgent,
accept: acceptHeader,
referer: referer,
}, },
),
base: strings.TrimRight(parsed.String(), "/"), base: strings.TrimRight(parsed.String(), "/"),
dailyPath: dailyPath, dailyPath: dailyPath,
directoryPath: directoryPath, directoryPath: directoryPath,
@@ -161,20 +180,32 @@ func (c *cbrConnector) refreshDirectory() error {
return err return err
} }
req, err := http.NewRequest(http.MethodGet, endpoint, nil) req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
if err != nil { if err != nil {
return merrors.InternalWrap(err, "cbr: build directory request") return merrors.InternalWrap(err, "cbr: build directory request")
} }
resp, err := c.client.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CBR directory request failed", zap.Error(err)) 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")),
)
return merrors.InternalWrap(err, "cbr: directory request failed") return merrors.InternalWrap(err, "cbr: directory request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR directory returned non-OK status", zap.Int("status", resp.StatusCode)) 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")),
)
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -183,12 +214,13 @@ func (c *cbrConnector) refreshDirectory() error {
var directory valuteDirectory var directory valuteDirectory
if err := decoder.Decode(&directory); err != nil { if err := decoder.Decode(&directory); err != nil {
c.logger.Warn("CBR directory decode failed", zap.Error(err)) c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
return merrors.InternalWrap(err, "cbr: decode directory") return merrors.InternalWrap(err, "cbr: decode directory")
} }
mapping, err := buildValuteMapping(directory.Items) mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
if err != nil { if err != nil {
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
return err return err
} }
@@ -200,23 +232,32 @@ func (c *cbrConnector) refreshDirectory() error {
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) { func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
endpoint, err := c.buildURL(c.dailyPath, nil) endpoint, err := c.buildURL(c.dailyPath, nil)
if err != nil { if err != nil {
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
return "", err return "", err
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
if err != 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") return "", merrors.InternalWrap(err, "cbr: build daily request")
} }
resp, err := c.client.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CBR daily request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err)) 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")),
)
return "", merrors.InternalWrap(err, "cbr: daily request failed") return "", merrors.InternalWrap(err, "cbr: daily request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR daily returned non-OK status", zap.Int("status", resp.StatusCode)) c.logger.Warn(
"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -225,7 +266,9 @@ func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (s
var payload dailyRates var payload dailyRates
if err := decoder.Decode(&payload); err != nil { if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR daily decode failed", zap.Error(err)) c.logger.Warn("CBR daily decode failed", zap.Error(err),
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
)
return "", merrors.InternalWrap(err, "cbr: decode daily response") return "", merrors.InternalWrap(err, "cbr: decode daily response")
} }
@@ -247,25 +290,40 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
"date_req2": date.Format("02/01/2006"), "date_req2": date.Format("02/01/2006"),
"VAL_NM_RQ": valute.ID, "VAL_NM_RQ": valute.ID,
} }
dateStr := date.Format("2006-01-02")
endpoint, err := c.buildURL(c.dynamicPath, query) endpoint, err := c.buildURL(c.dynamicPath, query)
if err != nil { if err != nil {
return "", err return "", err
} }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
if err != nil { if err != nil {
return "", merrors.InternalWrap(err, "cbr: build historical request") return "", merrors.InternalWrap(err, "cbr: build historical request")
} }
resp, err := c.client.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
c.logger.Warn("CBR historical request failed", zap.String("currency", valute.ISOCharCode), zap.Error(err)) 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),
)
return "", merrors.InternalWrap(err, "cbr: historical request failed") return "", merrors.InternalWrap(err, "cbr: historical request failed")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.logger.Warn("CBR historical returned non-OK status", zap.Int("status", resp.StatusCode)) 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),
)
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode)) return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
} }
@@ -274,7 +332,13 @@ func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInf
var payload dynamicRates var payload dynamicRates
if err := decoder.Decode(&payload); err != nil { if err := decoder.Decode(&payload); err != nil {
c.logger.Warn("CBR historical decode failed", zap.Error(err)) c.logger.Warn(
"CBR historical decode failed",
zap.String("currency", valute.ISOCharCode),
zap.String("date", dateStr),
zap.String("endpoint", endpoint),
zap.Error(err),
)
return "", merrors.InternalWrap(err, "cbr: decode historical response") return "", merrors.InternalWrap(err, "cbr: decode historical response")
} }
@@ -337,7 +401,7 @@ type valuteMapping struct {
byID map[string]valuteInfo byID map[string]valuteInfo
} }
func buildValuteMapping(items []valuteItem) (*valuteMapping, error) { func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) {
byISO := make(map[string]valuteInfo, len(items)) byISO := make(map[string]valuteInfo, len(items))
byID := make(map[string]valuteInfo, len(items)) byID := make(map[string]valuteInfo, len(items))
byNum := make(map[string]string, len(items)) byNum := make(map[string]string, len(items))
@@ -348,12 +412,19 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
isoNum := strings.TrimSpace(item.ISONum) isoNum := strings.TrimSpace(item.ISONum)
name := strings.TrimSpace(item.Name) name := strings.TrimSpace(item.Name)
engName := strings.TrimSpace(item.EngName) engName := strings.TrimSpace(item.EngName)
nominal, err := parseNominal(item.NominalStr) nominal, err := parseNominal(item.NominalStr)
if err != nil { if err != nil {
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error()) return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
} }
if id == "" || isoChar == "" { if id == "" || isoChar == "" {
return nil, merrors.InvalidDataType("cbr: directory contains entry with empty id or iso code") logger.Info("Skipping invalid currency entry",
zap.String("id", id),
zap.String("iso_char", isoChar),
zap.String("name", name),
)
continue
} }
info := valuteInfo{ info := valuteInfo{
@@ -365,12 +436,60 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
Nominal: nominal, Nominal: nominal,
} }
if existing, ok := byISO[isoChar]; ok && existing.ID != id { // Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
return nil, merrors.InvalidDataType("cbr: duplicate ISO code " + isoChar) 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
} }
if existing, ok := byID[id]; ok && existing.ISOCharCode != isoChar {
return nil, merrors.InvalidDataType("cbr: duplicate valute id " + id) // 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 isoNum != "" {
if existingID, ok := byNum[isoNum]; ok && existingID != id { if existingID, ok := byNum[isoNum]; ok && existingID != id {
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum) return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
@@ -378,6 +497,24 @@ func buildValuteMapping(items []valuteItem) (*valuteMapping, error) {
byNum[isoNum] = id byNum[isoNum] = id
} }
continue
}
// 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)
}
byNum[isoNum] = id
}
logger.Info("Installing currency code", zap.String("iso_code", isoChar), zap.String("id", id), zap.Int64("nominal", nominal))
byISO[isoChar] = info byISO[isoChar] = info
byID[id] = info byID[id] = info
} }

View File

@@ -0,0 +1,85 @@
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)
}

View File

@@ -40,15 +40,15 @@ func main() {
application, err := app.New(logger, *configFile) application, err := app.New(logger, *configFile)
if err != nil { if err != nil {
logger.Fatal("Failed to initialise application", zap.Error(err)) logger.Error("Failed to initialise application", zap.Error(err))
} } else {
if err := application.Run(ctx); err != nil { if err := application.Run(ctx); err != nil {
if errors.Is(err, context.Canceled) { if errors.Is(err, context.Canceled) {
logger.Info("FX ingestor stopped") logger.Info("FX ingestor stopped")
return return
} }
logger.Fatal("Ingestor terminated with error", zap.Error(err)) logger.Error("Ingestor terminated with error", zap.Error(err))
}
} }
logger.Info("FX ingestor stopped") logger.Info("FX ingestor stopped")

View File

@@ -14,7 +14,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View File

@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -8,9 +8,9 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
) )
@@ -138,11 +138,11 @@ func (qc *quoteComputation) buildModelQuote(firm bool, expiryMillis int64, req *
Pair: qc.pair.Pair, Pair: qc.pair.Pair,
Side: qc.sideModel, Side: qc.sideModel,
Price: formatRat(qc.priceRounded, qc.priceScale), Price: formatRat(qc.priceRounded, qc.priceScale),
BaseAmount: model.Money{ BaseAmount: smodel.Money{
Currency: qc.pair.Pair.Base, Currency: qc.pair.Pair.Base,
Amount: formatRat(qc.baseRounded, qc.baseScale), Amount: formatRat(qc.baseRounded, qc.baseScale),
}, },
QuoteAmount: model.Money{ QuoteAmount: smodel.Money{
Currency: qc.pair.Pair.Quote, Currency: qc.pair.Pair.Quote,
Amount: formatRat(qc.quoteRounded, qc.quoteScale), Amount: formatRat(qc.quoteRounded, qc.quoteScale),
}, },
@@ -170,10 +170,13 @@ func buildQuoteMeta(meta *oraclev1.RequestMeta) *model.QuoteMeta {
} }
trace := meta.GetTrace() trace := meta.GetTrace()
qm := &model.QuoteMeta{ qm := &model.QuoteMeta{
RequestRef: deriveRequestRef(meta, trace),
TenantRef: meta.GetTenantRef(), TenantRef: meta.GetTenantRef(),
TraceRef: deriveTraceRef(meta, trace), }
IdempotencyKey: deriveIdempotencyKey(meta, trace),
if trace != nil {
qm.RequestRef = trace.GetRequestRef()
qm.TraceRef = trace.GetTraceRef()
qm.IdempotencyKey = trace.GetIdempotencyKey()
} }
if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" { if org := strings.TrimSpace(meta.GetOrganizationRef()); org != "" {
if objID, err := primitive.ObjectIDFromHex(org); err == nil { if objID, err := primitive.ObjectIDFromHex(org); err == nil {
@@ -200,24 +203,3 @@ func computeExpiry(now time.Time, ttlMs int64) (int64, error) {
} }
return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil return now.Add(time.Duration(ttlMs) * time.Millisecond).UnixMilli(), nil
} }
func deriveRequestRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetRequestRef() != "" {
return trace.GetRequestRef()
}
return meta.GetRequestRef()
}
func deriveTraceRef(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetTraceRef() != "" {
return trace.GetTraceRef()
}
return meta.GetTraceRef()
}
func deriveIdempotencyKey(meta *oraclev1.RequestMeta, trace *tracev1.TraceContext) string {
if trace != nil && trace.GetIdempotencyKey() != "" {
return trace.GetIdempotencyKey()
}
return meta.GetIdempotencyKey()
}

View File

@@ -0,0 +1,90 @@
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
}

View File

@@ -3,7 +3,6 @@ package oracle
import ( import (
"math/big" "math/big"
"strings" "strings"
"time"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/decimal" "github.com/tech/sendico/pkg/decimal"
@@ -61,7 +60,3 @@ func priceFromRate(rate *model.RateSnapshot, side fxv1.Side) (*big.Rat, error) {
return ratFromString(priceStr) return ratFromString(priceStr)
} }
func timeFromUnixMilli(ms int64) time.Time {
return time.Unix(0, ms*int64(time.Millisecond))
}

View File

@@ -101,22 +101,27 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if req == nil { if req == nil {
req = &oraclev1.GetQuoteRequest{} req = &oraclev1.GetQuoteRequest{}
} }
s.logger.Debug("Handling GetQuote", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote()), zap.Bool("firm", req.GetFirm())) logger := s.logger.With(quoteRequestFields(req)...)
logger.Debug("Handling GetQuote")
if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED { if req.GetSide() == fxv1.Side_SIDE_UNSPECIFIED {
logger.Warn("GetQuote invalid: side missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errSideRequired)
} }
if req.GetBaseAmount() != nil && req.GetQuoteAmount() != nil { 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) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountsMutuallyExclusive)
} }
if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil { if req.GetBaseAmount() == nil && req.GetQuoteAmount() == nil {
logger.Warn("GetQuote invalid: amount missing")
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errAmountRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during GetQuote", zap.Error(err)) logger.Warn("Storage unavailable during GetQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
pairMsg := req.GetPair() pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" { 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) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, errEmptyRequest)
} }
pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())} pairKey := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -125,8 +130,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): 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")) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, merrors.InvalidArgument("pair_not_supported"))
default: default:
logger.Warn("GetQuote failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -143,8 +150,10 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): 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) return gsresponse.FailedPrecondition[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, "rate_not_found", err)
default: 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) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -153,27 +162,31 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if maxAge := req.GetMaxAgeMs(); maxAge > 0 { if maxAge := req.GetMaxAgeMs(); maxAge > 0 {
age := now.UnixMilli() - rate.AsOfUnixMs age := now.UnixMilli() - rate.AsOfUnixMs
if age > int64(maxAge) { if age > int64(maxAge) {
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)) 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")) 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) comp, err := newQuoteComputation(pair, rate, req.GetSide(), provider)
if err != nil { if err != nil {
logger.Warn("GetQuote invalid input", zap.Error(err))
return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
if req.GetBaseAmount() != nil { if req.GetBaseAmount() != nil {
if err := comp.withBaseInput(req.GetBaseAmount()); err != 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) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} else if req.GetQuoteAmount() != nil { } else if req.GetQuoteAmount() != nil {
if err := comp.withQuoteInput(req.GetQuoteAmount()); err != 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) return gsresponse.InvalidArgument[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
if err := comp.compute(); err != nil { if err := comp.compute(); err != nil {
logger.Warn("GetQuote computation failed", zap.Error(err))
return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
@@ -195,12 +208,14 @@ func (s *Service) getQuoteResponder(ctx context.Context, req *oraclev1.GetQuoteR
if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil { if err := s.storage.Quotes().Issue(ctx, quoteModel); err != nil {
switch { switch {
case errors.Is(err, merrors.ErrDataConflict): 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) return gsresponse.Conflict[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
default: 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) return gsresponse.Internal[oraclev1.GetQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
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)) 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{ resp := &oraclev1.GetQuoteResponse{
@@ -214,18 +229,24 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
if req == nil { if req == nil {
req = &oraclev1.ValidateQuoteRequest{} req = &oraclev1.ValidateQuoteRequest{}
} }
s.logger.Debug("Handling ValidateQuote", zap.String("quote_ref", req.GetQuoteRef())) 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")
if req.GetQuoteRef() == "" { if req.GetQuoteRef() == "" {
logger.Warn("ValidateQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired) return gsresponse.InvalidArgument[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err)) logger.Warn("Storage unavailable during ValidateQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
} }
quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef()) quote, err := s.storage.Quotes().GetByRef(ctx, req.GetQuoteRef())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("ValidateQuote: quote not found", zap.String("quote_ref", req.GetQuoteRef()))
resp := &oraclev1.ValidateQuoteResponse{ resp := &oraclev1.ValidateQuoteResponse{
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Quote: nil, Quote: nil,
@@ -234,6 +255,7 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
} }
return gsresponse.Success(resp) return gsresponse.Success(resp)
default: default:
logger.Warn("ValidateQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ValidateQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -255,6 +277,11 @@ func (s *Service) validateQuoteResponder(ctx context.Context, req *oraclev1.Vali
Valid: valid, Valid: valid,
Reason: reason, 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) return gsresponse.Success(resp)
} }
@@ -262,29 +289,43 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
if req == nil { if req == nil {
req = &oraclev1.ConsumeQuoteRequest{} req = &oraclev1.ConsumeQuoteRequest{}
} }
s.logger.Debug("Handling ConsumeQuote", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef())) 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")
if req.GetQuoteRef() == "" { if req.GetQuoteRef() == "" {
logger.Warn("ConsumeQuote invalid: quote_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired) return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errQuoteRefRequired)
} }
if req.GetLedgerTxnRef() == "" { if req.GetLedgerTxnRef() == "" {
logger.Warn("ConsumeQuote invalid: ledger_txn_ref missing")
return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired) return gsresponse.InvalidArgument[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, errLedgerTxnRefRequired)
} }
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err)) logger.Warn("Storage unavailable during ConsumeQuote", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
} }
_, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now()) _, err := s.storage.Quotes().Consume(ctx, req.GetQuoteRef(), req.GetLedgerTxnRef(), time.Now())
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, storage.ErrQuoteExpired): case errors.Is(err, storage.ErrQuoteExpired):
logger.Warn("ConsumeQuote failed: expired")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "expired", err)
case errors.Is(err, storage.ErrQuoteConsumed): case errors.Is(err, storage.ErrQuoteConsumed):
logger.Warn("ConsumeQuote failed: already consumed")
return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "consumed", err)
case errors.Is(err, storage.ErrQuoteNotFirm): 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) return gsresponse.FailedPrecondition[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, "not_firm", err)
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("ConsumeQuote failed: quote not found")
return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("ConsumeQuote failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ConsumeQuoteResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -294,7 +335,7 @@ func (s *Service) consumeQuoteResponder(ctx context.Context, req *oraclev1.Consu
Consumed: true, Consumed: true,
Reason: "consumed", Reason: "consumed",
} }
s.logger.Debug("Quote consumed", zap.String("quote_ref", req.GetQuoteRef()), zap.String("ledger_txn_ref", req.GetLedgerTxnRef())) logger.Info("Quote consumed")
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -302,13 +343,21 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if req == nil { if req == nil {
req = &oraclev1.LatestRateRequest{} req = &oraclev1.LatestRateRequest{}
} }
s.logger.Debug("Handling LatestRate", zap.String("pair", req.GetPair().GetBase()+"/"+req.GetPair().GetQuote())) 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")
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during LatestRate", zap.Error(err)) logger.Warn("Storage unavailable during LatestRate", zap.Error(err))
return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
pairMsg := req.GetPair() pairMsg := req.GetPair()
if pairMsg == nil || strings.TrimSpace(pairMsg.GetBase()) == "" || strings.TrimSpace(pairMsg.GetQuote()) == "" { 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) return gsresponse.InvalidArgument[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, errEmptyRequest)
} }
pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())} pair := model.CurrencyPair{Base: strings.ToUpper(pairMsg.GetBase()), Quote: strings.ToUpper(pairMsg.GetQuote())}
@@ -317,8 +366,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): case errors.Is(err, merrors.ErrNoData):
logger.Warn("LatestRate pair not found")
return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("LatestRate failed to load pair", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -335,8 +386,10 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, merrors.ErrNoData): 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) return gsresponse.NotFound[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
default: default:
logger.Warn("LatestRate failed", zap.Error(err))
return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.LatestRateResponse](s.logger, mservice.FXOracle, err)
} }
} }
@@ -345,6 +398,7 @@ func (s *Service) latestRateResponder(ctx context.Context, req *oraclev1.LatestR
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Rate: rateModelToProto(rate), Rate: rateModelToProto(rate),
} }
logger.Debug("LatestRate succeeded", zap.String("provider", provider), zap.Int64("asof_unix_ms", rate.AsOfUnixMs))
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }
@@ -352,13 +406,15 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
if req == nil { if req == nil {
req = &oraclev1.ListPairsRequest{} req = &oraclev1.ListPairsRequest{}
} }
s.logger.Debug("Handling ListPairs") logger := s.logger.With(requestMetaFields(req.GetMeta())...)
logger.Debug("Handling ListPairs")
if err := s.pingStorage(ctx); err != nil { if err := s.pingStorage(ctx); err != nil {
s.logger.Warn("Storage unavailable during ListPairs", zap.Error(err)) logger.Warn("Storage unavailable during ListPairs", zap.Error(err))
return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err) return gsresponse.Unavailable[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
} }
pairs, err := s.storage.Pairs().ListEnabled(ctx) pairs, err := s.storage.Pairs().ListEnabled(ctx)
if err != nil { if err != nil {
logger.Warn("ListPairs failed", zap.Error(err))
return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err) return gsresponse.Internal[oraclev1.ListPairsResponse](s.logger, mservice.FXOracle, err)
} }
result := make([]*oraclev1.PairMeta, 0, len(pairs)) result := make([]*oraclev1.PairMeta, 0, len(pairs))
@@ -369,7 +425,7 @@ func (s *Service) listPairsResponder(ctx context.Context, req *oraclev1.ListPair
Meta: buildResponseMeta(req.GetMeta()), Meta: buildResponseMeta(req.GetMeta()),
Pairs: result, Pairs: result,
} }
s.logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs()))) logger.Debug("ListPairs returning metadata", zap.Int("pairs", len(resp.GetPairs())))
return gsresponse.Success(resp) return gsresponse.Success(resp)
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/fx/storage" "github.com/tech/sendico/fx/storage"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
@@ -381,8 +382,8 @@ func TestServiceValidateQuote(t *testing.T) {
Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"}, Pair: model.CurrencyPair{Base: "USD", Quote: "EUR"},
Side: model.QuoteSideBuyBaseSellQuote, Side: model.QuoteSideBuyBaseSellQuote,
Price: "1.10", Price: "1.10",
BaseAmount: model.Money{Currency: "USD", Amount: "100"}, BaseAmount: smodel.Money{Currency: "USD", Amount: "100"},
QuoteAmount: model.Money{Currency: "EUR", Amount: "110"}, QuoteAmount: smodel.Money{Currency: "EUR", Amount: "110"},
ExpiresAtUnixMs: now.UnixMilli(), ExpiresAtUnixMs: now.UnixMilli(),
Status: model.QuoteStatusIssued, Status: model.QuoteStatusIssued,
}, nil }, nil

View File

@@ -4,9 +4,9 @@ import (
"strings" "strings"
"github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/fx/storage/model"
smodel "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
) )
@@ -15,18 +15,11 @@ func buildResponseMeta(meta *oraclev1.RequestMeta) *oraclev1.ResponseMeta {
if meta == nil { if meta == nil {
return resp return resp
} }
resp.RequestRef = meta.GetRequestRef()
resp.TraceRef = meta.GetTraceRef()
trace := meta.GetTrace() trace := meta.GetTrace()
if trace == nil { if trace != nil {
trace = &tracev1.TraceContext{
RequestRef: meta.GetRequestRef(),
IdempotencyKey: meta.GetIdempotencyKey(),
TraceRef: meta.GetTraceRef(),
}
}
resp.Trace = trace resp.Trace = trace
}
return resp return resp
} }
@@ -49,7 +42,7 @@ func quoteModelToProto(q *model.Quote) *oraclev1.Quote {
} }
} }
func moneyModelToProto(m *model.Money) *moneyv1.Money { func moneyModelToProto(m *smodel.Money) *moneyv1.Money {
if m == nil { if m == nil {
return nil return nil
} }

View File

@@ -28,5 +28,5 @@ require (
golang.org/x/crypto v0.46.0 // indirect golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -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.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/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= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -4,6 +4,7 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/model"
) )
// Quote represents a firm or indicative quote persisted by the oracle. // Quote represents a firm or indicative quote persisted by the oracle.
@@ -16,8 +17,8 @@ type Quote struct {
Pair CurrencyPair `bson:"pair" json:"pair"` Pair CurrencyPair `bson:"pair" json:"pair"`
Side QuoteSide `bson:"side" json:"side"` Side QuoteSide `bson:"side" json:"side"`
Price string `bson:"price" json:"price"` Price string `bson:"price" json:"price"`
BaseAmount Money `bson:"baseAmount" json:"baseAmount"` BaseAmount model.Money `bson:"baseAmount" json:"baseAmount"`
QuoteAmount Money `bson:"quoteAmount" json:"quoteAmount"` QuoteAmount model.Money `bson:"quoteAmount" json:"quoteAmount"`
AmountType QuoteAmountType `bson:"amountType" json:"amountType"` AmountType QuoteAmountType `bson:"amountType" json:"amountType"`
ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"` ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs" json:"expiresAtUnixMs"`
ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"` ExpiresAt *time.Time `bson:"expiresAt,omitempty" json:"expiresAt,omitempty"`

View File

@@ -51,12 +51,6 @@ type CurrencyPair struct {
Quote string `bson:"quote" json:"quote"` Quote string `bson:"quote" json:"quote"`
} }
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}
// QuoteMeta carries request-scoped metadata associated with a quote. // QuoteMeta carries request-scoped metadata associated with a quote.
type QuoteMeta struct { type QuoteMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -16,13 +16,13 @@ require (
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251211224604-2e727cd2e6fe // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect

View File

@@ -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/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 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
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-20251211224604-2e727cd2e6fe h1:Z93WiwkZABbBBb0hGVFSF9nofjiYRvdF7PUxB75oeyE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251208031133-be43a854e4be/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251211224604-2e727cd2e6fe/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= 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/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -0,0 +1,84 @@
package client
import (
"context"
"strings"
"time"
"github.com/tech/sendico/pkg/merrors"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
// Client wraps the Monetix gateway gRPC API.
type Client interface {
CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
Close() error
}
type gatewayClient struct {
conn *grpc.ClientConn
client mntxv1.MntxGatewayServiceClient
cfg Config
}
// New dials the Monetix gateway.
func New(ctx context.Context, cfg Config, opts ...grpc.DialOption) (Client, error) {
cfg.setDefaults()
if strings.TrimSpace(cfg.Address) == "" {
return nil, merrors.InvalidArgument("mntx: address is required")
}
dialCtx, cancel := context.WithTimeout(ctx, cfg.DialTimeout)
defer cancel()
dialOpts := make([]grpc.DialOption, 0, len(opts)+1)
dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials()))
dialOpts = append(dialOpts, opts...)
conn, err := grpc.DialContext(dialCtx, cfg.Address, dialOpts...)
if err != nil {
return nil, merrors.Internal("mntx: dial failed: " + err.Error())
}
return &gatewayClient{
conn: conn,
client: mntxv1.NewMntxGatewayServiceClient(conn),
cfg: cfg,
}, nil
}
func (g *gatewayClient) Close() error {
if g.conn != nil {
return g.conn.Close()
}
return nil
}
func (g *gatewayClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := g.cfg.CallTimeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return context.WithTimeout(ctx, timeout)
}
func (g *gatewayClient) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardPayout(ctx, req)
}
func (g *gatewayClient) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.CreateCardTokenPayout(ctx, req)
}
func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
ctx, cancel := g.callContext(ctx)
defer cancel()
return g.client.GetCardPayoutStatus(ctx, req)
}

View File

@@ -0,0 +1,19 @@
package client
import "time"
// Config holds Monetix gateway client settings.
type Config struct {
Address string
DialTimeout time.Duration
CallTimeout time.Duration
}
func (c *Config) setDefaults() {
if c.DialTimeout <= 0 {
c.DialTimeout = 5 * time.Second
}
if c.CallTimeout <= 0 {
c.CallTimeout = 10 * time.Second
}
}

View File

@@ -0,0 +1,37 @@
package client
import (
"context"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
)
// Fake implements Client for tests.
type Fake struct {
CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error)
CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error)
GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error)
}
func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) {
if f.CreateCardPayoutFn != nil {
return f.CreateCardPayoutFn(ctx, req)
}
return &mntxv1.CardPayoutResponse{}, nil
}
func (f *Fake) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) {
if f.CreateCardTokenPayoutFn != nil {
return f.CreateCardTokenPayoutFn(ctx, req)
}
return &mntxv1.CardTokenPayoutResponse{}, nil
}
func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) {
if f.GetCardPayoutStatusFn != nil {
return f.GetCardPayoutStatusFn(ctx, req)
}
return &mntxv1.GetCardPayoutStatusResponse{}, nil
}
func (f *Fake) Close() error { return nil }

View File

@@ -12,7 +12,7 @@ require (
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View File

@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -12,7 +12,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View File

@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -62,12 +62,6 @@ const (
OutboxStatusFailed OutboxStatus = "failed" OutboxStatusFailed OutboxStatus = "failed"
) )
// Money represents an exact decimal amount with its currency.
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"` // stored as string for exact decimal representation
}
// LedgerMeta carries organization-scoped metadata for ledger entities. // LedgerMeta carries organization-scoped metadata for ledger entities.
type LedgerMeta struct { type LedgerMeta struct {
model.OrganizationBoundBase `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"`

View File

@@ -54,5 +54,5 @@ require (
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.11 // indirect
) )

View File

@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -56,3 +56,11 @@ oracle:
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 3 call_timeout_seconds: 3
insecure: true insecure: true
card_gateways:
monetix:
funding_address: "wallet_funding_monetix"
fee_address: "wallet_fee_monetix"
fee_ledger_accounts:
monetix: "ledger:fees:monetix"

View File

@@ -8,6 +8,8 @@ replace github.com/tech/sendico/billing/fees => ../../billing/fees
replace github.com/tech/sendico/gateway/chain => ../../gateway/chain replace github.com/tech/sendico/gateway/chain => ../../gateway/chain
replace github.com/tech/sendico/gateway/mntx => ../../gateway/mntx
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
replace github.com/tech/sendico/ledger => ../../ledger replace github.com/tech/sendico/ledger => ../../ledger
@@ -17,12 +19,13 @@ require (
github.com/shopspring/decimal v1.4.0 github.com/shopspring/decimal v1.4.0
github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000 github.com/tech/sendico/fx/oracle v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/gateway/mntx v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )

View File

@@ -119,8 +119,8 @@ github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+L
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -7,8 +7,8 @@ import (
"strings" "strings"
"time" "time"
chainclient "github.com/tech/sendico/gateway/chain/client"
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
@@ -45,6 +45,8 @@ type config struct {
Ledger clientConfig `yaml:"ledger"` Ledger clientConfig `yaml:"ledger"`
Gateway clientConfig `yaml:"gateway"` Gateway clientConfig `yaml:"gateway"`
Oracle clientConfig `yaml:"oracle"` Oracle clientConfig `yaml:"oracle"`
CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"`
FeeAccounts map[string]string `yaml:"fee_ledger_accounts"`
} }
type clientConfig struct { type clientConfig struct {
@@ -54,6 +56,11 @@ type clientConfig struct {
InsecureTransport bool `yaml:"insecure"` InsecureTransport bool `yaml:"insecure"`
} }
type cardGatewayRouteConfig struct {
FundingAddress string `yaml:"funding_address"`
FeeAddress string `yaml:"fee_address"`
}
func (c clientConfig) address() string { func (c clientConfig) address() string {
return strings.TrimSpace(c.Address) return strings.TrimSpace(c.Address)
} }
@@ -150,6 +157,12 @@ func (i *Imp) Start() error {
if oracleClient != nil { if oracleClient != nil {
opts = append(opts, orchestrator.WithOracleClient(oracleClient)) opts = append(opts, orchestrator.WithOracleClient(oracleClient))
} }
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 return orchestrator.NewService(logger, repo, opts...), nil
} }
@@ -296,3 +309,37 @@ func (i *Imp) loadConfig() (*config, error) {
return cfg, nil return cfg, nil
} }
func buildCardGatewayRoutes(src map[string]cardGatewayRouteConfig) map[string]orchestrator.CardGatewayRoute {
if len(src) == 0 {
return nil
}
result := make(map[string]orchestrator.CardGatewayRoute, len(src))
for key, route := range src {
trimmedKey := strings.TrimSpace(key)
if trimmedKey == "" {
continue
}
result[trimmedKey] = orchestrator.CardGatewayRoute{
FundingAddress: strings.TrimSpace(route.FundingAddress),
FeeAddress: strings.TrimSpace(route.FeeAddress),
}
}
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
}

View File

@@ -0,0 +1,252 @@
package orchestrator
import (
"context"
"strings"
"github.com/shopspring/decimal"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
const defaultCardGateway = "monetix"
func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) {
if len(s.deps.cardRoutes) == 0 {
s.logger.Warn("card routing not configured", zap.String("gateway", gateway))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured")
}
key := strings.ToLower(strings.TrimSpace(gateway))
if key == "" {
key = defaultCardGateway
}
route, ok := s.deps.cardRoutes[key]
if !ok {
s.logger.Warn("card routing missing for gateway", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key)
}
if strings.TrimSpace(route.FundingAddress) == "" {
s.logger.Warn("card routing missing funding address", zap.String("gateway", key))
return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key)
}
return route, nil
}
func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
source := intent.Source.ManagedWallet
if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" {
return merrors.InvalidArgument("card funding: source managed wallet is required")
}
if !s.deps.gateway.available() {
s.logger.Warn("card funding aborted: chain gateway unavailable")
return merrors.InvalidArgument("card funding: chain gateway unavailable")
}
route, err := s.cardRoute(defaultCardGateway)
if err != nil {
return err
}
amount := cloneMoney(intent.Amount)
if amount == nil {
return merrors.InvalidArgument("card funding: amount is required")
}
exec := payment.Execution
if exec == nil {
exec = &model.ExecutionRefs{}
}
// Transfer payout amount to funding wallet.
fundReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fund",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FundingAddress)},
},
Amount: amount,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq)
if err != nil {
s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
if fundResp != nil && fundResp.GetTransfer() != nil {
exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
feeMoney := quote.GetExpectedFeeTotal()
if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" {
if strings.TrimSpace(route.FeeAddress) == "" {
return merrors.InvalidArgument("card funding: fee address is required when fee exists")
}
feeDecimal, err := decimalFromMoney(feeMoney)
if err != nil {
return err
}
if feeDecimal.IsPositive() {
feeReq := &chainv1.SubmitTransferRequest{
IdempotencyKey: payment.IdempotencyKey + ":card:fee",
OrganizationRef: payment.OrganizationRef.Hex(),
SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef),
Destination: &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(route.FeeAddress)},
},
Amount: feeMoney,
Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef,
}
feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq)
if feeErr != nil {
s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef))
return feeErr
}
if feeResp != nil && feeResp.GetTransfer() != nil {
exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef())
}
s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef))
}
}
payment.Execution = exec
return nil
}
func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error {
if payment == nil {
return merrors.InvalidArgument("payment is required")
}
intent := payment.Intent
card := intent.Destination.Card
if card == nil {
return merrors.InvalidArgument("card payout: card endpoint is required")
}
amount := cloneMoney(intent.Amount)
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
return merrors.InvalidArgument("card payout: amount is required")
}
amtDec, err := decimalFromMoney(amount)
if err != nil {
return err
}
minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart()
payoutID := payment.PaymentRef
currency := strings.TrimSpace(amount.GetCurrency())
holder := strings.TrimSpace(card.Cardholder)
meta := cloneMetadata(payment.Metadata)
var (
state *mntxv1.CardPayoutState
)
if token := strings.TrimSpace(card.Token); token != "" {
req := &mntxv1.CardTokenPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardToken: token,
CardHolder: holder,
MaskedPan: strings.TrimSpace(card.MaskedPan),
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req)
if err != nil {
s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else if pan := strings.TrimSpace(card.Pan); pan != "" {
req := &mntxv1.CardPayoutRequest{
PayoutId: payoutID,
AmountMinor: minor,
Currency: currency,
CardPan: pan,
CardExpYear: card.ExpYear,
CardExpMonth: card.ExpMonth,
CardHolder: holder,
Metadata: meta,
}
resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req)
if err != nil {
s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef))
return err
}
state = resp.GetPayout()
} else {
return merrors.InvalidArgument("card payout: either token or pan must be provided")
}
if state == nil {
return merrors.Internal("card payout: missing payout state")
}
recordCardPayoutState(payment, state)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(state.GetPayoutId())
}
s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", payment.Execution.CardPayoutRef))
return nil
}
func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) {
if payment == nil || state == nil {
return
}
if payment.CardPayout == nil {
payment.CardPayout = &model.CardPayout{}
}
payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId())
payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId())
payment.CardPayout.Status = state.GetStatus().String()
payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage())
payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode())
if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country)
}
if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil {
payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan)
}
payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId())
}
func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) {
if payment == nil || payout == nil {
return
}
recordCardPayoutState(payment, payout)
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.CardPayoutRef == "" {
payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId())
}
payment.State = mapMntxStatusToState(payout.GetStatus())
switch payout.GetStatus() {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage())
default:
// leave as-is for pending/unspecified
}
}

View File

@@ -0,0 +1,83 @@
package orchestrator
import (
"context"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/mlogger"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
type paymentEngine interface {
EnsureRepository(ctx context.Context) error
BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error)
ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error)
ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error
Repository() storage.Repository
}
type defaultPaymentEngine struct {
svc *Service
}
func (e defaultPaymentEngine) EnsureRepository(ctx context.Context) error {
return e.svc.ensureRepository(ctx)
}
func (e defaultPaymentEngine) BuildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
return e.svc.buildPaymentQuote(ctx, orgRef, req)
}
func (e defaultPaymentEngine) ResolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
return e.svc.resolvePaymentQuote(ctx, in)
}
func (e defaultPaymentEngine) ExecutePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
return e.svc.executePayment(ctx, store, payment, quote)
}
func (e defaultPaymentEngine) Repository() storage.Repository {
return e.svc.storage
}
type paymentCommandFactory struct {
engine paymentEngine
logger mlogger.Logger
}
func newPaymentCommandFactory(engine paymentEngine, logger mlogger.Logger) *paymentCommandFactory {
return &paymentCommandFactory{
engine: engine,
logger: logger.Named("commands"),
}
}
func (f *paymentCommandFactory) QuotePayment() *quotePaymentCommand {
return &quotePaymentCommand{
engine: f.engine,
logger: f.logger.Named("quote_payment"),
}
}
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
return &initiatePaymentCommand{
engine: f.engine,
logger: f.logger.Named("initiate_payment"),
}
}
func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand {
return &cancelPaymentCommand{
engine: f.engine,
logger: f.logger.Named("cancel_payment"),
}
}
func (f *paymentCommandFactory) InitiateConversion() *initiateConversionCommand {
return &initiateConversionCommand{
engine: f.engine,
logger: f.logger.Named("initiate_conversion"),
}
}

View File

@@ -67,6 +67,19 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin
} }
return result return result
} }
if card := src.GetCard(); card != nil {
result.Type = model.EndpointTypeCard
result.Card = &model.CardEndpoint{
Pan: strings.TrimSpace(card.GetPan()),
Token: strings.TrimSpace(card.GetToken()),
Cardholder: strings.TrimSpace(card.GetCardholderName()),
ExpMonth: card.GetExpMonth(),
ExpYear: card.GetExpYear(),
Country: strings.TrimSpace(card.GetCountry()),
MaskedPan: strings.TrimSpace(card.GetMaskedPan()),
}
return result
}
return result return result
} }
@@ -96,7 +109,6 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
FeeRules: cloneFeeRules(src.GetFeeRules()), FeeRules: cloneFeeRules(src.GetFeeRules()),
FXQuote: cloneFXQuote(src.GetFxQuote()), FXQuote: cloneFXQuote(src.GetFxQuote()),
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()), NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
QuoteRef: strings.TrimSpace(src.GetQuoteRef()), QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
} }
} }
@@ -116,6 +128,18 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
Execution: protoExecutionFromModel(src.Execution), Execution: protoExecutionFromModel(src.Execution),
Metadata: cloneMetadata(src.Metadata), Metadata: cloneMetadata(src.Metadata),
} }
if src.CardPayout != nil {
payment.CardPayout = &orchestratorv1.CardPayout{
PayoutRef: src.CardPayout.PayoutRef,
ProviderPaymentId: src.CardPayout.ProviderPaymentID,
Status: src.CardPayout.Status,
FailureReason: src.CardPayout.FailureReason,
CardCountry: src.CardPayout.CardCountry,
MaskedPan: src.CardPayout.MaskedPan,
ProviderCode: src.CardPayout.ProviderCode,
GatewayReference: src.CardPayout.GatewayReference,
}
}
if src.CreatedAt.IsZero() { if src.CreatedAt.IsZero() {
payment.CreatedAt = timestamppb.New(time.Now().UTC()) payment.CreatedAt = timestamppb.New(time.Now().UTC())
} else { } else {
@@ -176,6 +200,23 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn
}, },
} }
} }
case model.EndpointTypeCard:
if src.Card != nil {
card := &orchestratorv1.CardEndpoint{
CardholderName: src.Card.Cardholder,
ExpMonth: src.Card.ExpMonth,
ExpYear: src.Card.ExpYear,
Country: src.Card.Country,
MaskedPan: src.Card.MaskedPan,
}
if pan := strings.TrimSpace(src.Card.Pan); pan != "" {
card.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
if token := strings.TrimSpace(src.Card.Token); token != "" {
card.Card = &orchestratorv1.CardEndpoint_Token{Token: token}
}
endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_Card{Card: card}
}
default: default:
// leave unspecified // leave unspecified
} }
@@ -205,6 +246,8 @@ func protoExecutionFromModel(src *model.ExecutionRefs) *orchestratorv1.Execution
CreditEntryRef: src.CreditEntryRef, CreditEntryRef: src.CreditEntryRef,
FxEntryRef: src.FXEntryRef, FxEntryRef: src.FXEntryRef,
ChainTransferRef: src.ChainTransferRef, ChainTransferRef: src.ChainTransferRef,
CardPayoutRef: src.CardPayoutRef,
FeeTransferRef: src.FeeTransferRef,
} }
} }
@@ -220,7 +263,6 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
FeeRules: cloneFeeRules(src.FeeRules), FeeRules: cloneFeeRules(src.FeeRules),
FxQuote: cloneFXQuote(src.FXQuote), FxQuote: cloneFXQuote(src.FXQuote),
NetworkFee: cloneNetworkEstimate(src.NetworkFee), NetworkFee: cloneNetworkEstimate(src.NetworkFee),
FeeQuoteToken: src.FeeQuoteToken,
QuoteRef: strings.TrimSpace(src.QuoteRef), QuoteRef: strings.TrimSpace(src.QuoteRef),
} }
} }
@@ -402,6 +444,18 @@ func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) e
dst.Metadata = cloneMetadata(src.GetMetadata()) dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote()) dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution()) dst.Execution = executionFromProto(src.GetExecution())
if src.GetCardPayout() != nil {
dst.CardPayout = &model.CardPayout{
PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()),
ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()),
Status: strings.TrimSpace(src.GetCardPayout().GetStatus()),
FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()),
CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()),
MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()),
ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()),
GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()),
}
}
return nil return nil
} }
@@ -414,6 +468,8 @@ func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()), CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()), FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()), ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()),
FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()),
} }
} }

View File

@@ -0,0 +1,69 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestEndpointFromProtoCard(t *testing.T) {
protoEndpoint := &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Pan{Pan: " 411111 "},
CardholderName: " Jane Doe ",
ExpMonth: 12,
ExpYear: 2030,
Country: " US ",
MaskedPan: " ****1111 ",
},
},
Metadata: map[string]string{"k": "v"},
}
modelEndpoint := endpointFromProto(protoEndpoint)
if modelEndpoint.Type != model.EndpointTypeCard {
t.Fatalf("expected card type, got %s", modelEndpoint.Type)
}
if modelEndpoint.Card == nil {
t.Fatalf("card payload missing")
}
if modelEndpoint.Card.Pan != "411111" || modelEndpoint.Card.Cardholder != "Jane Doe" || modelEndpoint.Card.Country != "US" || modelEndpoint.Card.MaskedPan != "****1111" {
t.Fatalf("card payload not trimmed as expected: %#v", modelEndpoint.Card)
}
if modelEndpoint.Metadata["k"] != "v" {
t.Fatalf("metadata not preserved")
}
}
func TestProtoEndpointFromModelCard(t *testing.T) {
modelEndpoint := model.PaymentEndpoint{
Type: model.EndpointTypeCard,
Card: &model.CardEndpoint{
Token: "tok_123",
Cardholder: "Jane",
ExpMonth: 1,
ExpYear: 2028,
Country: "GB",
MaskedPan: "****1234",
},
Metadata: map[string]string{"k": "v"},
}
protoEndpoint := protoEndpointFromModel(modelEndpoint)
card := protoEndpoint.GetCard()
if card == nil {
t.Fatalf("card payload missing in proto")
}
token, ok := card.Card.(*orchestratorv1.CardEndpoint_Token)
if !ok || token.Token != "tok_123" {
t.Fatalf("expected token payload, got %T %#v", card.Card, card.Card)
}
if card.GetCardholderName() != "Jane" || card.GetCountry() != "GB" || card.GetMaskedPan() != "****1234" {
t.Fatalf("card details mismatch: %#v", card)
}
if protoEndpoint.GetMetadata()["k"] != "v" {
t.Fatalf("metadata not preserved in proto endpoint")
}
}

View File

@@ -0,0 +1,274 @@
package orchestrator
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
type quotePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := requireNonNilIntent(req.GetIntent()); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("stored payment quote", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
}
type initiatePaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiatePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent payment request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{Payment: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteSnapshot, err := h.engine.ResolvePaymentQuote(ctx, quoteResolutionInput{
OrgRef: orgRef,
OrgID: orgID,
Meta: req.GetMeta(),
Intent: intent,
QuoteRef: req.GetQuoteRef(),
IdempotencyKey: req.GetIdempotencyKey(),
})
if err != nil {
if qerr, ok := err.(quoteResolutionError); ok {
switch qerr.code {
case "quote_not_found":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_expired":
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.code, qerr.err)
case "quote_intent_mismatch":
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
default:
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, qerr.err)
}
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
entity := newPayment(orgID, intent, idempotencyKey, req.GetMetadata(), quoteSnapshot)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()), zap.String("kind", intent.GetKind().String()))
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
type cancelPaymentCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *cancelPaymentCommand) Execute(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.CancelPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("payment cancelled", zap.String("payment_ref", payment.PaymentRef), zap.String("org_ref", payment.OrganizationRef.Hex()))
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
type initiateConversionCommand struct {
engine paymentEngine
logger mlogger.Logger
}
func (h *initiateConversionCommand) Execute(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store, err := ensurePaymentsStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, idempotencyKey); err == nil && existing != nil {
h.logger.Debug("idempotent conversion request reused", zap.String("payment_ref", existing.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := h.engine.BuildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity := newPayment(orgID, intentProto, idempotencyKey, req.GetMetadata(), quote)
if err = store.Create(ctx, entity); err != nil {
if errors.Is(err, storage.ErrDuplicatePayment) {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if err := h.engine.ExecutePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("conversion initiated", zap.String("payment_ref", entity.PaymentRef), zap.String("org_ref", orgID.Hex()))
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}

View File

@@ -0,0 +1,139 @@
package orchestrator
import (
"context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentEventHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler {
return &paymentEventHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (h *paymentEventHandler) processDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("deposit observed matched payment", zap.String("payment_ref", payment.PaymentRef), zap.String("wallet_ref", walletRef))
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
}
func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessCardPayoutUpdateResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetPayout() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("event is required"))
}
payout := req.GetEvent().GetPayout()
paymentRef := strings.TrimSpace(payout.GetPayoutId())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payout_id is required"))
}
store := h.repo.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.ProcessCardPayoutUpdateResponse](mservice.PaymentOrchestrator, h.logger, err)
}
applyCardPayoutUpdate(payment, payout)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessCardPayoutUpdateResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("card payout update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", paymentRef), zap.Any("state", payment.State))
return gsresponse.Success(&orchestratorv1.ProcessCardPayoutUpdateResponse{
Payment: toProtoPayment(payment),
})
}

View File

@@ -0,0 +1,80 @@
package orchestrator
import (
"context"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
)
type paymentQueryHandler struct {
repo storage.Repository
ensureRepo func(ctx context.Context) error
logger mlogger.Logger
}
func newPaymentQueryHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentQueryHandler {
return &paymentQueryHandler{
repo: repo,
ensureRepo: ensure,
logger: logger,
}
}
func (h *paymentQueryHandler) getPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef, err := requirePaymentRef(req.GetPaymentRef())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
return paymentNotFoundResponder[orchestratorv1.GetPaymentResponse](mservice.PaymentOrchestrator, h.logger, err)
}
h.logger.Debug("payment fetched", zap.String("payment_ref", paymentRef))
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (h *paymentQueryHandler) listPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := h.ensureRepo(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store, err := ensurePaymentsStore(h.repo)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
h.logger.Debug("payments listed", zap.Int("count", len(resp.Payments)), zap.String("next_cursor", resp.GetPage().GetNextCursor()))
return gsresponse.Success(resp)
}

View File

@@ -383,6 +383,22 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic
return breakdown 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 { func moneyEquals(a, b *moneyv1.Money) bool {
if a == nil || b == nil { if a == nil || b == nil {
return false return false

View File

@@ -4,9 +4,11 @@ import (
"context" "context"
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
) )
@@ -51,10 +53,17 @@ func shouldEstimateNetworkFee(intent *orchestratorv1.PaymentIntent) bool {
if intent == nil { if intent == nil {
return false return false
} }
dest := intent.GetDestination()
if dest == nil {
return false
}
if dest.GetCard() != nil {
return false
}
if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT { if intent.GetKind() == orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT {
return true return true
} }
if intent.GetDestination().GetManagedWallet() != nil || intent.GetDestination().GetExternalChain() != nil { if dest.GetManagedWallet() != nil || dest.GetExternalChain() != nil {
return true return true
} }
return false return false
@@ -69,3 +78,16 @@ func shouldRequestFX(intent *orchestratorv1.PaymentIntent) bool {
} }
return intent.GetFx() != nil && intent.GetFx().GetPair() != nil return intent.GetFx() != nil && intent.GetFx().GetPair() != nil
} }
func mapMntxStatusToState(status mntxv1.PayoutStatus) model.PaymentState {
switch status {
case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED:
return model.PaymentStateSettled
case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED:
return model.PaymentStateFailed
case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING:
return model.PaymentStateSubmitted
default:
return model.PaymentStateUnspecified
}
}

View File

@@ -0,0 +1,51 @@
package orchestrator
import (
"testing"
"github.com/tech/sendico/payments/orchestrator/storage/model"
mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
)
func TestShouldEstimateNetworkFeeSkipsCard(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT,
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_Card{
Card: &orchestratorv1.CardEndpoint{},
},
},
}
if shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation to be skipped for card payouts")
}
}
func TestShouldEstimateNetworkFeeManagedWallet(t *testing.T) {
intent := &orchestratorv1.PaymentIntent{
Destination: &orchestratorv1.PaymentEndpoint{
Endpoint: &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ManagedWalletRef: "mw"},
},
},
}
if !shouldEstimateNetworkFee(intent) {
t.Fatalf("expected network fee estimation when destination is managed wallet")
}
}
func TestMapMntxStatusToState(t *testing.T) {
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED) != model.PaymentStateSettled {
t.Fatalf("processed should map to settled")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED) != model.PaymentStateFailed {
t.Fatalf("failed should map to failed")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING) != model.PaymentStateSubmitted {
t.Fatalf("pending should map to submitted")
}
if mapMntxStatusToState(mntxv1.PayoutStatus_PAYOUT_STATUS_UNSPECIFIED) != model.PaymentStateUnspecified {
t.Fatalf("unspecified should map to unspecified")
}
}

View File

@@ -1,10 +1,12 @@
package orchestrator package orchestrator
import ( import (
"strings"
"time" "time"
oracleclient "github.com/tech/sendico/fx/oracle/client" oracleclient "github.com/tech/sendico/fx/oracle/client"
chainclient "github.com/tech/sendico/gateway/chain/client" chainclient "github.com/tech/sendico/gateway/chain/client"
mntxclient "github.com/tech/sendico/gateway/mntx/client"
ledgerclient "github.com/tech/sendico/ledger/client" ledgerclient "github.com/tech/sendico/ledger/client"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
@@ -46,10 +48,24 @@ func (o oracleDependency) available() bool {
return o.client != nil return o.client != nil
} }
type mntxDependency struct {
client mntxclient.Client
}
func (m mntxDependency) available() bool {
return m.client != nil
}
// CardGatewayRoute maps a gateway to its funding and fee destinations (addresses).
type CardGatewayRoute struct {
FundingAddress string
FeeAddress string
}
// WithFeeEngine wires the fee engine client. // WithFeeEngine wires the fee engine client.
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option { func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
return func(s *Service) { return func(s *Service) {
s.fees = feesDependency{ s.deps.fees = feesDependency{
client: client, client: client,
timeout: timeout, timeout: timeout,
} }
@@ -59,21 +75,59 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option
// WithLedgerClient wires the ledger client. // WithLedgerClient wires the ledger client.
func WithLedgerClient(client ledgerclient.Client) Option { func WithLedgerClient(client ledgerclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.ledger = ledgerDependency{client: client} s.deps.ledger = ledgerDependency{client: client}
} }
} }
// WithChainGatewayClient wires the chain gateway client. // WithChainGatewayClient wires the chain gateway client.
func WithChainGatewayClient(client chainclient.Client) Option { func WithChainGatewayClient(client chainclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.gateway = gatewayDependency{client: client} s.deps.gateway = gatewayDependency{client: client}
} }
} }
// WithOracleClient wires the FX oracle client. // WithOracleClient wires the FX oracle client.
func WithOracleClient(client oracleclient.Client) Option { func WithOracleClient(client oracleclient.Client) Option {
return func(s *Service) { return func(s *Service) {
s.oracle = oracleDependency{client: client} s.deps.oracle = oracleDependency{client: client}
}
}
// WithMntxGateway wires the Monetix gateway client.
func WithMntxGateway(client mntxclient.Client) Option {
return func(s *Service) {
s.deps.mntx = mntxDependency{client: client}
}
}
// WithCardGatewayRoutes configures funding/fee wallet routing per gateway.
func WithCardGatewayRoutes(routes map[string]CardGatewayRoute) Option {
return func(s *Service) {
if len(routes) == 0 {
return
}
s.deps.cardRoutes = make(map[string]CardGatewayRoute, len(routes))
for k, v := range routes {
s.deps.cardRoutes[strings.ToLower(strings.TrimSpace(k))] = v
}
}
}
// 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
}
} }
} }

View File

@@ -3,215 +3,30 @@ package orchestrator
import ( import (
"context" "context"
"strings" "strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" "github.com/tech/sendico/pkg/mlogger"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
) )
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) { type paymentExecutor struct {
intent := req.GetIntent() deps *serviceDependencies
amount := intent.GetAmount() logger mlogger.Logger
fxSide := fxv1.Side_SIDE_UNSPECIFIED svc *Service
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeTotal := extractFeeTotal(feeQuote.GetLines(), feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: cloneFeeLines(feeQuote.GetLines()),
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
} }
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) { func newPaymentExecutor(deps *serviceDependencies, logger mlogger.Logger, svc *Service) *paymentExecutor {
if !s.fees.available() { return &paymentExecutor{deps: deps, logger: logger, svc: svc}
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.fees.timeout)
defer cancel()
resp, err := s.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Error("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
} }
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) { func (p *paymentExecutor) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if !s.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Error("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Error("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
return quoteToProto(quote), nil
}
func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if store == nil { if store == nil {
return errStorageUnavailable return errStorageUnavailable
} }
@@ -219,6 +34,7 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
charges := ledgerChargesFromFeeLines(quote.GetFeeLines()) charges := ledgerChargesFromFeeLines(quote.GetFeeLines())
ledgerNeeded := requiresLedger(payment) ledgerNeeded := requiresLedger(payment)
chainNeeded := requiresChain(payment) chainNeeded := requiresChain(payment)
cardNeeded := payment.Intent.Destination.Type == model.EndpointTypeCard
exec := payment.Execution exec := payment.Execution
if exec == nil { if exec == nil {
@@ -226,25 +42,26 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
} }
if ledgerNeeded { if ledgerNeeded {
if !s.ledger.available() { if !p.deps.ledger.available() {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable")) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable"))
} }
if err := s.performLedgerOperation(ctx, payment, quote, charges); err != nil { if err := p.performLedgerOperation(ctx, payment, quote, charges); err != nil {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err)
} }
payment.State = model.PaymentStateFundsReserved payment.State = model.PaymentStateFundsReserved
if err := s.persistPayment(ctx, store, payment); err != nil { if err := p.persistPayment(ctx, store, payment); err != nil {
return err return err
} }
p.logger.Info("ledger reservation completed", zap.String("payment_ref", payment.PaymentRef))
} }
if chainNeeded { if chainNeeded {
if !s.gateway.available() { if !p.deps.gateway.available() {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable")) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable"))
} }
resp, err := s.submitChainTransfer(ctx, payment, quote) resp, err := p.submitChainTransfer(ctx, payment, quote)
if err != nil { if err != nil {
return s.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
} }
exec = payment.Execution exec = payment.Execution
if exec == nil { if exec == nil {
@@ -255,17 +72,42 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor
} }
payment.Execution = exec payment.Execution = exec
payment.State = model.PaymentStateSubmitted payment.State = model.PaymentStateSubmitted
if err := s.persistPayment(ctx, store, payment); err != nil { if err := p.persistPayment(ctx, store, payment); err != nil {
return err return err
} }
p.logger.Info("chain transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef))
if !cardNeeded {
return nil
}
}
if cardNeeded {
if !p.deps.mntx.available() {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "card_gateway_unavailable", merrors.Internal("card_gateway_unavailable"))
}
if err := p.svc.submitCardFundingTransfers(ctx, payment, quote); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err)
}
if err := p.svc.submitCardPayout(ctx, payment); err != nil {
return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err)
}
payment.State = model.PaymentStateSubmitted
if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("card_payout_ref", payment.Execution.CardPayoutRef))
return nil return nil
} }
payment.State = model.PaymentStateSettled payment.State = model.PaymentStateSettled
return s.persistPayment(ctx, store, payment) if err := p.persistPayment(ctx, store, payment); err != nil {
return err
}
p.logger.Info("payment settled without chain", zap.String("payment_ref", payment.PaymentRef))
return nil
} }
func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error { func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error {
intent := payment.Intent intent := payment.Intent
if payment.OrganizationRef == primitive.NilObjectID { if payment.OrganizationRef == primitive.NilObjectID {
return merrors.InvalidArgument("ledger: organization_ref is required") return merrors.InvalidArgument("ledger: organization_ref is required")
@@ -285,7 +127,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
switch intent.Kind { switch intent.Kind {
case model.PaymentKindFXConversion: case model.PaymentKindFXConversion:
if err := s.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil { if err := p.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil {
return err return err
} }
case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified: case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified:
@@ -303,7 +145,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
Charges: charges, Charges: charges,
Metadata: metadata, Metadata: metadata,
} }
resp, err := s.ledger.client.TransferInternal(ctx, req) resp, err := p.deps.ledger.client.TransferInternal(ctx, req)
if err != nil { if err != nil {
return err return err
} }
@@ -316,7 +158,7 @@ func (s *Service) performLedgerOperation(ctx context.Context, payment *model.Pay
return nil return nil
} }
func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error {
intent := payment.Intent intent := payment.Intent
source := intent.Source.Ledger source := intent.Source.Ledger
destination := intent.Destination.Ledger destination := intent.Destination.Ledger
@@ -354,7 +196,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
Charges: charges, Charges: charges,
Metadata: metadata, Metadata: metadata,
} }
resp, err := s.ledger.client.ApplyFXWithCharges(ctx, req) resp, err := p.deps.ledger.client.ApplyFXWithCharges(ctx, req)
if err != nil { if err != nil {
return err return err
} }
@@ -363,7 +205,7 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
return nil return nil
} }
func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) { func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) {
intent := payment.Intent intent := payment.Intent
source := intent.Source.ManagedWallet source := intent.Source.ManagedWallet
destination := intent.Destination destination := intent.Destination
@@ -389,23 +231,23 @@ func (s *Service) submitChainTransfer(ctx context.Context, payment *model.Paymen
Metadata: cloneMetadata(payment.Metadata), Metadata: cloneMetadata(payment.Metadata),
ClientReference: payment.PaymentRef, ClientReference: payment.PaymentRef,
} }
return s.gateway.client.SubmitTransfer(ctx, req) return p.deps.gateway.client.SubmitTransfer(ctx, req)
} }
func (s *Service) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error {
if store == nil { if store == nil {
return errStorageUnavailable return errStorageUnavailable
} }
return store.Update(ctx, payment) return store.Update(ctx, payment)
} }
func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error { func (p *paymentExecutor) failPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, code model.PaymentFailureCode, reason string, err error) error {
payment.State = model.PaymentStateFailed payment.State = model.PaymentStateFailed
payment.FailureCode = code payment.FailureCode = code
payment.FailureReason = strings.TrimSpace(reason) payment.FailureReason = strings.TrimSpace(reason)
if store != nil { if store != nil {
if updateErr := store.Update(ctx, payment); updateErr != nil { if updateErr := store.Update(ctx, payment); updateErr != nil {
s.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef)) p.logger.Error("failed to persist payment failure", zap.Error(updateErr), zap.String("payment_ref", payment.PaymentRef))
} }
} }
if err != nil { if err != nil {
@@ -414,6 +256,21 @@ func (s *Service) failPayment(ctx context.Context, store storage.PaymentsStore,
return merrors.Internal(reason) return merrors.Internal(reason)
} }
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) { func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
source := intent.Source.Ledger source := intent.Source.Ledger
destination := intent.Destination.Ledger destination := intent.Destination.Ledger
@@ -432,21 +289,6 @@ func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) {
return strings.TrimSpace(source.LedgerAccountRef), to, nil return strings.TrimSpace(source.LedgerAccountRef), to, nil
} }
func paymentDescription(payment *model.Payment) string {
if payment == nil {
return ""
}
if val := strings.TrimSpace(payment.Intent.Attributes["description"]); val != "" {
return val
}
if payment.Metadata != nil {
if val := strings.TrimSpace(payment.Metadata["description"]); val != "" {
return val
}
}
return payment.PaymentRef
}
func requiresLedger(payment *model.Payment) bool { func requiresLedger(payment *model.Payment) bool {
if payment == nil { if payment == nil {
return false return false

View File

@@ -0,0 +1,261 @@
package orchestrator
import (
"context"
"strings"
"time"
oracleclient "github.com/tech/sendico/fx/oracle/client"
"github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.uber.org/zap"
"google.golang.org/protobuf/types/known/timestamppb"
)
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
intent := req.GetIntent()
amount := intent.GetAmount()
fxSide := fxv1.Side_SIDE_UNSPECIFIED
if intent.GetFx() != nil {
fxSide = intent.GetFx().GetSide()
}
var fxQuote *oraclev1.Quote
var err error
if shouldRequestFX(intent) {
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("fx quote attached to payment quote", zap.String("org_ref", orgRef))
}
payAmount, settlementAmountBeforeFees := resolveTradeAmounts(amount, fxQuote, fxSide)
feeBaseAmount := payAmount
if feeBaseAmount == nil {
feeBaseAmount = cloneMoney(amount)
}
feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount)
if err != nil {
return nil, time.Time{}, err
}
feeCurrency := ""
if feeBaseAmount != nil {
feeCurrency = feeBaseAmount.GetCurrency()
} else if amount != nil {
feeCurrency = amount.GetCurrency()
}
feeLines := cloneFeeLines(feeQuote.GetLines())
s.assignFeeLedgerAccounts(intent, feeLines)
feeTotal := extractFeeTotal(feeLines, feeCurrency)
var networkFee *chainv1.EstimateTransferFeeResponse
if shouldEstimateNetworkFee(intent) {
networkFee, err = s.estimateNetworkFee(ctx, intent)
if err != nil {
return nil, time.Time{}, err
}
s.logger.Debug("network fee estimated", zap.String("org_ref", orgRef))
}
debitAmount, settlementAmount := computeAggregates(payAmount, settlementAmountBeforeFees, feeTotal, networkFee, fxQuote)
quote := &orchestratorv1.PaymentQuote{
DebitAmount: debitAmount,
ExpectedSettlementAmount: settlementAmount,
ExpectedFeeTotal: feeTotal,
FeeLines: feeLines,
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
FxQuote: fxQuote,
NetworkFee: networkFee,
}
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
return quote, expiresAt, nil
}
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest, baseAmount *moneyv1.Money) (*feesv1.PrecomputeFeesResponse, error) {
if !s.deps.fees.available() {
return &feesv1.PrecomputeFeesResponse{}, nil
}
intent := req.GetIntent()
amount := cloneMoney(baseAmount)
if amount == nil {
amount = cloneMoney(intent.GetAmount())
}
feeIntent := &feesv1.Intent{
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
BaseAmount: amount,
BookedAt: timestamppb.New(s.clock.Now()),
OriginType: "payments.orchestrator.quote",
OriginRef: strings.TrimSpace(req.GetIdempotencyKey()),
Attributes: cloneMetadata(intent.GetAttributes()),
}
timeout := req.GetMeta().GetTrace()
ctxTimeout, cancel := s.withTimeout(ctx, s.deps.fees.timeout)
defer cancel()
resp, err := s.deps.fees.client.PrecomputeFees(ctxTimeout, &feesv1.PrecomputeFeesRequest{
Meta: &feesv1.RequestMeta{
OrganizationRef: orgRef,
Trace: timeout,
},
Intent: feeIntent,
TtlMs: defaultFeeQuoteTTLMillis,
})
if err != nil {
s.logger.Warn("fees precompute failed", zap.Error(err))
return nil, merrors.Internal("fees_precompute_failed")
}
return resp, nil
}
func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1.PaymentIntent) (*chainv1.EstimateTransferFeeResponse, error) {
if !s.deps.gateway.available() {
return nil, nil
}
req := &chainv1.EstimateTransferFeeRequest{
Amount: cloneMoney(intent.GetAmount()),
}
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef())
}
if dst := intent.GetDestination().GetManagedWallet(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(dst.GetManagedWalletRef())},
}
}
if dst := intent.GetDestination().GetExternalChain(); dst != nil {
req.Destination = &chainv1.TransferDestination{
Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(dst.GetAddress())},
Memo: strings.TrimSpace(dst.GetMemo()),
}
req.Asset = dst.GetAsset()
}
if req.Asset == nil {
if src := intent.GetSource().GetManagedWallet(); src != nil {
req.Asset = src.GetAsset()
}
}
resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, req)
if err != nil {
s.logger.Warn("chain gateway fee estimation failed", zap.Error(err))
return nil, merrors.Internal("chain_gateway_fee_estimation_failed")
}
return resp, nil
}
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
if !s.deps.oracle.available() {
return nil, nil
}
intent := req.GetIntent()
meta := req.GetMeta()
fxIntent := intent.GetFx()
if fxIntent == nil {
return nil, nil
}
ttl := fxIntent.GetTtlMs()
if ttl <= 0 {
ttl = defaultOracleTTLMillis
}
params := oracleclient.GetQuoteParams{
Meta: oracleclient.RequestMeta{
OrganizationRef: orgRef,
Trace: meta.GetTrace(),
},
Pair: fxIntent.GetPair(),
Side: fxIntent.GetSide(),
Firm: fxIntent.GetFirm(),
TTL: time.Duration(ttl) * time.Millisecond,
PreferredProvider: strings.TrimSpace(fxIntent.GetPreferredProvider()),
}
if fxIntent.GetMaxAgeMs() > 0 {
params.MaxAge = time.Duration(fxIntent.GetMaxAgeMs()) * time.Millisecond
}
if amount := intent.GetAmount(); amount != nil {
pair := fxIntent.GetPair()
if pair != nil {
switch {
case strings.EqualFold(amount.GetCurrency(), pair.GetBase()):
params.BaseAmount = cloneMoney(amount)
case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()):
params.QuoteAmount = cloneMoney(amount)
default:
params.BaseAmount = cloneMoney(amount)
}
} else {
params.BaseAmount = cloneMoney(amount)
}
}
quote, err := s.deps.oracle.client.GetQuote(ctx, params)
if err != nil {
s.logger.Warn("fx oracle quote failed", zap.Error(err))
return nil, merrors.Internal("fx_quote_failed")
}
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)
}

View File

@@ -19,6 +19,7 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
svc := &Service{ svc := &Service{
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
deps: serviceDependencies{
oracle: oracleDependency{ oracle: oracleDependency{
client: &oracleclient.Fake{ client: &oracleclient.Fake{
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) { GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
@@ -35,6 +36,7 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
}, },
}, },
}, },
},
} }
req := &orchestratorv1.QuotePaymentRequest{ req := &orchestratorv1.QuotePaymentRequest{

View File

@@ -2,21 +2,14 @@ package orchestrator
import ( import (
"context" "context"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
clockpkg "github.com/tech/sendico/pkg/clock" clockpkg "github.com/tech/sendico/pkg/clock"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/protobuf/proto"
) )
type serviceError string type serviceError string
@@ -40,12 +33,31 @@ type Service struct {
storage storage.Repository storage storage.Repository
clock clockpkg.Clock clock clockpkg.Clock
deps serviceDependencies
h handlerSet
comp componentSet
orchestratorv1.UnimplementedPaymentOrchestratorServer
}
type serviceDependencies struct {
fees feesDependency fees feesDependency
ledger ledgerDependency ledger ledgerDependency
gateway gatewayDependency gateway gatewayDependency
oracle oracleDependency oracle oracleDependency
mntx mntxDependency
cardRoutes map[string]CardGatewayRoute
feeLedgerAccounts map[string]string
}
orchestratorv1.UnimplementedPaymentOrchestratorServer type handlerSet struct {
commands *paymentCommandFactory
queries *paymentQueryHandler
events *paymentEventHandler
}
type componentSet struct {
executor *paymentExecutor
} }
// NewService constructs a payment orchestrator service. // NewService constructs a payment orchestrator service.
@@ -68,9 +80,30 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option)
svc.clock = clockpkg.NewSystem() svc.clock = clockpkg.NewSystem()
} }
engine := defaultPaymentEngine{svc: svc}
svc.h.commands = newPaymentCommandFactory(engine, svc.logger)
svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries"))
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"))
svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc)
return svc return svc
} }
func (s *Service) ensureHandlers() {
if s.h.commands == nil {
s.h.commands = newPaymentCommandFactory(defaultPaymentEngine{svc: s}, s.logger)
}
if s.h.queries == nil {
s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries"))
}
if s.h.events == nil {
s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"))
}
if s.comp.executor == nil {
s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s)
}
}
// Register attaches the service to the supplied gRPC router. // Register attaches the service to the supplied gRPC router.
func (s *Service) Register(router routers.GRPC) error { func (s *Service) Register(router routers.GRPC) error {
return router.Register(func(reg grpc.ServiceRegistrar) { return router.Register(func(reg grpc.ServiceRegistrar) {
@@ -80,474 +113,59 @@ func (s *Service) Register(router routers.GRPC) error {
// QuotePayment aggregates downstream quotes. // QuotePayment aggregates downstream quotes.
func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) { func (s *Service) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) {
return executeUnary(ctx, s, "QuotePayment", s.quotePaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "QuotePayment", s.h.commands.QuotePayment().Execute, req)
} }
// InitiatePayment captures a payment intent and reserves funds orchestration. // InitiatePayment captures a payment intent and reserves funds orchestration.
func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
return executeUnary(ctx, s, "InitiatePayment", s.initiatePaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req)
} }
// CancelPayment attempts to cancel an in-flight payment. // CancelPayment attempts to cancel an in-flight payment.
func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) {
return executeUnary(ctx, s, "CancelPayment", s.cancelPaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "CancelPayment", s.h.commands.CancelPayment().Execute, req)
} }
// GetPayment returns a stored payment record. // GetPayment returns a stored payment record.
func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) { func (s *Service) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) {
return executeUnary(ctx, s, "GetPayment", s.getPaymentHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "GetPayment", s.h.queries.getPayment, req)
} }
// ListPayments lists stored payment records. // ListPayments lists stored payment records.
func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) { func (s *Service) ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) {
return executeUnary(ctx, s, "ListPayments", s.listPaymentsHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "ListPayments", s.h.queries.listPayments, req)
} }
// InitiateConversion orchestrates standalone FX conversions. // InitiateConversion orchestrates standalone FX conversions.
func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) { func (s *Service) InitiateConversion(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) (*orchestratorv1.InitiateConversionResponse, error) {
return executeUnary(ctx, s, "InitiateConversion", s.initiateConversionHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "InitiateConversion", s.h.commands.InitiateConversion().Execute, req)
} }
// ProcessTransferUpdate reconciles chain events back into payment state. // ProcessTransferUpdate reconciles chain events back into payment state.
func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) { func (s *Service) ProcessTransferUpdate(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) (*orchestratorv1.ProcessTransferUpdateResponse, error) {
return executeUnary(ctx, s, "ProcessTransferUpdate", s.processTransferUpdateHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "ProcessTransferUpdate", s.h.events.processTransferUpdate, req)
} }
// ProcessDepositObserved reconciles deposit events to ledger. // ProcessDepositObserved reconciles deposit events to ledger.
func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) { func (s *Service) ProcessDepositObserved(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) (*orchestratorv1.ProcessDepositObservedResponse, error) {
return executeUnary(ctx, s, "ProcessDepositObserved", s.processDepositObservedHandler, req) s.ensureHandlers()
return executeUnary(ctx, s, "ProcessDepositObserved", s.h.events.processDepositObserved, req)
} }
func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] { // ProcessCardPayoutUpdate reconciles card payout events back into payment state.
if err := s.ensureRepository(ctx); err != nil { func (s *Service) ProcessCardPayoutUpdate(ctx context.Context, req *orchestratorv1.ProcessCardPayoutUpdateRequest) (*orchestratorv1.ProcessCardPayoutUpdateResponse, error) {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) s.ensureHandlers()
} return executeUnary(ctx, s, "ProcessCardPayoutUpdate", s.h.events.processCardPayoutUpdate, req)
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !req.GetPreviewOnly() {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgObjectID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
} }
func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentResponse] { func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error {
if err := s.ensureRepository(ctx); err != nil { s.ensureHandlers()
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err) return s.comp.executor.executePayment(ctx, store, payment, quote)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
intent := req.GetIntent()
if intent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
}
if intent.GetAmount() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey)
if err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(existing),
})
}
if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := strings.TrimSpace(req.GetQuoteRef())
quote := strings.TrimSpace(req.GetFeeQuoteToken())
var quoteSnapshot *orchestratorv1.PaymentQuote
if quoteRef != "" {
quotesStore := s.storage.Quotes()
if quotesStore == nil {
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef)
if err != nil {
if err == storage.ErrQuoteNotFound {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired"))
}
if !proto.Equal(protoIntentFromModel(record.Intent), intent) {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent"))
}
quoteSnapshot = modelQuoteToProto(record.Quote)
if quoteSnapshot == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
}
quoteSnapshot.QuoteRef = quoteRef
} else if quote == "" {
quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: req.GetIntent(),
PreviewOnly: false,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
} else {
quoteSnapshot = &orchestratorv1.PaymentQuote{FeeQuoteToken: quote}
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quoteSnapshot)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if quoteSnapshot == nil {
quoteSnapshot = &orchestratorv1.PaymentQuote{}
}
if err := s.executePayment(ctx, store, entity, quoteSnapshot); err != nil {
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiatePaymentResponse{
Payment: toProtoPayment(entity),
})
}
func (s *Service) cancelPaymentHandler(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) gsresponse.Responder[orchestratorv1.CancelPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if payment.State != model.PaymentStateAccepted {
reason := merrors.InvalidArgument("payment cannot be cancelled in current state")
return gsresponse.FailedPrecondition[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, "payment_not_cancellable", reason)
}
payment.State = model.PaymentStateCancelled
payment.FailureCode = model.PaymentFailureCodePolicy
payment.FailureReason = strings.TrimSpace(req.GetReason())
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.CancelPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.CancelPaymentResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) getPaymentHandler(ctx context.Context, req *orchestratorv1.GetPaymentRequest) gsresponse.Responder[orchestratorv1.GetPaymentResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
paymentRef := strings.TrimSpace(req.GetPaymentRef())
if paymentRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("payment_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
entity, err := store.GetByPaymentRef(ctx, paymentRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.GetPaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.GetPaymentResponse{Payment: toProtoPayment(entity)})
}
func (s *Service) listPaymentsHandler(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) gsresponse.Responder[orchestratorv1.ListPaymentsResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := filterFromProto(req)
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ListPaymentsResponse](s.logger, mservice.PaymentOrchestrator, err)
}
resp := &orchestratorv1.ListPaymentsResponse{
Page: &paginationv1.CursorPageResponse{
NextCursor: result.NextCursor,
},
}
resp.Payments = make([]*orchestratorv1.Payment, 0, len(result.Items))
for _, item := range result.Items {
resp.Payments = append(resp.Payments, toProtoPayment(item))
}
return gsresponse.Success(resp)
}
func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestratorv1.InitiateConversionRequest) gsresponse.Responder[orchestratorv1.InitiateConversionResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
meta := req.GetMeta()
if meta == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("meta is required"))
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
}
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
if parseErr != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
}
idempotencyKey := strings.TrimSpace(req.GetIdempotencyKey())
if idempotencyKey == "" {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("idempotency_key is required"))
}
if req.GetSource() == nil || req.GetSource().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("source ledger endpoint is required"))
}
if req.GetDestination() == nil || req.GetDestination().GetLedger() == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("destination ledger endpoint is required"))
}
fxIntent := req.GetFx()
if fxIntent == nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("fx intent is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
if existing, err := store.GetByIdempotencyKey(ctx, orgObjectID, idempotencyKey); err == nil && existing != nil {
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{Conversion: toProtoPayment(existing)})
} else if err != nil && err != storage.ErrPaymentNotFound {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
amount, err := conversionAmountFromMetadata(req.GetMetadata(), fxIntent)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
intentProto := &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION,
Source: req.GetSource(),
Destination: req.GetDestination(),
Amount: amount,
RequiresFx: true,
Fx: fxIntent,
FeePolicy: req.GetFeePolicy(),
}
quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: req.GetIdempotencyKey(),
Intent: intentProto,
})
if err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgObjectID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intentProto)
entity.Metadata = cloneMetadata(req.GetMetadata())
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
if err = store.Create(ctx, entity); err != nil {
if err == storage.ErrDuplicatePayment {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
}
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if err := s.executePayment(ctx, store, entity, quote); err != nil {
return gsresponse.Auto[orchestratorv1.InitiateConversionResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.InitiateConversionResponse{
Conversion: toProtoPayment(entity),
})
}
func (s *Service) processTransferUpdateHandler(ctx context.Context, req *orchestratorv1.ProcessTransferUpdateRequest) gsresponse.Responder[orchestratorv1.ProcessTransferUpdateResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil || req.GetEvent().GetTransfer() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer event is required"))
}
transfer := req.GetEvent().GetTransfer()
transferRef := strings.TrimSpace(transfer.GetTransferRef())
if transferRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("transfer_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
payment, err := store.GetByChainTransferRef(ctx, transferRef)
if err != nil {
if err == storage.ErrPaymentNotFound {
return gsresponse.NotFound[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
applyTransferStatus(req.GetEvent(), payment)
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)})
}
func (s *Service) processDepositObservedHandler(ctx context.Context, req *orchestratorv1.ProcessDepositObservedRequest) gsresponse.Responder[orchestratorv1.ProcessDepositObservedResponse] {
if err := s.ensureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
if req == nil || req.GetEvent() == nil {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("deposit event is required"))
}
event := req.GetEvent()
walletRef := strings.TrimSpace(event.GetWalletRef())
if walletRef == "" {
return gsresponse.InvalidArgument[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("wallet_ref is required"))
}
store := s.storage.Payments()
if store == nil {
return gsresponse.Unavailable[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
}
filter := &model.PaymentFilter{
States: []model.PaymentState{model.PaymentStateSubmitted, model.PaymentStateFundsReserved},
DestinationRef: walletRef,
}
result, err := store.List(ctx, filter)
if err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
for _, payment := range result.Items {
if payment.Intent.Destination.Type != model.EndpointTypeManagedWallet {
continue
}
if !moneyEquals(payment.Intent.Amount, event.GetAmount()) {
continue
}
payment.State = model.PaymentStateSettled
payment.FailureCode = model.PaymentFailureCodeUnspecified
payment.FailureReason = ""
if payment.Execution == nil {
payment.Execution = &model.ExecutionRefs{}
}
if payment.Execution.ChainTransferRef == "" {
payment.Execution.ChainTransferRef = strings.TrimSpace(event.GetTransactionHash())
}
if err := store.Update(ctx, payment); err != nil {
return gsresponse.Auto[orchestratorv1.ProcessDepositObservedResponse](s.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{Payment: toProtoPayment(payment)})
}
return gsresponse.Success(&orchestratorv1.ProcessDepositObservedResponse{})
} }

View File

@@ -0,0 +1,165 @@
package orchestrator
import (
"context"
"errors"
"strings"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"google.golang.org/protobuf/proto"
)
func validateMetaAndOrgRef(meta *orchestratorv1.RequestMeta) (string, primitive.ObjectID, error) {
if meta == nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("meta is required")
}
orgRef := strings.TrimSpace(meta.GetOrganizationRef())
if orgRef == "" {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref is required")
}
orgID, err := primitive.ObjectIDFromHex(orgRef)
if err != nil {
return "", primitive.NilObjectID, merrors.InvalidArgument("organization_ref must be a valid objectID")
}
return orgRef, orgID, nil
}
func requireIdempotencyKey(k string) (string, error) {
key := strings.TrimSpace(k)
if key == "" {
return "", merrors.InvalidArgument("idempotency_key is required")
}
return key, nil
}
func requirePaymentRef(ref string) (string, error) {
val := strings.TrimSpace(ref)
if val == "" {
return "", merrors.InvalidArgument("payment_ref is required")
}
return val, nil
}
func requireNonNilIntent(intent *orchestratorv1.PaymentIntent) error {
if intent == nil {
return merrors.InvalidArgument("intent is required")
}
if intent.GetAmount() == nil {
return merrors.InvalidArgument("intent.amount is required")
}
return nil
}
func ensurePaymentsStore(repo storage.Repository) (storage.PaymentsStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Payments()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func ensureQuotesStore(repo storage.Repository) (storage.QuotesStore, error) {
if repo == nil {
return nil, errStorageUnavailable
}
store := repo.Quotes()
if store == nil {
return nil, errStorageUnavailable
}
return store, nil
}
func getPaymentByIdempotencyKey(ctx context.Context, store storage.PaymentsStore, orgID primitive.ObjectID, key string) (*model.Payment, error) {
payment, err := store.GetByIdempotencyKey(ctx, orgID, key)
if err != nil {
return nil, err
}
return payment, nil
}
type quoteResolutionInput struct {
OrgRef string
OrgID primitive.ObjectID
Meta *orchestratorv1.RequestMeta
Intent *orchestratorv1.PaymentIntent
QuoteRef string
IdempotencyKey string
}
type quoteResolutionError struct {
code string
err error
}
func (e quoteResolutionError) Error() string { return e.err.Error() }
func (s *Service) resolvePaymentQuote(ctx context.Context, in quoteResolutionInput) (*orchestratorv1.PaymentQuote, error) {
if ref := strings.TrimSpace(in.QuoteRef); ref != "" {
quotesStore, err := ensureQuotesStore(s.storage)
if err != nil {
return nil, err
}
record, err := quotesStore.GetByRef(ctx, in.OrgID, ref)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, quoteResolutionError{code: "quote_not_found", err: merrors.InvalidArgument("quote_ref not found or expired")}
}
return nil, err
}
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
return nil, quoteResolutionError{code: "quote_expired", err: merrors.InvalidArgument("quote_ref expired")}
}
if !proto.Equal(protoIntentFromModel(record.Intent), in.Intent) {
return nil, quoteResolutionError{code: "quote_intent_mismatch", err: merrors.InvalidArgument("quote_ref does not match intent")}
}
quote := modelQuoteToProto(record.Quote)
if quote == nil {
return nil, merrors.InvalidArgument("stored quote is empty")
}
quote.QuoteRef = ref
return quote, nil
}
req := &orchestratorv1.QuotePaymentRequest{
Meta: in.Meta,
IdempotencyKey: in.IdempotencyKey,
Intent: in.Intent,
PreviewOnly: false,
}
quote, _, err := s.buildPaymentQuote(ctx, in.OrgRef, req)
if err != nil {
return nil, err
}
return quote, nil
}
func newPayment(orgID primitive.ObjectID, intent *orchestratorv1.PaymentIntent, idempotencyKey string, metadata map[string]string, quote *orchestratorv1.PaymentQuote) *model.Payment {
entity := &model.Payment{}
entity.SetID(primitive.NewObjectID())
entity.SetOrganizationRef(orgID)
entity.PaymentRef = entity.GetID().Hex()
entity.IdempotencyKey = idempotencyKey
entity.State = model.PaymentStateAccepted
entity.Intent = intentFromProto(intent)
entity.Metadata = cloneMetadata(metadata)
entity.LastQuote = quoteSnapshotToModel(quote)
entity.Normalize()
return entity
}
func paymentNotFoundResponder[T any](svc mservice.Type, logger mlogger.Logger, err error) gsresponse.Responder[T] {
if errors.Is(err, storage.ErrPaymentNotFound) {
return gsresponse.NotFound[T](logger, svc, err)
}
return gsresponse.Auto[T](logger, svc, err)
}

View File

@@ -0,0 +1,232 @@
package orchestrator
import (
"context"
"testing"
"time"
"github.com/tech/sendico/payments/orchestrator/storage"
"github.com/tech/sendico/payments/orchestrator/storage/model"
clockpkg "github.com/tech/sendico/pkg/clock"
mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestValidateMetaAndOrgRef(t *testing.T) {
org := primitive.NewObjectID()
meta := &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()}
ref, id, err := validateMetaAndOrgRef(meta)
if err != nil {
t.Fatalf("expected nil error: %v", err)
}
if ref != org.Hex() || id != org {
t.Fatalf("unexpected org parsing: %s %s", ref, id.Hex())
}
if _, _, err := validateMetaAndOrgRef(nil); err == nil {
t.Fatalf("expected error on nil meta")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: ""}); err == nil {
t.Fatalf("expected error on empty orgRef")
}
if _, _, err := validateMetaAndOrgRef(&orchestratorv1.RequestMeta{OrganizationRef: "bad"}); err == nil {
t.Fatalf("expected error on invalid orgRef")
}
}
func TestRequireIdempotencyKey(t *testing.T) {
if _, err := requireIdempotencyKey(" "); err == nil {
t.Fatalf("expected error for empty key")
}
val, err := requireIdempotencyKey(" key ")
if err != nil || val != "key" {
t.Fatalf("unexpected result %s err %v", val, err)
}
}
func TestNewPayment(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "10"},
}
quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"}
p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote)
if p.PaymentRef == "" || p.IdempotencyKey != "idem" || p.State != model.PaymentStateAccepted {
t.Fatalf("unexpected payment fields: %+v", p)
}
if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" {
t.Fatalf("intent not copied")
}
if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" {
t.Fatalf("quote not copied")
}
}
func TestResolvePaymentQuote_NotFound(t *testing.T) {
org := primitive.NewObjectID()
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{}},
clock: clockpkg.NewSystem(),
}
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}},
QuoteRef: "missing",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_not_found" {
t.Fatalf("expected quote_not_found, got %v", err)
}
}
func TestResolvePaymentQuote_Expired(t *testing.T) {
org := primitive.NewObjectID()
intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}}
record := &model.PaymentQuoteRecord{
QuoteRef: "q1",
Intent: intentFromProto(intent),
Quote: &model.PaymentQuoteSnapshot{},
ExpiresAt: time.Now().Add(-time.Minute),
}
svc := &Service{
storage: stubRepo{quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}},
clock: clockpkg.NewSystem(),
}
_, err := svc.resolvePaymentQuote(context.Background(), quoteResolutionInput{
OrgRef: org.Hex(),
OrgID: org,
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
QuoteRef: "q1",
})
if qerr, ok := err.(quoteResolutionError); !ok || qerr.code != "quote_expired" {
t.Fatalf("expected quote_expired, got %v", err)
}
}
func TestInitiatePaymentIdempotency(t *testing.T) {
logger := mloggerfactory.NewLogger(false)
org := primitive.NewObjectID()
store := newHelperPaymentStore()
svc := NewService(logger, stubRepo{
payments: store,
}, WithClock(clockpkg.NewSystem()))
svc.ensureHandlers()
intent := &orchestratorv1.PaymentIntent{
Amount: &moneyv1.Money{Currency: "USD", Amount: "1"},
}
req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{OrganizationRef: org.Hex()},
Intent: intent,
IdempotencyKey: "k1",
}
resp, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("first call failed: %v", err)
}
resp2, err := svc.h.commands.InitiatePayment().Execute(context.Background(), req)(context.Background())
if err != nil {
t.Fatalf("second call failed: %v", err)
}
if resp == nil || resp2 == nil || resp.Payment.GetPaymentRef() != resp2.Payment.GetPaymentRef() {
t.Fatalf("idempotent call returned different payments")
}
}
// --- test doubles ---
type stubRepo struct {
payments storage.PaymentsStore
quotes storage.QuotesStore
pingErr error
}
func (s stubRepo) Ping(context.Context) error { return s.pingErr }
func (s stubRepo) Payments() storage.PaymentsStore { return s.payments }
func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes }
type helperPaymentStore struct {
byRef map[string]*model.Payment
byIdem map[string]*model.Payment
byChain map[string]*model.Payment
}
func newHelperPaymentStore() *helperPaymentStore {
return &helperPaymentStore{
byRef: make(map[string]*model.Payment),
byIdem: make(map[string]*model.Payment),
byChain: make(map[string]*model.Payment),
}
}
func (s *helperPaymentStore) Create(_ context.Context, p *model.Payment) error {
if _, ok := s.byRef[p.PaymentRef]; ok {
return storage.ErrDuplicatePayment
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
if p.Execution != nil && p.Execution.ChainTransferRef != "" {
s.byChain[p.Execution.ChainTransferRef] = p
}
return nil
}
func (s *helperPaymentStore) Update(_ context.Context, p *model.Payment) error {
if p == nil {
return storage.ErrPaymentNotFound
}
if _, ok := s.byRef[p.PaymentRef]; !ok {
return storage.ErrPaymentNotFound
}
s.byRef[p.PaymentRef] = p
if p.IdempotencyKey != "" {
s.byIdem[p.IdempotencyKey] = p
}
return nil
}
func (s *helperPaymentStore) GetByPaymentRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byRef[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByIdempotencyKey(_ context.Context, _ primitive.ObjectID, key string) (*model.Payment, error) {
if p, ok := s.byIdem[key]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) GetByChainTransferRef(_ context.Context, ref string) (*model.Payment, error) {
if p, ok := s.byChain[ref]; ok {
return p, nil
}
return nil, storage.ErrPaymentNotFound
}
func (s *helperPaymentStore) List(_ context.Context, _ *model.PaymentFilter) (*model.PaymentList, error) {
return &model.PaymentList{}, nil
}
type helperQuotesStore struct {
records map[string]*model.PaymentQuoteRecord
}
func (s *helperQuotesStore) Create(_ context.Context, _ *model.PaymentQuoteRecord) error { return nil }
func (s *helperQuotesStore) GetByRef(_ context.Context, _ primitive.ObjectID, ref string) (*model.PaymentQuoteRecord, error) {
if s.records == nil {
return nil, storage.ErrQuoteNotFound
}
if rec, ok := s.records[ref]; ok {
return rec, nil
}
return nil, storage.ErrQuoteNotFound
}

View File

@@ -32,11 +32,13 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) {
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: repo, storage: repo,
deps: serviceDependencies{
ledger: ledgerDependency{client: &ledgerclient.Fake{ ledger: ledgerDependency{client: &ledgerclient.Fake{
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
}, },
}}, }},
},
} }
payment := &model.Payment{ payment := &model.Payment{
@@ -88,11 +90,13 @@ func TestExecutePayment_ChainFailure(t *testing.T) {
logger: zap.NewNop(), logger: zap.NewNop(),
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: repo, storage: repo,
deps: serviceDependencies{
gateway: gatewayDependency{client: &chainclient.Fake{ gateway: gatewayDependency{client: &chainclient.Fake{
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
return nil, errors.New("chain failure") return nil, errors.New("chain failure")
}, },
}}, }},
},
} }
payment := &model.Payment{ payment := &model.Payment{
@@ -146,6 +150,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: &stubRepository{store: store}, storage: &stubRepository{store: store},
} }
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessTransferUpdateRequest{ req := &orchestratorv1.ProcessTransferUpdateRequest{
Event: &chainv1.TransferStatusChangedEvent{ Event: &chainv1.TransferStatusChangedEvent{
@@ -156,7 +161,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) {
}, },
} }
reSP, err := gsresponse.Execute(ctx, svc.processTransferUpdateHandler(ctx, req)) reSP, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req))
if err != nil { if err != nil {
t.Fatalf("handler returned error: %v", err) t.Fatalf("handler returned error: %v", err)
} }
@@ -189,6 +194,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
clock: testClock{now: time.Now()}, clock: testClock{now: time.Now()},
storage: &stubRepository{store: store}, storage: &stubRepository{store: store},
} }
svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger)
req := &orchestratorv1.ProcessDepositObservedRequest{ req := &orchestratorv1.ProcessDepositObservedRequest{
Event: &chainv1.WalletDepositObservedEvent{ Event: &chainv1.WalletDepositObservedEvent{
@@ -197,7 +203,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
}, },
} }
reSP, err := gsresponse.Execute(ctx, svc.processDepositObservedHandler(ctx, req)) reSP, err := gsresponse.Execute(ctx, svc.h.events.processDepositObserved(ctx, req))
if err != nil { if err != nil {
t.Fatalf("handler returned error: %v", err) t.Fatalf("handler returned error: %v", err)
} }

View File

@@ -57,6 +57,7 @@ const (
EndpointTypeLedger PaymentEndpointType = "ledger" EndpointTypeLedger PaymentEndpointType = "ledger"
EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet" EndpointTypeManagedWallet PaymentEndpointType = "managed_wallet"
EndpointTypeExternalChain PaymentEndpointType = "external_chain" EndpointTypeExternalChain PaymentEndpointType = "external_chain"
EndpointTypeCard PaymentEndpointType = "card"
) )
// LedgerEndpoint describes ledger routing. // LedgerEndpoint describes ledger routing.
@@ -78,12 +79,36 @@ type ExternalChainEndpoint struct {
Memo string `bson:"memo,omitempty" json:"memo,omitempty"` Memo string `bson:"memo,omitempty" json:"memo,omitempty"`
} }
// CardEndpoint describes a card payout destination.
type CardEndpoint struct {
Pan string `bson:"pan,omitempty" json:"pan,omitempty"`
Token string `bson:"token,omitempty" json:"token,omitempty"`
Cardholder string `bson:"cardholder,omitempty" json:"cardholder,omitempty"`
ExpMonth uint32 `bson:"expMonth,omitempty" json:"expMonth,omitempty"`
ExpYear uint32 `bson:"expYear,omitempty" json:"expYear,omitempty"`
Country string `bson:"country,omitempty" json:"country,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
}
// CardPayout stores gateway payout tracking info.
type CardPayout struct {
PayoutRef string `bson:"payoutRef,omitempty" json:"payoutRef,omitempty"`
ProviderPaymentID string `bson:"providerPaymentId,omitempty" json:"providerPaymentId,omitempty"`
Status string `bson:"status,omitempty" json:"status,omitempty"`
FailureReason string `bson:"failureReason,omitempty" json:"failureReason,omitempty"`
CardCountry string `bson:"cardCountry,omitempty" json:"cardCountry,omitempty"`
MaskedPan string `bson:"maskedPan,omitempty" json:"maskedPan,omitempty"`
ProviderCode string `bson:"providerCode,omitempty" json:"providerCode,omitempty"`
GatewayReference string `bson:"gatewayReference,omitempty" json:"gatewayReference,omitempty"`
}
// PaymentEndpoint is a polymorphic payment destination/source. // PaymentEndpoint is a polymorphic payment destination/source.
type PaymentEndpoint struct { type PaymentEndpoint struct {
Type PaymentEndpointType `bson:"type" json:"type"` Type PaymentEndpointType `bson:"type" json:"type"`
Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"` Ledger *LedgerEndpoint `bson:"ledger,omitempty" json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"` ManagedWallet *ManagedWalletEndpoint `bson:"managedWallet,omitempty" json:"managedWallet,omitempty"`
ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"` ExternalChain *ExternalChainEndpoint `bson:"externalChain,omitempty" json:"externalChain,omitempty"`
Card *CardEndpoint `bson:"card,omitempty" json:"card,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
} }
@@ -118,7 +143,6 @@ type PaymentQuoteSnapshot struct {
FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"` FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"`
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"` NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
} }
@@ -128,6 +152,8 @@ type ExecutionRefs struct {
CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"` CreditEntryRef string `bson:"creditEntryRef,omitempty" json:"creditEntryRef,omitempty"`
FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"` FXEntryRef string `bson:"fxEntryRef,omitempty" json:"fxEntryRef,omitempty"`
ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"` ChainTransferRef string `bson:"chainTransferRef,omitempty" json:"chainTransferRef,omitempty"`
CardPayoutRef string `bson:"cardPayoutRef,omitempty" json:"cardPayoutRef,omitempty"`
FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"`
} }
// Payment persists orchestrated payment lifecycle. // Payment persists orchestrated payment lifecycle.
@@ -144,6 +170,7 @@ type Payment struct {
LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"` LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"`
Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"` Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"`
} }
// Collection implements storable.Storable. // Collection implements storable.Storable.
@@ -223,5 +250,13 @@ func normalizeEndpoint(ep *PaymentEndpoint) {
ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress)) ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress))
} }
} }
case EndpointTypeCard:
if ep.Card != nil {
ep.Card.Pan = strings.TrimSpace(ep.Card.Pan)
ep.Card.Token = strings.TrimSpace(ep.Card.Token)
ep.Card.Cardholder = strings.TrimSpace(ep.Card.Cardholder)
ep.Card.Country = strings.TrimSpace(ep.Card.Country)
ep.Card.MaskedPan = strings.TrimSpace(ep.Card.MaskedPan)
}
} }
} }

View File

@@ -18,7 +18,7 @@ require (
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
google.golang.org/grpc v1.77.0 google.golang.org/grpc v1.77.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
) )
require ( require (

View File

@@ -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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

View File

@@ -8,6 +8,7 @@ import (
) )
type CryptoAddressPaymentData struct { type CryptoAddressPaymentData struct {
Currency Currency `bson:"currency" json:"currency"`
Address string `bson:"address" json:"address"` Address string `bson:"address" json:"address"`
Network string `bson:"network" json:"network"` Network string `bson:"network" json:"network"`
DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"` DestinationTag *string `bson:"destinationTag,omitempty" json:"destinationTag,omitempty"`

View File

@@ -9,6 +9,7 @@ const (
CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia CurrencyUAH Currency = "UAH" // Ukrainian Hryvnia
CurrencyPLN Currency = "PLN" // Polish Złoty CurrencyPLN Currency = "PLN" // Polish Złoty
CurrencyCZK Currency = "CZK" // Czech Koruna CurrencyCZK Currency = "CZK" // Czech Koruna
CurrencyUSDT Currency = "USDT" // Czech Koruna
) )
// All supported currencies // All supported currencies
@@ -19,6 +20,7 @@ var SupportedCurrencies = []Currency{
CurrencyUAH, CurrencyUAH,
CurrencyPLN, CurrencyPLN,
CurrencyCZK, CurrencyCZK,
CurrencyUSDT,
} }
type Amount struct { type Amount struct {

6
api/pkg/model/money.go Normal file
View File

@@ -0,0 +1,6 @@
package model
type Money struct {
Currency string `bson:"currency" json:"currency"`
Amount string `bson:"amount" json:"amount"`
}

View File

@@ -19,6 +19,7 @@ const (
PaymentTypeBankAccount PaymentTypeBankAccount
PaymentTypeWallet PaymentTypeWallet
PaymentTypeCryptoAddress PaymentTypeCryptoAddress
PaymentTypeLedger
) )
var paymentTypeToString = map[PaymentType]string{ var paymentTypeToString = map[PaymentType]string{
@@ -28,6 +29,7 @@ var paymentTypeToString = map[PaymentType]string{
PaymentTypeBankAccount: "bankAccount", PaymentTypeBankAccount: "bankAccount",
PaymentTypeWallet: "wallet", PaymentTypeWallet: "wallet",
PaymentTypeCryptoAddress: "cryptoAddress", PaymentTypeCryptoAddress: "cryptoAddress",
PaymentTypeLedger: "ledger",
} }
var paymentTypeFromString = map[string]PaymentType{ var paymentTypeFromString = map[string]PaymentType{
@@ -37,6 +39,7 @@ var paymentTypeFromString = map[string]PaymentType{
"bankAccount": PaymentTypeBankAccount, "bankAccount": PaymentTypeBankAccount,
"wallet": PaymentTypeWallet, "wallet": PaymentTypeWallet,
"cryptoAddress": PaymentTypeCryptoAddress, "cryptoAddress": PaymentTypeCryptoAddress,
"ledger": PaymentTypeLedger,
} }
func (t PaymentType) String() string { func (t PaymentType) String() string {

View File

@@ -20,17 +20,18 @@ message RateSnapshot {
} }
message RequestMeta { message RequestMeta {
string request_ref = 1 [deprecated = true]; reserved 1, 4, 5;
reserved "request_ref", "idempotency_key", "trace_ref";
string tenant_ref = 2; string tenant_ref = 2;
string organization_ref = 3; string organization_ref = 3;
string idempotency_key = 4 [deprecated = true];
string trace_ref = 5 [deprecated = true];
common.trace.v1.TraceContext trace = 6; common.trace.v1.TraceContext trace = 6;
} }
message ResponseMeta { message ResponseMeta {
string request_ref = 1 [deprecated = true]; reserved 1, 2;
string trace_ref = 2 [deprecated = true]; reserved "request_ref", "trace_ref";
common.trace.v1.TraceContext trace = 3; common.trace.v1.TraceContext trace = 3;
} }

View File

@@ -11,6 +11,7 @@ import "common/trace/v1/trace.proto";
import "common/pagination/v1/cursor.proto"; import "common/pagination/v1/cursor.proto";
import "billing/fees/v1/fees.proto"; import "billing/fees/v1/fees.proto";
import "gateway/chain/v1/chain.proto"; import "gateway/chain/v1/chain.proto";
import "gateway/mntx/v1/mntx.proto";
import "oracle/v1/oracle.proto"; import "oracle/v1/oracle.proto";
enum PaymentKind { enum PaymentKind {
@@ -20,6 +21,13 @@ enum PaymentKind {
PAYMENT_KIND_FX_CONVERSION = 3; PAYMENT_KIND_FX_CONVERSION = 3;
} }
// SettlementMode defines how to treat fees/FX variance for payouts.
enum SettlementMode {
SETTLEMENT_MODE_UNSPECIFIED = 0;
SETTLEMENT_MODE_FIX_SOURCE = 1; // customer pays fees; sent amount fixed
SETTLEMENT_MODE_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes
}
enum PaymentState { enum PaymentState {
PAYMENT_STATE_UNSPECIFIED = 0; PAYMENT_STATE_UNSPECIFIED = 0;
PAYMENT_STATE_ACCEPTED = 1; PAYMENT_STATE_ACCEPTED = 1;
@@ -61,11 +69,26 @@ message ExternalChainEndpoint {
string memo = 3; string memo = 3;
} }
// Card payout destination.
message CardEndpoint {
oneof card {
string pan = 1; // raw PAN
string token = 2; // network or gateway-issued token
}
string cardholder_name = 3;
string cardholder_surname = 4;
uint32 exp_month = 5;
uint32 exp_year = 6;
string country = 7;
string masked_pan = 8;
}
message PaymentEndpoint { message PaymentEndpoint {
oneof endpoint { oneof endpoint {
LedgerEndpoint ledger = 1; LedgerEndpoint ledger = 1;
ManagedWalletEndpoint managed_wallet = 2; ManagedWalletEndpoint managed_wallet = 2;
ExternalChainEndpoint external_chain = 3; ExternalChainEndpoint external_chain = 3;
CardEndpoint card = 4;
} }
map<string, string> metadata = 10; map<string, string> metadata = 10;
} }
@@ -88,6 +111,7 @@ message PaymentIntent {
FXIntent fx = 6; FXIntent fx = 6;
fees.v1.PolicyOverrides fee_policy = 7; fees.v1.PolicyOverrides fee_policy = 7;
map<string, string> attributes = 8; map<string, string> attributes = 8;
SettlementMode settlement_mode = 9;
} }
message PaymentQuote { message PaymentQuote {
@@ -98,8 +122,7 @@ message PaymentQuote {
repeated fees.v1.AppliedRule fee_rules = 5; repeated fees.v1.AppliedRule fee_rules = 5;
oracle.v1.Quote fx_quote = 6; oracle.v1.Quote fx_quote = 6;
chain.gateway.v1.EstimateTransferFeeResponse network_fee = 7; chain.gateway.v1.EstimateTransferFeeResponse network_fee = 7;
string fee_quote_token = 8; string quote_ref = 8;
string quote_ref = 9;
} }
message ExecutionRefs { message ExecutionRefs {
@@ -107,6 +130,20 @@ message ExecutionRefs {
string credit_entry_ref = 2; string credit_entry_ref = 2;
string fx_entry_ref = 3; string fx_entry_ref = 3;
string chain_transfer_ref = 4; string chain_transfer_ref = 4;
string card_payout_ref = 5;
string fee_transfer_ref = 6;
}
// Card payout gateway tracking info.
message CardPayout {
string payout_ref = 1;
string provider_payment_id = 2;
string status = 3;
string failure_reason = 4;
string card_country = 5;
string masked_pan = 6;
string provider_code = 7;
string gateway_reference = 8;
} }
message Payment { message Payment {
@@ -121,6 +158,7 @@ message Payment {
map<string, string> metadata = 9; map<string, string> metadata = 9;
google.protobuf.Timestamp created_at = 10; google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11; google.protobuf.Timestamp updated_at = 11;
CardPayout card_payout = 12;
} }
message QuotePaymentRequest { message QuotePaymentRequest {
@@ -138,10 +176,8 @@ message InitiatePaymentRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
PaymentIntent intent = 3; PaymentIntent intent = 3;
string fee_quote_token = 4; map<string, string> metadata = 4;
string fx_quote_ref = 5; string quote_ref = 5;
map<string, string> metadata = 6;
string quote_ref = 7;
} }
message InitiatePaymentResponse { message InitiatePaymentResponse {
@@ -198,6 +234,15 @@ message ProcessDepositObservedResponse {
Payment payment = 1; Payment payment = 1;
} }
message ProcessCardPayoutUpdateRequest {
RequestMeta meta = 1;
mntx.gateway.v1.CardPayoutStatusChangedEvent event = 2;
}
message ProcessCardPayoutUpdateResponse {
Payment payment = 1;
}
message InitiateConversionRequest { message InitiateConversionRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string idempotency_key = 2; string idempotency_key = 2;
@@ -221,4 +266,5 @@ service PaymentOrchestrator {
rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse); rpc InitiateConversion(InitiateConversionRequest) returns (InitiateConversionResponse);
rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse); rpc ProcessTransferUpdate(ProcessTransferUpdateRequest) returns (ProcessTransferUpdateResponse);
rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse); rpc ProcessDepositObserved(ProcessDepositObservedRequest) returns (ProcessDepositObservedResponse);
rpc ProcessCardPayoutUpdate(ProcessCardPayoutUpdateRequest) returns (ProcessCardPayoutUpdateResponse);
} }

View File

@@ -16,8 +16,7 @@ api:
CORS: CORS:
max_age: 300 max_age: 300
allowed_origins: allowed_origins:
- "http://*" - "*"
- "https://*"
allowed_methods: allowed_methods:
- "GET" - "GET"
- "POST" - "POST"
@@ -91,8 +90,8 @@ api:
call_timeout_seconds: 5 call_timeout_seconds: 5
insecure: true insecure: true
payment_orchestrator: payment_orchestrator:
address: sendico_payment_orchestrator:50062 address: sendico_payments_orchestrator:50062
address_env: PAYMENT_ORCHESTRATOR_ADDRESS address_env: PAYMENTS_ADDRESS
dial_timeout_seconds: 5 dial_timeout_seconds: 5
call_timeout_seconds: 5 call_timeout_seconds: 5
insecure: true insecure: true

View File

@@ -12,17 +12,18 @@ replace github.com/tech/sendico/gateway/chain => ../gateway/chain
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.0 github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.4 github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.4 github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
github.com/go-chi/chi/v5 v5.2.3 github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-chi/jwtauth/v5 v5.3.3 github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-chi/metrics v0.1.1 github.com/go-chi/metrics v0.1.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/mitchellh/mapstructure v1.5.0 github.com/mitchellh/mapstructure v1.5.0
github.com/shopspring/decimal v1.4.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tech/sendico/gateway/chain v0.1.0 github.com/tech/sendico/gateway/chain v0.0.0-00010101000000-000000000000
github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000 github.com/tech/sendico/ledger v0.0.0-00010101000000-000000000000
github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000 github.com/tech/sendico/payments/orchestrator v0.0.0-00010101000000-000000000000
github.com/tech/sendico/pkg v0.1.0 github.com/tech/sendico/pkg v0.1.0
@@ -31,7 +32,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6 go.mongodb.org/mongo-driver v1.17.6
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/net v0.48.0 golang.org/x/net v0.48.0
google.golang.org/protobuf v1.36.10 google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
moul.io/chizap v1.0.3 moul.io/chizap v1.0.3
) )
@@ -59,7 +60,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/smithy-go v1.24.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect

View File

@@ -10,10 +10,10 @@ github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgP
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.4 h1:gl+DxVuadpkYoaDcWllZqLkhGEbvwyqgNVRTmlaf5PI= github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
github.com/aws/aws-sdk-go-v2/config v1.32.4/go.mod h1:MBUp9Og/bzMmQHjMwace4aJfyvJeadzXjoTcR/SxLV0= github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4 h1:KeIZxHVbGWRLhPvhdPbbi/DtFBHNKm6OsVDuiuFefdQ= github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.4/go.mod h1:Smw5n0nCZE9PeFEguofdXyt8kUC4JNrkDTfBOioPhFA= github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
@@ -32,16 +32,16 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A= github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1 h1:5FhzzN6JmlGQF6c04kDIb5KNGm6KnNdLISNrfivIhHg= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.1/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8= github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4 h1:YCu/iAhQer8WZ66lldyKkpvMyv+HkPufMa4dyT6wils= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.4/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
@@ -213,6 +213,8 @@ github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFt
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -363,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/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 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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-20180628173108-788fd7840127/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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=

View File

@@ -0,0 +1,76 @@
package srequest
// Asset represents a chain/token pair for blockchain endpoints.
type Asset struct {
Chain ChainNetwork `json:"chain"`
TokenSymbol string `json:"token_symbol"`
ContractAddress string `json:"contract_address,omitempty"`
}
// LedgerEndpoint represents a ledger account payload.
type LedgerEndpoint struct {
LedgerAccountRef string `json:"ledger_account_ref"`
ContraLedgerAccountRef string `json:"contra_ledger_account_ref,omitempty"`
}
// ManagedWalletEndpoint represents a managed wallet payload.
type ManagedWalletEndpoint struct {
ManagedWalletRef string `json:"managed_wallet_ref"`
Asset *Asset `json:"asset,omitempty"`
}
// ExternalChainEndpoint represents an external chain address payload.
type ExternalChainEndpoint struct {
Asset *Asset `json:"asset,omitempty"`
Address string `json:"address"`
Memo string `json:"memo,omitempty"`
}
// CardEndpoint represents a card payout payload (PAN or network token).
type CardEndpoint struct {
Pan string `json:"pan"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
ExpMonth uint32 `json:"exp_month,omitempty"`
ExpYear uint32 `json:"exp_year,omitempty"`
Country string `json:"country,omitempty"`
}
// CardTokenEndpoint represents a vaulted card token payout payload.
type CardTokenEndpoint struct {
Token string `json:"token"`
MaskedPan string `json:"masked_pan"`
}
// WalletEndpoint represents a Sendico wallet payout payload.
type WalletEndpoint struct {
WalletID string `json:"walletId"`
}
// BankAccountEndpoint represents a domestic bank account payout payload.
type BankAccountEndpoint struct {
RecipientName string `json:"recipientName"`
Inn string `json:"inn"`
Kpp string `json:"kpp"`
BankName string `json:"bankName"`
Bik string `json:"bik"`
AccountNumber string `json:"accountNumber"`
CorrespondentAccount string `json:"correspondentAccount"`
}
// IBANEndpoint represents an international bank account payout payload.
type IBANEndpoint struct {
IBAN string `json:"iban"`
AccountHolder string `json:"accountHolder"`
BIC string `json:"bic,omitempty"`
BankName string `json:"bankName,omitempty"`
}
// LegacyPaymentEndpoint mirrors the previous bag-of-pointers DTO for backward compatibility.
type LegacyPaymentEndpoint struct {
Ledger *LedgerEndpoint `json:"ledger,omitempty"`
ManagedWallet *ManagedWalletEndpoint `json:"managed_wallet,omitempty"`
ExternalChain *ExternalChainEndpoint `json:"external_chain,omitempty"`
Card *CardEndpoint `json:"card,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}

View File

@@ -0,0 +1,266 @@
package srequest
import (
"encoding/json"
"github.com/tech/sendico/pkg/merrors"
)
type EndpointType string
const (
EndpointTypeLedger EndpointType = "ledger"
EndpointTypeManagedWallet EndpointType = "managedWallet"
EndpointTypeExternalChain EndpointType = "cryptoAddress"
EndpointTypeCard EndpointType = "card"
EndpointTypeCardToken EndpointType = "cardToken"
EndpointTypeWallet EndpointType = "wallet"
EndpointTypeBankAccount EndpointType = "bankAccount"
EndpointTypeIBAN EndpointType = "iban"
)
// Endpoint is a discriminated union for payment endpoints.
type Endpoint struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata,omitempty"`
}
func newEndpoint(kind EndpointType, payload interface{}, metadata map[string]string) (Endpoint, error) {
data, err := json.Marshal(payload)
if err != nil {
return Endpoint{}, merrors.Internal("marshal endpoint payload failed")
}
return Endpoint{
Type: kind,
Data: data,
Metadata: cloneStringMap(metadata),
}, nil
}
func (e Endpoint) decodePayload(expected EndpointType, dst interface{}) error {
actual := normalizeEndpointType(e.Type)
if actual == "" {
return merrors.InvalidArgument("endpoint type is required")
}
if actual != expected {
return merrors.InvalidArgument("expected endpoint type " + string(expected) + ", got " + string(e.Type))
}
if len(e.Data) == 0 {
return merrors.InvalidArgument("endpoint data is required for type " + string(expected))
}
if err := json.Unmarshal(e.Data, dst); err != nil {
return merrors.InvalidArgument("decode " + string(expected) + " endpoint: " + err.Error())
}
return nil
}
func (e *Endpoint) UnmarshalJSON(data []byte) error {
var envelope struct {
Type EndpointType `json:"type"`
Data json.RawMessage `json:"data"`
Metadata map[string]string `json:"metadata"`
}
if err := json.Unmarshal(data, &envelope); err == nil {
if envelope.Type != "" || len(envelope.Data) > 0 {
if envelope.Type == "" {
return merrors.InvalidArgument("endpoint type is required")
}
*e = Endpoint{
Type: normalizeEndpointType(envelope.Type),
Data: envelope.Data,
Metadata: cloneStringMap(envelope.Metadata),
}
return nil
}
}
var legacy LegacyPaymentEndpoint
if err := json.Unmarshal(data, &legacy); err != nil {
return err
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(&legacy)
if err != nil {
return err
}
if endpoint == nil {
return merrors.InvalidArgument("endpoint payload is empty")
}
*e = *endpoint
return nil
}
func NewLedgerEndpointDTO(payload LedgerEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeLedger, payload, metadata)
}
func NewManagedWalletEndpointDTO(payload ManagedWalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeManagedWallet, payload, metadata)
}
func NewExternalChainEndpointDTO(payload ExternalChainEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeExternalChain, payload, metadata)
}
func NewCardEndpointDTO(payload CardEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCard, payload, metadata)
}
func NewCardTokenEndpointDTO(payload CardTokenEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeCardToken, payload, metadata)
}
func NewWalletEndpointDTO(payload WalletEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeWallet, payload, metadata)
}
func NewBankAccountEndpointDTO(payload BankAccountEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeBankAccount, payload, metadata)
}
func NewIBANEndpointDTO(payload IBANEndpoint, metadata map[string]string) (Endpoint, error) {
return newEndpoint(EndpointTypeIBAN, payload, metadata)
}
func (e Endpoint) DecodeLedger() (LedgerEndpoint, error) {
var payload LedgerEndpoint
return payload, e.decodePayload(EndpointTypeLedger, &payload)
}
func (e Endpoint) DecodeManagedWallet() (ManagedWalletEndpoint, error) {
var payload ManagedWalletEndpoint
return payload, e.decodePayload(EndpointTypeManagedWallet, &payload)
}
func (e Endpoint) DecodeExternalChain() (ExternalChainEndpoint, error) {
var payload ExternalChainEndpoint
return payload, e.decodePayload(EndpointTypeExternalChain, &payload)
}
func (e Endpoint) DecodeCard() (CardEndpoint, error) {
var payload CardEndpoint
return payload, e.decodePayload(EndpointTypeCard, &payload)
}
func (e Endpoint) DecodeCardToken() (CardTokenEndpoint, error) {
var payload CardTokenEndpoint
return payload, e.decodePayload(EndpointTypeCardToken, &payload)
}
func (e Endpoint) DecodeWallet() (WalletEndpoint, error) {
var payload WalletEndpoint
return payload, e.decodePayload(EndpointTypeWallet, &payload)
}
func (e Endpoint) DecodeBankAccount() (BankAccountEndpoint, error) {
var payload BankAccountEndpoint
return payload, e.decodePayload(EndpointTypeBankAccount, &payload)
}
func (e Endpoint) DecodeIBAN() (IBANEndpoint, error) {
var payload IBANEndpoint
return payload, e.decodePayload(EndpointTypeIBAN, &payload)
}
func LegacyPaymentEndpointToEndpointDTO(old *LegacyPaymentEndpoint) (*Endpoint, error) {
if old == nil {
return nil, nil
}
count := 0
var endpoint Endpoint
var err error
if old.Ledger != nil {
count++
endpoint, err = NewLedgerEndpointDTO(*old.Ledger, old.Metadata)
}
if old.ManagedWallet != nil {
count++
endpoint, err = NewManagedWalletEndpointDTO(*old.ManagedWallet, old.Metadata)
}
if old.ExternalChain != nil {
count++
endpoint, err = NewExternalChainEndpointDTO(*old.ExternalChain, old.Metadata)
}
if old.Card != nil {
count++
endpoint, err = NewCardEndpointDTO(*old.Card, old.Metadata)
}
if err != nil {
return nil, err
}
if count == 0 {
return nil, merrors.InvalidArgument("exactly one endpoint must be set")
}
if count > 1 {
return nil, merrors.InvalidArgument("only one endpoint can be set")
}
return &endpoint, nil
}
func EndpointDTOToLegacyPaymentEndpoint(new *Endpoint) (*LegacyPaymentEndpoint, error) {
if new == nil {
return nil, nil
}
legacy := &LegacyPaymentEndpoint{
Metadata: cloneStringMap(new.Metadata),
}
switch normalizeEndpointType(new.Type) {
case EndpointTypeLedger:
payload, err := new.DecodeLedger()
if err != nil {
return nil, err
}
legacy.Ledger = &payload
case EndpointTypeManagedWallet:
payload, err := new.DecodeManagedWallet()
if err != nil {
return nil, err
}
legacy.ManagedWallet = &payload
case EndpointTypeExternalChain:
payload, err := new.DecodeExternalChain()
if err != nil {
return nil, err
}
legacy.ExternalChain = &payload
case EndpointTypeCard:
payload, err := new.DecodeCard()
if err != nil {
return nil, err
}
legacy.Card = &payload
default:
return nil, merrors.InvalidArgument("unsupported endpoint type: " + string(new.Type))
}
return legacy, nil
}
var endpointTypeAliases = map[EndpointType]EndpointType{
"managed_wallet": EndpointTypeManagedWallet,
"external_chain": EndpointTypeExternalChain,
"card_token": EndpointTypeCardToken,
"bank_account": EndpointTypeBankAccount,
}
func normalizeEndpointType(t EndpointType) EndpointType {
if canonical, ok := endpointTypeAliases[t]; ok {
return canonical
}
return t
}
func cloneStringMap(src map[string]string) map[string]string {
if len(src) == 0 {
return nil
}
dst := make(map[string]string, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

View File

@@ -1,17 +1,70 @@
package srequest package srequest
type QuotePayment struct { import (
"github.com/tech/sendico/pkg/merrors"
)
type PaymentBase struct {
IdempotencyKey string `json:"idempotencyKey"` IdempotencyKey string `json:"idempotencyKey"`
Intent *PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
Metadata map[string]string `json:"metadata,omitempty"` Metadata map[string]string `json:"metadata,omitempty"`
} }
func (b *PaymentBase) Validate() error {
if b.IdempotencyKey == "" {
return merrors.InvalidArgument("idempotencyKey is required", "idempotencyKey")
}
return nil
}
type QuotePayment struct {
PaymentBase `json:",inline"`
Intent PaymentIntent `json:"intent"`
PreviewOnly bool `json:"previewOnly"`
}
func (r *QuotePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
// intent is mandatory, so validate always
if err := r.Intent.Validate(); err != nil {
return err
}
return nil
}
type InitiatePayment struct { type InitiatePayment struct {
IdempotencyKey string `json:"idempotencyKey"` PaymentBase `json:",inline"`
Intent *PaymentIntent `json:"intent"` Intent *PaymentIntent `json:"intent,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FxQuoteRef string `json:"fxQuoteRef,omitempty"`
QuoteRef string `json:"quoteRef,omitempty"` QuoteRef string `json:"quoteRef,omitempty"`
} }
func (r InitiatePayment) Validate() error {
// base checks
if err := r.PaymentBase.Validate(); err != nil {
return err
}
hasIntent := r.Intent != nil
hasQuote := r.QuoteRef != ""
// must be exactly one
switch {
case !hasIntent && !hasQuote:
return merrors.NoData("either intent or quoteRef must be provided")
case hasIntent && hasQuote:
return merrors.DataConflict("intent and quoteRef are mutually exclusive")
}
// if intent provided → validate it
if hasIntent {
if err := r.Intent.Validate(); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,50 @@
package srequest
// PaymentKind mirrors the orchestrator payment kinds without importing generated proto types.
// Strings keep JSON readable; conversion helpers map these to proto enums.
type PaymentKind string
const (
PaymentKindUnspecified PaymentKind = "unspecified"
PaymentKindPayout PaymentKind = "payout"
PaymentKindInternalTransfer PaymentKind = "internal_transfer"
PaymentKindFxConversion PaymentKind = "fx_conversion"
)
// SettlementMode matches orchestrator settlement behavior.
type SettlementMode string
const (
SettlementModeUnspecified SettlementMode = "unspecified"
SettlementModeFixSource SettlementMode = "fix_source"
SettlementModeFixReceived SettlementMode = "fix_received"
)
// FXSide mirrors the common FX side enum.
type FXSide string
const (
FXSideUnspecified FXSide = "unspecified"
FXSideBuyBaseSellQuote FXSide = "buy_base_sell_quote"
FXSideSellBaseBuyQuote FXSide = "sell_base_buy_quote"
)
// ChainNetwork mirrors the chain network enum used by managed wallets.
type ChainNetwork string
const (
ChainNetworkUnspecified ChainNetwork = "unspecified"
ChainNetworkEthereumMainnet ChainNetwork = "ethereum_mainnet"
ChainNetworkArbitrumOne ChainNetwork = "arbitrum_one"
ChainNetworkOtherEVM ChainNetwork = "other_evm"
)
// InsufficientNetPolicy mirrors the fee engine policy override.
type InsufficientNetPolicy string
const (
InsufficientNetPolicyUnspecified InsufficientNetPolicy = "unspecified"
InsufficientNetPolicyBlockPosting InsufficientNetPolicy = "block_posting"
InsufficientNetPolicySweepOrgCash InsufficientNetPolicy = "sweep_org_cash"
InsufficientNetPolicyInvoiceLater InsufficientNetPolicy = "invoice_later"
)

View File

@@ -0,0 +1,54 @@
package srequest
import (
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
)
type PaymentIntent struct {
Kind PaymentKind `json:"kind,omitempty"`
Source *Endpoint `json:"source,omitempty"`
Destination *Endpoint `json:"destination,omitempty"`
Amount *model.Money `json:"amount,omitempty"`
FX *FXIntent `json:"fx,omitempty"`
SettlementMode SettlementMode `json:"settlement_mode,omitempty"`
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
if p.Kind == zeroKind {
return merrors.InvalidArgument("kind is required", "intent.kind")
}
if p.Source == nil {
return merrors.InvalidArgument("source is required", "intent.source")
}
if p.Destination == nil {
return merrors.InvalidArgument("destination is required", "intent.destination")
}
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 {
return err
}
if p.FX != nil {
if err := p.FX.Validate(); err != nil {
return err
}
}
return nil
}

View File

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

View File

@@ -0,0 +1,427 @@
package srequest
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/tech/sendico/pkg/model"
)
func TestEndpointDTOBuildersAndDecoders(t *testing.T) {
meta := map[string]string{"note": "meta"}
t.Run("ledger", func(t *testing.T) {
payload := LedgerEndpoint{LedgerAccountRef: "acc-1", ContraLedgerAccountRef: "contra-1"}
endpoint, err := NewLedgerEndpointDTO(payload, meta)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
if string(endpoint.Data) != `{"ledger_account_ref":"acc-1","contra_ledger_account_ref":"contra-1"}` {
t.Fatalf("unexpected data: %s", endpoint.Data)
}
decoded, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
meta["note"] = "changed"
if endpoint.Metadata["note"] != "meta" {
t.Fatalf("metadata should be copied, got %s", endpoint.Metadata["note"])
}
})
t.Run("managed wallet", func(t *testing.T) {
payload := ManagedWalletEndpoint{
ManagedWalletRef: "mw-1",
Asset: &Asset{
Chain: ChainNetworkArbitrumOne,
TokenSymbol: "USDC",
ContractAddress: "0xabc",
},
}
endpoint, err := NewManagedWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build managed wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("external chain", func(t *testing.T) {
payload := ExternalChainEndpoint{
Asset: &Asset{
Chain: ChainNetworkOtherEVM,
TokenSymbol: "ETH",
},
Address: "0x123",
Memo: "memo",
}
endpoint, err := NewExternalChainEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build external chain endpoint: %v", err)
}
if endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("expected type %s got %s", EndpointTypeExternalChain, endpoint.Type)
}
decoded, err := endpoint.DecodeExternalChain()
if err != nil {
t.Fatalf("decode external chain: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("card", func(t *testing.T) {
payload := CardEndpoint{
Pan: "pan",
FirstName: "Jane",
LastName: "Doe",
ExpMonth: 12,
ExpYear: 2030,
Country: "US",
}
endpoint, err := NewCardEndpointDTO(payload, map[string]string{"k": "v"})
if err != nil {
t.Fatalf("build card endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCard {
t.Fatalf("expected type %s got %s", EndpointTypeCard, endpoint.Type)
}
decoded, err := endpoint.DecodeCard()
if err != nil {
t.Fatalf("decode card: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["k"] != "v" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["k"])
}
})
t.Run("card token", func(t *testing.T) {
payload := CardTokenEndpoint{Token: "token", MaskedPan: "****1234"}
endpoint, err := NewCardTokenEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build card token endpoint: %v", err)
}
if endpoint.Type != EndpointTypeCardToken {
t.Fatalf("expected type %s got %s", EndpointTypeCardToken, endpoint.Type)
}
decoded, err := endpoint.DecodeCardToken()
if err != nil {
t.Fatalf("decode card token: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("wallet", func(t *testing.T) {
payload := WalletEndpoint{WalletID: "wallet-1"}
endpoint, err := NewWalletEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build wallet endpoint: %v", err)
}
if endpoint.Type != EndpointTypeWallet {
t.Fatalf("expected type %s got %s", EndpointTypeWallet, endpoint.Type)
}
decoded, err := endpoint.DecodeWallet()
if err != nil {
t.Fatalf("decode wallet: %v", err)
}
if decoded != payload {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("bank account", func(t *testing.T) {
payload := BankAccountEndpoint{
RecipientName: "ACME",
Inn: "inn",
Kpp: "kpp",
BankName: "bank",
Bik: "bik",
AccountNumber: "123",
CorrespondentAccount: "456",
}
endpoint, err := NewBankAccountEndpointDTO(payload, map[string]string{"note": "n"})
if err != nil {
t.Fatalf("build bank account endpoint: %v", err)
}
if endpoint.Type != EndpointTypeBankAccount {
t.Fatalf("expected type %s got %s", EndpointTypeBankAccount, endpoint.Type)
}
decoded, err := endpoint.DecodeBankAccount()
if err != nil {
t.Fatalf("decode bank account: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
if endpoint.Metadata["note"] != "n" {
t.Fatalf("expected metadata copy, got %s", endpoint.Metadata["note"])
}
})
t.Run("iban", func(t *testing.T) {
payload := IBANEndpoint{
IBAN: "DE123",
AccountHolder: "John Doe",
BIC: "BICCODE",
BankName: "BankName",
}
endpoint, err := NewIBANEndpointDTO(payload, nil)
if err != nil {
t.Fatalf("build iban endpoint: %v", err)
}
if endpoint.Type != EndpointTypeIBAN {
t.Fatalf("expected type %s got %s", EndpointTypeIBAN, endpoint.Type)
}
decoded, err := endpoint.DecodeIBAN()
if err != nil {
t.Fatalf("decode iban: %v", err)
}
if !reflect.DeepEqual(decoded, payload) {
t.Fatalf("decoded payload mismatch: %#v vs %#v", decoded, payload)
}
})
t.Run("type mismatch", func(t *testing.T) {
endpoint, err := NewLedgerEndpointDTO(LedgerEndpoint{LedgerAccountRef: "acc"}, nil)
if err != nil {
t.Fatalf("build ledger endpoint: %v", err)
}
if _, err := endpoint.DecodeCard(); err == nil || !strings.Contains(err.Error(), "expected endpoint type") {
t.Fatalf("expected type mismatch error, got %v", err)
}
})
t.Run("invalid json data", func(t *testing.T) {
endpoint := Endpoint{Type: EndpointTypeLedger, Data: json.RawMessage("not-json")}
if _, err := endpoint.DecodeLedger(); err == nil {
t.Fatalf("expected decode error")
}
})
t.Run("legacy type alias normalizes", func(t *testing.T) {
raw := []byte(`{"type":"managed_wallet","data":{"managed_wallet_ref":"mw-legacy"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal with legacy type: %v", err)
}
if endpoint.Type != EndpointTypeManagedWallet {
t.Fatalf("expected normalized type %s got %s", EndpointTypeManagedWallet, endpoint.Type)
}
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode managed wallet with alias: %v", err)
}
if payload.ManagedWalletRef != "mw-legacy" {
t.Fatalf("decoded payload mismatch from alias: %#v", payload)
}
})
}
func TestPaymentIntentJSONRoundTrip(t *testing.T) {
sourcePayload := LedgerEndpoint{LedgerAccountRef: "source"}
source, err := NewLedgerEndpointDTO(sourcePayload, map[string]string{"src": "meta"})
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := ExternalChainEndpoint{Address: "0xabc", Asset: &Asset{Chain: ChainNetworkEthereumMainnet, TokenSymbol: "USDC"}}
dest, err := NewExternalChainEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindPayout,
Source: &source,
Destination: &dest,
Amount: &model.Money{Amount: "10", Currency: "USD"},
FX: &FXIntent{
Pair: &CurrencyPair{Base: "USD", Quote: "EUR"},
Side: FXSideBuyBaseSellQuote,
Firm: true,
TTLms: 5000,
PreferredProvider: "provider",
MaxAgeMs: 10,
},
SettlementMode: SettlementModeFixReceived,
Attributes: map[string]string{"k": "v"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.SettlementMode != intent.SettlementMode {
t.Fatalf("scalar fields changed after round trip")
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.FX == nil || decoded.FX.PreferredProvider != intent.FX.PreferredProvider {
t.Fatalf("fx mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("source/destination missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeLedger()
if err != nil {
t.Fatalf("decode source after round trip: %v", err)
}
if sourceDecoded != sourcePayload {
t.Fatalf("source payload mismatch after round trip: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeExternalChain()
if err != nil {
t.Fatalf("decode destination after round trip: %v", err)
}
if !reflect.DeepEqual(destDecoded, destPayload) {
t.Fatalf("destination payload mismatch after round trip: %#v vs %#v", destDecoded, destPayload)
}
if decoded.Attributes["k"] != "v" {
t.Fatalf("attributes mismatch after round trip")
}
}
func TestPaymentIntentMinimalRoundTrip(t *testing.T) {
sourcePayload := ManagedWalletEndpoint{ManagedWalletRef: "mw"}
source, err := NewManagedWalletEndpointDTO(sourcePayload, nil)
if err != nil {
t.Fatalf("build source endpoint: %v", err)
}
destPayload := LedgerEndpoint{LedgerAccountRef: "dest-ledger"}
dest, err := NewLedgerEndpointDTO(destPayload, nil)
if err != nil {
t.Fatalf("build destination endpoint: %v", err)
}
intent := &PaymentIntent{
Kind: PaymentKindInternalTransfer,
Source: &source,
Destination: &dest,
Amount: &model.Money{Amount: "1", Currency: "USD"},
}
data, err := json.Marshal(intent)
if err != nil {
t.Fatalf("marshal intent: %v", err)
}
var decoded PaymentIntent
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("unmarshal intent: %v", err)
}
if decoded.Kind != intent.Kind || decoded.FX != nil {
t.Fatalf("unexpected fx data in minimal intent: %#v", decoded)
}
if decoded.Amount == nil || *decoded.Amount != *intent.Amount {
t.Fatalf("amount mismatch after round trip")
}
if decoded.Source == nil || decoded.Destination == nil {
t.Fatalf("endpoints missing after round trip")
}
sourceDecoded, err := decoded.Source.DecodeManagedWallet()
if err != nil {
t.Fatalf("decode source: %v", err)
}
if !reflect.DeepEqual(sourceDecoded, sourcePayload) {
t.Fatalf("source payload mismatch: %#v vs %#v", sourceDecoded, sourcePayload)
}
destDecoded, err := decoded.Destination.DecodeLedger()
if err != nil {
t.Fatalf("decode destination: %v", err)
}
if destDecoded != destPayload {
t.Fatalf("destination payload mismatch: %#v vs %#v", destDecoded, destPayload)
}
}
func TestLegacyEndpointRoundTrip(t *testing.T) {
legacy := &LegacyPaymentEndpoint{
ExternalChain: &ExternalChainEndpoint{
Asset: &Asset{Chain: ChainNetworkOtherEVM, TokenSymbol: "DAI", ContractAddress: "0xdef"},
Address: "0x123",
Memo: "memo",
},
Metadata: map[string]string{"note": "legacy"},
}
endpoint, err := LegacyPaymentEndpointToEndpointDTO(legacy)
if err != nil {
t.Fatalf("convert legacy to dto: %v", err)
}
if endpoint == nil || endpoint.Type != EndpointTypeExternalChain {
t.Fatalf("unexpected endpoint result: %#v", endpoint)
}
legacy.Metadata["note"] = "changed"
if endpoint.Metadata["note"] != "legacy" {
t.Fatalf("metadata should be copied from legacy")
}
roundTrip, err := EndpointDTOToLegacyPaymentEndpoint(endpoint)
if err != nil {
t.Fatalf("convert dto back to legacy: %v", err)
}
if roundTrip == nil || roundTrip.ExternalChain == nil {
t.Fatalf("round trip legacy missing payload: %#v", roundTrip)
}
if !reflect.DeepEqual(roundTrip.ExternalChain, legacy.ExternalChain) {
t.Fatalf("round trip payload mismatch: %#v vs %#v", roundTrip.ExternalChain, legacy.ExternalChain)
}
if roundTrip.Metadata["note"] != "legacy" {
t.Fatalf("metadata mismatch after round trip: %v", roundTrip.Metadata)
}
}
func TestLegacyEndpointConversionRejectsMultiple(t *testing.T) {
_, err := LegacyPaymentEndpointToEndpointDTO(&LegacyPaymentEndpoint{
Ledger: &LedgerEndpoint{LedgerAccountRef: "a"},
Card: &CardEndpoint{Pan: "t"},
})
if err == nil {
t.Fatalf("expected error when multiple legacy endpoints are set")
}
}
func TestEndpointUnmarshalLegacyShape(t *testing.T) {
raw := []byte(`{"ledger":{"ledger_account_ref":"abc"}}`)
var endpoint Endpoint
if err := json.Unmarshal(raw, &endpoint); err != nil {
t.Fatalf("unmarshal legacy shape: %v", err)
}
if endpoint.Type != EndpointTypeLedger {
t.Fatalf("expected type %s got %s", EndpointTypeLedger, endpoint.Type)
}
payload, err := endpoint.DecodeLedger()
if err != nil {
t.Fatalf("decode ledger from legacy shape: %v", err)
}
if payload.LedgerAccountRef != "abc" {
t.Fatalf("unexpected payload from legacy shape: %#v", payload)
}
}

View File

@@ -0,0 +1,131 @@
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) == "" {
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 AZ09, length 210)",
"intent.currency",
)
}
// Dictionary validation
if assetResolver == nil {
return merrors.InvalidArgument("asset resolver is not configured", "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
}
return nil
}
type CurrencyPair struct {
Base string `json:"base"`
Quote string `json:"quote"`
}
func (p *CurrencyPair) Validate() error {
if p == nil {
return merrors.InvalidArgument("currency pair is required", "currncy_pair")
}
if err := ValidateCurrency(p.Base, &AssetResolverStub{}); err != nil {
return merrors.InvalidArgument("invalid base currency in pair: "+err.Error(), "currency_pair.base")
}
if err := ValidateCurrency(p.Quote, &AssetResolverStub{}); err != nil {
return merrors.InvalidArgument("invalid quote currency in pair: "+err.Error(), "currency_pair.quote")
}
return nil
}
type FXIntent struct {
Pair *CurrencyPair `json:"pair,omitempty"`
Side FXSide `json:"side,omitempty"`
Firm bool `json:"firm,omitempty"`
TTLms int64 `json:"ttl_ms,omitempty"`
PreferredProvider string `json:"preferred_provider,omitempty"`
MaxAgeMs int32 `json:"max_age_ms,omitempty"`
}
func (fx *FXIntent) Validate() error {
if fx.Pair != nil {
if err := fx.Pair.Validate(); err != nil {
return err
}
}
var zeroSide FXSide
if fx.Side == zeroSide {
return merrors.InvalidArgument("fx side is required", "intent.fx.side")
}
if fx.TTLms < 0 {
return merrors.InvalidArgument("fx ttl_ms cannot be negative", "intent.fx.ttl_ms")
}
if fx.TTLms == 0 && fx.Firm {
return merrors.InvalidArgument("firm quote requires positive ttl_ms", "intent.fx.ttl_ms")
}
if fx.MaxAgeMs < 0 {
return merrors.InvalidArgument("fx max_age_ms cannot be negative", "intent.fx.max_age_ms")
}
return nil
}

View File

@@ -0,0 +1,5 @@
package srequest
type Validatable interface {
Validate() error
}

View File

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

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
@@ -13,14 +14,14 @@ import (
type FeeLine struct { type FeeLine struct {
LedgerAccountRef string `json:"ledgerAccountRef,omitempty"` LedgerAccountRef string `json:"ledgerAccountRef,omitempty"`
Amount *Money `json:"amount,omitempty"` Amount *model.Money `json:"amount,omitempty"`
LineType string `json:"lineType,omitempty"` LineType string `json:"lineType,omitempty"`
Side string `json:"side,omitempty"` Side string `json:"side,omitempty"`
Meta map[string]string `json:"meta,omitempty"` Meta map[string]string `json:"meta,omitempty"`
} }
type NetworkFee struct { type NetworkFee struct {
NetworkFee *Money `json:"networkFee,omitempty"` NetworkFee *model.Money `json:"networkFee,omitempty"`
EstimationContext string `json:"estimationContext,omitempty"` EstimationContext string `json:"estimationContext,omitempty"`
} }
@@ -30,8 +31,8 @@ type FxQuote struct {
QuoteCurrency string `json:"quoteCurrency,omitempty"` QuoteCurrency string `json:"quoteCurrency,omitempty"`
Side string `json:"side,omitempty"` Side string `json:"side,omitempty"`
Price string `json:"price,omitempty"` Price string `json:"price,omitempty"`
BaseAmount *Money `json:"baseAmount,omitempty"` BaseAmount *model.Money `json:"baseAmount,omitempty"`
QuoteAmount *Money `json:"quoteAmount,omitempty"` QuoteAmount *model.Money `json:"quoteAmount,omitempty"`
ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"` ExpiresAtUnixMs int64 `json:"expiresAtUnixMs,omitempty"`
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
RateRef string `json:"rateRef,omitempty"` RateRef string `json:"rateRef,omitempty"`
@@ -40,10 +41,9 @@ type FxQuote struct {
type PaymentQuote struct { type PaymentQuote struct {
QuoteRef string `json:"quoteRef,omitempty"` QuoteRef string `json:"quoteRef,omitempty"`
DebitAmount *Money `json:"debitAmount,omitempty"` DebitAmount *model.Money `json:"debitAmount,omitempty"`
ExpectedSettlementAmount *Money `json:"expectedSettlementAmount,omitempty"` ExpectedSettlementAmount *model.Money `json:"expectedSettlementAmount,omitempty"`
ExpectedFeeTotal *Money `json:"expectedFeeTotal,omitempty"` ExpectedFeeTotal *model.Money `json:"expectedFeeTotal,omitempty"`
FeeQuoteToken string `json:"feeQuoteToken,omitempty"`
FeeLines []FeeLine `json:"feeLines,omitempty"` FeeLines []FeeLine `json:"feeLines,omitempty"`
NetworkFee *NetworkFee `json:"networkFee,omitempty"` NetworkFee *NetworkFee `json:"networkFee,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"` FxQuote *FxQuote `json:"fxQuote,omitempty"`
@@ -152,7 +152,6 @@ func toPaymentQuote(q *orchestratorv1.PaymentQuote) *PaymentQuote {
DebitAmount: toMoney(q.GetDebitAmount()), DebitAmount: toMoney(q.GetDebitAmount()),
ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()), ExpectedSettlementAmount: toMoney(q.GetExpectedSettlementAmount()),
ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()), ExpectedFeeTotal: toMoney(q.GetExpectedFeeTotal()),
FeeQuoteToken: q.GetFeeQuoteToken(),
FeeLines: toFeeLines(q.GetFeeLines()), FeeLines: toFeeLines(q.GetFeeLines()),
NetworkFee: toNetworkFee(q.GetNetworkFee()), NetworkFee: toNetworkFee(q.GetNetworkFee()),
FxQuote: toFxQuote(q.GetFxQuote()), FxQuote: toFxQuote(q.GetFxQuote()),

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
@@ -36,9 +37,9 @@ type walletsResponse struct {
} }
type walletBalance struct { type walletBalance struct {
Available *Money `json:"available,omitempty"` Available *model.Money `json:"available,omitempty"`
PendingInbound *Money `json:"pendingInbound,omitempty"` PendingInbound *model.Money `json:"pendingInbound,omitempty"`
PendingOutbound *Money `json:"pendingOutbound,omitempty"` PendingOutbound *model.Money `json:"pendingOutbound,omitempty"`
CalculatedAt string `json:"calculatedAt,omitempty"` CalculatedAt string `json:"calculatedAt,omitempty"`
} }

View File

@@ -248,12 +248,11 @@ func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization,
a.logger.Warn("Chain gateway client not configured, skipping wallet creation", mzap.StorableRef(org)) a.logger.Warn("Chain gateway client not configured, skipping wallet creation", mzap.StorableRef(org))
return merrors.Internal("chain gateway client is not configured") return merrors.Internal("chain gateway client is not configured")
} }
asset := *a.chainAsset
req := &chainv1.CreateManagedWalletRequest{ req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(), IdempotencyKey: uuid.NewString(),
OrganizationRef: org.ID.Hex(), OrganizationRef: org.ID.Hex(),
OwnerRef: org.ID.Hex(), OwnerRef: org.ID.Hex(),
Asset: &asset, Asset: a.chainAsset,
Metadata: map[string]string{ Metadata: map[string]string{
"source": "signup", "source": "signup",
"login": sr.Account.Login, "login": sr.Account.Login,

View File

@@ -1,8 +1,10 @@
package paymentapiimp package paymentapiimp
import ( import (
"strings"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" "github.com/tech/sendico/pkg/model"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
@@ -15,6 +17,16 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
return nil, merrors.InvalidArgument("intent is required") return nil, merrors.InvalidArgument("intent is required")
} }
kind, err := mapPaymentKind(intent.Kind)
if err != nil {
return nil, err
}
settlementMode, err := mapSettlementMode(intent.SettlementMode)
if err != nil {
return nil, err
}
source, err := mapPaymentEndpoint(intent.Source, "source") source, err := mapPaymentEndpoint(intent.Source, "source")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -30,48 +42,76 @@ func mapPaymentIntent(intent *srequest.PaymentIntent) (*orchestratorv1.PaymentIn
} }
return &orchestratorv1.PaymentIntent{ return &orchestratorv1.PaymentIntent{
Kind: orchestratorv1.PaymentKind(intent.Kind), Kind: kind,
Source: source, Source: source,
Destination: destination, Destination: destination,
Amount: mapMoney(intent.Amount), Amount: mapMoney(intent.Amount),
RequiresFx: intent.RequiresFX, RequiresFx: fx != nil,
Fx: fx, Fx: fx,
FeePolicy: mapPolicyOverrides(intent.FeePolicy), SettlementMode: settlementMode,
Attributes: copyStringMap(intent.Attributes), Attributes: copyStringMap(intent.Attributes),
}, nil }, nil
} }
func mapPaymentEndpoint(endpoint *srequest.PaymentEndpoint, field string) (*orchestratorv1.PaymentEndpoint, error) { func mapPaymentEndpoint(endpoint *srequest.Endpoint, field string) (*orchestratorv1.PaymentEndpoint, error) {
if endpoint == nil { if endpoint == nil {
return nil, nil return nil, nil
} }
var ( var result orchestratorv1.PaymentEndpoint
count int switch endpoint.Type {
result orchestratorv1.PaymentEndpoint case srequest.EndpointTypeLedger:
) payload, err := endpoint.DecodeLedger()
if err != nil {
if endpoint.Ledger != nil { return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
count++ }
result.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{ result.Endpoint = &orchestratorv1.PaymentEndpoint_Ledger{
Ledger: mapLedgerEndpoint(endpoint.Ledger), Ledger: mapLedgerEndpoint(&payload),
} }
case srequest.EndpointTypeManagedWallet:
payload, err := endpoint.DecodeManagedWallet()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
mw, err := mapManagedWalletEndpoint(&payload)
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
} }
if endpoint.ManagedWallet != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{ result.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{
ManagedWallet: mapManagedWalletEndpoint(endpoint.ManagedWallet), ManagedWallet: mw,
} }
case srequest.EndpointTypeExternalChain:
payload, err := endpoint.DecodeExternalChain()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
ext, err := mapExternalChainEndpoint(&payload)
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
} }
if endpoint.ExternalChain != nil {
count++
result.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{ result.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{
ExternalChain: mapExternalChainEndpoint(endpoint.ExternalChain), ExternalChain: ext,
} }
case srequest.EndpointTypeCard:
payload, err := endpoint.DecodeCard()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
} }
result.Endpoint = &orchestratorv1.PaymentEndpoint_Card{
if count > 1 { Card: mapCardEndpoint(&payload),
return nil, merrors.InvalidArgument(field + " endpoint must set only one of ledger, managed_wallet, external_chain") }
case srequest.EndpointTypeCardToken:
payload, err := endpoint.DecodeCardToken()
if err != nil {
return nil, merrors.InvalidArgument(field + " endpoint: " + err.Error())
}
result.Endpoint = &orchestratorv1.PaymentEndpoint_Card{
Card: mapCardTokenEndpoint(&payload),
}
case "":
return nil, merrors.InvalidArgument(field + " endpoint type is required")
default:
return nil, merrors.InvalidArgument(field + " endpoint has unsupported type: " + string(endpoint.Type))
} }
result.Metadata = copyStringMap(endpoint.Metadata) result.Metadata = copyStringMap(endpoint.Metadata)
@@ -88,39 +128,51 @@ func mapLedgerEndpoint(endpoint *srequest.LedgerEndpoint) *orchestratorv1.Ledger
} }
} }
func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) *orchestratorv1.ManagedWalletEndpoint { func mapManagedWalletEndpoint(endpoint *srequest.ManagedWalletEndpoint) (*orchestratorv1.ManagedWalletEndpoint, error) {
if endpoint == nil { if endpoint == nil {
return nil return nil, nil
}
asset, err := mapAsset(endpoint.Asset)
if err != nil {
return nil, err
} }
return &orchestratorv1.ManagedWalletEndpoint{ return &orchestratorv1.ManagedWalletEndpoint{
ManagedWalletRef: endpoint.ManagedWalletRef, ManagedWalletRef: endpoint.ManagedWalletRef,
Asset: mapAsset(endpoint.Asset), Asset: asset,
} }, nil
} }
func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) *orchestratorv1.ExternalChainEndpoint { func mapExternalChainEndpoint(endpoint *srequest.ExternalChainEndpoint) (*orchestratorv1.ExternalChainEndpoint, error) {
if endpoint == nil { if endpoint == nil {
return nil return nil, nil
}
asset, err := mapAsset(endpoint.Asset)
if err != nil {
return nil, err
} }
return &orchestratorv1.ExternalChainEndpoint{ return &orchestratorv1.ExternalChainEndpoint{
Asset: mapAsset(endpoint.Asset), Asset: asset,
Address: endpoint.Address, Address: endpoint.Address,
Memo: endpoint.Memo, Memo: endpoint.Memo,
} }, nil
} }
func mapAsset(asset *srequest.Asset) *chainv1.Asset { func mapAsset(asset *srequest.Asset) (*chainv1.Asset, error) {
if asset == nil { if asset == nil {
return nil return nil, nil
}
chain, err := mapChainNetwork(asset.Chain)
if err != nil {
return nil, err
} }
return &chainv1.Asset{ return &chainv1.Asset{
Chain: chainv1.ChainNetwork(asset.Chain), Chain: chain,
TokenSymbol: asset.TokenSymbol, TokenSymbol: asset.TokenSymbol,
ContractAddress: asset.ContractAddress, ContractAddress: asset.ContractAddress,
} }, nil
} }
func mapMoney(m *srequest.Money) *moneyv1.Money { func mapMoney(m *model.Money) *moneyv1.Money {
if m == nil { if m == nil {
return nil return nil
} }
@@ -134,9 +186,13 @@ func mapFXIntent(fx *srequest.FXIntent) (*orchestratorv1.FXIntent, error) {
if fx == nil { if fx == nil {
return nil, nil return nil, nil
} }
side, err := mapFXSide(fx.Side)
if err != nil {
return nil, err
}
return &orchestratorv1.FXIntent{ return &orchestratorv1.FXIntent{
Pair: mapCurrencyPair(fx.Pair), Pair: mapCurrencyPair(fx.Pair),
Side: fxv1.Side(fx.Side), Side: side,
Firm: fx.Firm, Firm: fx.Firm,
TtlMs: fx.TTLms, TtlMs: fx.TTLms,
PreferredProvider: fx.PreferredProvider, PreferredProvider: fx.PreferredProvider,
@@ -154,12 +210,86 @@ func mapCurrencyPair(pair *srequest.CurrencyPair) *fxv1.CurrencyPair {
} }
} }
func mapPolicyOverrides(policy *srequest.PolicyOverrides) *feesv1.PolicyOverrides { func mapCardEndpoint(card *srequest.CardEndpoint) *orchestratorv1.CardEndpoint {
if policy == nil { if card == nil {
return nil return nil
} }
return &feesv1.PolicyOverrides{ result := &orchestratorv1.CardEndpoint{
InsufficientNet: feesv1.InsufficientNetPolicy(policy.InsufficientNet), CardholderName: strings.TrimSpace(card.FirstName),
CardholderSurname: strings.TrimSpace(card.LastName),
ExpMonth: card.ExpMonth,
ExpYear: card.ExpYear,
Country: strings.TrimSpace(card.Country),
}
if pan := strings.TrimSpace(card.Pan); pan != "" {
result.Card = &orchestratorv1.CardEndpoint_Pan{Pan: pan}
}
return result
}
func mapCardTokenEndpoint(card *srequest.CardTokenEndpoint) *orchestratorv1.CardEndpoint {
if card == nil {
return nil
}
return &orchestratorv1.CardEndpoint{
Card: &orchestratorv1.CardEndpoint_Token{Token: strings.TrimSpace(card.Token)},
MaskedPan: strings.TrimSpace(card.MaskedPan),
}
}
func mapPaymentKind(kind srequest.PaymentKind) (orchestratorv1.PaymentKind, error) {
switch strings.TrimSpace(string(kind)) {
case "", string(srequest.PaymentKindUnspecified):
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, nil
case string(srequest.PaymentKindPayout):
return orchestratorv1.PaymentKind_PAYMENT_KIND_PAYOUT, nil
case string(srequest.PaymentKindInternalTransfer):
return orchestratorv1.PaymentKind_PAYMENT_KIND_INTERNAL_TRANSFER, nil
case string(srequest.PaymentKindFxConversion):
return orchestratorv1.PaymentKind_PAYMENT_KIND_FX_CONVERSION, nil
default:
return orchestratorv1.PaymentKind_PAYMENT_KIND_UNSPECIFIED, merrors.InvalidArgument("unsupported payment kind: " + string(kind))
}
}
func mapSettlementMode(mode srequest.SettlementMode) (orchestratorv1.SettlementMode, error) {
switch strings.TrimSpace(string(mode)) {
case "", string(srequest.SettlementModeUnspecified):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, nil
case string(srequest.SettlementModeFixSource):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE, nil
case string(srequest.SettlementModeFixReceived):
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED, nil
default:
return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode))
}
}
func mapFXSide(side srequest.FXSide) (fxv1.Side, error) {
switch strings.TrimSpace(string(side)) {
case "", string(srequest.FXSideUnspecified):
return fxv1.Side_SIDE_UNSPECIFIED, nil
case string(srequest.FXSideBuyBaseSellQuote):
return fxv1.Side_BUY_BASE_SELL_QUOTE, nil
case string(srequest.FXSideSellBaseBuyQuote):
return fxv1.Side_SELL_BASE_BUY_QUOTE, nil
default:
return fxv1.Side_SIDE_UNSPECIFIED, merrors.InvalidArgument("unsupported fx side: " + string(side))
}
}
func mapChainNetwork(chain srequest.ChainNetwork) (chainv1.ChainNetwork, error) {
switch strings.TrimSpace(string(chain)) {
case "", string(srequest.ChainNetworkUnspecified):
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
case string(srequest.ChainNetworkEthereumMainnet):
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case string(srequest.ChainNetworkArbitrumOne):
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case string(srequest.ChainNetworkOtherEVM):
return chainv1.ChainNetwork_CHAIN_NETWORK_OTHER_EVM, nil
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument("unsupported chain network: " + string(chain))
} }
} }

View File

@@ -39,17 +39,30 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
if err != nil { if err != nil {
return response.BadPayload(a.logger, a.Name(), err) return response.BadPayload(a.logger, a.Name(), err)
} }
if expectQuote && strings.TrimSpace(payload.QuoteRef) == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quote_ref is required")) if expectQuote {
if payload.QuoteRef == "" {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("quoteRef is required"))
}
if payload.Intent != nil {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be combined with intent"))
}
} else {
if payload.Intent == nil {
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("intent is required"))
}
if payload.QuoteRef != "" {
return response.BadPayload(a.logger, a.Name(), merrors.DataConflict("quoteRef cannot be used when intent is provided"))
} }
if !expectQuote {
payload.QuoteRef = ""
} }
intent, err := mapPaymentIntent(payload.Intent) var intent *orchestratorv1.PaymentIntent
if payload.Intent != nil {
intent, err = mapPaymentIntent(payload.Intent)
if err != nil { if err != nil {
return response.BadPayload(a.logger, a.Name(), err) return response.BadPayload(a.logger, a.Name(), err)
} }
}
req := &orchestratorv1.InitiatePaymentRequest{ req := &orchestratorv1.InitiatePaymentRequest{
Meta: &orchestratorv1.RequestMeta{ Meta: &orchestratorv1.RequestMeta{
@@ -57,8 +70,6 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
}, },
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
Intent: intent, Intent: intent,
FeeQuoteToken: strings.TrimSpace(payload.FeeQuoteToken),
FxQuoteRef: strings.TrimSpace(payload.FxQuoteRef),
QuoteRef: strings.TrimSpace(payload.QuoteRef), QuoteRef: strings.TrimSpace(payload.QuoteRef),
Metadata: payload.Metadata, Metadata: payload.Metadata,
} }
@@ -80,11 +91,10 @@ func decodeInitiatePayload(r *http.Request) (*srequest.InitiatePayment, error) {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
} }
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" { payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
return nil, merrors.InvalidArgument("idempotencyKey is required")
} if err := payload.Validate(); err != nil {
if payload.Intent == nil { return nil, err
return nil, merrors.InvalidArgument("intent is required")
} }
return payload, nil return payload, nil
} }

View File

@@ -36,11 +36,17 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
payload, err := decodeQuotePayload(r) payload, err := decodeQuotePayload(r)
if err != nil { if err != nil {
a.logger.Debug("Failed to decode payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err) return response.BadPayload(a.logger, a.Name(), err)
} }
if err := payload.Validate(); err != nil {
a.logger.Debug("Failed to validate payload", zap.Error(err), mutil.PLog(a.oph, r))
return response.Auto(a.logger, a.Name(), err)
}
intent, err := mapPaymentIntent(payload.Intent) intent, err := mapPaymentIntent(&payload.Intent)
if err != nil { if err != nil {
a.logger.Debug("Failed to map payment intent", zap.Error(err), mutil.PLog(a.oph, r))
return response.BadPayload(a.logger, a.Name(), err) return response.BadPayload(a.logger, a.Name(), err)
} }
@@ -50,7 +56,6 @@ func (a *PaymentAPI) quotePayment(r *http.Request, account *model.Account, token
}, },
IdempotencyKey: payload.IdempotencyKey, IdempotencyKey: payload.IdempotencyKey,
Intent: intent, Intent: intent,
PreviewOnly: payload.PreviewOnly,
} }
resp, err := a.client.QuotePayment(ctx, req) resp, err := a.client.QuotePayment(ctx, req)
@@ -67,14 +72,11 @@ func decodeQuotePayload(r *http.Request) (*srequest.QuotePayment, error) {
payload := &srequest.QuotePayment{} payload := &srequest.QuotePayment{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil { if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) return nil, merrors.InvalidArgument("invalid payload: "+err.Error(), "payload")
} }
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
if payload.IdempotencyKey == "" { if err := payload.Validate(); err != nil {
return nil, merrors.InvalidArgument("idempotencyKey is required") return nil, err
}
if payload.Intent == nil {
return nil, merrors.InvalidArgument("intent is required")
} }
return payload, nil return payload, nil
} }

View File

@@ -35,7 +35,7 @@ type PaymentAPI struct {
permissionRef primitive.ObjectID permissionRef primitive.ObjectID
} }
func (a *PaymentAPI) Name() mservice.Type { return mservice.PaymentOrchestrator } func (a *PaymentAPI) Name() mservice.Type { return mservice.Payments }
func (a *PaymentAPI) Finish(ctx context.Context) error { func (a *PaymentAPI) Finish(ctx context.Context) error {
if a.client != nil { if a.client != nil {
@@ -48,12 +48,12 @@ func (a *PaymentAPI) Finish(ctx context.Context) error {
func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
p := &PaymentAPI{ p := &PaymentAPI{
logger: apiCtx.Logger().Named(mservice.PaymentOrchestrator), logger: apiCtx.Logger().Named(mservice.Payments),
enf: apiCtx.Permissions().Enforcer(), enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations), oph: mutil.CreatePH(mservice.Organizations),
} }
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.PaymentOrchestrator) desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
if err != nil { if err != nil {
p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err)) p.logger.Warn("Failed to fetch payment orchestrator permission description", zap.Error(err))
return nil, err return nil, err

View File

@@ -31,6 +31,7 @@ services:
NATS_PASSWORD: ${NATS_PASSWORD} NATS_PASSWORD: ${NATS_PASSWORD}
CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_SERVICE_NAME}:${CHAIN_GATEWAY_GRPC_PORT} CHAIN_GATEWAY_ADDRESS: ${CHAIN_GATEWAY_SERVICE_NAME}:${CHAIN_GATEWAY_GRPC_PORT}
LEDGER_ADDRESS: ${LEDGER_SERVICE_NAME}:${LEDGER_GRPC_PORT} LEDGER_ADDRESS: ${LEDGER_SERVICE_NAME}:${LEDGER_GRPC_PORT}
PAYMENTS_ADDRESS: ${PAYMENTS_SERVICE_NAME}:${PAYMENTS_GRPC_PORT}
MONGO_HOST: ${MONGO_HOST} MONGO_HOST: ${MONGO_HOST}
MONGO_PORT: ${MONGO_PORT} MONGO_PORT: ${MONGO_PORT}
MONGO_DATABASE: ${MONGO_DATABASE} MONGO_DATABASE: ${MONGO_DATABASE}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'base.g.dart';
@JsonSerializable()
class PaymentBaseRequest {
final String idempotencyKey;
final Map<String, String>? metadata;
const PaymentBaseRequest({
required this.idempotencyKey,
this.metadata,
});
factory PaymentBaseRequest.fromJson(Map<String, dynamic> json) => _$PaymentBaseRequestFromJson(json);
Map<String, dynamic> toJson() => _$PaymentBaseRequestToJson(this);
}

View File

@@ -0,0 +1,24 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'initiate.g.dart';
@JsonSerializable()
class InitiatePaymentRequest extends PaymentBaseRequest {
final PaymentIntentDTO? intent;
final String? quoteRef;
const InitiatePaymentRequest({
required super.idempotencyKey,
super.metadata,
this.intent,
this.quoteRef,
});
factory InitiatePaymentRequest.fromJson(Map<String, dynamic> json) => _$InitiatePaymentRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$InitiatePaymentRequestToJson(this);
}

View File

@@ -0,0 +1,26 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'quote.g.dart';
@JsonSerializable()
class QuotePaymentRequest extends PaymentBaseRequest {
final PaymentIntentDTO intent;
@JsonKey(defaultValue: false)
final bool previewOnly;
const QuotePaymentRequest({
required super.idempotencyKey,
super.metadata,
required this.intent,
this.previewOnly = false,
});
factory QuotePaymentRequest.fromJson(Map<String, dynamic> json) => _$QuotePaymentRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$QuotePaymentRequestToJson(this);
}

View File

@@ -4,7 +4,7 @@ import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart'; import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/method.dart'; import 'package:pshared/data/dto/payment/method.dart';
part 'payment_method.g.dart'; part 'method.g.dart';
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'quotation.g.dart';
@JsonSerializable(explicitToJson: true)
class PaymentQuoteResponse extends BaseAuthorizedResponse {
final PaymentQuoteDTO quote;
const PaymentQuoteResponse({required super.accessToken, required this.quote});
factory PaymentQuoteResponse.fromJson(Map<String, dynamic> json) => _$PaymentQuoteResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$PaymentQuoteResponseToJson(this);
}

View File

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

View File

@@ -15,8 +15,6 @@ class Constants extends CommonConstants {
static String get currentOrgKey => CommonConstants.currentOrgKey; static String get currentOrgKey => CommonConstants.currentOrgKey;
static String get apiUrl => CommonConstants.apiUrl; static String get apiUrl => CommonConstants.apiUrl;
static String get serviceUrl => CommonConstants.serviceUrl; 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 int get defaultDimensionLength => CommonConstants.defaultDimensionLength;
static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey; static String get deviceIdStorageKey => CommonConstants.deviceIdStorageKey;
static String get nilObjectRef => CommonConstants.nilObjectRef; static String get nilObjectRef => CommonConstants.nilObjectRef;

Some files were not shown because too many files have changed in this diff Show More