Compare commits
94 Commits
4bb18f0210
...
SEND010
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ecd17d2dc | ||
| d649748f6f | |||
|
|
61177a4e30 | ||
| c7b9b70d57 | |||
|
|
5030453807 | ||
| 5565081b69 | |||
| b216aa68b7 | |||
| 7ac1c519e3 | |||
| 076b0c6434 | |||
|
|
9a90e6a03b | ||
|
|
5218632c00 | ||
|
|
a2c05745ad | ||
|
|
82b2f88122 | ||
|
|
28d74d058b | ||
|
|
6ee146b95a | ||
| 67b52af150 | |||
|
|
058a3fefaf | ||
| c8a97d940c | |||
|
|
00045c1e65 | ||
| d64d7dab58 | |||
|
|
4746a00eee | ||
| 3f8399d647 | |||
|
|
028b29fe08 | ||
| cb3f59a9d5 | |||
|
|
2b8d02d95c | ||
| d90d8cda11 | |||
|
|
17333df7af | ||
| 681d53e856 | |||
|
|
dc608fd257 | ||
| cd2efdc2f3 | |||
|
|
fd47867101 | ||
| 2ca1a6956c | |||
|
|
a5ad4f4c3c | ||
| 8b202e0c60 | |||
|
|
4626d0a1a7 | ||
| da72121109 | |||
|
|
5bebadf17c | ||
| 1bab0b14ef | |||
|
|
39f323d050 | ||
| 7cd9e14618 | |||
|
|
b77d2c16ab | ||
|
|
324f150950 | ||
| dd6bcf843c | |||
|
|
874cc4971b | ||
| bfe4695b2d | |||
|
|
99161c8e7d | ||
| 6901791dd2 | |||
|
|
acb3d14b47 | ||
| aa5f7e271e | |||
|
|
0a01995f53 | ||
| 97f71d125e | |||
|
|
8db2f3926c | ||
|
|
2b68b59eca | ||
| d07e64fc4f | |||
|
|
8e40e6247b | ||
| 779cb0ead9 | |||
|
|
2e0057f839 | ||
| 25080ae168 | |||
|
|
e6b001dc61 | ||
| 97d1470515 | |||
|
|
a4481fb63d | ||
| bdf766075e | |||
|
|
47899e25d4 | ||
| 4ec934c96b | |||
|
|
19df740550 | ||
| 1079ad7d0a | |||
| 81d2db394b | |||
|
|
8d6a302cb8 | ||
| 0e48d2a318 | |||
|
|
32653e11fc | ||
| a24ead2c36 | |||
|
|
ce59cb1b26 | ||
| cecaebfc5e | |||
|
|
660f689a7a | ||
| e16f11d48a | |||
|
|
0804ad71f7 | ||
| 7a2f921de9 | |||
|
|
999f0684cb | ||
| 602b77ddc7 | |||
|
|
8115abb569 | ||
|
|
64ad8c8b38 | ||
| f478219990 | |||
|
|
bf39b1d401 | ||
| f7bf3138ac | |||
|
|
7cb747f9a9 | ||
| f2658aea44 | |||
|
|
5e49ee3244 | ||
| 1073be187f | |||
|
|
e854963fa6 | ||
| e5f283432b | |||
|
|
d62a3413b2 | ||
| f720ba9bdf | |||
|
|
98f254e34b | ||
|
|
980bb96c74 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,4 +8,6 @@ devtools_options.yaml
|
|||||||
untranslated.txt
|
untranslated.txt
|
||||||
generate_protos.sh
|
generate_protos.sh
|
||||||
update_dep.sh
|
update_dep.sh
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.gocache/
|
||||||
|
.cache/
|
||||||
@@ -18,7 +18,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -31,7 +31,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
@@ -44,11 +44,11 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.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-20251213004720-97cd9d5aeac2 // indirect
|
||||||
google.golang.org/protobuf v1.36.10
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
@@ -212,12 +212,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -2,448 +2,16 @@ package fees
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"math/big"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
|
||||||
dmath "github.com/tech/sendico/pkg/decimal"
|
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
|
||||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
"go.uber.org/zap"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
||||||
|
// Implementation lives under internal/service/fees/internal/calculator.
|
||||||
type Calculator interface {
|
type Calculator interface {
|
||||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
|
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*types.CalculationResult, error)
|
||||||
}
|
|
||||||
|
|
||||||
// CalculationResult contains derived fee lines and audit metadata.
|
|
||||||
type CalculationResult struct {
|
|
||||||
Lines []*feesv1.DerivedPostingLine
|
|
||||||
Applied []*feesv1.AppliedRule
|
|
||||||
FxUsed *feesv1.FXUsed
|
|
||||||
}
|
|
||||||
|
|
||||||
// quoteCalculator is the default Calculator implementation.
|
|
||||||
type fxOracle interface {
|
|
||||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type quoteCalculator struct {
|
|
||||||
logger mlogger.Logger
|
|
||||||
oracle fxOracle
|
|
||||||
}
|
|
||||||
|
|
||||||
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
|
|
||||||
return "eCalculator{
|
|
||||||
logger: logger.Named("calculator"),
|
|
||||||
oracle: oracle,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
|
||||||
if plan == nil {
|
|
||||||
return nil, merrors.InvalidArgument("plan is required")
|
|
||||||
}
|
|
||||||
if intent == nil {
|
|
||||||
return nil, merrors.InvalidArgument("intent is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger := convertTrigger(intent.GetTrigger())
|
|
||||||
if trigger == model.TriggerUnspecified {
|
|
||||||
return nil, merrors.InvalidArgument("unsupported trigger")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
|
||||||
if err != nil {
|
|
||||||
return nil, merrors.InvalidArgument("invalid base amount")
|
|
||||||
}
|
|
||||||
if baseAmount.Sign() < 0 {
|
|
||||||
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
|
||||||
}
|
|
||||||
|
|
||||||
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
|
||||||
|
|
||||||
rules := make([]model.FeeRule, len(plan.Rules))
|
|
||||||
copy(rules, plan.Rules)
|
|
||||||
sort.SliceStable(rules, func(i, j int) bool {
|
|
||||||
if rules[i].Priority == rules[j].Priority {
|
|
||||||
return rules[i].RuleID < rules[j].RuleID
|
|
||||||
}
|
|
||||||
return rules[i].Priority < rules[j].Priority
|
|
||||||
})
|
|
||||||
|
|
||||||
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
|
||||||
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
|
||||||
|
|
||||||
planID := ""
|
|
||||||
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
|
||||||
planID = planRef.Hex()
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, rule := range rules {
|
|
||||||
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
|
||||||
if ledgerAccountRef == "" {
|
|
||||||
c.logger.Warn("fee rule missing ledger account reference", zap.String("rule_id", rule.RuleID))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
|
||||||
if calcErr != nil {
|
|
||||||
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
|
||||||
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if amount.Sign() == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
currency := intent.GetBaseAmount().GetCurrency()
|
|
||||||
if override := strings.TrimSpace(rule.Currency); override != "" {
|
|
||||||
currency = override
|
|
||||||
}
|
|
||||||
|
|
||||||
entrySide := mapEntrySide(rule.EntrySide)
|
|
||||||
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
|
||||||
entrySide = accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := map[string]string{
|
|
||||||
"fee_rule_id": rule.RuleID,
|
|
||||||
}
|
|
||||||
if planID != "" {
|
|
||||||
meta["fee_plan_id"] = planID
|
|
||||||
}
|
|
||||||
if rule.Metadata != nil {
|
|
||||||
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
|
||||||
meta["tax_code"] = taxCode
|
|
||||||
}
|
|
||||||
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
|
||||||
meta["tax_rate"] = taxRate
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines = append(lines, &feesv1.DerivedPostingLine{
|
|
||||||
LedgerAccountRef: ledgerAccountRef,
|
|
||||||
Money: &moneyv1.Money{
|
|
||||||
Amount: dmath.FormatRat(amount, scale),
|
|
||||||
Currency: currency,
|
|
||||||
},
|
|
||||||
LineType: mapLineType(rule.LineType),
|
|
||||||
Side: entrySide,
|
|
||||||
Meta: meta,
|
|
||||||
})
|
|
||||||
|
|
||||||
applied = append(applied, &feesv1.AppliedRule{
|
|
||||||
RuleId: rule.RuleID,
|
|
||||||
RuleVersion: planID,
|
|
||||||
Formula: rule.Formula,
|
|
||||||
Rounding: mapRoundingMode(rule.Rounding),
|
|
||||||
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
|
||||||
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
|
||||||
Parameters: cloneStringMap(rule.Metadata),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var fxUsed *feesv1.FXUsed
|
|
||||||
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
|
||||||
fxUsed = c.buildFxUsed(ctx, intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &CalculationResult{
|
|
||||||
Lines: lines,
|
|
||||||
Applied: applied,
|
|
||||||
FxUsed: fxUsed,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
|
||||||
scale, err := resolveRuleScale(rule, baseScale)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
result := new(big.Rat)
|
|
||||||
|
|
||||||
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
|
||||||
percentageRat, perr := dmath.RatFromString(percentage)
|
|
||||||
if perr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
|
||||||
}
|
|
||||||
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
|
||||||
}
|
|
||||||
|
|
||||||
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
|
||||||
fixedRat, ferr := dmath.RatFromString(fixed)
|
|
||||||
if ferr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
|
||||||
}
|
|
||||||
result = dmath.AddRat(result, fixedRat)
|
|
||||||
}
|
|
||||||
|
|
||||||
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
|
||||||
minRat, merr := dmath.RatFromString(minStr)
|
|
||||||
if merr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
|
||||||
}
|
|
||||||
if dmath.CmpRat(result, minRat) < 0 {
|
|
||||||
result = new(big.Rat).Set(minRat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
|
||||||
maxRat, merr := dmath.RatFromString(maxStr)
|
|
||||||
if merr != nil {
|
|
||||||
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
|
||||||
}
|
|
||||||
if dmath.CmpRat(result, maxRat) > 0 {
|
|
||||||
result = new(big.Rat).Set(maxRat)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.Sign() < 0 {
|
|
||||||
result = new(big.Rat).Abs(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
|
||||||
if rerr != nil {
|
|
||||||
return nil, 0, rerr
|
|
||||||
}
|
|
||||||
|
|
||||||
return rounded, scale, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
attrFxBaseCurrency = "fx_base_currency"
|
|
||||||
attrFxQuoteCurrency = "fx_quote_currency"
|
|
||||||
attrFxProvider = "fx_provider"
|
|
||||||
attrFxSide = "fx_side"
|
|
||||||
attrFxRateOverride = "fx_rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
|
||||||
if intent == nil || c.oracle == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
attrs := intent.GetAttributes()
|
|
||||||
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
|
||||||
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
|
||||||
if base == "" || quote == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
|
||||||
provider := strings.TrimSpace(attrs[attrFxProvider])
|
|
||||||
|
|
||||||
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
|
||||||
Meta: oracleclient.RequestMeta{},
|
|
||||||
Pair: pair,
|
|
||||||
Provider: provider,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if snapshot == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Mid
|
|
||||||
}
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Ask
|
|
||||||
}
|
|
||||||
if rateValue == "" {
|
|
||||||
rateValue = snapshot.Bid
|
|
||||||
}
|
|
||||||
|
|
||||||
return &feesv1.FXUsed{
|
|
||||||
Pair: pair,
|
|
||||||
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
|
||||||
Rate: &moneyv1.Decimal{Value: rateValue},
|
|
||||||
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
|
||||||
Provider: snapshot.Provider,
|
|
||||||
RateRef: snapshot.RateRef,
|
|
||||||
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseFxSide(value string) fxv1.Side {
|
|
||||||
switch strings.ToLower(value) {
|
|
||||||
case "buy_base", "buy_base_sell_quote", "buy":
|
|
||||||
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
|
||||||
case "sell_base", "sell_base_buy_quote", "sell":
|
|
||||||
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
|
||||||
default:
|
|
||||||
return fxv1.Side_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func inferScale(amount string) uint32 {
|
|
||||||
value := strings.TrimSpace(amount)
|
|
||||||
if value == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
|
||||||
value = value[:idx]
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
|
||||||
value = value[1:]
|
|
||||||
}
|
|
||||||
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
|
||||||
return uint32(len(value[dot+1:]))
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
|
||||||
if rule.Trigger != trigger {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if rule.EffectiveFrom.After(bookedAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ruleMatchesAttributes(rule, attributes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
|
||||||
if rule.Metadata != nil {
|
|
||||||
for _, field := range []string{"scale", "decimals", "precision"} {
|
|
||||||
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
|
||||||
return parseScale(field, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseScale(field, value string) (uint32, error) {
|
|
||||||
clean := strings.TrimSpace(value)
|
|
||||||
if clean == "" {
|
|
||||||
return 0, merrors.InvalidArgument(field + " is empty")
|
|
||||||
}
|
|
||||||
parsed, err := strconv.ParseUint(clean, 10, 32)
|
|
||||||
if err != nil {
|
|
||||||
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
|
||||||
}
|
|
||||||
return uint32(parsed), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func metadataValue(meta map[string]string, key string) string {
|
|
||||||
if meta == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(meta[key])
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneStringMap(src map[string]string) map[string]string {
|
|
||||||
if len(src) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make(map[string]string, len(src))
|
|
||||||
for k, v := range src {
|
|
||||||
cloned[k] = v
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
|
||||||
if len(rule.AppliesTo) == 0 {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for key, value := range rule.AppliesTo {
|
|
||||||
if attributes == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
|
||||||
switch trigger {
|
|
||||||
case feesv1.Trigger_TRIGGER_CAPTURE:
|
|
||||||
return model.TriggerCapture
|
|
||||||
case feesv1.Trigger_TRIGGER_REFUND:
|
|
||||||
return model.TriggerRefund
|
|
||||||
case feesv1.Trigger_TRIGGER_DISPUTE:
|
|
||||||
return model.TriggerDispute
|
|
||||||
case feesv1.Trigger_TRIGGER_PAYOUT:
|
|
||||||
return model.TriggerPayout
|
|
||||||
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
|
||||||
return model.TriggerFXConversion
|
|
||||||
default:
|
|
||||||
return model.TriggerUnspecified
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapLineType(lineType string) accountingv1.PostingLineType {
|
|
||||||
switch strings.ToLower(lineType) {
|
|
||||||
case "tax":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
|
||||||
case "spread":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
|
||||||
case "reversal":
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
|
||||||
default:
|
|
||||||
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
|
||||||
switch strings.ToLower(entrySide) {
|
|
||||||
case "debit":
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
|
||||||
case "credit":
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
|
||||||
default:
|
|
||||||
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toDecimalRounding(mode string) dmath.RoundingMode {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
||||||
case "half_up":
|
|
||||||
return dmath.RoundingModeHalfUp
|
|
||||||
case "down":
|
|
||||||
return dmath.RoundingModeDown
|
|
||||||
case "half_even", "bankers":
|
|
||||||
return dmath.RoundingModeHalfEven
|
|
||||||
default:
|
|
||||||
return dmath.RoundingModeHalfEven
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
|
||||||
switch strings.ToLower(mode) {
|
|
||||||
case "half_up":
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_UP
|
|
||||||
case "down":
|
|
||||||
return moneyv1.RoundingMode_ROUND_DOWN
|
|
||||||
default:
|
|
||||||
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
package calculator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
dmath "github.com/tech/sendico/pkg/decimal"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fxOracle captures the oracle dependency for FX conversions.
|
||||||
|
type fxOracle interface {
|
||||||
|
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New constructs the default calculator implementation.
|
||||||
|
func New(logger mlogger.Logger, oracle fxOracle) *quoteCalculator {
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return "eCalculator{
|
||||||
|
logger: logger.Named("calculator"),
|
||||||
|
oracle: oracle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type quoteCalculator struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
oracle fxOracle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||||
|
if plan == nil {
|
||||||
|
return nil, merrors.InvalidArgument("plan is required")
|
||||||
|
}
|
||||||
|
if intent == nil {
|
||||||
|
return nil, merrors.InvalidArgument("intent is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger := convertTrigger(intent.GetTrigger())
|
||||||
|
if trigger == model.TriggerUnspecified {
|
||||||
|
return nil, merrors.InvalidArgument("unsupported trigger")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseAmount, err := dmath.RatFromString(intent.GetBaseAmount().GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgument("invalid base amount")
|
||||||
|
}
|
||||||
|
if baseAmount.Sign() < 0 {
|
||||||
|
return nil, merrors.InvalidArgument("base amount cannot be negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseScale := inferScale(intent.GetBaseAmount().GetAmount())
|
||||||
|
|
||||||
|
rules := make([]model.FeeRule, len(plan.Rules))
|
||||||
|
copy(rules, plan.Rules)
|
||||||
|
sort.SliceStable(rules, func(i, j int) bool {
|
||||||
|
if rules[i].Priority == rules[j].Priority {
|
||||||
|
return rules[i].RuleID < rules[j].RuleID
|
||||||
|
}
|
||||||
|
return rules[i].Priority < rules[j].Priority
|
||||||
|
})
|
||||||
|
|
||||||
|
lines := make([]*feesv1.DerivedPostingLine, 0, len(rules))
|
||||||
|
applied := make([]*feesv1.AppliedRule, 0, len(rules))
|
||||||
|
|
||||||
|
planID := ""
|
||||||
|
if planRef := plan.GetID(); planRef != nil && !planRef.IsZero() {
|
||||||
|
planID = planRef.Hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
if !shouldApplyRule(rule, trigger, intent.GetAttributes(), bookedAt) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ledgerAccountRef := strings.TrimSpace(rule.LedgerAccountRef)
|
||||||
|
|
||||||
|
amount, scale, calcErr := c.calculateRuleAmount(baseAmount, baseScale, rule)
|
||||||
|
if calcErr != nil {
|
||||||
|
if !errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||||
|
c.logger.Warn("failed to calculate fee rule amount", zap.String("rule_id", rule.RuleID), zap.Error(calcErr))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if amount.Sign() == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := intent.GetBaseAmount().GetCurrency()
|
||||||
|
if override := strings.TrimSpace(rule.Currency); override != "" {
|
||||||
|
currency = override
|
||||||
|
}
|
||||||
|
|
||||||
|
entrySide := mapEntrySide(rule.EntrySide)
|
||||||
|
if entrySide == accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED {
|
||||||
|
// 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{
|
||||||
|
"fee_rule_id": rule.RuleID,
|
||||||
|
}
|
||||||
|
if planID != "" {
|
||||||
|
meta["fee_plan_id"] = planID
|
||||||
|
}
|
||||||
|
if rule.Metadata != nil {
|
||||||
|
if taxCode := strings.TrimSpace(rule.Metadata["tax_code"]); taxCode != "" {
|
||||||
|
meta["tax_code"] = taxCode
|
||||||
|
}
|
||||||
|
if taxRate := strings.TrimSpace(rule.Metadata["tax_rate"]); taxRate != "" {
|
||||||
|
meta["tax_rate"] = taxRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, &feesv1.DerivedPostingLine{
|
||||||
|
LedgerAccountRef: ledgerAccountRef,
|
||||||
|
Money: &moneyv1.Money{
|
||||||
|
Amount: dmath.FormatRat(amount, scale),
|
||||||
|
Currency: currency,
|
||||||
|
},
|
||||||
|
LineType: mapLineType(rule.LineType),
|
||||||
|
Side: entrySide,
|
||||||
|
Meta: meta,
|
||||||
|
})
|
||||||
|
|
||||||
|
applied = append(applied, &feesv1.AppliedRule{
|
||||||
|
RuleId: rule.RuleID,
|
||||||
|
RuleVersion: planID,
|
||||||
|
Formula: rule.Formula,
|
||||||
|
Rounding: mapRoundingMode(rule.Rounding),
|
||||||
|
TaxCode: metadataValue(rule.Metadata, "tax_code"),
|
||||||
|
TaxRate: metadataValue(rule.Metadata, "tax_rate"),
|
||||||
|
Parameters: cloneStringMap(rule.Metadata),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var fxUsed *feesv1.FXUsed
|
||||||
|
if trigger == model.TriggerFXConversion && c.oracle != nil {
|
||||||
|
fxUsed = c.buildFxUsed(ctx, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.CalculationResult{
|
||||||
|
Lines: lines,
|
||||||
|
Applied: applied,
|
||||||
|
FxUsed: fxUsed,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *quoteCalculator) calculateRuleAmount(baseAmount *big.Rat, baseScale uint32, rule model.FeeRule) (*big.Rat, uint32, error) {
|
||||||
|
scale, err := resolveRuleScale(rule, baseScale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := new(big.Rat)
|
||||||
|
|
||||||
|
if percentage := strings.TrimSpace(rule.Percentage); percentage != "" {
|
||||||
|
percentageRat, perr := dmath.RatFromString(percentage)
|
||||||
|
if perr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid percentage")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, dmath.MulRat(baseAmount, percentageRat))
|
||||||
|
}
|
||||||
|
|
||||||
|
if fixed := strings.TrimSpace(rule.FixedAmount); fixed != "" {
|
||||||
|
fixedRat, ferr := dmath.RatFromString(fixed)
|
||||||
|
if ferr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid fixed amount")
|
||||||
|
}
|
||||||
|
result = dmath.AddRat(result, fixedRat)
|
||||||
|
}
|
||||||
|
|
||||||
|
if minStr := strings.TrimSpace(rule.MinimumAmount); minStr != "" {
|
||||||
|
minRat, merr := dmath.RatFromString(minStr)
|
||||||
|
if merr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid minimum amount")
|
||||||
|
}
|
||||||
|
if dmath.CmpRat(result, minRat) < 0 {
|
||||||
|
result = new(big.Rat).Set(minRat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxStr := strings.TrimSpace(rule.MaximumAmount); maxStr != "" {
|
||||||
|
maxRat, merr := dmath.RatFromString(maxStr)
|
||||||
|
if merr != nil {
|
||||||
|
return nil, 0, merrors.InvalidArgument("invalid maximum amount")
|
||||||
|
}
|
||||||
|
if dmath.CmpRat(result, maxRat) > 0 {
|
||||||
|
result = new(big.Rat).Set(maxRat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Sign() < 0 {
|
||||||
|
result = new(big.Rat).Abs(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
rounded, rerr := dmath.RoundRatToScale(result, scale, toDecimalRounding(rule.Rounding))
|
||||||
|
if rerr != nil {
|
||||||
|
return nil, 0, rerr
|
||||||
|
}
|
||||||
|
|
||||||
|
return rounded, scale, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
attrFxBaseCurrency = "fx_base_currency"
|
||||||
|
attrFxQuoteCurrency = "fx_quote_currency"
|
||||||
|
attrFxProvider = "fx_provider"
|
||||||
|
attrFxSide = "fx_side"
|
||||||
|
attrFxRateOverride = "fx_rate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (c *quoteCalculator) buildFxUsed(ctx context.Context, intent *feesv1.Intent) *feesv1.FXUsed {
|
||||||
|
if intent == nil || c.oracle == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
attrs := intent.GetAttributes()
|
||||||
|
base := strings.TrimSpace(attrs[attrFxBaseCurrency])
|
||||||
|
quote := strings.TrimSpace(attrs[attrFxQuoteCurrency])
|
||||||
|
if base == "" || quote == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pair := &fxv1.CurrencyPair{Base: base, Quote: quote}
|
||||||
|
provider := strings.TrimSpace(attrs[attrFxProvider])
|
||||||
|
|
||||||
|
snapshot, err := c.oracle.LatestRate(ctx, oracleclient.LatestRateParams{
|
||||||
|
Meta: oracleclient.RequestMeta{},
|
||||||
|
Pair: pair,
|
||||||
|
Provider: provider,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("fees: failed to fetch FX context", zap.Error(err))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rateValue := strings.TrimSpace(attrs[attrFxRateOverride])
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Mid
|
||||||
|
}
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Ask
|
||||||
|
}
|
||||||
|
if rateValue == "" {
|
||||||
|
rateValue = snapshot.Bid
|
||||||
|
}
|
||||||
|
|
||||||
|
return &feesv1.FXUsed{
|
||||||
|
Pair: pair,
|
||||||
|
Side: parseFxSide(strings.TrimSpace(attrs[attrFxSide])),
|
||||||
|
Rate: &moneyv1.Decimal{Value: rateValue},
|
||||||
|
AsofUnixMs: snapshot.AsOf.UnixMilli(),
|
||||||
|
Provider: snapshot.Provider,
|
||||||
|
RateRef: snapshot.RateRef,
|
||||||
|
SpreadBps: &moneyv1.Decimal{Value: snapshot.SpreadBps},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFxSide(value string) fxv1.Side {
|
||||||
|
switch strings.ToLower(value) {
|
||||||
|
case "buy_base", "buy_base_sell_quote", "buy":
|
||||||
|
return fxv1.Side_BUY_BASE_SELL_QUOTE
|
||||||
|
case "sell_base", "sell_base_buy_quote", "sell":
|
||||||
|
return fxv1.Side_SELL_BASE_BUY_QUOTE
|
||||||
|
default:
|
||||||
|
return fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func inferScale(amount string) uint32 {
|
||||||
|
value := strings.TrimSpace(amount)
|
||||||
|
if value == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if idx := strings.IndexAny(value, "eE"); idx >= 0 {
|
||||||
|
value = value[:idx]
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(value, "+") || strings.HasPrefix(value, "-") {
|
||||||
|
value = value[1:]
|
||||||
|
}
|
||||||
|
if dot := strings.IndexByte(value, '.'); dot >= 0 {
|
||||||
|
return uint32(len(value[dot+1:]))
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldApplyRule(rule model.FeeRule, trigger model.Trigger, attributes map[string]string, bookedAt time.Time) bool {
|
||||||
|
if rule.Trigger != trigger {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.EffectiveFrom.After(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.EffectiveTo != nil && rule.EffectiveTo.Before(bookedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ruleMatchesAttributes(rule, attributes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveRuleScale(rule model.FeeRule, fallback uint32) (uint32, error) {
|
||||||
|
if rule.Metadata != nil {
|
||||||
|
for _, field := range []string{"scale", "decimals", "precision"} {
|
||||||
|
if value, ok := rule.Metadata[field]; ok && strings.TrimSpace(value) != "" {
|
||||||
|
return parseScale(field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseScale(field, value string) (uint32, error) {
|
||||||
|
clean := strings.TrimSpace(value)
|
||||||
|
if clean == "" {
|
||||||
|
return 0, merrors.InvalidArgument(field + " is empty")
|
||||||
|
}
|
||||||
|
parsed, err := strconv.ParseUint(clean, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, merrors.InvalidArgument("invalid " + field + " value")
|
||||||
|
}
|
||||||
|
return uint32(parsed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func metadataValue(meta map[string]string, key string) string {
|
||||||
|
if meta == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(meta[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneStringMap(src map[string]string) map[string]string {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string]string, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
cloned[k] = v
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
|
func ruleMatchesAttributes(rule model.FeeRule, attributes map[string]string) bool {
|
||||||
|
if len(rule.AppliesTo) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for key, value := range rule.AppliesTo {
|
||||||
|
if attributes == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attrValue, ok := attributes[key]; !ok || attrValue != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapLineType(lineType string) accountingv1.PostingLineType {
|
||||||
|
switch strings.ToLower(lineType) {
|
||||||
|
case "tax":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_TAX
|
||||||
|
case "spread":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_SPREAD
|
||||||
|
case "reversal":
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_REVERSAL
|
||||||
|
default:
|
||||||
|
return accountingv1.PostingLineType_POSTING_LINE_FEE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapEntrySide(entrySide string) accountingv1.EntrySide {
|
||||||
|
switch strings.ToLower(entrySide) {
|
||||||
|
case "debit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_DEBIT
|
||||||
|
case "credit":
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_CREDIT
|
||||||
|
default:
|
||||||
|
return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDecimalRounding(mode string) dmath.RoundingMode {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||||
|
case "half_up":
|
||||||
|
return dmath.RoundingModeHalfUp
|
||||||
|
case "down":
|
||||||
|
return dmath.RoundingModeDown
|
||||||
|
case "half_even", "bankers":
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
default:
|
||||||
|
return dmath.RoundingModeHalfEven
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapRoundingMode(mode string) moneyv1.RoundingMode {
|
||||||
|
switch strings.ToLower(mode) {
|
||||||
|
case "half_up":
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_UP
|
||||||
|
case "down":
|
||||||
|
return moneyv1.RoundingMode_ROUND_DOWN
|
||||||
|
default:
|
||||||
|
return moneyv1.RoundingMode_ROUND_HALF_EVEN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||||
|
switch trigger {
|
||||||
|
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||||
|
return model.TriggerCapture
|
||||||
|
case feesv1.Trigger_TRIGGER_REFUND:
|
||||||
|
return model.TriggerRefund
|
||||||
|
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||||
|
return model.TriggerDispute
|
||||||
|
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||||
|
return model.TriggerPayout
|
||||||
|
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||||
|
return model.TriggerFXConversion
|
||||||
|
default:
|
||||||
|
return model.TriggerUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import "github.com/tech/sendico/pkg/merrors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNoFeeRuleFound indicates that no applicable rule exists for the given context.
|
||||||
|
ErrNoFeeRuleFound = merrors.ErrNoData
|
||||||
|
// ErrConflictingFeeRules indicates multiple rules share the same highest priority.
|
||||||
|
ErrConflictingFeeRules = merrors.ErrDataConflict
|
||||||
|
)
|
||||||
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
148
api/billing/fees/internal/service/fees/internal/resolver/impl.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type planFinder interface {
|
||||||
|
FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||||
|
FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type feeResolver struct {
|
||||||
|
plans storage.PlansStore
|
||||||
|
finder planFinder
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(plans storage.PlansStore, logger *zap.Logger) *feeResolver {
|
||||||
|
var finder planFinder
|
||||||
|
if pf, ok := plans.(planFinder); ok {
|
||||||
|
finder = pf
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
logger = zap.NewNop()
|
||||||
|
}
|
||||||
|
return &feeResolver{
|
||||||
|
plans: plans,
|
||||||
|
finder: finder,
|
||||||
|
logger: logger.Named("resolver"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *feeResolver) ResolveFeeRule(ctx context.Context, orgRef *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error) {
|
||||||
|
if r.plans == nil {
|
||||||
|
return nil, nil, merrors.InvalidArgument("fees: plans store is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try org-specific first if provided.
|
||||||
|
if orgRef != nil && !orgRef.IsZero() {
|
||||||
|
if plan, err := r.getOrgPlan(ctx, *orgRef, at); err == nil {
|
||||||
|
if rule, selErr := selectRule(plan, trigger, at, attrs); selErr == nil {
|
||||||
|
return plan, rule, nil
|
||||||
|
} else if !errors.Is(selErr, ErrNoFeeRuleFound) {
|
||||||
|
r.logger.Warn("failed selecting rule for org plan", zap.Error(selErr), zap.String("org_ref", orgRef.Hex()))
|
||||||
|
return nil, nil, selErr
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
r.logger.Warn("failed resolving org fee plan", zap.Error(err), zap.String("org_ref", orgRef.Hex()))
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := r.getGlobalPlan(ctx, at)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
|
}
|
||||||
|
r.logger.Warn("failed resolving global fee plan", zap.Error(err))
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rule, err := selectRule(plan, trigger, at, attrs)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, ErrNoFeeRuleFound) {
|
||||||
|
r.logger.Warn("failed selecting rule in global plan", zap.Error(err))
|
||||||
|
}
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return plan, rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *feeResolver) getOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if r.finder != nil {
|
||||||
|
return r.finder.FindActiveOrgPlan(ctx, orgRef, at)
|
||||||
|
}
|
||||||
|
return r.plans.GetActivePlan(ctx, orgRef, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *feeResolver) getGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if r.finder != nil {
|
||||||
|
return r.finder.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
// Treat zero ObjectID as global in legacy path.
|
||||||
|
return r.plans.GetActivePlan(ctx, primitive.NilObjectID, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectRule(plan *model.FeePlan, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeeRule, error) {
|
||||||
|
if plan == nil {
|
||||||
|
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
|
}
|
||||||
|
|
||||||
|
var selected *model.FeeRule
|
||||||
|
var highestPriority int
|
||||||
|
for _, rule := range plan.Rules {
|
||||||
|
if rule.Trigger != trigger {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.EffectiveFrom.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.EffectiveTo != nil && !rule.EffectiveTo.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !matchesAppliesTo(rule.AppliesTo, attrs) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected == nil || rule.Priority > highestPriority {
|
||||||
|
copy := rule
|
||||||
|
selected = ©
|
||||||
|
highestPriority = rule.Priority
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rule.Priority == highestPriority {
|
||||||
|
return nil, merrors.DataConflict("fees: conflicting fee rules")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if selected == nil {
|
||||||
|
return nil, merrors.NoData("fees: no applicable fee rule found")
|
||||||
|
}
|
||||||
|
return selected, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func matchesAppliesTo(appliesTo map[string]string, attrs map[string]string) bool {
|
||||||
|
if len(appliesTo) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for key, value := range appliesTo {
|
||||||
|
if attrs == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if attrs[key] != value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package resolver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolver_GlobalFallbackWhenOrgMissing(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
orgA := primitive.NewObjectID()
|
||||||
|
plan, rule, err := resolver.ResolveFeeRule(context.Background(), &orgA, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||||
|
}
|
||||||
|
if plan.OrganizationRef != nil && !plan.OrganizationRef.IsZero() {
|
||||||
|
t.Fatalf("expected global plan, got orgRef %s", plan.OrganizationRef.Hex())
|
||||||
|
}
|
||||||
|
if rule.RuleID != "global_capture" {
|
||||||
|
t.Fatalf("unexpected rule selected: %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_OrgOverridesGlobal(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "global_capture", Trigger: model.TriggerCapture, Priority: 5, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "org_capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan.OrganizationRef = &org
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{globalPlan, orgPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected org plan rule, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "org_capture" {
|
||||||
|
t.Fatalf("expected org rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherOrg := primitive.NewObjectID()
|
||||||
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), &otherOrg, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected global fallback for other org, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "global_capture" {
|
||||||
|
t.Fatalf("expected global rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_SelectsHighestPriority(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "low", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "high", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
plan.OrganizationRef = &org
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected rule resolution, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "high" {
|
||||||
|
t.Fatalf("expected highest priority rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.Rules = append(plan.Rules, model.FeeRule{
|
||||||
|
RuleID: "conflict",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 200,
|
||||||
|
Percentage: "0.02",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
})
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, ErrConflictingFeeRules) {
|
||||||
|
t.Fatalf("expected conflicting fee rules error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_EffectiveDateFiltering(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
past := now.Add(-24 * time.Hour)
|
||||||
|
future := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
|
orgPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: past,
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "expired", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &past},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
orgPlan.OrganizationRef = &org
|
||||||
|
|
||||||
|
globalPlan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: past,
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "current", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: past, EffectiveTo: &future},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{orgPlan, globalPlan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected fallback to global, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "current" {
|
||||||
|
t.Fatalf("expected current global rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_AppliesToFiltering(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "card", Trigger: model.TriggerCapture, Priority: 200, Percentage: "0.03", AppliesTo: map[string]string{"paymentMethod": "card"}, EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
{RuleID: "default", Trigger: model.TriggerCapture, Priority: 100, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
_, rule, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "card"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected card rule, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "card" {
|
||||||
|
t.Fatalf("expected card rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, rule, err = resolver.ResolveFeeRule(context.Background(), nil, model.TriggerCapture, now, map[string]string{"paymentMethod": "bank"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected default rule, got error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.RuleID != "default" {
|
||||||
|
t.Fatalf("expected default rule, got %s", rule.RuleID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_MissingTriggerReturnsErr(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
plan := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "capture", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{plan}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), nil, model.TriggerRefund, now, nil); !errors.Is(err, ErrNoFeeRuleFound) {
|
||||||
|
t.Fatalf("expected ErrNoFeeRuleFound, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolver_MultipleActivePlansConflict(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
org := primitive.NewObjectID()
|
||||||
|
p1 := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "r1", Trigger: model.TriggerCapture, Priority: 10, Percentage: "0.05", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p1.OrganizationRef = &org
|
||||||
|
p2 := &model.FeePlan{
|
||||||
|
Active: true,
|
||||||
|
EffectiveFrom: now.Add(-30 * time.Minute),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{RuleID: "r2", Trigger: model.TriggerCapture, Priority: 20, Percentage: "0.03", EffectiveFrom: now.Add(-time.Hour)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
p2.OrganizationRef = &org
|
||||||
|
|
||||||
|
store := &memoryPlansStore{plans: []*model.FeePlan{p1, p2}}
|
||||||
|
resolver := New(store, zap.NewNop())
|
||||||
|
|
||||||
|
if _, _, err := resolver.ResolveFeeRule(context.Background(), &org, model.TriggerCapture, now, nil); !errors.Is(err, storage.ErrConflictingFeePlans) {
|
||||||
|
t.Fatalf("expected conflicting plans error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type memoryPlansStore struct {
|
||||||
|
plans []*model.FeePlan
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) Create(context.Context, *model.FeePlan) error { return nil }
|
||||||
|
func (m *memoryPlansStore) Update(context.Context, *model.FeePlan) error { return nil }
|
||||||
|
func (m *memoryPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
if plan, err := m.FindActiveOrgPlan(ctx, orgRef, at); err == nil {
|
||||||
|
return plan, nil
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
var matches []*model.FeePlan
|
||||||
|
for _, plan := range m.plans {
|
||||||
|
if plan == nil || plan.OrganizationRef == nil || plan.OrganizationRef.IsZero() || (*plan.OrganizationRef != orgRef) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !plan.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveFrom.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, plan)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return nil, storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
|
var matches []*model.FeePlan
|
||||||
|
for _, plan := range m.plans {
|
||||||
|
if plan == nil || ((plan.OrganizationRef != nil) && !plan.OrganizationRef.IsZero()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !plan.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveFrom.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if plan.EffectiveTo != nil && !plan.EffectiveTo.After(at) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, plan)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if len(matches) > 1 {
|
||||||
|
return nil, storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return matches[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PlansStore = (*memoryPlansStore)(nil)
|
||||||
88
api/billing/fees/internal/service/fees/logging.go
Normal file
88
api/billing/fees/internal/service/fees/logging.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package fees
|
package fees
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
)
|
)
|
||||||
@@ -30,8 +31,18 @@ func WithCalculator(calculator Calculator) Option {
|
|||||||
func WithOracleClient(oracle oracleclient.Client) Option {
|
func WithOracleClient(oracle oracleclient.Client) Option {
|
||||||
return func(s *Service) {
|
return func(s *Service) {
|
||||||
s.oracle = oracle
|
s.oracle = oracle
|
||||||
if qc, ok := s.calculator.(*quoteCalculator); ok {
|
// Rebuild default calculator if none was injected.
|
||||||
qc.oracle = oracle
|
if s.calculator == nil {
|
||||||
|
s.calculator = internalcalculator.New(s.logger, oracle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFeeResolver injects a custom fee resolver (useful for tests).
|
||||||
|
func WithFeeResolver(r FeeResolver) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
if r != nil {
|
||||||
|
s.resolver = r
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
15
api/billing/fees/internal/service/fees/resolver.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FeeResolver centralises plan/rule resolution with org override and global fallback.
|
||||||
|
// Implementations live under the internal/resolver package.
|
||||||
|
type FeeResolver interface {
|
||||||
|
ResolveFeeRule(ctx context.Context, orgID *primitive.ObjectID, trigger model.Trigger, at time.Time, attrs map[string]string) (*model.FeePlan, *model.FeeRule, error)
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator"
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
@@ -32,6 +35,7 @@ type Service struct {
|
|||||||
clock clockpkg.Clock
|
clock clockpkg.Clock
|
||||||
calculator Calculator
|
calculator Calculator
|
||||||
oracle oracleclient.Client
|
oracle oracleclient.Client
|
||||||
|
resolver FeeResolver
|
||||||
feesv1.UnimplementedFeeEngineServer
|
feesv1.UnimplementedFeeEngineServer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +56,10 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
svc.clock = clockpkg.NewSystem()
|
svc.clock = clockpkg.NewSystem()
|
||||||
}
|
}
|
||||||
if svc.calculator == nil {
|
if svc.calculator == nil {
|
||||||
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
|
svc.calculator = internalcalculator.New(svc.logger, svc.oracle)
|
||||||
|
}
|
||||||
|
if svc.resolver == nil {
|
||||||
|
svc.resolver = resolver.New(repo.Plans(), svc.logger)
|
||||||
}
|
}
|
||||||
|
|
||||||
return svc
|
return svc
|
||||||
@@ -65,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
|
||||||
}
|
}
|
||||||
@@ -105,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
|
||||||
}
|
}
|
||||||
@@ -127,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
|
||||||
}
|
}
|
||||||
@@ -141,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(),
|
||||||
@@ -152,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
|
||||||
}
|
}
|
||||||
@@ -169,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 {
|
||||||
@@ -184,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
|
||||||
}
|
}
|
||||||
@@ -195,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
|
||||||
}
|
}
|
||||||
|
|
||||||
trigger = payload.Intent.GetTrigger()
|
logger = logger.With(logFieldsFromTokenPayload(&payload)...)
|
||||||
|
|
||||||
|
if payload.Intent != nil {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -273,21 +387,50 @@ func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.Obj
|
|||||||
bookedAt = intent.GetBookedAt().AsTime()
|
bookedAt = intent.GetBookedAt().AsTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
|
logFields := []zap.Field{
|
||||||
if err != nil {
|
zap.Time("booked_at_used", bookedAt),
|
||||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
|
||||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
|
||||||
}
|
|
||||||
s.logger.Warn("failed to load active fee plan", zap.Error(err))
|
|
||||||
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
|
|
||||||
}
|
}
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
logFields = append(logFields, zap.String("organization_ref", orgRef.Hex()))
|
||||||
|
}
|
||||||
|
logFields = append(logFields, logFieldsFromIntent(intent)...)
|
||||||
|
logFields = append(logFields, logFieldsFromTrace(trace)...)
|
||||||
|
logger := s.logger.With(logFields...)
|
||||||
|
|
||||||
|
var orgPtr *primitive.ObjectID
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
orgPtr = &orgRef
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, rule, err := s.resolver.ResolveFeeRule(ctx, orgPtr, convertTrigger(intent.GetTrigger()), bookedAt, intent.GetAttributes())
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, merrors.ErrNoData):
|
||||||
|
return nil, nil, nil, status.Error(codes.NotFound, "fee rule not found")
|
||||||
|
case errors.Is(err, merrors.ErrDataConflict):
|
||||||
|
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee rules")
|
||||||
|
case errors.Is(err, storage.ErrConflictingFeePlans):
|
||||||
|
return nil, nil, nil, status.Error(codes.FailedPrecondition, "conflicting fee plans")
|
||||||
|
case errors.Is(err, storage.ErrFeePlanNotFound):
|
||||||
|
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||||
|
default:
|
||||||
|
logger.Warn("failed to resolve fee rule", zap.Error(err))
|
||||||
|
return nil, nil, nil, status.Error(codes.Internal, "failed to resolve fee rule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRules := plan.Rules
|
||||||
|
plan.Rules = []model.FeeRule{*rule}
|
||||||
|
defer func() {
|
||||||
|
plan.Rules = originalRules
|
||||||
|
}()
|
||||||
|
|
||||||
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
||||||
if calcErr != nil {
|
if calcErr != nil {
|
||||||
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ package fees
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/billing/fees/internal/service/fees/types"
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
@@ -47,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(),
|
||||||
@@ -161,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(),
|
||||||
@@ -222,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(),
|
||||||
@@ -263,11 +265,21 @@ func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
|||||||
plan := &model.FeePlan{
|
plan := &model.FeePlan{
|
||||||
Active: true,
|
Active: true,
|
||||||
EffectiveFrom: now.Add(-time.Hour),
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
Rules: []model.FeeRule{
|
||||||
|
{
|
||||||
|
RuleID: "stub",
|
||||||
|
Trigger: model.TriggerCapture,
|
||||||
|
Priority: 1,
|
||||||
|
Percentage: "0.01",
|
||||||
|
LedgerAccountRef: "acct:stub",
|
||||||
|
EffectiveFrom: now.Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
plan.SetID(primitive.NewObjectID())
|
plan.SetID(primitive.NewObjectID())
|
||||||
plan.SetOrganizationRef(orgRef)
|
plan.OrganizationRef = &orgRef
|
||||||
|
|
||||||
result := &CalculationResult{
|
result := &types.CalculationResult{
|
||||||
Lines: []*feesv1.DerivedPostingLine{
|
Lines: []*feesv1.DerivedPostingLine{
|
||||||
{
|
{
|
||||||
LedgerAccountRef: "acct:stub",
|
LedgerAccountRef: "acct:stub",
|
||||||
@@ -341,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) {
|
||||||
@@ -409,7 +421,8 @@ func (s *stubRepository) Plans() storage.PlansStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubPlansStore struct {
|
type stubPlansStore struct {
|
||||||
plan *model.FeePlan
|
plan *model.FeePlan
|
||||||
|
globalPlan *model.FeePlan
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
||||||
@@ -425,24 +438,51 @@ func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePla
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if !orgRef.IsZero() {
|
||||||
|
if plan, err := s.FindActiveOrgPlan(context.Background(), orgRef, at); err == nil {
|
||||||
|
return plan, nil
|
||||||
|
} else if !errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.FindActiveGlobalPlan(context.Background(), at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) FindActiveOrgPlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if s.plan == nil {
|
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 {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
|
if s.plan.EffectiveFrom.After(at) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
|
if s.plan.EffectiveTo != nil && !s.plan.EffectiveTo.After(at) {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
return s.plan, nil
|
return s.plan, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubPlansStore) FindActiveGlobalPlan(_ context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
|
if s.globalPlan == nil {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if !s.globalPlan.Active {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if s.globalPlan.EffectiveFrom.After(at) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
if s.globalPlan.EffectiveTo != nil && !s.globalPlan.EffectiveTo.After(at) {
|
||||||
|
return nil, storage.ErrFeePlanNotFound
|
||||||
|
}
|
||||||
|
return s.globalPlan, nil
|
||||||
|
}
|
||||||
|
|
||||||
type noopProducer struct{}
|
type noopProducer struct{}
|
||||||
|
|
||||||
func (noopProducer) SendMessage(me.Envelope) error {
|
func (noopProducer) SendMessage(me.Envelope) error {
|
||||||
@@ -458,14 +498,14 @@ func (f fixedClock) Now() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type stubCalculator struct {
|
type stubCalculator struct {
|
||||||
result *CalculationResult
|
result *types.CalculationResult
|
||||||
err error
|
err error
|
||||||
called bool
|
called bool
|
||||||
gotPlan *model.FeePlan
|
gotPlan *model.FeePlan
|
||||||
bookedAt time.Time
|
bookedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*types.CalculationResult, error) {
|
||||||
s.called = true
|
s.called = true
|
||||||
s.gotPlan = plan
|
s.gotPlan = plan
|
||||||
s.bookedAt = bookedAt
|
s.bookedAt = bookedAt
|
||||||
|
|||||||
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
23
api/billing/fees/internal/service/fees/trigger.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package fees
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func convertTrigger(trigger feesv1.Trigger) model.Trigger {
|
||||||
|
switch trigger {
|
||||||
|
case feesv1.Trigger_TRIGGER_CAPTURE:
|
||||||
|
return model.TriggerCapture
|
||||||
|
case feesv1.Trigger_TRIGGER_REFUND:
|
||||||
|
return model.TriggerRefund
|
||||||
|
case feesv1.Trigger_TRIGGER_DISPUTE:
|
||||||
|
return model.TriggerDispute
|
||||||
|
case feesv1.Trigger_TRIGGER_PAYOUT:
|
||||||
|
return model.TriggerPayout
|
||||||
|
case feesv1.Trigger_TRIGGER_FX_CONVERSION:
|
||||||
|
return model.TriggerFXConversion
|
||||||
|
default:
|
||||||
|
return model.TriggerUnspecified
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package types
|
||||||
|
|
||||||
|
import (
|
||||||
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CalculationResult contains derived fee lines and audit metadata.
|
||||||
|
type CalculationResult struct {
|
||||||
|
Lines []*feesv1.DerivedPostingLine
|
||||||
|
Applied []*feesv1.AppliedRule
|
||||||
|
FxUsed *feesv1.FXUsed
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
@@ -25,14 +26,14 @@ 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"`
|
||||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collection implements storable.Storable.
|
// Collection implements storable.Storable.
|
||||||
@@ -42,21 +43,21 @@ func (*FeePlan) Collection() string {
|
|||||||
|
|
||||||
// FeeRule represents a single pricing rule within a plan.
|
// FeeRule represents a single pricing rule within a plan.
|
||||||
type FeeRule struct {
|
type FeeRule struct {
|
||||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||||
Priority int `bson:"priority" json:"priority"`
|
Priority int `bson:"priority" json:"priority"`
|
||||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||||
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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/billing/fees/storage"
|
"github.com/tech/sendico/billing/fees/storage"
|
||||||
"github.com/tech/sendico/billing/fees/storage/model"
|
"github.com/tech/sendico/billing/fees/storage/model"
|
||||||
|
dmath "github.com/tech/sendico/pkg/decimal"
|
||||||
"github.com/tech/sendico/pkg/db/repository"
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
@@ -53,6 +57,19 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Recommended index to speed up active-plan lookups (org/global + active + dates).
|
||||||
|
activeIndex := &ri.Definition{
|
||||||
|
Keys: []ri.Key{
|
||||||
|
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||||
|
{Field: "active", Sort: ri.Asc},
|
||||||
|
{Field: "effectiveFrom", Sort: ri.Asc},
|
||||||
|
{Field: "effectiveTo", Sort: ri.Asc},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := repo.CreateIndex(activeIndex); err != nil {
|
||||||
|
logger.Warn("failed to ensure fee plan active index", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
return &plansStore{
|
return &plansStore{
|
||||||
logger: logger.Named("plans"),
|
logger: logger.Named("plans"),
|
||||||
repo: repo,
|
repo: repo,
|
||||||
@@ -60,9 +77,13 @@ func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||||
if plan == nil {
|
if err := validatePlan(plan); err != nil {
|
||||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
return err
|
||||||
}
|
}
|
||||||
|
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
||||||
if errors.Is(err, merrors.ErrDataConflict) {
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
return storage.ErrDuplicateFeePlan
|
return storage.ErrDuplicateFeePlan
|
||||||
@@ -77,6 +98,13 @@ func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
|||||||
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
||||||
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||||
}
|
}
|
||||||
|
if err := validatePlan(plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.ensureNoOverlap(ctx, plan); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := p.repo.Update(ctx, plan); err != nil {
|
if err := p.repo.Update(ctx, plan); err != nil {
|
||||||
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
||||||
return err
|
return err
|
||||||
@@ -99,13 +127,42 @@ func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*mode
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
|
// Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global.
|
||||||
|
if orgRef.IsZero() {
|
||||||
|
return p.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := p.FindActiveOrgPlan(ctx, orgRef, at)
|
||||||
|
if err == nil {
|
||||||
|
return plan, nil
|
||||||
|
}
|
||||||
|
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||||
|
return p.FindActiveGlobalPlan(ctx, at)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||||
if orgRef.IsZero() {
|
if orgRef.IsZero() {
|
||||||
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||||
}
|
}
|
||||||
|
query := repository.Query().Filter(repository.OrgField(), orgRef)
|
||||||
|
return p.findActivePlan(ctx, query, at)
|
||||||
|
}
|
||||||
|
|
||||||
limit := int64(1)
|
func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) {
|
||||||
query := repository.Query().
|
globalQuery := repository.Query().Or(
|
||||||
Filter(repository.OrgField(), orgRef).
|
repository.Exists(repository.OrgField(), false),
|
||||||
|
repository.Query().Filter(repository.OrgField(), nil),
|
||||||
|
)
|
||||||
|
return p.findActivePlan(ctx, globalQuery, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.PlansStore = (*plansStore)(nil)
|
||||||
|
|
||||||
|
func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) {
|
||||||
|
limit := int64(2)
|
||||||
|
query := orgQuery.
|
||||||
Filter(repository.Field("active"), true).
|
Filter(repository.Field("active"), true).
|
||||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||||
Sort(repository.Field("effectiveFrom"), false).
|
Sort(repository.Field("effectiveFrom"), false).
|
||||||
@@ -118,13 +175,13 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
var plan *model.FeePlan
|
var plans []*model.FeePlan
|
||||||
decoder := func(cursor *mongo.Cursor) error {
|
decoder := func(cursor *mongo.Cursor) error {
|
||||||
target := &model.FeePlan{}
|
target := &model.FeePlan{}
|
||||||
if err := cursor.Decode(target); err != nil {
|
if err := cursor.Decode(target); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
plan = target
|
plans = append(plans, target)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,10 +192,127 @@ func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectI
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if plan == nil {
|
if len(plans) == 0 {
|
||||||
return nil, storage.ErrFeePlanNotFound
|
return nil, storage.ErrFeePlanNotFound
|
||||||
}
|
}
|
||||||
return plan, nil
|
if len(plans) > 1 {
|
||||||
|
return nil, storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return plans[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ storage.PlansStore = (*plansStore)(nil)
|
func validatePlan(plan *model.FeePlan) error {
|
||||||
|
if plan == nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||||
|
}
|
||||||
|
if len(plan.Rules) == 0 {
|
||||||
|
return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule")
|
||||||
|
}
|
||||||
|
if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) {
|
||||||
|
return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure unique priority per (trigger, appliesTo) combination.
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, rule := range plan.Rules {
|
||||||
|
if strings.TrimSpace(rule.Percentage) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.Percentage); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule percentage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.FixedAmount) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.FixedAmount); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule fixed amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.MinimumAmount) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule minimum amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rule.MaximumAmount) != "" {
|
||||||
|
if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil {
|
||||||
|
return merrors.InvalidArgument("plansStore: invalid rule maximum amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appliesKey := normalizeAppliesTo(rule.AppliesTo)
|
||||||
|
priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey)
|
||||||
|
if _, ok := seen[priorityKey]; ok {
|
||||||
|
return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo")
|
||||||
|
}
|
||||||
|
seen[priorityKey] = struct{}{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAppliesTo(applies map[string]string) string {
|
||||||
|
if len(applies) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
keys := make([]string, 0, len(applies))
|
||||||
|
for k := range applies {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
parts = append(parts, k+"="+applies[k])
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error {
|
||||||
|
if plan == nil || !plan.Active {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
orgQuery := repository.Query()
|
||||||
|
if plan.OrganizationRef.IsZero() {
|
||||||
|
orgQuery = repository.Query().Or(
|
||||||
|
repository.Exists(repository.OrgField(), false),
|
||||||
|
repository.Query().Filter(repository.OrgField(), nil),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||||
|
newFrom := plan.EffectiveFrom
|
||||||
|
newTo := maxTime
|
||||||
|
if plan.EffectiveTo != nil {
|
||||||
|
newTo = *plan.EffectiveTo
|
||||||
|
}
|
||||||
|
|
||||||
|
query := orgQuery.
|
||||||
|
Filter(repository.Field("active"), true).
|
||||||
|
Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo).
|
||||||
|
And(repository.Query().Or(
|
||||||
|
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
||||||
|
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom),
|
||||||
|
))
|
||||||
|
|
||||||
|
if id := plan.GetID(); id != nil && !id.IsZero() {
|
||||||
|
query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id))
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := int64(1)
|
||||||
|
query = query.Limit(&limit)
|
||||||
|
|
||||||
|
var overlapFound bool
|
||||||
|
decoder := func(cursor *mongo.Cursor) error {
|
||||||
|
overlapFound = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if overlapFound {
|
||||||
|
return storage.ErrConflictingFeePlans
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ var (
|
|||||||
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
||||||
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
||||||
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
||||||
|
// ErrConflictingFeePlans indicates multiple active plans matched a query.
|
||||||
|
ErrConflictingFeePlans = storageError("billing.fees.storage: conflicting fee plans")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Repository defines the root storage contract for the fees service.
|
// Repository defines the root storage contract for the fees service.
|
||||||
@@ -32,5 +34,6 @@ type PlansStore interface {
|
|||||||
Create(ctx context.Context, plan *model.FeePlan) error
|
Create(ctx context.Context, plan *model.FeePlan) error
|
||||||
Update(ctx context.Context, plan *model.FeePlan) error
|
Update(ctx context.Context, plan *model.FeePlan) error
|
||||||
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
||||||
|
// Legacy helper that now prefers an org plan and falls back to a global plan.
|
||||||
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ market:
|
|||||||
- driver: COINGECKO
|
- driver: COINGECKO
|
||||||
settings:
|
settings:
|
||||||
base_url: "https://api.coingecko.com/api/v3"
|
base_url: "https://api.coingecko.com/api/v3"
|
||||||
|
- driver: CBR
|
||||||
|
settings:
|
||||||
|
base_url: "https://www.cbr.ru"
|
||||||
|
user_agent: "Mozilla/5.0 (compatible; SendicoFX/1.0; +https://app.sendico.io)"
|
||||||
|
accept_header: "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
||||||
pairs:
|
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"
|
||||||
@@ -26,6 +35,15 @@ market:
|
|||||||
- base: "USDT"
|
- base: "USDT"
|
||||||
quote: "RUB"
|
quote: "RUB"
|
||||||
symbol: "tether:rub"
|
symbol: "tether:rub"
|
||||||
|
CBR:
|
||||||
|
- base: "USD"
|
||||||
|
quote: "RUB"
|
||||||
|
symbol: "USD"
|
||||||
|
provider: "cbr"
|
||||||
|
- base: "EUR"
|
||||||
|
quote: "RUB"
|
||||||
|
symbol: "EUR"
|
||||||
|
provider: "cbr"
|
||||||
|
|
||||||
metrics:
|
metrics:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -13,13 +13,14 @@ require (
|
|||||||
github.com/tech/sendico/fx/storage v0.0.0
|
github.com/tech/sendico/fx/storage v0.0.0
|
||||||
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
|
||||||
|
golang.org/x/net v0.48.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -31,7 +32,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -44,12 +45,11 @@ require (
|
|||||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
@@ -212,12 +212,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import (
|
|||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
"github.com/tech/sendico/fx/ingestor/internal/appversion"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
|
"github.com/tech/sendico/fx/ingestor/internal/ingestor"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/metrics"
|
"github.com/tech/sendico/fx/ingestor/internal/metrics"
|
||||||
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
mongostorage "github.com/tech/sendico/fx/storage/mongo"
|
||||||
"github.com/tech/sendico/pkg/api/routers/health"
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ type App struct {
|
|||||||
|
|
||||||
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
|
func New(logger mlogger.Logger, cfgPath string) (*App, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return nil, fmerrors.New("app: logger is nil")
|
return nil, merrors.InvalidArgument("app: logger is nil")
|
||||||
}
|
}
|
||||||
path := strings.TrimSpace(cfgPath)
|
path := strings.TrimSpace(cfgPath)
|
||||||
if path == "" {
|
if path == "" {
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"github.com/tech/sendico/pkg/db"
|
"github.com/tech/sendico/pkg/db"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,33 +25,33 @@ type Config struct {
|
|||||||
|
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, fmerrors.New("config: path is empty")
|
return nil, merrors.InvalidArgument("config: path is empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("config: failed to read file", err)
|
return nil, merrors.InternalWrap(err, "config: failed to read file")
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{}
|
cfg := &Config{}
|
||||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
return nil, fmerrors.Wrap("config: failed to parse yaml", err)
|
return nil, merrors.InternalWrap(err, "config: failed to parse yaml")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Market.Sources) == 0 {
|
if len(cfg.Market.Sources) == 0 {
|
||||||
return nil, fmerrors.New("config: no market sources configured")
|
return nil, merrors.InvalidArgument("config: no market sources configured")
|
||||||
}
|
}
|
||||||
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
sourceSet := make(map[mmodel.Driver]struct{}, len(cfg.Market.Sources))
|
||||||
for idx := range cfg.Market.Sources {
|
for idx := range cfg.Market.Sources {
|
||||||
src := &cfg.Market.Sources[idx]
|
src := &cfg.Market.Sources[idx]
|
||||||
if src.Driver.IsEmpty() {
|
if src.Driver.IsEmpty() {
|
||||||
return nil, fmerrors.New("config: market source driver is empty")
|
return nil, merrors.InvalidArgument("config: market source driver is empty")
|
||||||
}
|
}
|
||||||
sourceSet[src.Driver] = struct{}{}
|
sourceSet[src.Driver] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(cfg.Market.Pairs) == 0 {
|
if len(cfg.Market.Pairs) == 0 {
|
||||||
return nil, fmerrors.New("config: no pairs configured")
|
return nil, merrors.InvalidArgument("config: no pairs configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
normalizedPairs := make(map[string][]PairConfig, len(cfg.Market.Pairs))
|
||||||
@@ -61,10 +61,10 @@ func Load(path string) (*Config, error) {
|
|||||||
for rawSource, pairList := range cfg.Market.Pairs {
|
for rawSource, pairList := range cfg.Market.Pairs {
|
||||||
driver := mmodel.Driver(rawSource)
|
driver := mmodel.Driver(rawSource)
|
||||||
if driver.IsEmpty() {
|
if driver.IsEmpty() {
|
||||||
return nil, fmerrors.New("config: pair source is empty")
|
return nil, merrors.InvalidArgument("config: pair source is empty")
|
||||||
}
|
}
|
||||||
if _, ok := sourceSet[driver]; !ok {
|
if _, ok := sourceSet[driver]; !ok {
|
||||||
return nil, fmerrors.New("config: pair references unknown source: " + driver.String())
|
return nil, merrors.InvalidArgument("config: pair references unknown source: "+driver.String(), "pairs."+driver.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
processed := make([]PairConfig, len(pairList))
|
processed := make([]PairConfig, len(pairList))
|
||||||
@@ -74,7 +74,7 @@ func Load(path string) (*Config, error) {
|
|||||||
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
pair.Quote = strings.ToUpper(strings.TrimSpace(pair.Quote))
|
||||||
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
pair.Symbol = strings.TrimSpace(pair.Symbol)
|
||||||
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
if pair.Base == "" || pair.Quote == "" || pair.Symbol == "" {
|
||||||
return nil, fmerrors.New("config: pair entries must define base, quote, and symbol")
|
return nil, merrors.InvalidArgument("config: pair entries must define base, quote, and symbol", "pairs."+driver.String())
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(pair.Provider) == "" {
|
if strings.TrimSpace(pair.Provider) == "" {
|
||||||
pair.Provider = strings.ToLower(driver.String())
|
pair.Provider = strings.ToLower(driver.String())
|
||||||
@@ -93,7 +93,7 @@ func Load(path string) (*Config, error) {
|
|||||||
cfg.pairsBySource = pairsBySource
|
cfg.pairsBySource = pairsBySource
|
||||||
cfg.pairs = flattened
|
cfg.pairs = flattened
|
||||||
if cfg.Database == nil {
|
if cfg.Database == nil {
|
||||||
return nil, fmerrors.New("config: database configuration is required")
|
return nil, merrors.InvalidArgument("config: database configuration is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
if cfg.Metrics != nil && cfg.Metrics.Enabled {
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
package fmerrors
|
|
||||||
|
|
||||||
type Error struct {
|
|
||||||
message string
|
|
||||||
cause error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Error) Error() string {
|
|
||||||
if e == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if e.cause == nil {
|
|
||||||
return e.message
|
|
||||||
}
|
|
||||||
return e.message + ": " + e.cause.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Error) Unwrap() error {
|
|
||||||
if e == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return e.cause
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(message string) error {
|
|
||||||
return &Error{message: message}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Wrap(message string, cause error) error {
|
|
||||||
return &Error{message: message, cause: cause}
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDecimal(value string) error {
|
|
||||||
return &Error{message: "invalid decimal \"" + value + "\""}
|
|
||||||
}
|
|
||||||
@@ -6,11 +6,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market"
|
"github.com/tech/sendico/fx/ingestor/internal/market"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"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/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -26,18 +26,18 @@ type Service struct {
|
|||||||
|
|
||||||
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
func New(logger mlogger.Logger, cfg *config.Config, repo storage.Repository) (*Service, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return nil, fmerrors.New("ingestor: nil logger")
|
return nil, merrors.InvalidArgument("ingestor: nil logger")
|
||||||
}
|
}
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, fmerrors.New("ingestor: nil config")
|
return nil, merrors.InvalidArgument("ingestor: nil config")
|
||||||
}
|
}
|
||||||
if repo == nil {
|
if repo == nil {
|
||||||
return nil, fmerrors.New("ingestor: nil repository")
|
return nil, merrors.InvalidArgument("ingestor: nil repository")
|
||||||
}
|
}
|
||||||
|
|
||||||
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
connectors, err := market.BuildConnectors(logger, cfg.Market.Sources)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("build connectors", err)
|
return nil, merrors.InternalWrap(err, "build connectors")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Service{
|
return &Service{
|
||||||
@@ -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,35 +97,45 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
||||||
connector, ok := s.connectors[pair.Source]
|
connector, ok := s.connectors[pair.Source]
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmerrors.Wrap("connector not configured for source "+pair.Source.String(), nil)
|
return merrors.InvalidArgument("connector not configured for source "+pair.Source.String(), "source")
|
||||||
}
|
}
|
||||||
|
|
||||||
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
ticker, err := connector.FetchTicker(ctx, pair.Symbol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmerrors.Wrap("fetch ticker", err)
|
return merrors.InternalWrap(err, "fetch ticker: "+pair.Symbol)
|
||||||
}
|
}
|
||||||
|
|
||||||
bid, err := parseDecimal(ticker.BidPrice)
|
bid, err := parseDecimal(ticker.BidPrice)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmerrors.Wrap("parse bid price", err)
|
return merrors.InvalidArgumentWrap(err, "parse bid price", "bid")
|
||||||
}
|
}
|
||||||
ask, err := parseDecimal(ticker.AskPrice)
|
ask, err := parseDecimal(ticker.AskPrice)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmerrors.Wrap("parse ask price", err)
|
return merrors.InvalidArgumentWrap(err, "parse ask price", "ask")
|
||||||
}
|
}
|
||||||
|
|
||||||
if pair.Invert {
|
if pair.Invert {
|
||||||
@@ -166,15 +177,20 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
if err := s.rates.UpsertSnapshot(ctx, snapshot); err != nil {
|
||||||
return fmerrors.Wrap("upsert snapshot", err)
|
return merrors.InternalWrap(err, "upsert snapshot")
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -183,7 +199,7 @@ func (s *Service) upsertPair(ctx context.Context, pair config.Pair) error {
|
|||||||
func parseDecimal(value string) (*big.Rat, error) {
|
func parseDecimal(value string) (*big.Rat, error) {
|
||||||
r := new(big.Rat)
|
r := new(big.Rat)
|
||||||
if _, ok := r.SetString(value); !ok {
|
if _, ok := r.SetString(value); !ok {
|
||||||
return nil, fmerrors.NewDecimal(value)
|
return nil, merrors.InvalidArgument("invalid decimal \""+value+"\"", "value")
|
||||||
}
|
}
|
||||||
return r, nil
|
return r, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import (
|
|||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmarket "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
"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"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ func TestServiceUpsertPairInvertsPrices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestServicePollOnceReturnsFirstError(t *testing.T) {
|
func TestServicePollOnceReturnsFirstError(t *testing.T) {
|
||||||
errFetch := fmerrors.New("fetch failed")
|
errFetch := merrors.Internal("fetch failed")
|
||||||
connectorSuccess := &connectorStub{
|
connectorSuccess := &connectorStub{
|
||||||
id: mmarket.DriverBinance,
|
id: mmarket.DriverBinance,
|
||||||
ticker: &mmarket.Ticker{
|
ticker: &mmarket.Ticker{
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -60,7 +60,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
|||||||
|
|
||||||
parsed, err := url.Parse(baseURL)
|
parsed, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("binance: invalid base url", err)
|
return nil, merrors.InvalidArgumentWrap(err, "binance: invalid base url", "base_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
@@ -89,12 +89,12 @@ func (c *binanceConnector) ID() mmodel.Driver {
|
|||||||
|
|
||||||
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||||
if strings.TrimSpace(symbol) == "" {
|
if strings.TrimSpace(symbol) == "" {
|
||||||
return nil, fmerrors.New("binance: symbol is empty")
|
return nil, merrors.InvalidArgument("binance: symbol is empty", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, err := url.Parse(c.base)
|
endpoint, err := url.Parse(c.base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("binance: parse base url", err)
|
return nil, merrors.InternalWrap(err, "binance: parse base url")
|
||||||
}
|
}
|
||||||
endpoint.Path = "/api/v3/ticker/bookTicker"
|
endpoint.Path = "/api/v3/ticker/bookTicker"
|
||||||
query := endpoint.Query()
|
query := endpoint.Query()
|
||||||
@@ -103,19 +103,19 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("binance: build request", err)
|
return nil, merrors.InternalWrap(err, "binance: build request")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("Binance request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("binance: request failed", err)
|
return nil, merrors.InternalWrap(err, "binance: request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
c.logger.Warn("Binance returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||||
return nil, fmerrors.New("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
|
return nil, merrors.Internal("binance: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload struct {
|
var payload struct {
|
||||||
@@ -126,7 +126,7 @@ func (c *binanceConnector) FetchTicker(ctx context.Context, symbol string) (*mmo
|
|||||||
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||||
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("Binance decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("binance: decode response", err)
|
return nil, merrors.InternalWrap(err, "binance: decode response")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &mmodel.Ticker{
|
return &mmodel.Ticker{
|
||||||
|
|||||||
674
api/fx/ingestor/internal/market/cbr/connector.go
Normal file
674
api/fx/ingestor/internal/market/cbr/connector.go
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
package cbr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/net/html/charset"
|
||||||
|
)
|
||||||
|
|
||||||
|
type cbrConnector struct {
|
||||||
|
id mmodel.Driver
|
||||||
|
provider string
|
||||||
|
http *httpClient
|
||||||
|
base string
|
||||||
|
dailyPath string
|
||||||
|
directoryPath string
|
||||||
|
dynamicPath string
|
||||||
|
logger mlogger.Logger
|
||||||
|
|
||||||
|
byISO map[string]valuteInfo
|
||||||
|
byID map[string]valuteInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultCBRBaseURL = "https://www.cbr.ru"
|
||||||
|
const (
|
||||||
|
defaultDirectoryPath = "/scripts/XML_valFull.asp"
|
||||||
|
defaultDailyPath = "/scripts/XML_daily.asp"
|
||||||
|
defaultDynamicPath = "/scripts/XML_dynamic.asp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultDialTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultDialKeepAliveSeconds = 30 * time.Second
|
||||||
|
defaultTLSHandshakeTimeoutSeconds = 5 * time.Second
|
||||||
|
defaultResponseHeaderTimeoutSeconds = 10 * time.Second
|
||||||
|
defaultRequestTimeoutSeconds = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Connector, error) {
|
||||||
|
baseURL := defaultCBRBaseURL
|
||||||
|
provider := strings.ToLower(mmodel.DriverCBR.String())
|
||||||
|
dialTimeout := defaultDialTimeoutSeconds
|
||||||
|
dialKeepAlive := defaultDialKeepAliveSeconds
|
||||||
|
tlsHandshakeTimeout := defaultTLSHandshakeTimeoutSeconds
|
||||||
|
responseHeaderTimeout := defaultResponseHeaderTimeoutSeconds
|
||||||
|
requestTimeout := defaultRequestTimeoutSeconds
|
||||||
|
directoryPath := defaultDirectoryPath
|
||||||
|
dailyPath := defaultDailyPath
|
||||||
|
dynamicPath := defaultDynamicPath
|
||||||
|
userAgent := defaultUserAgent
|
||||||
|
acceptHeader := defaultAccept
|
||||||
|
|
||||||
|
if settings != nil {
|
||||||
|
if value, ok := settings["base_url"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
baseURL = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["provider"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
provider = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["directory_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
directoryPath = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["daily_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
dailyPath = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["dynamic_path"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
dynamicPath = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["user_agent"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
userAgent = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
if value, ok := settings["accept_header"].(string); ok && strings.TrimSpace(value) != "" {
|
||||||
|
acceptHeader = strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
dialTimeout = common.DurationSetting(settings, "dial_timeout_seconds", dialTimeout)
|
||||||
|
dialKeepAlive = common.DurationSetting(settings, "dial_keep_alive_seconds", dialKeepAlive)
|
||||||
|
tlsHandshakeTimeout = common.DurationSetting(settings, "tls_handshake_timeout_seconds", tlsHandshakeTimeout)
|
||||||
|
responseHeaderTimeout = common.DurationSetting(settings, "response_header_timeout_seconds", responseHeaderTimeout)
|
||||||
|
requestTimeout = common.DurationSetting(settings, "request_timeout_seconds", requestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidArgumentWrap(err, "cbr: invalid base url", "base_url")
|
||||||
|
}
|
||||||
|
|
||||||
|
var transport http.RoundTripper = &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}).DialContext,
|
||||||
|
TLSHandshakeTimeout: tlsHandshakeTimeout,
|
||||||
|
ResponseHeaderTimeout: responseHeaderTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
if customTransport, ok := settings["http_round_tripper"].(http.RoundTripper); ok && customTransport != nil {
|
||||||
|
transport = customTransport
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: requestTimeout,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
referer := parsed.String()
|
||||||
|
|
||||||
|
connector := &cbrConnector{
|
||||||
|
id: mmodel.DriverCBR,
|
||||||
|
provider: provider,
|
||||||
|
http: newHTTPClient(
|
||||||
|
logger,
|
||||||
|
client,
|
||||||
|
httpClientOptions{
|
||||||
|
userAgent: userAgent,
|
||||||
|
accept: acceptHeader,
|
||||||
|
referer: referer,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
base: strings.TrimRight(parsed.String(), "/"),
|
||||||
|
dailyPath: dailyPath,
|
||||||
|
directoryPath: directoryPath,
|
||||||
|
dynamicPath: dynamicPath,
|
||||||
|
logger: logger.Named("cbr"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := connector.refreshDirectory(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return connector, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) ID() mmodel.Driver {
|
||||||
|
return c.id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) FetchTicker(ctx context.Context, symbol string) (*mmodel.Ticker, error) {
|
||||||
|
isoCode, asOfDate, err := parseSymbol(symbol)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
valute, ok := c.byISO[isoCode]
|
||||||
|
if !ok {
|
||||||
|
return nil, merrors.InvalidArgument("cbr: unknown currency "+isoCode, "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
var price string
|
||||||
|
if asOfDate != nil {
|
||||||
|
price, err = c.fetchHistoricalRate(ctx, valute, *asOfDate)
|
||||||
|
} else {
|
||||||
|
price, err = c.fetchDailyRate(ctx, valute)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
return &mmodel.Ticker{
|
||||||
|
Symbol: formatSymbol(isoCode, asOfDate),
|
||||||
|
BidPrice: price,
|
||||||
|
AskPrice: price,
|
||||||
|
Provider: c.provider,
|
||||||
|
Timestamp: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) refreshDirectory() error {
|
||||||
|
endpoint, err := c.buildURL(c.directoryPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := c.http.NewRequest(context.Background(), http.MethodGet, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.InternalWrap(err, "cbr: build directory request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn(
|
||||||
|
"CBR directory request failed",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.String("endpoint", endpoint),
|
||||||
|
zap.String("referer", c.http.headerValue("Referer")),
|
||||||
|
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||||
|
)
|
||||||
|
return merrors.InternalWrap(err, "cbr: directory request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn(
|
||||||
|
"CBR directory returned non-OK status",
|
||||||
|
zap.Int("status", resp.StatusCode),
|
||||||
|
zap.String("endpoint", endpoint),
|
||||||
|
zap.String("referer", c.http.headerValue("Referer")),
|
||||||
|
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||||
|
)
|
||||||
|
return merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(resp.Body)
|
||||||
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
|
var directory valuteDirectory
|
||||||
|
if err := decoder.Decode(&directory); err != nil {
|
||||||
|
c.logger.Warn("CBR directory decode failed", zap.Error(err), zap.String("endpoint", endpoint))
|
||||||
|
return merrors.InternalWrap(err, "cbr: decode directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping, err := buildValuteMapping(c.logger.Named("mapper"), directory.Items)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("Failed to build currencies mapping", zap.Error(err), zap.String("endpoint", endpoint))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
c.byISO = mapping.byISO
|
||||||
|
c.byID = mapping.byID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) fetchDailyRate(ctx context.Context, valute valuteInfo) (string, error) {
|
||||||
|
endpoint, err := c.buildURL(c.dailyPath, nil)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("Failed to build daily fetch URL", zap.Error(err), zap.String("path", c.dailyPath))
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("Failed to request daily rate", zap.Error(err), zap.String("endpoint", endpoint))
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: build daily request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn("CBR daily request failed",
|
||||||
|
zap.Error(err), zap.String("currency", valute.ISOCharCode),
|
||||||
|
zap.String("endpoint", endpoint), zap.String("referer", c.http.headerValue("Referer")),
|
||||||
|
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||||
|
)
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: daily request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn(
|
||||||
|
"CBR daily returned non-OK status", zap.Int("status", resp.StatusCode),
|
||||||
|
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
|
||||||
|
)
|
||||||
|
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(resp.Body)
|
||||||
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
|
var payload dailyRates
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
c.logger.Warn("CBR daily decode failed", zap.Error(err),
|
||||||
|
zap.String("currency", valute.ISOCharCode), zap.String("endpoint", endpoint),
|
||||||
|
)
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: decode daily response")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := payload.find(valute.ID)
|
||||||
|
if entry == nil {
|
||||||
|
return "", merrors.NoData("cbr: currency not found in daily rates: " + valute.ISOCharCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateDailyEntry(valute, entry); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return computePrice(entry.Value, entry.Nominal)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) fetchHistoricalRate(ctx context.Context, valute valuteInfo, date time.Time) (string, error) {
|
||||||
|
query := map[string]string{
|
||||||
|
"date_req1": date.Format("02/01/2006"),
|
||||||
|
"date_req2": date.Format("02/01/2006"),
|
||||||
|
"VAL_NM_RQ": valute.ID,
|
||||||
|
}
|
||||||
|
dateStr := date.Format("2006-01-02")
|
||||||
|
endpoint, err := c.buildURL(c.dynamicPath, query)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := c.http.NewRequest(ctx, http.MethodGet, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: build historical request")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Warn(
|
||||||
|
"CBR historical request failed",
|
||||||
|
zap.String("currency", valute.ISOCharCode),
|
||||||
|
zap.String("date", dateStr),
|
||||||
|
zap.String("endpoint", endpoint),
|
||||||
|
zap.String("referer", c.http.headerValue("Referer")),
|
||||||
|
zap.String("user_agent", c.http.headerValue("User-Agent")),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: historical request failed")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
c.logger.Warn(
|
||||||
|
"CBR historical returned non-OK status",
|
||||||
|
zap.Int("status", resp.StatusCode),
|
||||||
|
zap.String("currency", valute.ISOCharCode),
|
||||||
|
zap.String("date", dateStr),
|
||||||
|
zap.String("endpoint", endpoint),
|
||||||
|
)
|
||||||
|
return "", merrors.Internal("cbr: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder := xml.NewDecoder(resp.Body)
|
||||||
|
decoder.CharsetReader = charset.NewReaderLabel
|
||||||
|
|
||||||
|
var payload dynamicRates
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
c.logger.Warn(
|
||||||
|
"CBR historical decode failed",
|
||||||
|
zap.String("currency", valute.ISOCharCode),
|
||||||
|
zap.String("date", dateStr),
|
||||||
|
zap.String("endpoint", endpoint),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: decode historical response")
|
||||||
|
}
|
||||||
|
|
||||||
|
record := payload.find(valute.ID, date)
|
||||||
|
if record == nil {
|
||||||
|
return "", merrors.NoData("cbr: historical rate not found for " + valute.ISOCharCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.Nominal != "" {
|
||||||
|
nominal, err := parseNominal(record.Nominal)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.InvalidDataType(err.Error())
|
||||||
|
}
|
||||||
|
if nominal != valute.Nominal {
|
||||||
|
return "", merrors.Internal("cbr: historical nominal mismatch for " + valute.ISOCharCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return computePrice(record.Value, strconv.FormatInt(valute.Nominal, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *cbrConnector) buildURL(path string, query map[string]string) (string, error) {
|
||||||
|
base, err := url.Parse(c.base)
|
||||||
|
if err != nil {
|
||||||
|
return "", merrors.InternalWrap(err, "cbr: parse base url")
|
||||||
|
}
|
||||||
|
base.Path = strings.TrimRight(base.Path, "/") + path
|
||||||
|
q := base.Query()
|
||||||
|
for key, value := range query {
|
||||||
|
q.Set(key, value)
|
||||||
|
}
|
||||||
|
base.RawQuery = q.Encode()
|
||||||
|
return base.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteDirectory struct {
|
||||||
|
Items []valuteItem `xml:"Item"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteItem struct {
|
||||||
|
ID string `xml:"ID,attr"`
|
||||||
|
ISOChar string `xml:"ISO_Char_Code"`
|
||||||
|
ISONum string `xml:"ISO_Num_Code"`
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
EngName string `xml:"EngName"`
|
||||||
|
NominalStr string `xml:"Nominal"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteInfo struct {
|
||||||
|
ID string
|
||||||
|
ISOCharCode string
|
||||||
|
ISONumCode string
|
||||||
|
Name string
|
||||||
|
EngName string
|
||||||
|
Nominal int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type valuteMapping struct {
|
||||||
|
byISO map[string]valuteInfo
|
||||||
|
byID map[string]valuteInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildValuteMapping(logger *zap.Logger, items []valuteItem) (*valuteMapping, error) {
|
||||||
|
byISO := make(map[string]valuteInfo, len(items))
|
||||||
|
byID := make(map[string]valuteInfo, len(items))
|
||||||
|
byNum := make(map[string]string, len(items))
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
id := strings.TrimSpace(item.ID)
|
||||||
|
isoChar := strings.ToUpper(strings.TrimSpace(item.ISOChar))
|
||||||
|
isoNum := strings.TrimSpace(item.ISONum)
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
engName := strings.TrimSpace(item.EngName)
|
||||||
|
|
||||||
|
nominal, err := parseNominal(item.NominalStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: parse directory nominal: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if id == "" || isoChar == "" {
|
||||||
|
logger.Info("Skipping invalid currency entry",
|
||||||
|
zap.String("id", id),
|
||||||
|
zap.String("iso_char", isoChar),
|
||||||
|
zap.String("name", name),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info := valuteInfo{
|
||||||
|
ID: id,
|
||||||
|
ISOCharCode: isoChar,
|
||||||
|
ISONumCode: isoNum,
|
||||||
|
Name: name,
|
||||||
|
EngName: engName,
|
||||||
|
Nominal: nominal,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle duplicate ISO char codes (e.g. DEM with different IDs / nominals).
|
||||||
|
if existing, ok := byISO[isoChar]; ok {
|
||||||
|
// Same ISO + same ID: duplicate entry, just ignore.
|
||||||
|
if existing.ID == id {
|
||||||
|
logger.Debug("Duplicate directory entry for same ISO and ID, ignoring",
|
||||||
|
zap.String("iso_code", isoChar),
|
||||||
|
zap.String("id", id),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different IDs but same ISO char.
|
||||||
|
// Choose canonical entry:
|
||||||
|
// 1) Prefer nominal == 1
|
||||||
|
// 2) Otherwise prefer smaller nominal
|
||||||
|
keepExisting := true
|
||||||
|
|
||||||
|
if existing.Nominal != 1 && info.Nominal == 1 {
|
||||||
|
keepExisting = false
|
||||||
|
} else if existing.Nominal == 1 && info.Nominal != 1 {
|
||||||
|
keepExisting = true
|
||||||
|
} else if info.Nominal < existing.Nominal {
|
||||||
|
keepExisting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if keepExisting {
|
||||||
|
logger.Warn("Ignoring duplicate ISO currency entry with less preferred nominal",
|
||||||
|
zap.String("iso_code", isoChar),
|
||||||
|
zap.String("existing_id", existing.ID),
|
||||||
|
zap.Int64("existing_nominal", existing.Nominal),
|
||||||
|
zap.String("new_id", info.ID),
|
||||||
|
zap.Int64("new_nominal", info.Nominal),
|
||||||
|
)
|
||||||
|
// We keep the old one, just skip the new.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace existing mapping with the new, more canonical one.
|
||||||
|
logger.Warn("Replacing currency mapping due to more canonical nominal",
|
||||||
|
zap.String("iso_code", isoChar),
|
||||||
|
zap.String("old_id", existing.ID),
|
||||||
|
zap.Int64("old_nominal", existing.Nominal),
|
||||||
|
zap.String("new_id", info.ID),
|
||||||
|
zap.Int64("new_nominal", info.Nominal),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update byID: drop old ID, add new one
|
||||||
|
delete(byID, existing.ID)
|
||||||
|
byID[id] = info
|
||||||
|
|
||||||
|
// Update ISO mapping
|
||||||
|
byISO[isoChar] = info
|
||||||
|
|
||||||
|
// Update numeric-code index if present
|
||||||
|
if isoNum != "" {
|
||||||
|
if existingID, ok := byNum[isoNum]; ok && existingID != id {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: duplicate ISO numeric code " + isoNum)
|
||||||
|
}
|
||||||
|
byNum[isoNum] = id
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
byID[id] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(byISO) == 0 {
|
||||||
|
return nil, merrors.InvalidDataType("cbr: empty directory received")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &valuteMapping{
|
||||||
|
byISO: byISO,
|
||||||
|
byID: byID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyRates struct {
|
||||||
|
Valutes []dailyValute `xml:"Valute"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dailyValute struct {
|
||||||
|
ID string `xml:"ID,attr"`
|
||||||
|
NumCode string `xml:"NumCode"`
|
||||||
|
CharCode string `xml:"CharCode"`
|
||||||
|
Nominal string `xml:"Nominal"`
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
Value string `xml:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dailyRates) find(id string) *dailyValute {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for idx := range d.Valutes {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(d.Valutes[idx].ID), id) {
|
||||||
|
return &d.Valutes[idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type dynamicRates struct {
|
||||||
|
Records []dynamicRecord `xml:"Record"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type dynamicRecord struct {
|
||||||
|
ID string `xml:"Id,attr"`
|
||||||
|
DateRaw string `xml:"Date,attr"`
|
||||||
|
Nominal string `xml:"Nominal"`
|
||||||
|
Value string `xml:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *dynamicRates) find(id string, date time.Time) *dynamicRecord {
|
||||||
|
if d == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
target := date.Format("02.01.2006")
|
||||||
|
for idx := range d.Records {
|
||||||
|
rec := &d.Records[idx]
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(rec.ID), id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(rec.DateRaw) == target {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDailyEntry(expected valuteInfo, entry *dailyValute) error {
|
||||||
|
if entry == nil {
|
||||||
|
return merrors.NoData("cbr: missing daily entry")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(entry.CharCode), expected.ISOCharCode) {
|
||||||
|
return merrors.Internal("cbr: char code mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
if expected.ISONumCode != "" && strings.TrimSpace(entry.NumCode) != expected.ISONumCode {
|
||||||
|
return merrors.Internal("cbr: iso numeric mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
if expected.Name != "" && strings.TrimSpace(entry.Name) != expected.Name {
|
||||||
|
return merrors.Internal("cbr: currency name mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
nominal, err := parseNominal(entry.Nominal)
|
||||||
|
if err != nil {
|
||||||
|
return merrors.InvalidDataType("cbr: parse daily nominal: " + err.Error())
|
||||||
|
}
|
||||||
|
if nominal != expected.Nominal {
|
||||||
|
return merrors.Internal("cbr: nominal mismatch for " + expected.ISOCharCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSymbol(symbol string) (string, *time.Time, error) {
|
||||||
|
trimmed := strings.TrimSpace(symbol)
|
||||||
|
if trimmed == "" {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: symbol is empty", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(trimmed, "@")
|
||||||
|
if len(parts) > 2 {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: invalid symbol format", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
iso := strings.ToUpper(strings.TrimSpace(parts[0]))
|
||||||
|
if len(iso) != 3 {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: symbol must be ISO currency code", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) == 1 {
|
||||||
|
return iso, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
datePart := strings.TrimSpace(parts[1])
|
||||||
|
if datePart == "" {
|
||||||
|
return "", nil, merrors.InvalidArgument("cbr: date component is empty", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := time.Parse("2006-01-02", datePart)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, merrors.InvalidArgumentWrap(err, "cbr: invalid date component", "symbol")
|
||||||
|
}
|
||||||
|
|
||||||
|
return iso, &parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNominal(value string) (int64, error) {
|
||||||
|
nominal, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||||
|
if err != nil || nominal <= 0 {
|
||||||
|
return 0, merrors.InvalidDataType("cbr: invalid nominal \"" + value + "\"")
|
||||||
|
}
|
||||||
|
return nominal, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func computePrice(value string, nominalStr string) (string, error) {
|
||||||
|
raw := strings.ReplaceAll(strings.TrimSpace(value), " ", "")
|
||||||
|
raw = strings.ReplaceAll(raw, ",", ".")
|
||||||
|
|
||||||
|
r := new(big.Rat)
|
||||||
|
if _, ok := r.SetString(raw); !ok {
|
||||||
|
return "", merrors.InvalidDataType("invalid decimal \"" + value + "\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
nominal, err := parseNominal(nominalStr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
den := big.NewRat(nominal, 1)
|
||||||
|
price := new(big.Rat).Quo(r, den)
|
||||||
|
return price.FloatString(8), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatSymbol(iso string, asOf *time.Time) string {
|
||||||
|
if asOf == nil {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
return iso + "@" + asOf.Format("2006-01-02")
|
||||||
|
}
|
||||||
226
api/fx/ingestor/internal/market/cbr/connector_test.go
Normal file
226
api/fx/ingestor/internal/market/cbr/connector_test.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package cbr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFetchTickerDaily(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_daily.asp": {body: dailyRatesXML},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
"request_timeout_seconds": 1,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker, err := conn.FetchTicker(context.Background(), "USD")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchTicker returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticker.Provider != "cbr" {
|
||||||
|
t.Fatalf("unexpected provider: %s", ticker.Provider)
|
||||||
|
}
|
||||||
|
if ticker.BidPrice != "95.12340000" || ticker.AskPrice != "95.12340000" {
|
||||||
|
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
|
||||||
|
}
|
||||||
|
if ticker.Symbol != "USD" {
|
||||||
|
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerValidatesDailyEntry(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_daily.asp": {body: strings.ReplaceAll(dailyRatesXML, "<CharCode>USD</CharCode>", "<CharCode>XXX</CharCode>")},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.FetchTicker(context.Background(), "USD"); err == nil {
|
||||||
|
t.Fatalf("FetchTicker expected to fail due to mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerHistorical(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_dynamic.asp": {
|
||||||
|
body: dynamicRatesXML,
|
||||||
|
check: func(r *http.Request) error {
|
||||||
|
if got := r.URL.Query().Get("VAL_NM_RQ"); got != "R01235" {
|
||||||
|
return fmt.Errorf("unexpected valute id: %s", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("date_req1"); got != "05/01/2023" {
|
||||||
|
return fmt.Errorf("unexpected date_req1: %s", got)
|
||||||
|
}
|
||||||
|
if got := r.URL.Query().Get("date_req2"); got != "05/01/2023" {
|
||||||
|
return fmt.Errorf("unexpected date_req2: %s", got)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker, err := conn.FetchTicker(context.Background(), "USD@2023-01-05")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FetchTicker returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ticker.BidPrice != "70.10000000" || ticker.AskPrice != "70.10000000" {
|
||||||
|
t.Fatalf("unexpected bid/ask: %s / %s", ticker.BidPrice, ticker.AskPrice)
|
||||||
|
}
|
||||||
|
if ticker.Symbol != "USD@2023-01-05" {
|
||||||
|
t.Fatalf("unexpected symbol: %s", ticker.Symbol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerUnknownCurrency(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/scripts/XML_valFull.asp": {body: valuteDirectoryXML},
|
||||||
|
"/scripts/XML_daily.asp": {body: dailyRatesXML},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = conn.FetchTicker(context.Background(), "ZZZ")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("FetchTicker expected to fail for unknown currency")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
|
t.Fatalf("expected invalid argument error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFetchTickerRespectsCustomPaths(t *testing.T) {
|
||||||
|
transport := &stubRoundTripper{
|
||||||
|
responses: map[string]stubResponse{
|
||||||
|
"/dir.xml": {body: valuteDirectoryXML},
|
||||||
|
"/rates.xml": {body: dailyRatesXML},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := NewConnector(zap.NewNop(), map[string]any{
|
||||||
|
"base_url": "http://cbr.test",
|
||||||
|
"directory_path": "/dir.xml",
|
||||||
|
"daily_path": "/rates.xml",
|
||||||
|
"http_round_tripper": transport,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewConnector returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.FetchTicker(context.Background(), "USD"); err != nil {
|
||||||
|
t.Fatalf("FetchTicker returned error with custom paths: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const valuteDirectoryXML = `
|
||||||
|
<Valuta name="Foreign Currency Market">
|
||||||
|
<Item ID="R01235">
|
||||||
|
<ISO_Num_Code>840</ISO_Num_Code>
|
||||||
|
<ISO_Char_Code>USD</ISO_Char_Code>
|
||||||
|
<Nominal>1</Nominal>
|
||||||
|
<Name>US Dollar</Name>
|
||||||
|
<EngName>US Dollar</EngName>
|
||||||
|
</Item>
|
||||||
|
</Valuta>`
|
||||||
|
|
||||||
|
const dailyRatesXML = `
|
||||||
|
<ValCurs Date="02.09.2024" name="Foreign Currency Market">
|
||||||
|
<Valute ID="R01235">
|
||||||
|
<NumCode>840</NumCode>
|
||||||
|
<CharCode>USD</CharCode>
|
||||||
|
<Nominal>1</Nominal>
|
||||||
|
<Name>US Dollar</Name>
|
||||||
|
<Value>95,1234</Value>
|
||||||
|
</Valute>
|
||||||
|
</ValCurs>`
|
||||||
|
|
||||||
|
const dynamicRatesXML = `
|
||||||
|
<ValCurs ID="R01235" DateRange1="05/01/2023" DateRange2="05/01/2023" name="Foreign Currency Market Dynamic">
|
||||||
|
<Record Date="05.01.2023" Id="R01235">
|
||||||
|
<Nominal>1</Nominal>
|
||||||
|
<Value>70,1</Value>
|
||||||
|
</Record>
|
||||||
|
</ValCurs>`
|
||||||
|
|
||||||
|
type stubResponse struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
check func(*http.Request) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubRoundTripper struct {
|
||||||
|
responses map[string]stubResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if s.responses == nil {
|
||||||
|
return nil, fmt.Errorf("no responses configured")
|
||||||
|
}
|
||||||
|
res, ok := s.responses[req.URL.Path]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected request path: %s", req.URL.Path)
|
||||||
|
}
|
||||||
|
if res.check != nil {
|
||||||
|
if err := res.check(req); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
status := res.status
|
||||||
|
if status == 0 {
|
||||||
|
status = http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: status,
|
||||||
|
Body: io.NopCloser(strings.NewReader(res.body)),
|
||||||
|
Header: http.Header{"Content-Type": []string{"text/xml"}},
|
||||||
|
Request: req,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
85
api/fx/ingestor/internal/market/cbr/http_client.go
Normal file
85
api/fx/ingestor/internal/market/cbr/http_client.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
"github.com/tech/sendico/fx/ingestor/internal/market/common"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -61,7 +61,7 @@ func NewConnector(logger mlogger.Logger, settings model.SettingsT) (mmodel.Conne
|
|||||||
|
|
||||||
parsed, err := url.Parse(baseURL)
|
parsed, err := url.Parse(baseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("coingecko: invalid base url", err)
|
return nil, merrors.InvalidArgumentWrap(err, "coingecko: invalid base url", "base_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
@@ -96,7 +96,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
|
|
||||||
endpoint, err := url.Parse(c.base)
|
endpoint, err := url.Parse(c.base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("coingecko: parse base url", err)
|
return nil, merrors.InternalWrap(err, "coingecko: parse base url")
|
||||||
}
|
}
|
||||||
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
|
endpoint.Path = strings.TrimRight(endpoint.Path, "/") + "/simple/price"
|
||||||
query := endpoint.Query()
|
query := endpoint.Query()
|
||||||
@@ -107,19 +107,19 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("coingecko: build request", err)
|
return nil, merrors.InternalWrap(err, "coingecko: build request")
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.client.Do(req)
|
resp, err := c.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("CoinGecko request failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("coingecko: request failed", err)
|
return nil, merrors.InternalWrap(err, "coingecko: request failed")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
c.logger.Warn("CoinGecko returned non-OK status", zap.String("symbol", symbol), zap.Int("status", resp.StatusCode))
|
||||||
return nil, fmerrors.New("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
|
return nil, merrors.Internal("coingecko: unexpected status " + strconv.Itoa(resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
decoder := json.NewDecoder(resp.Body)
|
decoder := json.NewDecoder(resp.Body)
|
||||||
@@ -128,21 +128,21 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
var payload map[string]map[string]interface{}
|
var payload map[string]map[string]interface{}
|
||||||
if err := decoder.Decode(&payload); err != nil {
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
|
c.logger.Warn("CoinGecko decode failed", zap.String("symbol", symbol), zap.Error(err))
|
||||||
return nil, fmerrors.Wrap("coingecko: decode response", err)
|
return nil, merrors.InternalWrap(err, "coingecko: decode response")
|
||||||
}
|
}
|
||||||
|
|
||||||
coinData, ok := payload[coinID]
|
coinData, ok := payload[coinID]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmerrors.New("coingecko: coin id not found in response")
|
return nil, merrors.Internal("coingecko: coin id not found in response")
|
||||||
}
|
}
|
||||||
priceValue, ok := coinData[vsCurrency]
|
priceValue, ok := coinData[vsCurrency]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmerrors.New("coingecko: vs currency not found in response")
|
return nil, merrors.Internal("coingecko: vs currency not found in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
price, ok := toFloat(priceValue)
|
price, ok := toFloat(priceValue)
|
||||||
if !ok || price <= 0 {
|
if !ok || price <= 0 {
|
||||||
return nil, fmerrors.New("coingecko: invalid price value in response")
|
return nil, merrors.Internal("coingecko: invalid price value in response")
|
||||||
}
|
}
|
||||||
|
|
||||||
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
|
priceStr := strconv.FormatFloat(price, 'f', -1, 64)
|
||||||
@@ -171,7 +171,7 @@ func (c *coingeckoConnector) FetchTicker(ctx context.Context, symbol string) (*m
|
|||||||
func parseSymbol(symbol string) (string, string, error) {
|
func parseSymbol(symbol string) (string, string, error) {
|
||||||
trimmed := strings.TrimSpace(symbol)
|
trimmed := strings.TrimSpace(symbol)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
return "", "", fmerrors.New("coingecko: symbol is empty")
|
return "", "", merrors.InvalidArgument("coingecko: symbol is empty", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
|
parts := strings.FieldsFunc(strings.ToLower(trimmed), func(r rune) bool {
|
||||||
@@ -183,13 +183,13 @@ func parseSymbol(symbol string) (string, string, error) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return "", "", fmerrors.New("coingecko: symbol must be <coin_id>/<vs_currency>")
|
return "", "", merrors.InvalidArgument("coingecko: symbol must be <coin_id>/<vs_currency>", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
coinID := strings.TrimSpace(parts[0])
|
coinID := strings.TrimSpace(parts[0])
|
||||||
vsCurrency := strings.TrimSpace(parts[1])
|
vsCurrency := strings.TrimSpace(parts[1])
|
||||||
if coinID == "" || vsCurrency == "" {
|
if coinID == "" || vsCurrency == "" {
|
||||||
return "", "", fmerrors.New("coingecko: symbol contains empty segments")
|
return "", "", merrors.InvalidArgument("coingecko: symbol contains empty segments", "symbol")
|
||||||
}
|
}
|
||||||
|
|
||||||
return coinID, vsCurrency, nil
|
return coinID, vsCurrency, nil
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
|
"github.com/tech/sendico/fx/ingestor/internal/market/binance"
|
||||||
|
"github.com/tech/sendico/fx/ingestor/internal/market/cbr"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
|
"github.com/tech/sendico/fx/ingestor/internal/market/coingecko"
|
||||||
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
mmodel "github.com/tech/sendico/fx/ingestor/internal/model"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
)
|
)
|
||||||
@@ -21,7 +22,7 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
|
|||||||
for _, cfg := range configs {
|
for _, cfg := range configs {
|
||||||
driver := mmodel.NormalizeDriver(cfg.Driver)
|
driver := mmodel.NormalizeDriver(cfg.Driver)
|
||||||
if driver.IsEmpty() {
|
if driver.IsEmpty() {
|
||||||
return nil, fmerrors.New("market: connector driver is empty")
|
return nil, merrors.InvalidArgument("market: connector driver is empty", "driver")
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -34,12 +35,14 @@ func BuildConnectors(logger mlogger.Logger, configs []model.DriverConfig[mmodel.
|
|||||||
conn, err = binance.NewConnector(logger, cfg.Settings)
|
conn, err = binance.NewConnector(logger, cfg.Settings)
|
||||||
case mmodel.DriverCoinGecko:
|
case mmodel.DriverCoinGecko:
|
||||||
conn, err = coingecko.NewConnector(logger, cfg.Settings)
|
conn, err = coingecko.NewConnector(logger, cfg.Settings)
|
||||||
|
case mmodel.DriverCBR:
|
||||||
|
conn, err = cbr.NewConnector(logger, cfg.Settings)
|
||||||
default:
|
default:
|
||||||
err = fmerrors.New("market: unsupported driver " + driver.String())
|
err = merrors.InvalidArgument("market: unsupported driver "+driver.String(), "driver")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmerrors.Wrap("market: build connector "+driver.String(), err)
|
return nil, merrors.InternalWrap(err, "market: build connector "+driver.String())
|
||||||
}
|
}
|
||||||
connectors[driver] = conn
|
connectors[driver] = conn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/config"
|
"github.com/tech/sendico/fx/ingestor/internal/config"
|
||||||
"github.com/tech/sendico/fx/ingestor/internal/fmerrors"
|
|
||||||
"github.com/tech/sendico/pkg/api/routers"
|
"github.com/tech/sendico/pkg/api/routers"
|
||||||
"github.com/tech/sendico/pkg/api/routers/health"
|
"github.com/tech/sendico/pkg/api/routers/health"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ type Server interface {
|
|||||||
|
|
||||||
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
|
func NewServer(logger mlogger.Logger, cfg *config.MetricsConfig) (Server, error) {
|
||||||
if logger == nil {
|
if logger == nil {
|
||||||
return nil, fmerrors.New("metrics: logger is nil")
|
return nil, merrors.InvalidArgument("metrics: logger is nil")
|
||||||
}
|
}
|
||||||
if cfg == nil || !cfg.Enabled {
|
if cfg == nil || !cfg.Enabled {
|
||||||
logger.Debug("Metrics disabled; using noop server")
|
logger.Debug("Metrics disabled; using noop server")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Driver string
|
|||||||
const (
|
const (
|
||||||
DriverBinance Driver = "BINANCE"
|
DriverBinance Driver = "BINANCE"
|
||||||
DriverCoinGecko Driver = "COINGECKO"
|
DriverCoinGecko Driver = "COINGECKO"
|
||||||
|
DriverCBR Driver = "CBR"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (d Driver) String() string {
|
func (d Driver) String() string {
|
||||||
|
|||||||
@@ -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.Error("Ingestor terminated with error", zap.Error(err))
|
||||||
}
|
}
|
||||||
logger.Fatal("Ingestor terminated with error", zap.Error(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("FX ingestor stopped")
|
logger.Info("FX ingestor stopped")
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ 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/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -45,10 +45,10 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.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-20251213004720-97cd9d5aeac2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -176,35 +176,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
@@ -212,12 +212,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
90
api/fx/oracle/internal/service/oracle/logging.go
Normal file
90
api/fx/oracle/internal/service/oracle/logging.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{
|
resp.Trace = trace
|
||||||
RequestRef: meta.GetRequestRef(),
|
|
||||||
IdempotencyKey: meta.GetIdempotencyKey(),
|
|
||||||
TraceRef: meta.GetTraceRef(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ require (
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
@@ -25,8 +25,8 @@ require (
|
|||||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/text v0.31.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -138,8 +138,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
|||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -147,29 +147,29 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
|||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -55,3 +55,6 @@ key_management:
|
|||||||
namespace: ""
|
namespace: ""
|
||||||
mount_path: kv
|
mount_path: kv
|
||||||
key_prefix: gateway/chain/wallets
|
key_prefix: gateway/chain/wallets
|
||||||
|
|
||||||
|
cache:
|
||||||
|
wallet_balance_ttl_seconds: 120
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ 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-20251119083800-2aa1d4cc79d7 // indirect
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // 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
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
@@ -60,7 +60,7 @@ require (
|
|||||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
@@ -79,12 +79,12 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.32.0 // indirect
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
|
|||||||
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
|
github.com/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-20251119083800-2aa1d4cc79d7 h1:uups37roJCTtR/BrJa0WoMrxt3rzgV+Qrj+TxYyJoAo=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE=
|
||||||
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251119083800-2aa1d4cc79d7/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
|
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62/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=
|
||||||
@@ -17,8 +17,8 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -207,8 +207,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -320,8 +320,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
@@ -329,12 +329,12 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -343,16 +343,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
@@ -362,12 +362,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ type Imp struct {
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
Chains []chainConfig `yaml:"chains"`
|
Chains []chainConfig `yaml:"chains"`
|
||||||
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
|
ServiceWallet serviceWalletConfig `yaml:"service_wallet"`
|
||||||
KeyManagement keymanager.Config `yaml:"key_management"`
|
KeyManagement keymanager.Config `yaml:"key_management"`
|
||||||
|
Settings gatewayservice.CacheSettings `yaml:"cache"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chainConfig struct {
|
type chainConfig struct {
|
||||||
@@ -111,11 +112,12 @@ func (i *Imp) Start() error {
|
|||||||
gatewayservice.WithServiceWallet(walletConfig),
|
gatewayservice.WithServiceWallet(walletConfig),
|
||||||
gatewayservice.WithKeyManager(keyManager),
|
gatewayservice.WithKeyManager(keyManager),
|
||||||
gatewayservice.WithTransferExecutor(executor),
|
gatewayservice.WithTransferExecutor(executor),
|
||||||
|
gatewayservice.WithSettings(cfg.Settings),
|
||||||
}
|
}
|
||||||
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
return gatewayservice.NewService(logger, repo, producer, opts...), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "chain_gateway", cfg.Config, i.debug, repoFactory, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
|
"github.com/tech/sendico/gateway/chain/storage/model"
|
||||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
@@ -14,6 +17,8 @@ import (
|
|||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const fallbackBalanceCacheTTL = 2 * time.Minute
|
||||||
|
|
||||||
type getWalletBalanceCommand struct {
|
type getWalletBalanceCommand struct {
|
||||||
deps Deps
|
deps Deps
|
||||||
}
|
}
|
||||||
@@ -48,30 +53,88 @@ func (c *getWalletBalanceCommand) Execute(ctx context.Context, req *chainv1.GetW
|
|||||||
|
|
||||||
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
balance, chainErr := onChainWalletBalance(ctx, c.deps, wallet)
|
||||||
if chainErr != nil {
|
if chainErr != nil {
|
||||||
c.deps.Logger.Warn("on-chain balance fetch failed, falling back to stored balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("on-chain balance fetch failed, attempting cached balance", zap.Error(chainErr), zap.String("wallet_ref", walletRef))
|
||||||
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
stored, err := c.deps.Storage.Wallets().GetBalance(ctx, walletRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, merrors.ErrNoData) {
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
c.deps.Logger.Warn("stored balance not found", zap.String("wallet_ref", walletRef))
|
c.deps.Logger.Warn("cached balance not found", zap.String("wallet_ref", walletRef))
|
||||||
return gsresponse.NotFound[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
||||||
}
|
}
|
||||||
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, err)
|
||||||
}
|
}
|
||||||
|
if c.isCachedBalanceStale(stored) {
|
||||||
|
c.deps.Logger.Warn("cached balance is stale",
|
||||||
|
zap.String("wallet_ref", walletRef),
|
||||||
|
zap.Time("calculated_at", stored.CalculatedAt),
|
||||||
|
zap.Duration("ttl", c.cacheTTL()),
|
||||||
|
)
|
||||||
|
return gsresponse.Auto[chainv1.GetWalletBalanceResponse](c.deps.Logger, mservice.ChainGateway, chainErr)
|
||||||
|
}
|
||||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)})
|
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: toProtoWalletBalance(stored)})
|
||||||
}
|
}
|
||||||
|
|
||||||
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{Balance: onChainBalanceToProto(balance)})
|
calculatedAt := c.now()
|
||||||
|
c.persistCachedBalance(ctx, walletRef, balance, calculatedAt)
|
||||||
|
|
||||||
|
return gsresponse.Success(&chainv1.GetWalletBalanceResponse{
|
||||||
|
Balance: onChainBalanceToProto(balance, calculatedAt),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func onChainBalanceToProto(balance *moneyv1.Money) *chainv1.WalletBalance {
|
func onChainBalanceToProto(balance *moneyv1.Money, calculatedAt time.Time) *chainv1.WalletBalance {
|
||||||
if balance == nil {
|
if balance == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
zero := &moneyv1.Money{Currency: balance.Currency, Amount: "0"}
|
zero := zeroMoney(balance.Currency)
|
||||||
return &chainv1.WalletBalance{
|
return &chainv1.WalletBalance{
|
||||||
Available: balance,
|
Available: balance,
|
||||||
PendingInbound: zero,
|
PendingInbound: zero,
|
||||||
PendingOutbound: zero,
|
PendingOutbound: zero,
|
||||||
CalculatedAt: timestamppb.Now(),
|
CalculatedAt: timestamppb.New(calculatedAt.UTC()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) persistCachedBalance(ctx context.Context, walletRef string, available *moneyv1.Money, calculatedAt time.Time) {
|
||||||
|
if available == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
record := &model.WalletBalance{
|
||||||
|
WalletRef: walletRef,
|
||||||
|
Available: shared.CloneMoney(available),
|
||||||
|
PendingInbound: zeroMoney(available.Currency),
|
||||||
|
PendingOutbound: zeroMoney(available.Currency),
|
||||||
|
CalculatedAt: calculatedAt,
|
||||||
|
}
|
||||||
|
if err := c.deps.Storage.Wallets().SaveBalance(ctx, record); err != nil {
|
||||||
|
c.deps.Logger.Warn("failed to cache wallet balance", zap.String("wallet_ref", walletRef), zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) isCachedBalanceStale(balance *model.WalletBalance) bool {
|
||||||
|
if balance == nil || balance.CalculatedAt.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return c.now().After(balance.CalculatedAt.Add(c.cacheTTL()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) cacheTTL() time.Duration {
|
||||||
|
if c.deps.BalanceCacheTTL > 0 {
|
||||||
|
return c.deps.BalanceCacheTTL
|
||||||
|
}
|
||||||
|
// Fallback to sane default if not configured.
|
||||||
|
return fallbackBalanceCacheTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *getWalletBalanceCommand) now() time.Time {
|
||||||
|
if c.deps.Clock != nil {
|
||||||
|
return c.deps.Clock.Now().UTC()
|
||||||
|
}
|
||||||
|
return time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func zeroMoney(currency string) *moneyv1.Money {
|
||||||
|
if strings.TrimSpace(currency) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &moneyv1.Money{Currency: currency, Amount: "0"}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package wallet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
"github.com/tech/sendico/gateway/chain/internal/keymanager"
|
||||||
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
|
||||||
"github.com/tech/sendico/gateway/chain/storage"
|
"github.com/tech/sendico/gateway/chain/storage"
|
||||||
|
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,6 +16,8 @@ type Deps struct {
|
|||||||
Networks map[string]shared.Network
|
Networks map[string]shared.Network
|
||||||
KeyManager keymanager.Manager
|
KeyManager keymanager.Manager
|
||||||
Storage storage.Repository
|
Storage storage.Repository
|
||||||
|
Clock clockpkg.Clock
|
||||||
|
BalanceCacheTTL time.Duration
|
||||||
EnsureRepository func(context.Context) error
|
EnsureRepository func(context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,3 +67,10 @@ func WithClock(clk clockpkg.Clock) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithSettings applies gateway settings.
|
||||||
|
func WithSettings(settings CacheSettings) Option {
|
||||||
|
return func(s *Service) {
|
||||||
|
s.settings = settings.withDefaults()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ type Service struct {
|
|||||||
producer msg.Producer
|
producer msg.Producer
|
||||||
clock clockpkg.Clock
|
clock clockpkg.Clock
|
||||||
|
|
||||||
|
settings CacheSettings
|
||||||
|
|
||||||
networks map[string]shared.Network
|
networks map[string]shared.Network
|
||||||
serviceWallet shared.ServiceWallet
|
serviceWallet shared.ServiceWallet
|
||||||
keyManager keymanager.Manager
|
keyManager keymanager.Manager
|
||||||
@@ -52,6 +54,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
storage: repo,
|
storage: repo,
|
||||||
producer: producer,
|
producer: producer,
|
||||||
clock: clockpkg.System{},
|
clock: clockpkg.System{},
|
||||||
|
settings: defaultSettings(),
|
||||||
networks: map[string]shared.Network{},
|
networks: map[string]shared.Network{},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
|
|||||||
if svc.networks == nil {
|
if svc.networks == nil {
|
||||||
svc.networks = map[string]shared.Network{}
|
svc.networks = map[string]shared.Network{}
|
||||||
}
|
}
|
||||||
|
svc.settings = svc.settings.withDefaults()
|
||||||
|
|
||||||
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
svc.commands = commands.NewRegistry(commands.RegistryDeps{
|
||||||
Wallet: commandsWalletDeps(svc),
|
Wallet: commandsWalletDeps(svc),
|
||||||
@@ -130,6 +134,8 @@ func commandsWalletDeps(s *Service) wallet.Deps {
|
|||||||
Networks: s.networks,
|
Networks: s.networks,
|
||||||
KeyManager: s.keyManager,
|
KeyManager: s.keyManager,
|
||||||
Storage: s.storage,
|
Storage: s.storage,
|
||||||
|
Clock: s.clock,
|
||||||
|
BalanceCacheTTL: s.settings.walletBalanceCacheTTL(),
|
||||||
EnsureRepository: s.ensureRepository,
|
EnsureRepository: s.ensureRepository,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
api/gateway/chain/internal/service/gateway/settings.go
Normal file
30
api/gateway/chain/internal/service/gateway/settings.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const defaultWalletBalanceCacheTTL = 120 * time.Second
|
||||||
|
|
||||||
|
// CacheSettings holds tunable gateway behaviour.
|
||||||
|
type CacheSettings struct {
|
||||||
|
WalletBalanceCacheTTLSeconds int `yaml:"wallet_balance_ttl_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultSettings() CacheSettings {
|
||||||
|
return CacheSettings{
|
||||||
|
WalletBalanceCacheTTLSeconds: int(defaultWalletBalanceCacheTTL.Seconds()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) withDefaults() CacheSettings {
|
||||||
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
|
s.WalletBalanceCacheTTLSeconds = int(defaultWalletBalanceCacheTTL.Seconds())
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s CacheSettings) walletBalanceCacheTTL() time.Duration {
|
||||||
|
if s.WalletBalanceCacheTTLSeconds <= 0 {
|
||||||
|
return defaultWalletBalanceCacheTTL
|
||||||
|
}
|
||||||
|
return time.Duration(s.WalletBalanceCacheTTLSeconds) * time.Second
|
||||||
|
}
|
||||||
@@ -13,5 +13,5 @@ func factory(logger mlogger.Logger, file string, debug bool) (server.Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
smain.RunServer("main", appversion.Create(), factory)
|
smain.RunServer("gateway", appversion.Create(), factory)
|
||||||
}
|
}
|
||||||
|
|||||||
84
api/gateway/mntx/client/client.go
Normal file
84
api/gateway/mntx/client/client.go
Normal 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)
|
||||||
|
}
|
||||||
19
api/gateway/mntx/client/config.go
Normal file
19
api/gateway/mntx/client/config.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
37
api/gateway/mntx/client/fake.go
Normal file
37
api/gateway/mntx/client/fake.go
Normal 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 }
|
||||||
@@ -12,14 +12,14 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -30,7 +30,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -45,10 +45,10 @@ require (
|
|||||||
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
go.mongodb.org/mongo-driver v1.17.6 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.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-20251213004720-97cd9d5aeac2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -178,35 +178,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
@@ -214,12 +214,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ func (i *Imp) Start() error {
|
|||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := grpcapp.NewApp(i.logger, "mntx_gateway", cfg.Config, i.debug, nil, serviceFactory)
|
app, err := grpcapp.NewApp(i.logger, "monetix", cfg.Config, i.debug, nil, serviceFactory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ func (i *Imp) loadConfig() (*config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Metrics == nil {
|
if cfg.Metrics == nil {
|
||||||
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9404"}
|
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9405"}
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ func factory(logger mlogger.Logger, file string, debug bool) (server.Application
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
smain.RunServer("mntx_gateway", appversion.Create(), factory)
|
smain.RunServer("gateway", appversion.Create(), factory)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,14 +12,14 @@ 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/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
@@ -46,10 +46,10 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.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-20251213004720-97cd9d5aeac2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -178,35 +178,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
@@ -214,12 +214,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ require (
|
|||||||
github.com/xhit/go-simple-mail/v2 v2.16.0
|
github.com/xhit/go-simple-mail/v2 v2.16.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
|
||||||
golang.org/x/text v0.31.0
|
golang.org/x/text v0.32.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -33,7 +33,7 @@ require (
|
|||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||||
@@ -48,11 +48,11 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.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-20251213004720-97cd9d5aeac2 // 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -99,8 +99,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -191,35 +191,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
@@ -227,12 +227,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
// Client exposes typed helpers around the payment orchestrator gRPC API.
|
// Client exposes typed helpers around the payment orchestrator gRPC API.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
||||||
|
QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||||
|
InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||||
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||||
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
||||||
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
||||||
@@ -29,6 +31,8 @@ type Client interface {
|
|||||||
|
|
||||||
type grpcOrchestratorClient interface {
|
type grpcOrchestratorClient interface {
|
||||||
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
|
QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error)
|
||||||
|
QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||||
|
InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||||
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
|
InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||||
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
|
CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error)
|
||||||
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
|
GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error)
|
||||||
@@ -97,6 +101,18 @@ func (c *orchestratorClient) QuotePayment(ctx context.Context, req *orchestrator
|
|||||||
return c.client.QuotePayment(ctx, req)
|
return c.client.QuotePayment(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *orchestratorClient) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.QuotePayments(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||||
|
ctx, cancel := c.callContext(ctx)
|
||||||
|
defer cancel()
|
||||||
|
return c.client.InitiatePayments(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||||
ctx, cancel := c.callContext(ctx)
|
ctx, cancel := c.callContext(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
// Fake implements Client for tests.
|
// Fake implements Client for tests.
|
||||||
type Fake struct {
|
type Fake struct {
|
||||||
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error)
|
||||||
|
QuotePaymentsFn func(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error)
|
||||||
|
InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error)
|
||||||
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error)
|
||||||
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error)
|
||||||
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error)
|
||||||
@@ -26,6 +28,20 @@ func (f *Fake) QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymen
|
|||||||
return &orchestratorv1.QuotePaymentResponse{}, nil
|
return &orchestratorv1.QuotePaymentResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Fake) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
|
||||||
|
if f.QuotePaymentsFn != nil {
|
||||||
|
return f.QuotePaymentsFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &orchestratorv1.QuotePaymentsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||||
|
if f.InitiatePaymentsFn != nil {
|
||||||
|
return f.InitiatePaymentsFn(ctx, req)
|
||||||
|
}
|
||||||
|
return &orchestratorv1.InitiatePaymentsResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) {
|
||||||
if f.InitiatePaymentFn != nil {
|
if f.InitiatePaymentFn != nil {
|
||||||
return f.InitiatePaymentFn(ctx, req)
|
return f.InitiatePaymentFn(ctx, req)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,19 +19,20 @@ 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
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||||
github.com/casbin/casbin/v2 v2.134.0 // indirect
|
github.com/casbin/casbin/v2 v2.135.0 // indirect
|
||||||
github.com/casbin/govaluate v1.10.0 // indirect
|
github.com/casbin/govaluate v1.10.0 // indirect
|
||||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
@@ -42,7 +45,7 @@ require (
|
|||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/nats-io/nats.go v1.47.0 // indirect
|
github.com/nats-io/nats.go v1.48.0 // indirect
|
||||||
github.com/nats-io/nkeys v0.4.12 // indirect
|
github.com/nats-io/nkeys v0.4.12 // indirect
|
||||||
github.com/nats-io/nuid v1.0.1 // indirect
|
github.com/nats-io/nuid v1.0.1 // indirect
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
@@ -54,10 +57,10 @@ require (
|
|||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
golang.org/x/crypto v0.45.0 // indirect
|
golang.org/x/crypto v0.46.0 // indirect
|
||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.38.0 // indirect
|
golang.org/x/sys v0.39.0 // indirect
|
||||||
golang.org/x/text v0.31.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-20251213004720-97cd9d5aeac2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
|||||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||||
github.com/casbin/casbin/v2 v2.134.0 h1:wyO3hZb487GzlGVAI2hUoHQT0ehFD+9B5P+HVG9BVTM=
|
github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk=
|
||||||
github.com/casbin/casbin/v2 v2.134.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||||
@@ -95,8 +95,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
github.com/nats-io/nats.go v1.48.0 h1:pSFyXApG+yWU/TgbKCjmm5K4wrHu86231/w84qRVR+U=
|
||||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||||
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
github.com/nats-io/nkeys v0.4.12 h1:nssm7JKOG9/x4J8II47VWCL1Ds29avyiQDRn0ckMvDc=
|
||||||
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
github.com/nats-io/nkeys v0.4.12/go.mod h1:MT59A1HYcjIcyQDJStTfaOY6vhy9XTUjOFo+SVsvpBg=
|
||||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||||
@@ -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=
|
||||||
@@ -179,35 +179,35 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
|||||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
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=
|
||||||
@@ -215,12 +215,12 @@ 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=
|
||||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20251213004720-97cd9d5aeac2 h1:2I6GHUeJ/4shcDpoUlLs/2WPnhg7yJwvXtqcMJt9liA=
|
||||||
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-20251213004720-97cd9d5aeac2/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=
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -41,10 +41,12 @@ type Imp struct {
|
|||||||
|
|
||||||
type config struct {
|
type config struct {
|
||||||
*grpcapp.Config `yaml:",inline"`
|
*grpcapp.Config `yaml:",inline"`
|
||||||
Fees clientConfig `yaml:"fees"`
|
Fees clientConfig `yaml:"fees"`
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,7 @@ func (i *Imp) Shutdown() {
|
|||||||
|
|
||||||
func (i *Imp) Start() error {
|
func (i *Imp) Start() error {
|
||||||
cfg, err := i.loadConfig()
|
cfg, err := i.loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
i.config = cfg
|
i.config = cfg
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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 "ePaymentCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("quote_payment"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) QuotePayments() *quotePaymentsCommand {
|
||||||
|
return "ePaymentsCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("quote_payments"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand {
|
||||||
|
return &initiatePaymentCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("initiate_payment"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand {
|
||||||
|
return &initiatePaymentsCommand{
|
||||||
|
engine: f.engine,
|
||||||
|
logger: f.logger.Named("initiate_payments"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,14 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent {
|
|||||||
return model.PaymentIntent{}
|
return model.PaymentIntent{}
|
||||||
}
|
}
|
||||||
intent := model.PaymentIntent{
|
intent := model.PaymentIntent{
|
||||||
Kind: modelKindFromProto(src.GetKind()),
|
Kind: modelKindFromProto(src.GetKind()),
|
||||||
Source: endpointFromProto(src.GetSource()),
|
Source: endpointFromProto(src.GetSource()),
|
||||||
Destination: endpointFromProto(src.GetDestination()),
|
Destination: endpointFromProto(src.GetDestination()),
|
||||||
Amount: cloneMoney(src.GetAmount()),
|
Amount: cloneMoney(src.GetAmount()),
|
||||||
RequiresFX: src.GetRequiresFx(),
|
RequiresFX: src.GetRequiresFx(),
|
||||||
FeePolicy: src.GetFeePolicy(),
|
FeePolicy: src.GetFeePolicy(),
|
||||||
Attributes: cloneMetadata(src.GetAttributes()),
|
SettlementMode: src.GetSettlementMode(),
|
||||||
|
Attributes: cloneMetadata(src.GetAttributes()),
|
||||||
}
|
}
|
||||||
if src.GetFx() != nil {
|
if src.GetFx() != nil {
|
||||||
intent.FX = fxIntentFromProto(src.GetFx())
|
intent.FX = fxIntentFromProto(src.GetFx())
|
||||||
@@ -67,6 +68,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 +110,7 @@ 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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +129,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 {
|
||||||
@@ -128,13 +154,14 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment {
|
|||||||
|
|
||||||
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
|
func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent {
|
||||||
intent := &orchestratorv1.PaymentIntent{
|
intent := &orchestratorv1.PaymentIntent{
|
||||||
Kind: protoKindFromModel(src.Kind),
|
Kind: protoKindFromModel(src.Kind),
|
||||||
Source: protoEndpointFromModel(src.Source),
|
Source: protoEndpointFromModel(src.Source),
|
||||||
Destination: protoEndpointFromModel(src.Destination),
|
Destination: protoEndpointFromModel(src.Destination),
|
||||||
Amount: cloneMoney(src.Amount),
|
Amount: cloneMoney(src.Amount),
|
||||||
RequiresFx: src.RequiresFX,
|
RequiresFx: src.RequiresFX,
|
||||||
FeePolicy: src.FeePolicy,
|
FeePolicy: src.FeePolicy,
|
||||||
Attributes: cloneMetadata(src.Attributes),
|
SettlementMode: src.SettlementMode,
|
||||||
|
Attributes: cloneMetadata(src.Attributes),
|
||||||
}
|
}
|
||||||
if src.FX != nil {
|
if src.FX != nil {
|
||||||
intent.Fx = protoFXIntentFromModel(src.FX)
|
intent.Fx = protoFXIntentFromModel(src.FX)
|
||||||
@@ -175,6 +202,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
|
||||||
}
|
}
|
||||||
@@ -204,6 +248,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +265,7 @@ 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,6 +446,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,6 +470,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()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/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 quotePaymentsCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
intents := req.GetIntents()
|
||||||
|
if len(intents) == 0 {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intents are required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
baseKey := strings.TrimSpace(req.GetIdempotencyKey())
|
||||||
|
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
|
||||||
|
expires := make([]time.Time, 0, len(intents))
|
||||||
|
for i, intent := range intents {
|
||||||
|
if err := requireNonNilIntent(intent); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteReq := &orchestratorv1.QuotePaymentRequest{
|
||||||
|
Meta: req.GetMeta(),
|
||||||
|
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
|
||||||
|
Intent: intent,
|
||||||
|
PreviewOnly: req.GetPreviewOnly(),
|
||||||
|
}
|
||||||
|
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, quoteReq)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quotes = append(quotes, quote)
|
||||||
|
expires = append(expires, expiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregate, err := aggregatePaymentQuotes(quotes)
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InternalWrap(err, "quote aggregation failed"))
|
||||||
|
}
|
||||||
|
expiresAt, ok := minQuoteExpiry(expires)
|
||||||
|
if !ok {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.Internal("quote expiry missing"))
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteRef := ""
|
||||||
|
if !req.GetPreviewOnly() {
|
||||||
|
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteRef = primitive.NewObjectID().Hex()
|
||||||
|
record := &model.PaymentQuoteRecord{
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
Intents: intentsFromProto(intents),
|
||||||
|
Quotes: quoteSnapshotsFromProto(quotes),
|
||||||
|
ExpiresAt: expiresAt,
|
||||||
|
}
|
||||||
|
record.SetID(primitive.NewObjectID())
|
||||||
|
record.SetOrganizationRef(orgID)
|
||||||
|
if err := quotesStore.Create(ctx, record); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
h.logger.Info("stored payment quotes", zap.String("quote_ref", quoteRef), zap.String("org_ref", orgID.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
|
||||||
|
QuoteRef: quoteRef,
|
||||||
|
Aggregate: aggregate,
|
||||||
|
Quotes: quotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type initiatePaymentsCommand struct {
|
||||||
|
engine paymentEngine
|
||||||
|
logger mlogger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] {
|
||||||
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
if req == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
||||||
|
}
|
||||||
|
_, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||||
|
if quoteRef == "" {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
record, err := quotesStore.GetByRef(ctx, orgID, quoteRef)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, storage.ErrQuoteNotFound) {
|
||||||
|
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
intents := record.Intents
|
||||||
|
quotes := record.Quotes
|
||||||
|
if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified {
|
||||||
|
intents = []model.PaymentIntent{record.Intent}
|
||||||
|
}
|
||||||
|
if len(quotes) == 0 && record.Quote != nil {
|
||||||
|
quotes = []*model.PaymentQuoteSnapshot{record.Quote}
|
||||||
|
}
|
||||||
|
if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete"))
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := ensurePaymentsStore(h.engine.Repository())
|
||||||
|
if err != nil {
|
||||||
|
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payments := make([]*orchestratorv1.Payment, 0, len(intents))
|
||||||
|
for i := range intents {
|
||||||
|
intentProto := protoIntentFromModel(intents[i])
|
||||||
|
if err := requireNonNilIntent(intentProto); err != nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
quoteProto := modelQuoteToProto(quotes[i])
|
||||||
|
if quoteProto == nil {
|
||||||
|
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
|
||||||
|
}
|
||||||
|
quoteProto.QuoteRef = quoteRef
|
||||||
|
|
||||||
|
perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents))
|
||||||
|
if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil {
|
||||||
|
payments = append(payments, toProtoPayment(existing))
|
||||||
|
continue
|
||||||
|
} else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto)
|
||||||
|
if err = store.Create(ctx, entity); err != nil {
|
||||||
|
if errors.Is(err, storage.ErrDuplicatePayment) {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists"))
|
||||||
|
}
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil {
|
||||||
|
return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payments = append(payments, toProtoPayment(entity))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments})
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package orchestrator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
|
|
||||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
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"
|
||||||
)
|
)
|
||||||
@@ -108,30 +110,101 @@ func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *money
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func computeAggregates(base, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse) (*moneyv1.Money, *moneyv1.Money) {
|
func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) {
|
||||||
if base == nil {
|
if fxQuote == nil {
|
||||||
|
return cloneMoney(intentAmount), cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
qSide := fxQuote.GetSide()
|
||||||
|
if qSide == fxv1.Side_SIDE_UNSPECIFIED {
|
||||||
|
qSide = side
|
||||||
|
}
|
||||||
|
|
||||||
|
switch qSide {
|
||||||
|
case fxv1.Side_BUY_BASE_SELL_QUOTE:
|
||||||
|
pay := cloneMoney(fxQuote.GetQuoteAmount())
|
||||||
|
settle := cloneMoney(fxQuote.GetBaseAmount())
|
||||||
|
if pay == nil {
|
||||||
|
pay = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
if settle == nil {
|
||||||
|
settle = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
return pay, settle
|
||||||
|
case fxv1.Side_SELL_BASE_BUY_QUOTE:
|
||||||
|
pay := cloneMoney(fxQuote.GetBaseAmount())
|
||||||
|
settle := cloneMoney(fxQuote.GetQuoteAmount())
|
||||||
|
if pay == nil {
|
||||||
|
pay = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
if settle == nil {
|
||||||
|
settle = cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
return pay, settle
|
||||||
|
default:
|
||||||
|
return cloneMoney(intentAmount), cloneMoney(intentAmount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.EstimateTransferFeeResponse, fxQuote *oraclev1.Quote, mode orchestratorv1.SettlementMode) (*moneyv1.Money, *moneyv1.Money) {
|
||||||
|
if pay == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
baseDecimal, err := decimalFromMoney(base)
|
debitDecimal, err := decimalFromMoney(pay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return cloneMoney(base), cloneMoney(base)
|
return cloneMoney(pay), cloneMoney(settlement)
|
||||||
}
|
|
||||||
debit := baseDecimal
|
|
||||||
settlement := baseDecimal
|
|
||||||
|
|
||||||
if feeDecimal, err := decimalFromMoneyMatching(base, fee); err == nil && feeDecimal != nil {
|
|
||||||
debit = debit.Add(*feeDecimal)
|
|
||||||
settlement = settlement.Sub(*feeDecimal)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if network != nil && network.GetNetworkFee() != nil {
|
settlementCurrency := pay.GetCurrency()
|
||||||
if networkDecimal, err := decimalFromMoneyMatching(base, network.GetNetworkFee()); err == nil && networkDecimal != nil {
|
if settlement != nil && strings.TrimSpace(settlement.GetCurrency()) != "" {
|
||||||
debit = debit.Add(*networkDecimal)
|
settlementCurrency = settlement.GetCurrency()
|
||||||
settlement = settlement.Sub(*networkDecimal)
|
}
|
||||||
|
|
||||||
|
settlementDecimal := debitDecimal
|
||||||
|
if settlement != nil {
|
||||||
|
if val, err := decimalFromMoney(settlement); err == nil {
|
||||||
|
settlementDecimal = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeMoney(base.GetCurrency(), debit), makeMoney(base.GetCurrency(), settlement)
|
applyChargeToDebit := func(m *moneyv1.Money) {
|
||||||
|
converted, err := ensureCurrency(m, pay.GetCurrency(), fxQuote)
|
||||||
|
if err != nil || converted == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val, err := decimalFromMoney(converted); err == nil {
|
||||||
|
debitDecimal = debitDecimal.Add(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyChargeToSettlement := func(m *moneyv1.Money) {
|
||||||
|
converted, err := ensureCurrency(m, settlementCurrency, fxQuote)
|
||||||
|
if err != nil || converted == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if val, err := decimalFromMoney(converted); err == nil {
|
||||||
|
settlementDecimal = settlementDecimal.Sub(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
|
||||||
|
// Sender pays the fee: keep settlement fixed, increase debit.
|
||||||
|
applyChargeToDebit(fee)
|
||||||
|
default:
|
||||||
|
// Recipient pays the fee (default): reduce settlement, keep debit fixed.
|
||||||
|
applyChargeToSettlement(fee)
|
||||||
|
}
|
||||||
|
|
||||||
|
if network != nil && network.GetNetworkFee() != nil {
|
||||||
|
switch mode {
|
||||||
|
case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED:
|
||||||
|
applyChargeToDebit(network.GetNetworkFee())
|
||||||
|
default:
|
||||||
|
applyChargeToSettlement(network.GetNetworkFee())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
|
||||||
@@ -162,6 +235,46 @@ func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quote) (*moneyv1.Money, error) {
|
||||||
|
if m == nil || strings.TrimSpace(targetCurrency) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if strings.EqualFold(m.GetCurrency(), targetCurrency) {
|
||||||
|
return cloneMoney(m), nil
|
||||||
|
}
|
||||||
|
return convertWithQuote(m, quote, targetCurrency)
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertWithQuote(m *moneyv1.Money, quote *oraclev1.Quote, targetCurrency string) (*moneyv1.Money, error) {
|
||||||
|
if m == nil || quote == nil || quote.GetPair() == nil || quote.GetPrice() == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
base := strings.TrimSpace(quote.GetPair().GetBase())
|
||||||
|
qt := strings.TrimSpace(quote.GetPair().GetQuote())
|
||||||
|
if base == "" || qt == "" || strings.TrimSpace(targetCurrency) == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
price, err := decimal.NewFromString(quote.GetPrice().GetValue())
|
||||||
|
if err != nil || price.IsZero() {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
value, err := decimalFromMoney(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(m.GetCurrency(), base) && strings.EqualFold(targetCurrency, qt):
|
||||||
|
return makeMoney(targetCurrency, value.Mul(price)), nil
|
||||||
|
case strings.EqualFold(m.GetCurrency(), qt) && strings.EqualFold(targetCurrency, base):
|
||||||
|
return makeMoney(targetCurrency, value.Div(price)), nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
|
func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote {
|
||||||
if src == nil {
|
if src == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -219,6 +332,23 @@ func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
|
||||||
|
expiry := time.Time{}
|
||||||
|
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
|
||||||
|
expiry = feeQuote.GetExpiresAt().AsTime()
|
||||||
|
}
|
||||||
|
if expiry.IsZero() {
|
||||||
|
expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond)
|
||||||
|
}
|
||||||
|
if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 {
|
||||||
|
fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC()
|
||||||
|
if fxExpiry.Before(expiry) {
|
||||||
|
expiry = fxExpiry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expiry
|
||||||
|
}
|
||||||
|
|
||||||
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown {
|
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown {
|
||||||
if quote == nil {
|
if quote == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -263,6 +393,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
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveTradeAmountsBuyBase(t *testing.T) {
|
||||||
|
fxQuote := &oraclev1.Quote{
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||||
|
BaseAmount: &moneyv1.Money{
|
||||||
|
Currency: "EUR",
|
||||||
|
Amount: "100",
|
||||||
|
},
|
||||||
|
QuoteAmount: &moneyv1.Money{
|
||||||
|
Currency: "USD",
|
||||||
|
Amount: "110",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pay, settle := resolveTradeAmounts(nil, fxQuote, fxv1.Side_SIDE_UNSPECIFIED)
|
||||||
|
if pay.GetCurrency() != "USD" || pay.GetAmount() != "110" {
|
||||||
|
t.Fatalf("expected pay amount in USD 110, got %s %s", pay.GetCurrency(), pay.GetAmount())
|
||||||
|
}
|
||||||
|
if settle.GetCurrency() != "EUR" || settle.GetAmount() != "100" {
|
||||||
|
t.Fatalf("expected settlement in EUR 100, got %s %s", settle.GetCurrency(), settle.GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeAggregatesConvertsCurrencies(t *testing.T) {
|
||||||
|
pay := &moneyv1.Money{Currency: "USD", Amount: "100"}
|
||||||
|
settle := &moneyv1.Money{Currency: "EUR", Amount: "50"}
|
||||||
|
fee := &moneyv1.Money{Currency: "USD", Amount: "10"}
|
||||||
|
network := &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Currency: "USD", Amount: "5"},
|
||||||
|
}
|
||||||
|
fxQuote := &oraclev1.Quote{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
Price: &moneyv1.Decimal{
|
||||||
|
Value: "2",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED)
|
||||||
|
if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" {
|
||||||
|
t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount())
|
||||||
|
}
|
||||||
|
if settlement.GetCurrency() != "EUR" || settlement.GetAmount() != "50" {
|
||||||
|
t.Fatalf("expected settlement 50 EUR, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComputeAggregatesRecipientPaysFee(t *testing.T) {
|
||||||
|
pay := &moneyv1.Money{Currency: "USDT", Amount: "100"}
|
||||||
|
settle := &moneyv1.Money{Currency: "RUB", Amount: "7932"} // 100 * 79.32
|
||||||
|
fee := &moneyv1.Money{Currency: "USDT", Amount: "7"} // 7% of 100
|
||||||
|
fxQuote := &oraclev1.Quote{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||||
|
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||||
|
Price: &moneyv1.Decimal{
|
||||||
|
Value: "79.32",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE)
|
||||||
|
if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" {
|
||||||
|
t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount())
|
||||||
|
}
|
||||||
|
if settlement.GetCurrency() != "RUB" || settlement.GetAmount() != "7376.76" {
|
||||||
|
t.Fatalf("expected settlement 7376.76 RUB, got %s %s", settlement.GetCurrency(), settlement.GetAmount())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"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"
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,175 +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"
|
||||||
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, error) {
|
type paymentExecutor struct {
|
||||||
intent := req.GetIntent()
|
deps *serviceDependencies
|
||||||
amount := intent.GetAmount()
|
logger mlogger.Logger
|
||||||
baseAmount := cloneMoney(amount)
|
svc *Service
|
||||||
feeQuote, err := s.quoteFees(ctx, orgRef, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
feeTotal := extractFeeTotal(feeQuote.GetLines(), amount.GetCurrency())
|
|
||||||
|
|
||||||
var networkFee *chainv1.EstimateTransferFeeResponse
|
|
||||||
if shouldEstimateNetworkFee(intent) {
|
|
||||||
networkFee, err = s.estimateNetworkFee(ctx, intent)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var fxQuote *oraclev1.Quote
|
|
||||||
if shouldRequestFX(intent) {
|
|
||||||
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
debitAmount, settlementAmount := computeAggregates(baseAmount, feeTotal, networkFee)
|
|
||||||
|
|
||||||
return &orchestratorv1.PaymentQuote{
|
|
||||||
DebitAmount: debitAmount,
|
|
||||||
ExpectedSettlementAmount: settlementAmount,
|
|
||||||
ExpectedFeeTotal: feeTotal,
|
|
||||||
FeeLines: cloneFeeLines(feeQuote.GetLines()),
|
|
||||||
FeeRules: cloneFeeRules(feeQuote.GetApplied()),
|
|
||||||
FxQuote: fxQuote,
|
|
||||||
NetworkFee: networkFee,
|
|
||||||
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*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()
|
|
||||||
feeIntent := &feesv1.Intent{
|
|
||||||
Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()),
|
|
||||||
BaseAmount: cloneMoney(intent.GetAmount()),
|
|
||||||
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 {
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -179,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 {
|
||||||
@@ -186,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 {
|
||||||
@@ -215,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")
|
||||||
@@ -245,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:
|
||||||
@@ -263,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
|
||||||
}
|
}
|
||||||
@@ -276,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
|
||||||
@@ -287,11 +169,14 @@ func (s *Service) applyFX(ctx context.Context, payment *model.Payment, quote *or
|
|||||||
if fq == nil {
|
if fq == nil {
|
||||||
return merrors.InvalidArgument("ledger: fx quote missing")
|
return merrors.InvalidArgument("ledger: fx quote missing")
|
||||||
}
|
}
|
||||||
fromMoney := cloneMoney(fq.GetBaseAmount())
|
fxSide := fxv1.Side_SIDE_UNSPECIFIED
|
||||||
|
if intent.FX != nil {
|
||||||
|
fxSide = intent.FX.Side
|
||||||
|
}
|
||||||
|
fromMoney, toMoney := resolveTradeAmounts(intent.Amount, fq, fxSide)
|
||||||
if fromMoney == nil {
|
if fromMoney == nil {
|
||||||
fromMoney = cloneMoney(intent.Amount)
|
fromMoney = cloneMoney(intent.Amount)
|
||||||
}
|
}
|
||||||
toMoney := cloneMoney(fq.GetQuoteAmount())
|
|
||||||
if toMoney == nil {
|
if toMoney == nil {
|
||||||
toMoney = cloneMoney(quote.GetExpectedSettlementAmount())
|
toMoney = cloneMoney(quote.GetExpectedSettlementAmount())
|
||||||
}
|
}
|
||||||
@@ -311,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
|
||||||
}
|
}
|
||||||
@@ -320,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
|
||||||
@@ -346,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 {
|
||||||
@@ -371,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
|
||||||
@@ -389,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
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func perIntentIdempotencyKey(base string, index int, total int) string {
|
||||||
|
base = strings.TrimSpace(base)
|
||||||
|
if base == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if total <= 1 {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d", base, index+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func minQuoteExpiry(expires []time.Time) (time.Time, bool) {
|
||||||
|
var min time.Time
|
||||||
|
for _, exp := range expires {
|
||||||
|
if exp.IsZero() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if min.IsZero() || exp.Before(min) {
|
||||||
|
min = exp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if min.IsZero() {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
return min, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregatePaymentQuotes(quotes []*orchestratorv1.PaymentQuote) (*orchestratorv1.PaymentQuoteAggregate, error) {
|
||||||
|
if len(quotes) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
debitTotals := map[string]decimal.Decimal{}
|
||||||
|
settlementTotals := map[string]decimal.Decimal{}
|
||||||
|
feeTotals := map[string]decimal.Decimal{}
|
||||||
|
networkTotals := map[string]decimal.Decimal{}
|
||||||
|
|
||||||
|
for _, quote := range quotes {
|
||||||
|
if quote == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := accumulateMoney(debitTotals, quote.GetDebitAmount()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := accumulateMoney(settlementTotals, quote.GetExpectedSettlementAmount()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := accumulateMoney(feeTotals, quote.GetExpectedFeeTotal()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if nf := quote.GetNetworkFee(); nf != nil {
|
||||||
|
if err := accumulateMoney(networkTotals, nf.GetNetworkFee()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &orchestratorv1.PaymentQuoteAggregate{
|
||||||
|
DebitAmounts: totalsToMoney(debitTotals),
|
||||||
|
ExpectedSettlementAmounts: totalsToMoney(settlementTotals),
|
||||||
|
ExpectedFeeTotals: totalsToMoney(feeTotals),
|
||||||
|
NetworkFeeTotals: totalsToMoney(networkTotals),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func accumulateMoney(totals map[string]decimal.Decimal, money *moneyv1.Money) error {
|
||||||
|
if money == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(money.GetCurrency())
|
||||||
|
if currency == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
amount, err := decimal.NewFromString(money.GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current, ok := totals[currency]; ok {
|
||||||
|
totals[currency] = current.Add(amount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
totals[currency] = amount
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalsToMoney(totals map[string]decimal.Decimal) []*moneyv1.Money {
|
||||||
|
if len(totals) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
currencies := make([]string, 0, len(totals))
|
||||||
|
for currency := range totals {
|
||||||
|
currencies = append(currencies, currency)
|
||||||
|
}
|
||||||
|
sort.Strings(currencies)
|
||||||
|
|
||||||
|
result := make([]*moneyv1.Money, 0, len(currencies))
|
||||||
|
for _, currency := range currencies {
|
||||||
|
amount := totals[currency]
|
||||||
|
result = append(result, &moneyv1.Money{
|
||||||
|
Amount: amount.String(),
|
||||||
|
Currency: currency,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func intentsFromProto(intents []*orchestratorv1.PaymentIntent) []model.PaymentIntent {
|
||||||
|
if len(intents) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]model.PaymentIntent, 0, len(intents))
|
||||||
|
for _, intent := range intents {
|
||||||
|
result = append(result, intentFromProto(intent))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func quoteSnapshotsFromProto(quotes []*orchestratorv1.PaymentQuote) []*model.PaymentQuoteSnapshot {
|
||||||
|
if len(quotes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]*model.PaymentQuoteSnapshot, 0, len(quotes))
|
||||||
|
for _, quote := range quotes {
|
||||||
|
if quote == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if snapshot := quoteSnapshotToModel(quote); snapshot != nil {
|
||||||
|
result = append(result, snapshot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAggregatePaymentQuotes(t *testing.T) {
|
||||||
|
quotes := []*orchestratorv1.PaymentQuote{
|
||||||
|
{
|
||||||
|
DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"},
|
||||||
|
ExpectedSettlementAmount: &moneyv1.Money{Amount: "8", Currency: "EUR"},
|
||||||
|
ExpectedFeeTotal: &moneyv1.Money{Amount: "1", Currency: "USD"},
|
||||||
|
NetworkFee: &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Amount: "0.5", Currency: "USD"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DebitAmount: &moneyv1.Money{Amount: "5", Currency: "USD"},
|
||||||
|
ExpectedSettlementAmount: &moneyv1.Money{Amount: "1000", Currency: "NGN"},
|
||||||
|
ExpectedFeeTotal: &moneyv1.Money{Amount: "2", Currency: "USD"},
|
||||||
|
NetworkFee: &chainv1.EstimateTransferFeeResponse{
|
||||||
|
NetworkFee: &moneyv1.Money{Amount: "100", Currency: "NGN"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
agg, err := aggregatePaymentQuotes(quotes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("aggregatePaymentQuotes returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertMoneyTotals(t, agg.GetDebitAmounts(), map[string]string{"USD": "15"})
|
||||||
|
assertMoneyTotals(t, agg.GetExpectedSettlementAmounts(), map[string]string{"EUR": "8", "NGN": "1000"})
|
||||||
|
assertMoneyTotals(t, agg.GetExpectedFeeTotals(), map[string]string{"USD": "3"})
|
||||||
|
assertMoneyTotals(t, agg.GetNetworkFeeTotals(), map[string]string{"USD": "0.5", "NGN": "100"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregatePaymentQuotesInvalidAmount(t *testing.T) {
|
||||||
|
quotes := []*orchestratorv1.PaymentQuote{
|
||||||
|
{
|
||||||
|
DebitAmount: &moneyv1.Money{Amount: "bad", Currency: "USD"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := aggregatePaymentQuotes(quotes); err == nil {
|
||||||
|
t.Fatal("expected error for invalid amount")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMinQuoteExpiry(t *testing.T) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
later := now.Add(10 * time.Minute)
|
||||||
|
earliest := now.Add(5 * time.Minute)
|
||||||
|
|
||||||
|
min, ok := minQuoteExpiry([]time.Time{later, time.Time{}, earliest})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected min expiry to be set")
|
||||||
|
}
|
||||||
|
if !min.Equal(earliest) {
|
||||||
|
t.Fatalf("expected min expiry %v, got %v", earliest, min)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := minQuoteExpiry([]time.Time{time.Time{}}); ok {
|
||||||
|
t.Fatal("expected min expiry to be unset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertMoneyTotals(t *testing.T, list []*moneyv1.Money, expected map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
got := make(map[string]decimal.Decimal, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val, err := decimal.NewFromString(item.GetAmount())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalid money amount %q: %v", item.GetAmount(), err)
|
||||||
|
}
|
||||||
|
got[item.GetCurrency()] = val
|
||||||
|
}
|
||||||
|
if len(got) != len(expected) {
|
||||||
|
t.Fatalf("expected %d totals, got %d", len(expected), len(got))
|
||||||
|
}
|
||||||
|
for currency, amount := range expected {
|
||||||
|
val, err := decimal.NewFromString(amount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("invalid expected amount %q: %v", amount, err)
|
||||||
|
}
|
||||||
|
gotVal, ok := got[currency]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing currency %s", currency)
|
||||||
|
}
|
||||||
|
if !gotVal.Equal(val) {
|
||||||
|
t.Fatalf("expected %s %s, got %s", amount, currency, gotVal.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, intent.GetSettlementMode())
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var captured oracleclient.GetQuoteParams
|
||||||
|
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
clock: testClock{now: time.Now()},
|
||||||
|
deps: serviceDependencies{
|
||||||
|
oracle: oracleDependency{
|
||||||
|
client: &oracleclient.Fake{
|
||||||
|
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||||
|
captured = params
|
||||||
|
return &oracleclient.Quote{
|
||||||
|
QuoteRef: "q",
|
||||||
|
Pair: params.Pair,
|
||||||
|
Side: params.Side,
|
||||||
|
Price: "1.1",
|
||||||
|
BaseAmount: params.BaseAmount,
|
||||||
|
QuoteAmount: params.QuoteAmount,
|
||||||
|
ExpiresAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &orchestratorv1.QuotePaymentRequest{
|
||||||
|
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||||
|
Intent: &orchestratorv1.PaymentIntent{
|
||||||
|
Amount: &moneyv1.Money{Currency: "USD", Amount: "100"},
|
||||||
|
Fx: &orchestratorv1.FXIntent{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "EUR", Quote: "USD"},
|
||||||
|
Side: fxv1.Side_BUY_BASE_SELL_QUOTE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.requestFXQuote(ctx, "org", req); err != nil {
|
||||||
|
t.Fatalf("requestFXQuote returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if captured.QuoteAmount == nil {
|
||||||
|
t.Fatal("expected quote amount to be populated")
|
||||||
|
}
|
||||||
|
if captured.BaseAmount != nil {
|
||||||
|
t.Fatal("expected base amount to be nil when using quote amount input")
|
||||||
|
}
|
||||||
|
if captured.QuoteAmount.GetCurrency() != "USD" {
|
||||||
|
t.Fatalf("expected quote amount currency USD, got %s", captured.QuoteAmount.GetCurrency())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,19 +2,13 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,14 +33,33 @@ type Service struct {
|
|||||||
storage storage.Repository
|
storage storage.Repository
|
||||||
clock clockpkg.Clock
|
clock clockpkg.Clock
|
||||||
|
|
||||||
fees feesDependency
|
deps serviceDependencies
|
||||||
ledger ledgerDependency
|
h handlerSet
|
||||||
gateway gatewayDependency
|
comp componentSet
|
||||||
oracle oracleDependency
|
|
||||||
|
|
||||||
orchestratorv1.UnimplementedPaymentOrchestratorServer
|
orchestratorv1.UnimplementedPaymentOrchestratorServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type serviceDependencies struct {
|
||||||
|
fees feesDependency
|
||||||
|
ledger ledgerDependency
|
||||||
|
gateway gatewayDependency
|
||||||
|
oracle oracleDependency
|
||||||
|
mntx mntxDependency
|
||||||
|
cardRoutes map[string]CardGatewayRoute
|
||||||
|
feeLedgerAccounts map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
|
func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) *Service {
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
@@ -67,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) {
|
||||||
@@ -79,426 +113,71 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotePayments aggregates downstream quotes for multiple intents.
|
||||||
|
func (s *Service) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) {
|
||||||
|
s.ensureHandlers()
|
||||||
|
return executeUnary(ctx, s, "QuotePayments", s.h.commands.QuotePayments().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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitiatePayments executes multiple payments using a stored quote reference.
|
||||||
|
func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) {
|
||||||
|
s.ensureHandlers()
|
||||||
|
return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().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"))
|
|
||||||
}
|
|
||||||
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, err := s.buildPaymentQuote(ctx, orgRef, req)
|
|
||||||
if 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
quote := req.GetFeeQuoteToken()
|
|
||||||
var quoteSnapshot *orchestratorv1.PaymentQuote
|
|
||||||
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{})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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"},
|
||||||
|
SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED,
|
||||||
|
}
|
||||||
|
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.Intent.SettlementMode != orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED {
|
||||||
|
t.Fatalf("settlement mode not preserved")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"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/gsresponse"
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
mo "github.com/tech/sendico/pkg/model"
|
mo "github.com/tech/sendico/pkg/model"
|
||||||
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"
|
||||||
@@ -31,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,
|
||||||
ledger: ledgerDependency{client: &ledgerclient.Fake{
|
deps: serviceDependencies{
|
||||||
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
|
ledger: ledgerDependency{client: &ledgerclient.Fake{
|
||||||
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
|
ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) {
|
||||||
},
|
return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil
|
||||||
}},
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
payment := &model.Payment{
|
payment := &model.Payment{
|
||||||
@@ -87,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,
|
||||||
gateway: gatewayDependency{client: &chainclient.Fake{
|
deps: serviceDependencies{
|
||||||
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
gateway: gatewayDependency{client: &chainclient.Fake{
|
||||||
return nil, errors.New("chain failure")
|
SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) {
|
||||||
},
|
return nil, errors.New("chain failure")
|
||||||
}},
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
payment := &model.Payment{
|
payment := &model.Payment{
|
||||||
@@ -145,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{
|
||||||
@@ -155,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)
|
||||||
}
|
}
|
||||||
@@ -188,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{
|
||||||
@@ -196,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)
|
||||||
}
|
}
|
||||||
@@ -208,11 +215,43 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
|
|||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
type stubRepository struct {
|
type stubRepository struct {
|
||||||
store *stubPaymentsStore
|
store *stubPaymentsStore
|
||||||
|
quotes storage.QuotesStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *stubRepository) Ping(context.Context) error { return nil }
|
func (r *stubRepository) Ping(context.Context) error { return nil }
|
||||||
func (r *stubRepository) Payments() storage.PaymentsStore { return r.store }
|
func (r *stubRepository) Payments() storage.PaymentsStore { return r.store }
|
||||||
|
func (r *stubRepository) Quotes() storage.QuotesStore {
|
||||||
|
if r.quotes != nil {
|
||||||
|
return r.quotes
|
||||||
|
}
|
||||||
|
return &stubQuotesStore{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubQuotesStore struct {
|
||||||
|
quotes map[string]*model.PaymentQuoteRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
|
||||||
|
if quote == nil {
|
||||||
|
return merrors.InvalidArgument("nil quote")
|
||||||
|
}
|
||||||
|
if s.quotes == nil {
|
||||||
|
s.quotes = map[string]*model.PaymentQuoteRecord{}
|
||||||
|
}
|
||||||
|
s.quotes[strings.TrimSpace(quote.QuoteRef)] = quote
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||||
|
if s.quotes == nil {
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok {
|
||||||
|
return q, nil
|
||||||
|
}
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
|
||||||
type stubPaymentsStore struct {
|
type stubPaymentsStore struct {
|
||||||
payments map[string]*model.Payment
|
payments map[string]*model.Payment
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
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"
|
||||||
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
|
||||||
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PaymentKind captures the orchestrator intent type.
|
// PaymentKind captures the orchestrator intent type.
|
||||||
@@ -57,6 +58,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 +80,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +125,15 @@ type FXIntent struct {
|
|||||||
|
|
||||||
// PaymentIntent models the requested payment operation.
|
// PaymentIntent models the requested payment operation.
|
||||||
type PaymentIntent struct {
|
type PaymentIntent struct {
|
||||||
Kind PaymentKind `bson:"kind" json:"kind"`
|
Kind PaymentKind `bson:"kind" json:"kind"`
|
||||||
Source PaymentEndpoint `bson:"source" json:"source"`
|
Source PaymentEndpoint `bson:"source" json:"source"`
|
||||||
Destination PaymentEndpoint `bson:"destination" json:"destination"`
|
Destination PaymentEndpoint `bson:"destination" json:"destination"`
|
||||||
Amount *moneyv1.Money `bson:"amount" json:"amount"`
|
Amount *moneyv1.Money `bson:"amount" json:"amount"`
|
||||||
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
|
RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"`
|
||||||
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
|
FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"`
|
||||||
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"`
|
||||||
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"`
|
||||||
|
Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PaymentQuoteSnapshot stores the latest quote info.
|
// PaymentQuoteSnapshot stores the latest quote info.
|
||||||
@@ -118,7 +145,7 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecutionRefs links to downstream systems.
|
// ExecutionRefs links to downstream systems.
|
||||||
@@ -127,6 +154,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.
|
||||||
@@ -143,6 +172,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.
|
||||||
@@ -222,5 +252,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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
api/payments/orchestrator/storage/model/quote.go
Normal file
26
api/payments/orchestrator/storage/model/quote.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/pkg/db/storable"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PaymentQuoteRecord stores a quoted payment snapshot for later execution.
|
||||||
|
type PaymentQuoteRecord struct {
|
||||||
|
storable.Base `bson:",inline" json:",inline"`
|
||||||
|
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||||
|
|
||||||
|
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
|
||||||
|
Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"`
|
||||||
|
Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"`
|
||||||
|
Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"`
|
||||||
|
Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"`
|
||||||
|
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collection implements storable.Storable.
|
||||||
|
func (*PaymentQuoteRecord) Collection() string {
|
||||||
|
return "payment_quotes"
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ type Store struct {
|
|||||||
ping func(context.Context) error
|
ping func(context.Context) error
|
||||||
|
|
||||||
payments storage.PaymentsStore
|
payments storage.PaymentsStore
|
||||||
|
quotes storage.QuotesStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
||||||
@@ -25,28 +26,37 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
|||||||
if conn == nil {
|
if conn == nil {
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
||||||
}
|
}
|
||||||
repo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
||||||
return NewWithRepository(logger, conn.Ping, repo)
|
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
|
||||||
|
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWithRepository constructs a payments repository using the provided primitives.
|
// NewWithRepository constructs a payments repository using the provided primitives.
|
||||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository) (*Store, error) {
|
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository) (*Store, error) {
|
||||||
if ping == nil {
|
if ping == nil {
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||||
}
|
}
|
||||||
if paymentsRepo == nil {
|
if paymentsRepo == nil {
|
||||||
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
|
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
|
||||||
}
|
}
|
||||||
|
if quotesRepo == nil {
|
||||||
|
return nil, merrors.InvalidArgument("payments.storage.mongo: quotes repository is nil")
|
||||||
|
}
|
||||||
|
|
||||||
childLogger := logger.Named("storage").Named("mongo")
|
childLogger := logger.Named("storage").Named("mongo")
|
||||||
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
quotesStore, err := store.NewQuotes(childLogger, quotesRepo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
result := &Store{
|
result := &Store{
|
||||||
logger: childLogger,
|
logger: childLogger,
|
||||||
ping: ping,
|
ping: ping,
|
||||||
payments: paymentsStore,
|
payments: paymentsStore,
|
||||||
|
quotes: quotesStore,
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
@@ -65,4 +75,9 @@ func (s *Store) Payments() storage.PaymentsStore {
|
|||||||
return s.payments
|
return s.payments
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Quotes returns the quotes store.
|
||||||
|
func (s *Store) Quotes() storage.QuotesStore {
|
||||||
|
return s.quotes
|
||||||
|
}
|
||||||
|
|
||||||
var _ storage.Repository = (*Store)(nil)
|
var _ storage.Repository = (*Store)(nil)
|
||||||
|
|||||||
127
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal file
127
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||||
|
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||||
|
"github.com/tech/sendico/pkg/db/repository"
|
||||||
|
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
|
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Quotes struct {
|
||||||
|
logger mlogger.Logger
|
||||||
|
repo repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQuotes constructs a Mongo-backed quotes store.
|
||||||
|
func NewQuotes(logger mlogger.Logger, repo repository.Repository) (*Quotes, error) {
|
||||||
|
if repo == nil {
|
||||||
|
return nil, merrors.InvalidArgument("quotesStore: repository is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
indexes := []*ri.Definition{
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "quoteRef", Sort: ri.Asc}},
|
||||||
|
Unique: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}},
|
||||||
|
TTL: int32Ptr(0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, def := range indexes {
|
||||||
|
if err := repo.CreateIndex(def); err != nil {
|
||||||
|
logger.Error("failed to ensure quotes index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Quotes{
|
||||||
|
logger: logger.Named("quotes"),
|
||||||
|
repo: repo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
|
||||||
|
if quote == nil {
|
||||||
|
return merrors.InvalidArgument("quotesStore: nil quote")
|
||||||
|
}
|
||||||
|
quote.QuoteRef = strings.TrimSpace(quote.QuoteRef)
|
||||||
|
if quote.QuoteRef == "" {
|
||||||
|
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||||
|
}
|
||||||
|
if quote.OrganizationRef == primitive.NilObjectID {
|
||||||
|
return merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||||
|
}
|
||||||
|
if quote.ExpiresAt.IsZero() {
|
||||||
|
return merrors.InvalidArgument("quotesStore: expires_at is required")
|
||||||
|
}
|
||||||
|
if quote.Intent.Attributes != nil {
|
||||||
|
for k, v := range quote.Intent.Attributes {
|
||||||
|
quote.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(quote.Intents) > 0 {
|
||||||
|
for i := range quote.Intents {
|
||||||
|
if quote.Intents[i].Attributes == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range quote.Intents[i].Attributes {
|
||||||
|
quote.Intents[i].Attributes[k] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quote.Update()
|
||||||
|
|
||||||
|
filter := repository.OrgFilter(quote.OrganizationRef).And(
|
||||||
|
repository.Filter("quoteRef", quote.QuoteRef),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := q.repo.Insert(ctx, quote, filter); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrDataConflict) {
|
||||||
|
return storage.ErrDuplicateQuote
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Quotes) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||||
|
quoteRef = strings.TrimSpace(quoteRef)
|
||||||
|
if quoteRef == "" {
|
||||||
|
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||||
|
}
|
||||||
|
if orgRef == primitive.NilObjectID {
|
||||||
|
return nil, merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||||
|
}
|
||||||
|
entity := &model.PaymentQuoteRecord{}
|
||||||
|
query := repository.OrgFilter(orgRef).And(repository.Filter("quoteRef", quoteRef))
|
||||||
|
if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil {
|
||||||
|
if errors.Is(err, merrors.ErrNoData) {
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) {
|
||||||
|
return nil, storage.ErrQuoteNotFound
|
||||||
|
}
|
||||||
|
return entity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ storage.QuotesStore = (*Quotes)(nil)
|
||||||
|
|
||||||
|
func int32Ptr(v int32) *int32 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user