service backend
This commit is contained in:
32
api/billing/fees/.air.toml
Normal file
32
api/billing/fees/.air.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
# Config file for Air in TOML format
|
||||
|
||||
root = "./../.."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o app -ldflags \"-X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildUser=$(whoami)' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Version=$APP_V' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Branch=$BUILD_BRANCH' -X 'github.com/tech/sendico/billing/fees/internal/appversion.Revision=$GIT_REV' -X 'github.com/tech/sendico/billing/fees/internal/appversion.BuildDate=$(date)'\""
|
||||
bin = "./app"
|
||||
full_bin = "./app --debug --config.file=config.yml"
|
||||
include_ext = ["go", "yaml", "yml"]
|
||||
exclude_dir = ["billing/fees/tmp", "pkg/.git", "billing/fees/env"]
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = true
|
||||
log = "air.log"
|
||||
delay = 0
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = 500
|
||||
args_bin = []
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
3
api/billing/fees/.gitignore
vendored
Normal file
3
api/billing/fees/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
internal/generated
|
||||
.gocache
|
||||
app
|
||||
40
api/billing/fees/config.yml
Normal file
40
api/billing/fees/config.yml
Normal file
@@ -0,0 +1,40 @@
|
||||
runtime:
|
||||
shutdown_timeout_seconds: 15
|
||||
|
||||
grpc:
|
||||
network: tcp
|
||||
address: ":50060"
|
||||
enable_reflection: true
|
||||
enable_health: true
|
||||
|
||||
metrics:
|
||||
address: ":9402"
|
||||
|
||||
database:
|
||||
driver: mongodb
|
||||
settings:
|
||||
host_env: FEES_MONGO_HOST
|
||||
port_env: FEES_MONGO_PORT
|
||||
database_env: FEES_MONGO_DATABASE
|
||||
user_env: FEES_MONGO_USER
|
||||
password_env: FEES_MONGO_PASSWORD
|
||||
auth_source_env: FEES_MONGO_AUTH_SOURCE
|
||||
replica_set_env: FEES_MONGO_REPLICA_SET
|
||||
|
||||
messaging:
|
||||
driver: NATS
|
||||
settings:
|
||||
url_env: NATS_URL
|
||||
host_env: NATS_HOST
|
||||
port_env: NATS_PORT
|
||||
username_env: NATS_USER
|
||||
password_env: NATS_PASSWORD
|
||||
broker_name: Billing Fees Service
|
||||
max_reconnects: 10
|
||||
reconnect_wait: 5
|
||||
|
||||
oracle:
|
||||
address: "sendico_fx_oracle:50051"
|
||||
dial_timeout_seconds: 5
|
||||
call_timeout_seconds: 3
|
||||
insecure: true
|
||||
1
api/billing/fees/env/.gitignore
vendored
Normal file
1
api/billing/fees/env/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env.api
|
||||
54
api/billing/fees/go.mod
Normal file
54
api/billing/fees/go.mod
Normal file
@@ -0,0 +1,54 @@
|
||||
module github.com/tech/sendico/billing/fees
|
||||
|
||||
go 1.25.3
|
||||
|
||||
replace github.com/tech/sendico/pkg => ../../pkg
|
||||
|
||||
replace github.com/tech/sendico/fx/oracle => ../../fx/oracle
|
||||
|
||||
require (
|
||||
github.com/tech/sendico/fx/oracle v0.0.0
|
||||
github.com/tech/sendico/pkg v0.1.0
|
||||
go.mongodb.org/mongo-driver v1.17.6
|
||||
go.uber.org/zap v1.27.0
|
||||
google.golang.org/grpc v1.76.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
|
||||
github.com/casbin/casbin/v2 v2.132.0 // indirect
|
||||
github.com/casbin/govaluate v1.10.0 // indirect
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/go-chi/chi/v5 v5.2.3 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/klauspost/compress v1.18.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // 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/nkeys v0.4.11 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.2 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
github.com/xdg-go/scram v1.1.2 // indirect
|
||||
github.com/xdg-go/stringprep v1.0.4 // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.43.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
|
||||
google.golang.org/protobuf v1.36.10
|
||||
)
|
||||
225
api/billing/fees/go.sum
Normal file
225
api/billing/fees/go.sum
Normal file
@@ -0,0 +1,225 @@
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
|
||||
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
|
||||
github.com/casbin/casbin/v2 v2.132.0 h1:73hGmOszGSL3hTVquwkAi98XLl3gPJ+BxB6D7G9Fxtk=
|
||||
github.com/casbin/casbin/v2 v2.132.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18=
|
||||
github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0=
|
||||
github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0 h1:w9c3bea1BGK4eZTAmk17JkY52yv/xSZDSHKji8q+z6E=
|
||||
github.com/casbin/mongodb-adapter/v3 v3.7.0/go.mod h1:F1mu4ojoJVE/8VhIMxMedhjfwRDdIXgANYs6Sd0MgVA=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
|
||||
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
|
||||
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
|
||||
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
|
||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co=
|
||||
github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 h1:mFWunSatvkQQDhpdyuFAYwyAan3hzCuma+Pz8sqvOfg=
|
||||
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
||||
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
|
||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
||||
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
|
||||
github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
|
||||
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
|
||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
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/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
|
||||
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
|
||||
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
|
||||
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8=
|
||||
github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
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.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
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/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
|
||||
github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0 h1:iXVA84s5hKMS5gn01GWOYHE3ymy/2b+0YkpFeTxB2XY=
|
||||
github.com/testcontainers/testcontainers-go/modules/mongodb v0.33.0/go.mod h1:R6tMjTojRiaoo89fh/hf7tOmfzohdqSU17R9DwSVSog=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
|
||||
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
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.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.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-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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
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.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 h1:tRPGkdGHuewF4UisLzzHHr1spKw92qLM98nIzxbC0wY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
28
api/billing/fees/internal/appversion/version.go
Normal file
28
api/billing/fees/internal/appversion/version.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package appversion
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/version"
|
||||
vf "github.com/tech/sendico/pkg/version/factory"
|
||||
)
|
||||
|
||||
// Build information populated at build time.
|
||||
var (
|
||||
Version string
|
||||
Revision string
|
||||
Branch string
|
||||
BuildUser string
|
||||
BuildDate string
|
||||
)
|
||||
|
||||
// Create initialises a version.Printer with the build details for this service.
|
||||
func Create() version.Printer {
|
||||
info := version.Info{
|
||||
Program: "Sendico Billing Fees Service",
|
||||
Revision: Revision,
|
||||
Branch: Branch,
|
||||
BuildUser: BuildUser,
|
||||
BuildDate: BuildDate,
|
||||
Version: Version,
|
||||
}
|
||||
return vf.Create(&info)
|
||||
}
|
||||
163
api/billing/fees/internal/server/internal/serverimp.go
Normal file
163
api/billing/fees/internal/server/internal/serverimp.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package serverimp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/internal/service/fees"
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
mongostorage "github.com/tech/sendico/billing/fees/storage/mongo"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server/grpcapp"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Imp struct {
|
||||
logger mlogger.Logger
|
||||
file string
|
||||
debug bool
|
||||
config *config
|
||||
app *grpcapp.App[storage.Repository]
|
||||
oracleClient oracleclient.Client
|
||||
}
|
||||
|
||||
type config struct {
|
||||
*grpcapp.Config `yaml:",inline"`
|
||||
Oracle OracleConfig `yaml:"oracle"`
|
||||
}
|
||||
|
||||
type OracleConfig struct {
|
||||
Address string `yaml:"address"`
|
||||
DialTimeoutSecs int `yaml:"dial_timeout_seconds"`
|
||||
CallTimeoutSecs int `yaml:"call_timeout_seconds"`
|
||||
InsecureTransport bool `yaml:"insecure"`
|
||||
}
|
||||
|
||||
func (c OracleConfig) dialTimeout() time.Duration {
|
||||
if c.DialTimeoutSecs <= 0 {
|
||||
return 5 * time.Second
|
||||
}
|
||||
return time.Duration(c.DialTimeoutSecs) * time.Second
|
||||
}
|
||||
|
||||
func (c OracleConfig) callTimeout() time.Duration {
|
||||
if c.CallTimeoutSecs <= 0 {
|
||||
return 3 * time.Second
|
||||
}
|
||||
return time.Duration(c.CallTimeoutSecs) * time.Second
|
||||
}
|
||||
|
||||
// Create initialises the billing fees server implementation.
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) {
|
||||
return &Imp{
|
||||
logger: logger.Named("server"),
|
||||
file: file,
|
||||
debug: debug,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (i *Imp) Shutdown() {
|
||||
if i.app == nil {
|
||||
if i.oracleClient != nil {
|
||||
_ = i.oracleClient.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
timeout := 15 * time.Second
|
||||
if i.config != nil && i.config.Runtime != nil {
|
||||
timeout = i.config.Runtime.ShutdownTimeout()
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
i.app.Shutdown(ctx)
|
||||
cancel()
|
||||
|
||||
if i.oracleClient != nil {
|
||||
_ = i.oracleClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (i *Imp) Start() error {
|
||||
cfg, err := i.loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.config = cfg
|
||||
|
||||
repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) {
|
||||
return mongostorage.New(logger, conn)
|
||||
}
|
||||
|
||||
var oracleClient oracleclient.Client
|
||||
if addr := strings.TrimSpace(cfg.Oracle.Address); addr != "" {
|
||||
dialCtx, cancel := context.WithTimeout(context.Background(), cfg.Oracle.dialTimeout())
|
||||
defer cancel()
|
||||
|
||||
oc, err := oracleclient.New(dialCtx, oracleclient.Config{
|
||||
Address: addr,
|
||||
DialTimeout: cfg.Oracle.dialTimeout(),
|
||||
CallTimeout: cfg.Oracle.callTimeout(),
|
||||
Insecure: cfg.Oracle.InsecureTransport,
|
||||
})
|
||||
if err != nil {
|
||||
i.logger.Warn("failed to initialise oracle client", zap.String("address", addr), zap.Error(err))
|
||||
} else {
|
||||
oracleClient = oc
|
||||
i.oracleClient = oc
|
||||
i.logger.Info("connected to oracle service", zap.String("address", addr))
|
||||
}
|
||||
}
|
||||
|
||||
serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) {
|
||||
opts := []fees.Option{}
|
||||
if oracleClient != nil {
|
||||
opts = append(opts, fees.WithOracleClient(oracleClient))
|
||||
}
|
||||
return fees.NewService(logger, repo, producer, opts...), nil
|
||||
}
|
||||
|
||||
app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.app = app
|
||||
|
||||
return i.app.Start()
|
||||
}
|
||||
|
||||
func (i *Imp) loadConfig() (*config, error) {
|
||||
data, err := os.ReadFile(i.file)
|
||||
if err != nil {
|
||||
i.logger.Error("Could not read configuration file", zap.String("config_file", i.file), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &config{Config: &grpcapp.Config{}}
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
i.logger.Error("Failed to parse configuration", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cfg.Runtime == nil {
|
||||
cfg.Runtime = &grpcapp.RuntimeConfig{ShutdownTimeoutSeconds: 15}
|
||||
}
|
||||
|
||||
if cfg.GRPC == nil {
|
||||
cfg.GRPC = &routers.GRPCConfig{
|
||||
Network: "tcp",
|
||||
Address: ":50060",
|
||||
EnableReflection: true,
|
||||
EnableHealth: true,
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
12
api/billing/fees/internal/server/server.go
Normal file
12
api/billing/fees/internal/server/server.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
serverimp "github.com/tech/sendico/billing/fees/internal/server/internal"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
)
|
||||
|
||||
// Create constructs the billing fees server implementation.
|
||||
func Create(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return serverimp.Create(logger, file, debug)
|
||||
}
|
||||
449
api/billing/fees/internal/service/fees/calculator.go
Normal file
449
api/billing/fees/internal/service/fees/calculator.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math/big"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
dmath "github.com/tech/sendico/pkg/decimal"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
|
||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Calculator isolates fee rule evaluation logic so it can be reused and tested.
|
||||
type Calculator interface {
|
||||
Compute(ctx context.Context, plan *model.FeePlan, intent *feesv1.Intent, bookedAt time.Time, trace *tracev1.TraceContext) (*CalculationResult, error)
|
||||
}
|
||||
|
||||
// CalculationResult contains derived fee lines and audit metadata.
|
||||
type CalculationResult struct {
|
||||
Lines []*feesv1.DerivedPostingLine
|
||||
Applied []*feesv1.AppliedRule
|
||||
FxUsed *feesv1.FXUsed
|
||||
}
|
||||
|
||||
// quoteCalculator is the default Calculator implementation.
|
||||
type fxOracle interface {
|
||||
LatestRate(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error)
|
||||
}
|
||||
|
||||
type quoteCalculator struct {
|
||||
logger mlogger.Logger
|
||||
oracle fxOracle
|
||||
}
|
||||
|
||||
func newQuoteCalculator(logger mlogger.Logger, oracle fxOracle) Calculator {
|
||||
return "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
|
||||
}
|
||||
}
|
||||
71
api/billing/fees/internal/service/fees/metrics.go
Normal file
71
api/billing/fees/internal/service/fees/metrics.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
var (
|
||||
metricsOnce sync.Once
|
||||
|
||||
quoteRequestsTotal *prometheus.CounterVec
|
||||
quoteLatency *prometheus.HistogramVec
|
||||
)
|
||||
|
||||
func initMetrics() {
|
||||
metricsOnce.Do(func() {
|
||||
quoteRequestsTotal = promauto.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Namespace: "billing",
|
||||
Subsystem: "fees",
|
||||
Name: "requests_total",
|
||||
Help: "Total number of fee service requests processed.",
|
||||
},
|
||||
[]string{"call", "trigger", "status", "fx_used"},
|
||||
)
|
||||
|
||||
quoteLatency = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Namespace: "billing",
|
||||
Subsystem: "fees",
|
||||
Name: "request_latency_seconds",
|
||||
Help: "Latency of fee service requests.",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"call", "trigger", "status", "fx_used"},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func observeMetrics(call string, trigger feesv1.Trigger, statusLabel string, fxUsed bool, took time.Duration) {
|
||||
triggerLabel := trigger.String()
|
||||
if trigger == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
triggerLabel = "TRIGGER_UNSPECIFIED"
|
||||
}
|
||||
fxLabel := strconv.FormatBool(fxUsed)
|
||||
quoteRequestsTotal.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Inc()
|
||||
quoteLatency.WithLabelValues(call, triggerLabel, statusLabel, fxLabel).Observe(took.Seconds())
|
||||
}
|
||||
|
||||
func statusFromError(err error) string {
|
||||
if err == nil {
|
||||
return "success"
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return "error"
|
||||
}
|
||||
code := st.Code()
|
||||
if code == codes.OK {
|
||||
return "success"
|
||||
}
|
||||
return strings.ToLower(code.String())
|
||||
}
|
||||
37
api/billing/fees/internal/service/fees/options.go
Normal file
37
api/billing/fees/internal/service/fees/options.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
)
|
||||
|
||||
// Option configures a Service instance.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithClock sets a custom clock implementation.
|
||||
func WithClock(clock clockpkg.Clock) Option {
|
||||
return func(s *Service) {
|
||||
if clock != nil {
|
||||
s.clock = clock
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithCalculator sets a custom calculator implementation.
|
||||
func WithCalculator(calculator Calculator) Option {
|
||||
return func(s *Service) {
|
||||
if calculator != nil {
|
||||
s.calculator = calculator
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithOracleClient wires an FX oracle client for FX trigger evaluations.
|
||||
func WithOracleClient(oracle oracleclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.oracle = oracle
|
||||
if qc, ok := s.calculator.(*quoteCalculator); ok {
|
||||
qc.oracle = oracle
|
||||
}
|
||||
}
|
||||
}
|
||||
322
api/billing/fees/internal/service/fees/service.go
Normal file
322
api/billing/fees/internal/service/fees/service.go
Normal file
@@ -0,0 +1,322 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
"github.com/tech/sendico/pkg/api/routers"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
msg "github.com/tech/sendico/pkg/messaging"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
logger mlogger.Logger
|
||||
storage storage.Repository
|
||||
producer msg.Producer
|
||||
clock clockpkg.Clock
|
||||
calculator Calculator
|
||||
oracle oracleclient.Client
|
||||
feesv1.UnimplementedFeeEngineServer
|
||||
}
|
||||
|
||||
func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Producer, opts ...Option) *Service {
|
||||
svc := &Service{
|
||||
logger: logger.Named("fees"),
|
||||
storage: repo,
|
||||
producer: producer,
|
||||
clock: clockpkg.NewSystem(),
|
||||
}
|
||||
initMetrics()
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
|
||||
if svc.clock == nil {
|
||||
svc.clock = clockpkg.NewSystem()
|
||||
}
|
||||
if svc.calculator == nil {
|
||||
svc.calculator = newQuoteCalculator(svc.logger, svc.oracle)
|
||||
}
|
||||
|
||||
return svc
|
||||
}
|
||||
|
||||
func (s *Service) Register(router routers.GRPC) error {
|
||||
return router.Register(func(reg grpc.ServiceRegistrar) {
|
||||
feesv1.RegisterFeeEngineServer(reg, s)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) {
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
}
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
}
|
||||
observeMetrics("quote", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
}()
|
||||
|
||||
if err = s.validateQuoteRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines, applied, fx, computeErr := s.computeQuote(ctx, orgRef, req.GetIntent(), req.GetPolicy(), req.GetMeta().GetTrace())
|
||||
if computeErr != nil {
|
||||
err = computeErr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp = &feesv1.QuoteFeesResponse{
|
||||
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fx,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) PrecomputeFees(ctx context.Context, req *feesv1.PrecomputeFeesRequest) (resp *feesv1.PrecomputeFeesResponse, err error) {
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
if req != nil && req.GetIntent() != nil {
|
||||
trigger = req.GetIntent().GetTrigger()
|
||||
}
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
if err == nil && resp != nil {
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
}
|
||||
observeMetrics("precompute", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
}()
|
||||
|
||||
if err = s.validatePrecomputeRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.clock.Now()
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(req.GetMeta().GetOrganizationRef())
|
||||
if parseErr != nil {
|
||||
err = status.Error(codes.InvalidArgument, "invalid organization_ref")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, req.GetIntent(), nil, req.GetMeta().GetTrace(), now)
|
||||
if computeErr != nil {
|
||||
err = computeErr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ttl := req.GetTtlMs()
|
||||
if ttl <= 0 {
|
||||
ttl = 60000
|
||||
}
|
||||
expiresAt := now.Add(time.Duration(ttl) * time.Millisecond)
|
||||
|
||||
payload := feeQuoteTokenPayload{
|
||||
OrganizationRef: req.GetMeta().GetOrganizationRef(),
|
||||
Intent: req.GetIntent(),
|
||||
ExpiresAtUnixMs: expiresAt.UnixMilli(),
|
||||
Trace: req.GetMeta().GetTrace(),
|
||||
}
|
||||
|
||||
var token string
|
||||
if token, err = encodeTokenPayload(payload); err != nil {
|
||||
s.logger.Warn("failed to encode fee quote token", zap.Error(err))
|
||||
err = status.Error(codes.Internal, "failed to encode fee quote token")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp = &feesv1.PrecomputeFeesResponse{
|
||||
Meta: &feesv1.ResponseMeta{Trace: req.GetMeta().GetTrace()},
|
||||
FeeQuoteToken: token,
|
||||
ExpiresAt: timestamppb.New(expiresAt),
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fx,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) ValidateFeeToken(ctx context.Context, req *feesv1.ValidateFeeTokenRequest) (resp *feesv1.ValidateFeeTokenResponse, err error) {
|
||||
start := s.clock.Now()
|
||||
trigger := feesv1.Trigger_TRIGGER_UNSPECIFIED
|
||||
var fxUsed bool
|
||||
defer func() {
|
||||
statusLabel := statusFromError(err)
|
||||
if err == nil && resp != nil {
|
||||
if !resp.GetValid() {
|
||||
statusLabel = "invalid"
|
||||
}
|
||||
fxUsed = resp.GetFxUsed() != nil
|
||||
if resp.GetIntent() != nil {
|
||||
trigger = resp.GetIntent().GetTrigger()
|
||||
}
|
||||
}
|
||||
observeMetrics("validate", trigger, statusLabel, fxUsed, time.Since(start))
|
||||
}()
|
||||
|
||||
if req == nil || strings.TrimSpace(req.GetFeeQuoteToken()) == "" {
|
||||
err = status.Error(codes.InvalidArgument, "fee_quote_token is required")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := s.clock.Now()
|
||||
|
||||
payload, decodeErr := decodeTokenPayload(req.GetFeeQuoteToken())
|
||||
if decodeErr != nil {
|
||||
s.logger.Warn("failed to decode fee quote token", zap.Error(decodeErr))
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
trigger = payload.Intent.GetTrigger()
|
||||
|
||||
if now.UnixMilli() > payload.ExpiresAtUnixMs {
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "expired"}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
orgRef, parseErr := primitive.ObjectIDFromHex(payload.OrganizationRef)
|
||||
if parseErr != nil {
|
||||
s.logger.Warn("token contained invalid organization reference", zap.Error(parseErr))
|
||||
resp = &feesv1.ValidateFeeTokenResponse{Meta: &feesv1.ResponseMeta{}, Valid: false, Reason: "invalid_token"}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
lines, applied, fx, computeErr := s.computeQuoteWithTime(ctx, orgRef, payload.Intent, nil, payload.Trace, now)
|
||||
if computeErr != nil {
|
||||
err = computeErr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp = &feesv1.ValidateFeeTokenResponse{
|
||||
Meta: &feesv1.ResponseMeta{Trace: payload.Trace},
|
||||
Valid: true,
|
||||
Intent: payload.Intent,
|
||||
Lines: lines,
|
||||
Applied: applied,
|
||||
FxUsed: fx,
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateQuoteRequest(req *feesv1.QuoteFeesRequest) error {
|
||||
if req == nil {
|
||||
return status.Error(codes.InvalidArgument, "request is required")
|
||||
}
|
||||
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetOrganizationRef()) == "" {
|
||||
return status.Error(codes.InvalidArgument, "meta.organization_ref is required")
|
||||
}
|
||||
if req.GetIntent() == nil {
|
||||
return status.Error(codes.InvalidArgument, "intent is required")
|
||||
}
|
||||
if req.GetIntent().GetTrigger() == feesv1.Trigger_TRIGGER_UNSPECIFIED {
|
||||
return status.Error(codes.InvalidArgument, "intent.trigger is required")
|
||||
}
|
||||
if req.GetIntent().GetBaseAmount() == nil {
|
||||
return status.Error(codes.InvalidArgument, "intent.base_amount is required")
|
||||
}
|
||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetAmount()) == "" {
|
||||
return status.Error(codes.InvalidArgument, "intent.base_amount.amount is required")
|
||||
}
|
||||
if strings.TrimSpace(req.GetIntent().GetBaseAmount().GetCurrency()) == "" {
|
||||
return status.Error(codes.InvalidArgument, "intent.base_amount.currency is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) validatePrecomputeRequest(req *feesv1.PrecomputeFeesRequest) error {
|
||||
if req == nil {
|
||||
return status.Error(codes.InvalidArgument, "request is required")
|
||||
}
|
||||
return s.validateQuoteRequest(&feesv1.QuoteFeesRequest{Meta: req.GetMeta(), Intent: req.GetIntent()})
|
||||
}
|
||||
|
||||
func (s *Service) computeQuote(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
||||
return s.computeQuoteWithTime(ctx, orgRef, intent, overrides, trace, s.clock.Now())
|
||||
}
|
||||
|
||||
func (s *Service) computeQuoteWithTime(ctx context.Context, orgRef primitive.ObjectID, intent *feesv1.Intent, overrides *feesv1.PolicyOverrides, trace *tracev1.TraceContext, now time.Time) ([]*feesv1.DerivedPostingLine, []*feesv1.AppliedRule, *feesv1.FXUsed, error) {
|
||||
bookedAt := now
|
||||
if intent.GetBookedAt() != nil && intent.GetBookedAt().IsValid() {
|
||||
bookedAt = intent.GetBookedAt().AsTime()
|
||||
}
|
||||
|
||||
plan, err := s.storage.Plans().GetActivePlan(ctx, orgRef, bookedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrFeePlanNotFound) {
|
||||
return nil, nil, nil, status.Error(codes.NotFound, "fee plan not found")
|
||||
}
|
||||
s.logger.Warn("failed to load active fee plan", zap.Error(err))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to load fee plan")
|
||||
}
|
||||
|
||||
result, calcErr := s.calculator.Compute(ctx, plan, intent, bookedAt, trace)
|
||||
if calcErr != nil {
|
||||
if errors.Is(calcErr, merrors.ErrInvalidArg) {
|
||||
return nil, nil, nil, status.Error(codes.InvalidArgument, calcErr.Error())
|
||||
}
|
||||
s.logger.Warn("failed to compute fee quote", zap.Error(calcErr))
|
||||
return nil, nil, nil, status.Error(codes.Internal, "failed to compute fee quote")
|
||||
}
|
||||
|
||||
return result.Lines, result.Applied, result.FxUsed, nil
|
||||
}
|
||||
|
||||
type feeQuoteTokenPayload struct {
|
||||
OrganizationRef string `json:"organization_ref"`
|
||||
Intent *feesv1.Intent `json:"intent"`
|
||||
ExpiresAtUnixMs int64 `json:"expires_at_unix_ms"`
|
||||
Trace *tracev1.TraceContext `json:"trace,omitempty"`
|
||||
}
|
||||
|
||||
func encodeTokenPayload(payload feeQuoteTokenPayload) (string, error) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", merrors.Internal("fees: failed to serialize token payload")
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(data), nil
|
||||
}
|
||||
|
||||
func decodeTokenPayload(token string) (feeQuoteTokenPayload, error) {
|
||||
var payload feeQuoteTokenPayload
|
||||
data, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return payload, merrors.InvalidArgument("fees: invalid token encoding")
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return payload, merrors.InvalidArgument("fees: invalid token payload")
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
476
api/billing/fees/internal/service/fees/service_test.go
Normal file
476
api/billing/fees/internal/service/fees/service_test.go
Normal file
@@ -0,0 +1,476 @@
|
||||
package fees
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
me "github.com/tech/sendico/pkg/messaging/envelope"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func TestQuoteFees_ComputesDerivedLines(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 1, 10, 16, 0, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{
|
||||
RuleID: "capture_default",
|
||||
Trigger: model.TriggerCapture,
|
||||
Priority: 10,
|
||||
Percentage: "0.029",
|
||||
FixedAmount: "0.30",
|
||||
LedgerAccountRef: "acct:fees",
|
||||
LineType: "fee",
|
||||
EntrySide: "credit",
|
||||
Rounding: "half_up",
|
||||
Metadata: map[string]string{
|
||||
"scale": "2",
|
||||
"tax_code": "VAT",
|
||||
"tax_rate": "0.20",
|
||||
},
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||
noopProducer{},
|
||||
WithClock(fixedClock{now: now}),
|
||||
)
|
||||
|
||||
req := &feesv1.QuoteFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{
|
||||
OrganizationRef: orgRef.Hex(),
|
||||
Trace: &tracev1.TraceContext{
|
||||
TraceRef: "trace-capture",
|
||||
},
|
||||
},
|
||||
Intent: &feesv1.Intent{
|
||||
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||
BaseAmount: &moneyv1.Money{
|
||||
Amount: "100.00",
|
||||
Currency: "USD",
|
||||
},
|
||||
BookedAt: timestamppb.New(now),
|
||||
Attributes: map[string]string{"channel": "card"},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := service.QuoteFees(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
|
||||
if resp.GetMeta().GetTrace().GetTraceRef() != "trace-capture" {
|
||||
t.Fatalf("expected trace_ref to round-trip, got %q", resp.GetMeta().GetTrace().GetTraceRef())
|
||||
}
|
||||
|
||||
if len(resp.GetLines()) != 1 {
|
||||
t.Fatalf("expected 1 derived line, got %d", len(resp.GetLines()))
|
||||
}
|
||||
|
||||
line := resp.GetLines()[0]
|
||||
if got := line.GetMoney().GetAmount(); got != "3.20" {
|
||||
t.Fatalf("expected fee amount 3.20, got %s", got)
|
||||
}
|
||||
if line.GetMoney().GetCurrency() != "USD" {
|
||||
t.Fatalf("expected currency USD, got %s", line.GetMoney().GetCurrency())
|
||||
}
|
||||
if line.GetLedgerAccountRef() != "acct:fees" {
|
||||
t.Fatalf("unexpected ledger account ref %s", line.GetLedgerAccountRef())
|
||||
}
|
||||
if meta := line.GetMeta(); meta["fee_rule_id"] != "capture_default" || meta["fee_plan_id"] != plan.GetID().Hex() || meta["tax_code"] != "VAT" {
|
||||
t.Fatalf("unexpected derived line metadata: %#v", meta)
|
||||
}
|
||||
|
||||
if len(resp.GetApplied()) != 1 {
|
||||
t.Fatalf("expected 1 applied rule, got %d", len(resp.GetApplied()))
|
||||
}
|
||||
|
||||
applied := resp.GetApplied()[0]
|
||||
if applied.GetTaxCode() != "VAT" || applied.GetTaxRate() != "0.20" {
|
||||
t.Fatalf("applied rule metadata mismatch: %+v", applied)
|
||||
}
|
||||
if applied.GetRounding() != moneyv1.RoundingMode_ROUND_HALF_UP {
|
||||
t.Fatalf("expected rounding HALF_UP, got %v", applied.GetRounding())
|
||||
}
|
||||
if applied.GetParameters()["scale"] != "2" {
|
||||
t.Fatalf("expected parameters to carry metadata scale, got %+v", applied.GetParameters())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_FiltersByAttributesAndDates(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 5, 20, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-24 * time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{
|
||||
RuleID: "base",
|
||||
Trigger: model.TriggerCapture,
|
||||
Priority: 1,
|
||||
Percentage: "0.10",
|
||||
LedgerAccountRef: "acct:base",
|
||||
Metadata: map[string]string{"scale": "2"},
|
||||
Rounding: "half_even",
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
},
|
||||
{
|
||||
RuleID: "future",
|
||||
Trigger: model.TriggerCapture,
|
||||
Priority: 2,
|
||||
Percentage: "0.50",
|
||||
LedgerAccountRef: "acct:future",
|
||||
Metadata: map[string]string{"scale": "2"},
|
||||
Rounding: "half_even",
|
||||
EffectiveFrom: now.Add(time.Hour),
|
||||
},
|
||||
{
|
||||
RuleID: "attr",
|
||||
Trigger: model.TriggerCapture,
|
||||
Priority: 3,
|
||||
Percentage: "0.30",
|
||||
LedgerAccountRef: "acct:attr",
|
||||
Metadata: map[string]string{"scale": "2"},
|
||||
AppliesTo: map[string]string{"region": "eu"},
|
||||
Rounding: "half_even",
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||
noopProducer{},
|
||||
WithClock(fixedClock{now: now}),
|
||||
)
|
||||
|
||||
req := &feesv1.QuoteFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||
Intent: &feesv1.Intent{
|
||||
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||
BaseAmount: &moneyv1.Money{
|
||||
Amount: "50.00",
|
||||
Currency: "EUR",
|
||||
},
|
||||
BookedAt: timestamppb.New(now),
|
||||
Attributes: map[string]string{"region": "us"},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := service.QuoteFees(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
if len(resp.GetLines()) != 1 {
|
||||
t.Fatalf("expected only base rule to fire, got %d lines", len(resp.GetLines()))
|
||||
}
|
||||
line := resp.GetLines()[0]
|
||||
if line.GetLedgerAccountRef() != "acct:base" {
|
||||
t.Fatalf("expected base rule to apply, got %s", line.GetLedgerAccountRef())
|
||||
}
|
||||
if line.GetMoney().GetAmount() != "5.00" {
|
||||
t.Fatalf("expected 5.00 amount, got %s", line.GetMoney().GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_RoundingDown(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 3, 15, 12, 0, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{
|
||||
RuleID: "round_down",
|
||||
Trigger: model.TriggerCapture,
|
||||
Priority: 1,
|
||||
FixedAmount: "0.015",
|
||||
LedgerAccountRef: "acct:round",
|
||||
Metadata: map[string]string{"scale": "2"},
|
||||
Rounding: "down",
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||
noopProducer{},
|
||||
WithClock(fixedClock{now: now}),
|
||||
)
|
||||
|
||||
req := &feesv1.QuoteFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||
Intent: &feesv1.Intent{
|
||||
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||
BaseAmount: &moneyv1.Money{
|
||||
Amount: "1.00",
|
||||
Currency: "USD",
|
||||
},
|
||||
BookedAt: timestamppb.New(now),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := service.QuoteFees(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
if len(resp.GetLines()) != 1 {
|
||||
t.Fatalf("expected single derived line, got %d", len(resp.GetLines()))
|
||||
}
|
||||
if resp.GetLines()[0].GetMoney().GetAmount() != "0.01" {
|
||||
t.Fatalf("expected rounding down to 0.01, got %s", resp.GetLines()[0].GetMoney().GetAmount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_UsesInjectedCalculator(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 6, 1, 8, 0, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
result := &CalculationResult{
|
||||
Lines: []*feesv1.DerivedPostingLine{
|
||||
{
|
||||
LedgerAccountRef: "acct:stub",
|
||||
Money: &moneyv1.Money{
|
||||
Amount: "1.23",
|
||||
Currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
Applied: []*feesv1.AppliedRule{
|
||||
{RuleId: "stub"},
|
||||
},
|
||||
}
|
||||
calc := &stubCalculator{result: result}
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||
noopProducer{},
|
||||
WithClock(fixedClock{now: now}),
|
||||
WithCalculator(calc),
|
||||
)
|
||||
|
||||
resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||
Intent: &feesv1.Intent{
|
||||
Trigger: feesv1.Trigger_TRIGGER_CAPTURE,
|
||||
BaseAmount: &moneyv1.Money{
|
||||
Amount: "10.00",
|
||||
Currency: "USD",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
if !calc.called {
|
||||
t.Fatalf("expected calculator to be invoked")
|
||||
}
|
||||
if calc.gotPlan != plan {
|
||||
t.Fatalf("expected calculator to receive plan pointer")
|
||||
}
|
||||
if len(resp.GetLines()) != len(result.Lines) {
|
||||
t.Fatalf("expected %d lines, got %d", len(result.Lines), len(resp.GetLines()))
|
||||
}
|
||||
if resp.GetLines()[0].GetLedgerAccountRef() != "acct:stub" {
|
||||
t.Fatalf("unexpected ledger account in response: %s", resp.GetLines()[0].GetLedgerAccountRef())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteFees_PopulatesFxUsed(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
now := time.Date(2024, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||
orgRef := primitive.NewObjectID()
|
||||
|
||||
plan := &model.FeePlan{
|
||||
Active: true,
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
Rules: []model.FeeRule{
|
||||
{
|
||||
RuleID: "fx_mark_up",
|
||||
Trigger: model.TriggerFXConversion,
|
||||
Priority: 1,
|
||||
Percentage: "0.03",
|
||||
LedgerAccountRef: "acct:fx",
|
||||
Metadata: map[string]string{"scale": "2"},
|
||||
Rounding: "half_even",
|
||||
EffectiveFrom: now.Add(-time.Hour),
|
||||
},
|
||||
},
|
||||
}
|
||||
plan.SetID(primitive.NewObjectID())
|
||||
plan.SetOrganizationRef(orgRef)
|
||||
|
||||
fakeOracle := &oracleclient.Fake{
|
||||
LatestRateFn: func(ctx context.Context, req oracleclient.LatestRateParams) (*oracleclient.RateSnapshot, error) {
|
||||
return &oracleclient.RateSnapshot{
|
||||
Pair: req.Pair,
|
||||
Mid: "1.2300",
|
||||
SpreadBps: "12",
|
||||
Provider: "TestProvider",
|
||||
RateRef: "rate-ref-123",
|
||||
AsOf: now.Add(-2 * time.Minute),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
service := NewService(
|
||||
zap.NewNop(),
|
||||
&stubRepository{plans: &stubPlansStore{plan: plan}},
|
||||
noopProducer{},
|
||||
WithClock(fixedClock{now: now}),
|
||||
WithOracleClient(fakeOracle),
|
||||
)
|
||||
|
||||
resp, err := service.QuoteFees(context.Background(), &feesv1.QuoteFeesRequest{
|
||||
Meta: &feesv1.RequestMeta{OrganizationRef: orgRef.Hex()},
|
||||
Intent: &feesv1.Intent{
|
||||
Trigger: feesv1.Trigger_TRIGGER_FX_CONVERSION,
|
||||
BaseAmount: &moneyv1.Money{
|
||||
Amount: "100.00",
|
||||
Currency: "USD",
|
||||
},
|
||||
Attributes: map[string]string{
|
||||
"fx_base_currency": "USD",
|
||||
"fx_quote_currency": "EUR",
|
||||
"fx_provider": "TestProvider",
|
||||
"fx_side": "buy_base",
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("QuoteFees returned error: %v", err)
|
||||
}
|
||||
|
||||
if resp.GetFxUsed() == nil {
|
||||
t.Fatalf("expected FxUsed to be populated")
|
||||
}
|
||||
fx := resp.GetFxUsed()
|
||||
if fx.GetProvider() != "TestProvider" || fx.GetRate().GetValue() != "1.2300" {
|
||||
t.Fatalf("unexpected FxUsed payload: %+v", fx)
|
||||
}
|
||||
if fx.GetPair().GetBase() != "USD" || fx.GetPair().GetQuote() != "EUR" {
|
||||
t.Fatalf("unexpected currency pair: %+v", fx.GetPair())
|
||||
}
|
||||
}
|
||||
|
||||
type stubRepository struct {
|
||||
plans storage.PlansStore
|
||||
}
|
||||
|
||||
func (s *stubRepository) Ping(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubRepository) Plans() storage.PlansStore {
|
||||
return s.plans
|
||||
}
|
||||
|
||||
type stubPlansStore struct {
|
||||
plan *model.FeePlan
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) Create(context.Context, *model.FeePlan) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) Update(context.Context, *model.FeePlan) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) Get(context.Context, primitive.ObjectID) (*model.FeePlan, error) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
|
||||
func (s *stubPlansStore) GetActivePlan(_ context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if s.plan == nil {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.GetOrganizationRef() != orgRef {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.plan.Active {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if !s.plan.EffectiveFrom.Before(at) && !s.plan.EffectiveFrom.Equal(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
if s.plan.EffectiveTo != nil && s.plan.EffectiveTo.Before(at) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
return s.plan, nil
|
||||
}
|
||||
|
||||
type noopProducer struct{}
|
||||
|
||||
func (noopProducer) SendMessage(me.Envelope) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (f fixedClock) Now() time.Time {
|
||||
return f.now
|
||||
}
|
||||
|
||||
type stubCalculator struct {
|
||||
result *CalculationResult
|
||||
err error
|
||||
called bool
|
||||
gotPlan *model.FeePlan
|
||||
bookedAt time.Time
|
||||
}
|
||||
|
||||
func (s *stubCalculator) Compute(_ context.Context, plan *model.FeePlan, _ *feesv1.Intent, bookedAt time.Time, _ *tracev1.TraceContext) (*CalculationResult, error) {
|
||||
s.called = true
|
||||
s.gotPlan = plan
|
||||
s.bookedAt = bookedAt
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.result, nil
|
||||
}
|
||||
17
api/billing/fees/main.go
Normal file
17
api/billing/fees/main.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/tech/sendico/billing/fees/internal/appversion"
|
||||
si "github.com/tech/sendico/billing/fees/internal/server"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/server"
|
||||
smain "github.com/tech/sendico/pkg/server/main"
|
||||
)
|
||||
|
||||
func factory(logger mlogger.Logger, file string, debug bool) (server.Application, error) {
|
||||
return si.Create(logger, file, debug)
|
||||
}
|
||||
|
||||
func main() {
|
||||
smain.RunServer("main", appversion.Create(), factory)
|
||||
}
|
||||
62
api/billing/fees/storage/model/plan.go
Normal file
62
api/billing/fees/storage/model/plan.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
const (
|
||||
FeePlansCollection = "fee_plans"
|
||||
)
|
||||
|
||||
// Trigger represents the event that causes a fee rule to apply.
|
||||
type Trigger string
|
||||
|
||||
const (
|
||||
TriggerUnspecified Trigger = "unspecified"
|
||||
TriggerCapture Trigger = "capture"
|
||||
TriggerRefund Trigger = "refund"
|
||||
TriggerDispute Trigger = "dispute"
|
||||
TriggerPayout Trigger = "payout"
|
||||
TriggerFXConversion Trigger = "fx_conversion"
|
||||
)
|
||||
|
||||
// FeePlan describes a collection of fee rules for an organisation.
|
||||
type FeePlan struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
model.Describable `bson:",inline" json:",inline"`
|
||||
Active bool `bson:"active" json:"active"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
Rules []FeeRule `bson:"rules,omitempty" json:"rules,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*FeePlan) Collection() string {
|
||||
return FeePlansCollection
|
||||
}
|
||||
|
||||
// FeeRule represents a single pricing rule within a plan.
|
||||
type FeeRule struct {
|
||||
RuleID string `bson:"ruleId" json:"ruleId"`
|
||||
Trigger Trigger `bson:"trigger" json:"trigger"`
|
||||
Priority int `bson:"priority" json:"priority"`
|
||||
Percentage string `bson:"percentage,omitempty" json:"percentage,omitempty"`
|
||||
FixedAmount string `bson:"fixedAmount,omitempty" json:"fixedAmount,omitempty"`
|
||||
Currency string `bson:"currency,omitempty" json:"currency,omitempty"`
|
||||
MinimumAmount string `bson:"minimumAmount,omitempty" json:"minimumAmount,omitempty"`
|
||||
MaximumAmount string `bson:"maximumAmount,omitempty" json:"maximumAmount,omitempty"`
|
||||
AppliesTo map[string]string `bson:"appliesTo,omitempty" json:"appliesTo,omitempty"`
|
||||
Formula string `bson:"formula,omitempty" json:"formula,omitempty"`
|
||||
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
|
||||
LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"`
|
||||
LineType string `bson:"lineType,omitempty" json:"lineType,omitempty"`
|
||||
EntrySide string `bson:"entrySide,omitempty" json:"entrySide,omitempty"`
|
||||
Rounding string `bson:"rounding,omitempty" json:"rounding,omitempty"`
|
||||
EffectiveFrom time.Time `bson:"effectiveFrom" json:"effectiveFrom"`
|
||||
EffectiveTo *time.Time `bson:"effectiveTo,omitempty" json:"effectiveTo,omitempty"`
|
||||
}
|
||||
69
api/billing/fees/storage/mongo/repository.go
Normal file
69
api/billing/fees/storage/mongo/repository.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/mongo/store"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
logger mlogger.Logger
|
||||
conn *db.MongoConnection
|
||||
db *mongo.Database
|
||||
|
||||
plans storage.PlansStore
|
||||
}
|
||||
|
||||
// New creates a repository backed by MongoDB for the billing fees service.
|
||||
func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
if conn == nil {
|
||||
return nil, merrors.InvalidArgument("mongo connection is nil")
|
||||
}
|
||||
|
||||
client := conn.Client()
|
||||
if client == nil {
|
||||
return nil, merrors.Internal("mongo client not initialised")
|
||||
}
|
||||
|
||||
database := conn.Database()
|
||||
result := &Store{
|
||||
logger: logger.Named("storage").Named("mongo"),
|
||||
conn: conn,
|
||||
db: database,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := result.Ping(ctx); err != nil {
|
||||
result.logger.Error("mongo ping failed during store init", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plansStore, err := store.NewPlans(result.logger, database)
|
||||
if err != nil {
|
||||
result.logger.Error("failed to initialise plans store", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
result.plans = plansStore
|
||||
|
||||
result.logger.Info("Billing fees MongoDB storage initialised")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
return s.conn.Ping(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) Plans() storage.PlansStore {
|
||||
return s.plans
|
||||
}
|
||||
|
||||
var _ storage.Repository = (*Store)(nil)
|
||||
144
api/billing/fees/storage/mongo/store/plans.go
Normal file
144
api/billing/fees/storage/mongo/store/plans.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage"
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
m "github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.mongodb.org/mongo-driver/mongo"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type plansStore struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
// NewPlans constructs a Mongo-backed PlansStore.
|
||||
func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) {
|
||||
repo := repository.CreateMongoRepository(db, mservice.FeePlans)
|
||||
|
||||
// Index for organisation lookups.
|
||||
orgIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||
{Field: "effectiveFrom", Sort: ri.Desc},
|
||||
},
|
||||
}
|
||||
if err := repo.CreateIndex(orgIndex); err != nil {
|
||||
logger.Error("failed to ensure fee plan organization index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Unique index for plan versions (per organisation + effectiveFrom).
|
||||
uniqueIndex := &ri.Definition{
|
||||
Keys: []ri.Key{
|
||||
{Field: m.OrganizationRefField, Sort: ri.Asc},
|
||||
{Field: "effectiveFrom", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
}
|
||||
if err := repo.CreateIndex(uniqueIndex); err != nil {
|
||||
logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &plansStore{
|
||||
logger: logger.Named("plans"),
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error {
|
||||
if plan == nil {
|
||||
return merrors.InvalidArgument("plansStore: nil fee plan")
|
||||
}
|
||||
if err := p.repo.Insert(ctx, plan, nil); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicateFeePlan
|
||||
}
|
||||
p.logger.Warn("failed to create fee plan", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error {
|
||||
if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() {
|
||||
return merrors.InvalidArgument("plansStore: invalid fee plan reference")
|
||||
}
|
||||
if err := p.repo.Update(ctx, plan); err != nil {
|
||||
p.logger.Warn("failed to update fee plan", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error) {
|
||||
if planRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("plansStore: zero plan reference")
|
||||
}
|
||||
result := &model.FeePlan{}
|
||||
if err := p.repo.Get(ctx, planRef, result); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) {
|
||||
if orgRef.IsZero() {
|
||||
return nil, merrors.InvalidArgument("plansStore: zero organization reference")
|
||||
}
|
||||
|
||||
limit := int64(1)
|
||||
query := repository.Query().
|
||||
Filter(repository.OrgField(), orgRef).
|
||||
Filter(repository.Field("active"), true).
|
||||
Comparison(repository.Field("effectiveFrom"), builder.Lte, at).
|
||||
Sort(repository.Field("effectiveFrom"), false).
|
||||
Limit(&limit)
|
||||
|
||||
query = query.And(
|
||||
repository.Query().Or(
|
||||
repository.Query().Filter(repository.Field("effectiveTo"), nil),
|
||||
repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, at),
|
||||
),
|
||||
)
|
||||
|
||||
var plan *model.FeePlan
|
||||
decoder := func(cursor *mongo.Cursor) error {
|
||||
target := &model.FeePlan{}
|
||||
if err := cursor.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
plan = target
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if plan == nil {
|
||||
return nil, storage.ErrFeePlanNotFound
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
var _ storage.PlansStore = (*plansStore)(nil)
|
||||
36
api/billing/fees/storage/storage.go
Normal file
36
api/billing/fees/storage/storage.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/billing/fees/storage/model"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
)
|
||||
|
||||
type storageError string
|
||||
|
||||
func (e storageError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrFeePlanNotFound indicates that a requested fee plan does not exist.
|
||||
ErrFeePlanNotFound = storageError("billing.fees.storage: fee plan not found")
|
||||
// ErrDuplicateFeePlan indicates that a unique plan constraint was violated.
|
||||
ErrDuplicateFeePlan = storageError("billing.fees.storage: duplicate fee plan")
|
||||
)
|
||||
|
||||
// Repository defines the root storage contract for the fees service.
|
||||
type Repository interface {
|
||||
Ping(ctx context.Context) error
|
||||
Plans() PlansStore
|
||||
}
|
||||
|
||||
// PlansStore exposes persistence operations for fee plans.
|
||||
type PlansStore interface {
|
||||
Create(ctx context.Context, plan *model.FeePlan) error
|
||||
Update(ctx context.Context, plan *model.FeePlan) error
|
||||
Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error)
|
||||
GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error)
|
||||
}
|
||||
Reference in New Issue
Block a user