From 9b93c5e44f6742661629db4817d4209ab4417922 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 7 May 2024 16:24:22 -0400 Subject: [PATCH 01/16] Migrate customer creation + subscriptions to lago --- api/server/shared/config/loader/loader.go | 2 +- api/types/billing_metronome.go | 4 +- go.mod | 8 +- go.sum | 28 +- internal/billing/metronome.go | 578 ---------------------- internal/billing/usage.go | 413 ++++++++++++++++ 6 files changed, 447 insertions(+), 586 deletions(-) delete mode 100644 internal/billing/metronome.go create mode 100644 internal/billing/usage.go diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index 17b6f1b8b9..eac2abded7 100644 --- a/api/server/shared/config/loader/loader.go +++ b/api/server/shared/config/loader/loader.go @@ -372,7 +372,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { } if sc.MetronomeAPIKey != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" { - metronomeClient, err = billing.NewMetronomeClient(InstanceEnvConf.ServerConf.MetronomeAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID) + metronomeClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.MetronomeAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID) if err != nil { return nil, fmt.Errorf("unable to create metronome client: %w", err) } diff --git a/api/types/billing_metronome.go b/api/types/billing_metronome.go index 4ce79daf2d..e123f35545 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_metronome.go @@ -82,8 +82,8 @@ type ListCreditGrantsRequest struct { // ListCreditGrantsResponse returns the total remaining and granted credits for a customer. type ListCreditGrantsResponse struct { - RemainingCredits float64 `json:"remaining_credits"` - GrantedCredits float64 `json:"granted_credits"` + RemainingBalanceCents int `json:"remaining_credits"` + GrantedBalanceCents int `json:"granted_credits"` } // ListCustomerUsageRequest is the request to list usage for a customer diff --git a/go.mod b/go.mod index 353d86277b..a0c0e16432 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/spf13/viper v1.10.0 github.com/stretchr/testify v1.9.0 golang.org/x/crypto v0.21.0 - golang.org/x/net v0.22.0 + golang.org/x/net v0.23.0 golang.org/x/oauth2 v0.18.0 google.golang.org/api v0.126.0 google.golang.org/genproto v0.0.0-20230530153820-e85fd2cbaebc @@ -76,6 +76,7 @@ require ( github.com/charmbracelet/huh v0.3.0 github.com/cloudflare/cloudflare-go v0.76.0 github.com/evanphx/json-patch/v5 v5.9.0 + github.com/getlago/lago-go-client v1.2.0 github.com/glebarez/sqlite v1.6.0 github.com/go-chi/chi/v5 v5.0.8 github.com/golang-jwt/jwt v3.2.1+incompatible @@ -148,7 +149,10 @@ require ( github.com/go-gorp/gorp/v3 v3.0.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-resty/resty/v2 v2.11.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-jwt/jwt v3.2.1+incompatible // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/google/gnostic v0.6.9 // indirect github.com/google/s2a-go v0.1.4 // indirect github.com/googleapis/enterprise-certificate-proxy v0.2.3 // indirect @@ -269,7 +273,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect - github.com/google/uuid v1.3.0 + github.com/google/uuid v1.4.0 github.com/googleapis/gax-go/v2 v2.11.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gosuri/uitable v0.0.4 // indirect diff --git a/go.sum b/go.sum index a029f74ff8..d48ba732cf 100644 --- a/go.sum +++ b/go.sum @@ -648,6 +648,8 @@ github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I= github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= +github.com/getlago/lago-go-client v1.2.0 h1:Pl5wD/eTjNdVI+yloAwRWRRB8aDXaxE1sHQ5zVN8WSU= +github.com/getlago/lago-go-client v1.2.0/go.mod h1:lQL306E/5yNqCxLT+9PYf1wDRv8ye9JbTfQC6sQBH/E= github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8= github.com/getsentry/sentry-go v0.11.0/go.mod h1:KBQIxiZAetw62Cj8Ri964vAEWVdgfaUCn30Q3bCvANo= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -727,6 +729,8 @@ github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GO github.com/go-redis/redis v6.15.8+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= github.com/go-redis/redis/v8 v8.11.0 h1:O1Td0mQ8UFChQ3N9zFQqo6kTU2cJ+/it88gDB+zg0wo= github.com/go-redis/redis/v8 v8.11.0/go.mod h1:DLomh7y2e3ggQXQLd1YgmvIfecPJoFl7WU5SOQ/r06M= +github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= +github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= @@ -788,6 +792,8 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.4.1 h1:pC5DB52sCeK48Wlb9oPcdhnjkz1TKt1D/P7WKJ0kUcQ= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= @@ -913,8 +919,9 @@ github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/enterprise-certificate-proxy v0.2.3 h1:yk9/cqRKtT9wXZSsRH9aurXEpJX+U6FLtpYTdC3R06k= github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= @@ -2043,6 +2050,7 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220314234659-1baeb1ce4c0b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2153,8 +2161,11 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -2186,6 +2197,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -2318,8 +2330,11 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= @@ -2330,6 +2345,9 @@ golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2343,6 +2361,9 @@ golang.org/x/text v0.3.6/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.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2470,6 +2491,7 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.6/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/billing/metronome.go b/internal/billing/metronome.go deleted file mode 100644 index 17074608cc..0000000000 --- a/internal/billing/metronome.go +++ /dev/null @@ -1,578 +0,0 @@ -package billing - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strconv" - "time" - - "github.com/google/uuid" - "github.com/porter-dev/porter/api/types" - "github.com/porter-dev/porter/internal/telemetry" -) - -const ( - metronomeBaseUrl = "https://api.metronome.com/v1/" - defaultCollectionMethod = "charge_automatically" - defaultMaxRetries = 10 - porterStandardTrialDays = 15 - defaultRewardAmountCents = 1000 - defaultPaidAmountCents = 0 - maxReferralRewards = 10 - maxIngestEventLimit = 100 -) - -// MetronomeClient is the client used to call the Metronome API -type MetronomeClient struct { - ApiKey string - billableMetrics []types.BillableMetric - PorterCloudPlanID uuid.UUID - PorterStandardPlanID uuid.UUID - - // DefaultRewardAmountCents is the default amount in USD cents rewarded to users - // who successfully refer a new user - DefaultRewardAmountCents float64 - // DefaultPaidAmountCents is the amount paid by the user to get the credits - // grant, if set to 0 it means they are free - DefaultPaidAmountCents float64 - // MaxReferralRewards is the maximum number of referral rewards a user can receive - MaxReferralRewards int64 -} - -// NewMetronomeClient returns a new Metronome client -func NewMetronomeClient(metronomeApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client MetronomeClient, err error) { - porterCloudPlanUUID, err := uuid.Parse(porterCloudPlanID) - if err != nil { - return client, err - } - - porterStandardPlanUUID, err := uuid.Parse(porterStandardPlanID) - if err != nil { - return client, err - } - - return MetronomeClient{ - ApiKey: metronomeApiKey, - PorterCloudPlanID: porterCloudPlanUUID, - PorterStandardPlanID: porterStandardPlanUUID, - DefaultRewardAmountCents: defaultRewardAmountCents, - DefaultPaidAmountCents: defaultPaidAmountCents, - MaxReferralRewards: maxReferralRewards, - }, nil -} - -// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan -func (m MetronomeClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID uuid.UUID, customerPlanID uuid.UUID, err error) { - ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") - defer span.End() - - var trialDays uint - planID := m.PorterStandardPlanID - projID := strconv.FormatUint(uint64(projectID), 10) - - if sandboxEnabled { - planID = m.PorterCloudPlanID - - // This is necessary to avoid conflicts with Porter standard projects - projID = fmt.Sprintf("porter-cloud-%s", projID) - } else { - trialDays = porterStandardTrialDays - } - - customerID, err = m.createCustomer(ctx, userEmail, projectName, projID, billingID) - if err != nil { - return customerID, customerPlanID, telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID)) - } - - customerPlanID, err = m.addCustomerPlan(ctx, customerID, planID, trialDays) - - return customerID, customerPlanID, err -} - -// createCustomer will create the customer in Metronome -func (m MetronomeClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID string, billingID string) (customerID uuid.UUID, err error) { - ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer") - defer span.End() - - path := "customers" - - customer := types.Customer{ - Name: projectName, - Aliases: []string{ - projectID, - }, - BillingConfig: types.BillingConfig{ - BillingProviderType: "stripe", - BillingProviderCustomerID: billingID, - StripeCollectionMethod: defaultCollectionMethod, - }, - CustomFields: map[string]string{ - "project_id": projectID, - "user_email": userEmail, - }, - } - - var result struct { - Data types.Customer `json:"data"` - } - - _, err = m.do(http.MethodPost, path, "", customer, &result) - if err != nil { - return customerID, telemetry.Error(ctx, span, err, "error creating customer") - } - return result.Data.ID, nil -} - -// addCustomerPlan will start the customer on the given plan -func (m MetronomeClient) addCustomerPlan(ctx context.Context, customerID uuid.UUID, planID uuid.UUID, trialDays uint) (customerPlanID uuid.UUID, err error) { - ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") - defer span.End() - - if customerID == uuid.Nil || planID == uuid.Nil { - return customerPlanID, telemetry.Error(ctx, span, err, "customer or plan id empty") - } - - path := fmt.Sprintf("/customers/%s/plans/add", customerID) - - // Plan start time must be midnight UTC, formatted as RFC3339 timestamp - now := time.Now() - midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - startOn := midnightUTC.Format(time.RFC3339) - - req := types.AddCustomerPlanRequest{ - PlanID: planID, - StartingOnUTC: startOn, - } - - if trialDays != 0 { - req.Trial = &types.TrialSpec{ - LengthInDays: int64(trialDays), - } - } - - var result struct { - Data struct { - CustomerPlanID uuid.UUID `json:"id"` - } `json:"data"` - } - - _, err = m.do(http.MethodPost, path, "", req, &result) - if err != nil { - return customerPlanID, telemetry.Error(ctx, span, err, "failed to add customer to plan") - } - - return result.Data.CustomerPlanID, nil -} - -// ListCustomerPlan will return the current active plan to which the user is subscribed -func (m MetronomeClient) ListCustomerPlan(ctx context.Context, customerID uuid.UUID) (plan types.Plan, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-customer-plans") - defer span.End() - - if customerID == uuid.Nil { - return plan, telemetry.Error(ctx, span, err, "customer id empty") - } - - path := fmt.Sprintf("/customers/%s/plans", customerID) - - var result struct { - Data []types.Plan `json:"data"` - } - - _, err = m.do(http.MethodGet, path, "", nil, &result) - if err != nil { - return plan, telemetry.Error(ctx, span, err, "failed to list customer plans") - } - - if len(result.Data) > 0 { - plan = result.Data[0] - } - - return plan, nil -} - -// EndCustomerPlan will immediately end the plan for the given customer -func (m MetronomeClient) EndCustomerPlan(ctx context.Context, customerID uuid.UUID, customerPlanID uuid.UUID) (err error) { - ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan") - defer span.End() - - if customerID == uuid.Nil || customerPlanID == uuid.Nil { - return telemetry.Error(ctx, span, err, "customer or customer plan id empty") - } - - path := fmt.Sprintf("/customers/%s/plans/%s/end", customerID, customerPlanID) - - // Plan start time must be midnight UTC, formatted as RFC3339 timestamp - now := time.Now() - midnightUTC := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) - endBefore := midnightUTC.Format(time.RFC3339) - - req := types.EndCustomerPlanRequest{ - EndingBeforeUTC: endBefore, - } - - _, err = m.do(http.MethodPost, path, "", req, nil) - if err != nil { - return telemetry.Error(ctx, span, err, "failed to end customer plan") - } - - return nil -} - -// ListCustomerCredits will return the total number of credits for the customer -func (m MetronomeClient) ListCustomerCredits(ctx context.Context, customerID uuid.UUID) (credits types.ListCreditGrantsResponse, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-customer-credits") - defer span.End() - - if customerID == uuid.Nil { - return credits, telemetry.Error(ctx, span, err, "customer id empty") - } - - path := "credits/listGrants" - - req := types.ListCreditGrantsRequest{ - CustomerIDs: []uuid.UUID{ - customerID, - }, - } - - var result struct { - Data []types.CreditGrant `json:"data"` - } - - _, err = m.do(http.MethodPost, path, "", req, &result) - if err != nil { - return credits, telemetry.Error(ctx, span, err, "failed to list customer credits") - } - - var response types.ListCreditGrantsResponse - for _, grant := range result.Data { - response.GrantedCredits += grant.GrantAmount.Amount - response.RemainingCredits += grant.Balance.IncludingPending - } - - return response, nil -} - -// CreateCreditsGrant will create a new credit grant for the customer with the specified amount -func (m MetronomeClient) CreateCreditsGrant(ctx context.Context, customerID uuid.UUID, reason string, grantAmount float64, paidAmount float64, expiresAt string) (err error) { - ctx, span := telemetry.NewSpan(ctx, "create-credits-grant") - defer span.End() - - if customerID == uuid.Nil { - return telemetry.Error(ctx, span, err, "customer id empty") - } - - path := "credits/createGrant" - creditTypeID, err := m.getCreditTypeID(ctx, "USD (cents)") - if err != nil { - return telemetry.Error(ctx, span, err, "failed to get credit type id") - } - - req := types.CreateCreditsGrantRequest{ - CustomerID: customerID, - UniquenessKey: uuid.NewString(), - GrantAmount: types.GrantAmountID{ - Amount: grantAmount, - CreditTypeID: creditTypeID, - }, - PaidAmount: types.PaidAmount{ - Amount: paidAmount, - CreditTypeID: creditTypeID, - }, - Name: "Porter Credits", - Reason: reason, - ExpiresAt: expiresAt, - Priority: 1, - } - - statusCode, err := m.do(http.MethodPost, path, "", req, nil) - if err != nil && statusCode != http.StatusConflict { - // a conflict response indicates the grant already exists - return telemetry.Error(ctx, span, err, "failed to create credits grant") - } - - return nil -} - -// ListCustomerUsage will return the aggregated usage for a customer -func (m MetronomeClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-customer-usage") - defer span.End() - - if customerID == uuid.Nil { - return usage, telemetry.Error(ctx, span, err, "customer id empty") - } - - if len(m.billableMetrics) == 0 { - billableMetrics, err := m.listBillableMetricIDs(ctx, customerID) - if err != nil { - return nil, telemetry.Error(ctx, span, err, "failed to list billable metrics") - } - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "billable-metric-count", Value: len(billableMetrics)}, - ) - - // Cache billable metric ids for future calls - m.billableMetrics = append(m.billableMetrics, billableMetrics...) - } - - path := "usage/groups" - - startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore) - if err != nil { - return nil, telemetry.Error(ctx, span, err, err.Error()) - } - - baseReq := types.ListCustomerUsageRequest{ - CustomerID: customerID, - WindowSize: windowsSize, - StartingOn: startingOnTimestamp, - EndingBefore: endingBeforeTimestamp, - CurrentPeriod: currentPeriod, - } - - for _, billableMetric := range m.billableMetrics { - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "billable-metric-id", Value: billableMetric.ID}, - ) - - var result struct { - Data []types.CustomerUsageMetric `json:"data"` - } - - baseReq.BillableMetricID = billableMetric.ID - _, err = m.do(http.MethodPost, path, "", baseReq, &result) - if err != nil { - return usage, telemetry.Error(ctx, span, err, "failed to get customer usage") - } - - usage = append(usage, types.Usage{ - MetricName: billableMetric.Name, - UsageMetrics: result.Data, - }) - } - - return usage, nil -} - -// ListCustomerCosts will return the costs for a customer over a time period -func (m MetronomeClient) ListCustomerCosts(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, limit int) (costs []types.FormattedCost, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-customer-costs") - defer span.End() - - if customerID == uuid.Nil { - return costs, telemetry.Error(ctx, span, err, "customer id empty") - } - - path := fmt.Sprintf("customers/%s/costs", customerID) - - var result struct { - Data []types.Cost `json:"data"` - } - - startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore) - if err != nil { - return nil, telemetry.Error(ctx, span, err, err.Error()) - } - - queryParams := fmt.Sprintf("starting_on=%s&ending_before=%s&limit=%d", startingOnTimestamp, endingBeforeTimestamp, limit) - - _, err = m.do(http.MethodGet, path, queryParams, nil, &result) - if err != nil { - return costs, telemetry.Error(ctx, span, err, "failed to create credits grant") - } - - for _, customerCost := range result.Data { - formattedCost := types.FormattedCost{ - StartTimestamp: customerCost.StartTimestamp, - EndTimestamp: customerCost.EndTimestamp, - } - for _, creditType := range customerCost.CreditTypes { - formattedCost.Cost += creditType.Cost - } - costs = append(costs, formattedCost) - } - - return costs, nil -} - -// IngestEvents sends a list of billing events to Metronome's ingest endpoint -func (m MetronomeClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) { - ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events") - defer span.End() - - if len(events) == 0 { - return nil - } - - path := "ingest" - - for i := 0; i < len(events); i += maxIngestEventLimit { - end := i + maxIngestEventLimit - if end > len(events) { - end = len(events) - } - - batch := events[i:end] - - // Retry each batch to make sure all events are ingested - var currentAttempts int - for currentAttempts < defaultMaxRetries { - statusCode, err := m.do(http.MethodPost, path, "", batch, nil) - // Check errors that are not from error http codes - if statusCode == 0 && err != nil { - return telemetry.Error(ctx, span, err, "failed to ingest billing events") - } - - if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized { - return telemetry.Error(ctx, span, err, "unauthorized") - } - - // 400 responses should not be retried - if statusCode == http.StatusBadRequest { - return telemetry.Error(ctx, span, err, "malformed billing events") - } - - // Any other status code can be safely retried - if statusCode == http.StatusOK { - break - } - currentAttempts++ - } - - if currentAttempts == defaultMaxRetries { - return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success") - } - } - - return nil -} - -func (m MetronomeClient) listBillableMetricIDs(ctx context.Context, customerID uuid.UUID) (billableMetrics []types.BillableMetric, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-billable-metrics") - defer span.End() - - if customerID == uuid.Nil { - return billableMetrics, telemetry.Error(ctx, span, err, "customer id empty") - } - - path := fmt.Sprintf("/customers/%s/billable-metrics", customerID) - - var result struct { - Data []types.BillableMetric `json:"data"` - } - - _, err = m.do(http.MethodGet, path, "", nil, &result) - if err != nil { - return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome") - } - - return result.Data, nil -} - -func (m MetronomeClient) getCreditTypeID(ctx context.Context, currencyCode string) (creditTypeID uuid.UUID, err error) { - ctx, span := telemetry.NewSpan(ctx, "get-credit-type-id") - defer span.End() - - path := "/credit-types/list" - - var result struct { - Data []types.PricingUnit `json:"data"` - } - - _, err = m.do(http.MethodGet, path, "", nil, &result) - if err != nil { - return creditTypeID, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome") - } - - for _, pricingUnit := range result.Data { - if pricingUnit.Name == currencyCode { - return pricingUnit.ID, nil - } - } - - return creditTypeID, telemetry.Error(ctx, span, fmt.Errorf("credit type not found for currency code %s", currencyCode), "failed to find credit type") -} - -// Utility function to parse and adjust times -func parseAndCheckTimestamps(startingOn string, endingBefore string) (startingOnTimestamp string, endingBeforeTimestamp string, err error) { - startingOnTime, err := time.Parse(time.RFC3339, startingOn) - if err != nil { - return startingOnTimestamp, endingBeforeTimestamp, fmt.Errorf("failed to parse starting on time: %w", err) - } - - endingBeforeTime, err := time.Parse(time.RFC3339, endingBefore) - if err != nil { - return startingOnTimestamp, endingBeforeTimestamp, fmt.Errorf("failed to parse ending before time: %w", err) - } - - if startingOnTime.Equal(endingBeforeTime) { - // If starting and ending timestamps are the same, change the ending timestamp to be one day in the future - endingBeforeTime = endingBeforeTime.Add(24 * time.Hour) - } - - return startingOnTime.Format(time.RFC3339), endingBeforeTime.Format(time.RFC3339), nil -} - -func (m MetronomeClient) do(method string, path string, queryParams string, body interface{}, data interface{}) (statusCode int, err error) { - client := http.Client{} - endpoint, err := url.JoinPath(metronomeBaseUrl, path) - if err != nil { - return statusCode, err - } - - var bodyJson []byte - if body != nil { - bodyJson, err = json.Marshal(body) - if err != nil { - return statusCode, err - } - } - - // Add raw query parameters to the endpoint - if queryParams != "" { - endpoint += "?" + queryParams - } - - req, err := http.NewRequest(method, endpoint, bytes.NewBuffer(bodyJson)) - if err != nil { - return statusCode, err - } - bearer := "Bearer " + m.ApiKey - req.Header.Set("Authorization", bearer) - req.Header.Set("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - return statusCode, err - } - statusCode = resp.StatusCode - - if resp.StatusCode != http.StatusOK { - // If there is an error, try to decode the message - var message map[string]string - err = json.NewDecoder(resp.Body).Decode(&message) - if err != nil { - return statusCode, fmt.Errorf("status code %d received, couldn't process response message", resp.StatusCode) - } - _ = resp.Body.Close() - - return statusCode, fmt.Errorf("status code %d received, response message: %v", resp.StatusCode, message) - } - - if data != nil { - err = json.NewDecoder(resp.Body).Decode(data) - if err != nil { - return statusCode, err - } - } - _ = resp.Body.Close() - - return statusCode, nil -} diff --git a/internal/billing/usage.go b/internal/billing/usage.go new file mode 100644 index 0000000000..ee052943fd --- /dev/null +++ b/internal/billing/usage.go @@ -0,0 +1,413 @@ +package billing + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/getlago/lago-go-client" + "github.com/google/uuid" + "github.com/porter-dev/porter/api/types" + "github.com/porter-dev/porter/internal/telemetry" +) + +const ( + defaultMaxRetries = 10 + porterStandardTrialDays = 15 + defaultRewardAmountCents = 1000 + defaultPaidAmountCents = 0 + maxReferralRewards = 10 + maxIngestEventLimit = 100 +) + +// LagoClient is the client used to call the Lago API +type LagoClient struct { + client lago.Client + billableMetrics []types.BillableMetric + PorterCloudPlanID string + PorterStandardPlanID string + + // DefaultRewardAmountCents is the default amount in USD cents rewarded to users + // who successfully refer a new user + DefaultRewardAmountCents int64 + // MaxReferralRewards is the maximum number of referral rewards a user can receive + MaxReferralRewards int64 +} + +// NewLagoClient returns a new Metronome client +func NewLagoClient(lagoApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client LagoClient, err error) { + lagoClient := lago.New(). + SetApiKey("__YOU_API_KEY__") + + return LagoClient{ + client: *lagoClient, + PorterCloudPlanID: porterCloudPlanID, + PorterStandardPlanID: porterStandardPlanID, + DefaultRewardAmountCents: defaultRewardAmountCents, + MaxReferralRewards: maxReferralRewards, + }, nil +} + +// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan +func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (err error) { + ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") + defer span.End() + + planID := m.PorterStandardPlanID + if sandboxEnabled { + planID = m.PorterCloudPlanID + } + + customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled) + if err != nil { + return telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID)) + } + + subscriptionID := m.generateSubscriptionID(projectID, sandboxEnabled) + + err = m.addCustomerPlan(ctx, customerID, planID, subscriptionID) + + return err +} + +// createCustomer will create the customer in Metronome +func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) { + ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer") + defer span.End() + + customerID = m.generateCustomerID(projectID, sandboxEnabled) + + customerInput := &lago.CustomerInput{ + ExternalID: customerID, + Name: projectName, + Email: userEmail, + BillingConfiguration: lago.CustomerBillingConfigurationInput{ + PaymentProvider: "stripe", + ProviderCustomerID: billingID, + }, + } + + _, lagoErr := m.client.Customer().Create(ctx, customerInput) + if err != nil { + return customerID, telemetry.Error(ctx, span, lagoErr.Err, "failed to create lago customer") + } + return customerID, nil +} + +// addCustomerPlan will create a plan subscription for the customer +func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planID string, subscriptionID string) (err error) { + ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") + defer span.End() + + if projectID == "" || planID == "" { + return telemetry.Error(ctx, span, err, "project and plan id are required") + } + + now := time.Now() + subscriptionInput := &lago.SubscriptionInput{ + ExternalCustomerID: projectID, + ExternalID: subscriptionID, + PlanCode: planID, + SubscriptionAt: &now, + BillingTime: lago.Calendar, + } + + _, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput) + if err != nil { + return telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription") + } + + return nil +} + +// ListCustomerPlan will return the current active plan to which the user is subscribed +func (m LagoClient) ListCustomerPlan(ctx context.Context, subscriptionID string) (plan types.Plan, err error) { + ctx, span := telemetry.NewSpan(ctx, "list-customer-plans") + defer span.End() + + if subscriptionID == "" { + return plan, telemetry.Error(ctx, span, err, "subscription id empty") + } + + subscription, lagoErr := m.client.Subscription().Get(ctx, subscriptionID) + if err != nil { + return plan, telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription") + } + + plan.StartingOn = subscription.StartedAt.Format(time.RFC3339) + plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339) + plan.TrialInfo.EndingBefore = subscription.TrialEndedAt.Format(time.RFC3339) + + return plan, nil +} + +// EndCustomerPlan will immediately end the plan for the given customer +func (m LagoClient) EndCustomerPlan(ctx context.Context, subscriptionID string) (err error) { + ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan") + defer span.End() + + if subscriptionID == "" { + return telemetry.Error(ctx, span, err, "subscription id empty") + } + + subscriptionTerminateInput := lago.SubscriptionTerminateInput{ + ExternalID: subscriptionID, + } + + _, lagoErr := m.client.Subscription().Terminate(ctx, subscriptionTerminateInput) + if lagoErr.Err != nil { + return telemetry.Error(ctx, span, lagoErr.Err, "failed to terminate subscription") + } + + return nil +} + +// ListCustomerCredits will return the total number of credits for the customer +func (m LagoClient) ListCustomerCredits(ctx context.Context, customerID string) (credits types.ListCreditGrantsResponse, err error) { + ctx, span := telemetry.NewSpan(ctx, "list-customer-credits") + defer span.End() + + if customerID == "" { + return credits, telemetry.Error(ctx, span, err, "customer id empty") + } + + walletListInput := &lago.WalletListInput{ + ExternalCustomerID: customerID, + } + + walletList, lagoErr := m.client.Wallet().GetList(ctx, walletListInput) + if lagoErr.Err != nil { + return credits, telemetry.Error(ctx, span, lagoErr.Err, "failed to get wallet") + } + + var response types.ListCreditGrantsResponse + for _, wallet := range walletList.Wallets { + response.GrantedBalanceCents += wallet.BalanceCents + response.RemainingBalanceCents += wallet.OngoingUsageBalanceCents + } + + return response, nil +} + +// CreateCreditsGrant will create a new credit grant for the customer with the specified amount +func (m LagoClient) CreateCreditsGrant(ctx context.Context, customerID string, reason string, grantAmount float64, expiresAt string) (err error) { + ctx, span := telemetry.NewSpan(ctx, "create-credits-grant") + defer span.End() + + if customerID == "" { + return telemetry.Error(ctx, span, err, "customer id empty") + } + + expiresAtTime, err := time.Parse(time.RFC3339, expiresAt) + if err != nil { + return telemetry.Error(ctx, span, err, "failed to parse credit expiration timestamp") + } + + walletInput := &lago.WalletInput{ + ExternalCustomerID: customerID, + Currency: lago.USD, + RateAmount: fmt.Sprintf("%.2f", grantAmount), + ExpirationAt: &expiresAtTime, + } + + _, lagoErr := m.client.Wallet().Create(ctx, walletInput) + if lagoErr.Err != nil { + return telemetry.Error(ctx, span, lagoErr.Err, "failed to create credits grant") + } + + return nil +} + +// ListCustomerUsage will return the aggregated usage for a customer +func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) { + ctx, span := telemetry.NewSpan(ctx, "list-customer-usage") + defer span.End() + + if customerID == uuid.Nil { + return usage, telemetry.Error(ctx, span, err, "customer id empty") + } + + if len(m.billableMetrics) == 0 { + billableMetrics, err := m.listBillableMetricIDs(ctx, customerID) + if err != nil { + return nil, telemetry.Error(ctx, span, err, "failed to list billable metrics") + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "billable-metric-count", Value: len(billableMetrics)}, + ) + + // Cache billable metric ids for future calls + m.billableMetrics = append(m.billableMetrics, billableMetrics...) + } + + path := "usage/groups" + + startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore) + if err != nil { + return nil, telemetry.Error(ctx, span, err, err.Error()) + } + + baseReq := types.ListCustomerUsageRequest{ + CustomerID: customerID, + WindowSize: windowsSize, + StartingOn: startingOnTimestamp, + EndingBefore: endingBeforeTimestamp, + CurrentPeriod: currentPeriod, + } + + for _, billableMetric := range m.billableMetrics { + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "billable-metric-id", Value: billableMetric.ID}, + ) + + var result struct { + Data []types.CustomerUsageMetric `json:"data"` + } + + baseReq.BillableMetricID = billableMetric.ID + _, err = m.do(http.MethodPost, path, "", baseReq, &result) + if err != nil { + return usage, telemetry.Error(ctx, span, err, "failed to get customer usage") + } + + usage = append(usage, types.Usage{ + MetricName: billableMetric.Name, + UsageMetrics: result.Data, + }) + } + + return usage, nil +} + +// ListCustomerCosts will return the costs for a customer over a time period +func (m LagoClient) ListCustomerCosts(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, limit int) (costs []types.FormattedCost, err error) { + ctx, span := telemetry.NewSpan(ctx, "list-customer-costs") + defer span.End() + + if customerID == uuid.Nil { + return costs, telemetry.Error(ctx, span, err, "customer id empty") + } + + path := fmt.Sprintf("customers/%s/costs", customerID) + + var result struct { + Data []types.Cost `json:"data"` + } + + startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore) + if err != nil { + return nil, telemetry.Error(ctx, span, err, err.Error()) + } + + queryParams := fmt.Sprintf("starting_on=%s&ending_before=%s&limit=%d", startingOnTimestamp, endingBeforeTimestamp, limit) + + _, err = m.do(http.MethodGet, path, queryParams, nil, &result) + if err != nil { + return costs, telemetry.Error(ctx, span, err, "failed to create credits grant") + } + + for _, customerCost := range result.Data { + formattedCost := types.FormattedCost{ + StartTimestamp: customerCost.StartTimestamp, + EndTimestamp: customerCost.EndTimestamp, + } + for _, creditType := range customerCost.CreditTypes { + formattedCost.Cost += creditType.Cost + } + costs = append(costs, formattedCost) + } + + return costs, nil +} + +// IngestEvents sends a list of billing events to Metronome's ingest endpoint +func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) { + ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events") + defer span.End() + + if len(events) == 0 { + return nil + } + + path := "ingest" + + for i := 0; i < len(events); i += maxIngestEventLimit { + end := i + maxIngestEventLimit + if end > len(events) { + end = len(events) + } + + batch := events[i:end] + + // Retry each batch to make sure all events are ingested + var currentAttempts int + for currentAttempts < defaultMaxRetries { + statusCode, err := m.do(http.MethodPost, path, "", batch, nil) + // Check errors that are not from error http codes + if statusCode == 0 && err != nil { + return telemetry.Error(ctx, span, err, "failed to ingest billing events") + } + + if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized { + return telemetry.Error(ctx, span, err, "unauthorized") + } + + // 400 responses should not be retried + if statusCode == http.StatusBadRequest { + return telemetry.Error(ctx, span, err, "malformed billing events") + } + + // Any other status code can be safely retried + if statusCode == http.StatusOK { + break + } + currentAttempts++ + } + + if currentAttempts == defaultMaxRetries { + return telemetry.Error(ctx, span, err, "max number of retry attempts reached with no success") + } + } + + return nil +} + +func (m LagoClient) listBillableMetricIDs(ctx context.Context, customerID uuid.UUID) (billableMetrics []types.BillableMetric, err error) { + ctx, span := telemetry.NewSpan(ctx, "list-billable-metrics") + defer span.End() + + if customerID == uuid.Nil { + return billableMetrics, telemetry.Error(ctx, span, err, "customer id empty") + } + + path := fmt.Sprintf("/customers/%s/billable-metrics", customerID) + + var result struct { + Data []types.BillableMetric `json:"data"` + } + + _, err = m.do(http.MethodGet, path, "", nil, &result) + if err != nil { + return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome") + } + + return result.Data, nil +} + +func (m LagoClient) generateCustomerID(projectID uint, sandboxEnabled bool) string { + if sandboxEnabled { + return fmt.Sprintf("cloud_cus_%d", projectID) + } + + return fmt.Sprintf("cus_%d", projectID) +} + +func (m LagoClient) generateSubscriptionID(projectID uint, sandboxEnabled bool) string { + if sandboxEnabled { + return fmt.Sprintf("cloud_sub_%d", projectID) + } + + return fmt.Sprintf("sub_%d", projectID) +} From 9513bf6a9bbd9c848a8bd4a0aa8f08b029cf9da0 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 7 May 2024 17:01:12 -0400 Subject: [PATCH 02/16] Remove usage and usage plan ids --- api/server/handlers/billing/create.go | 8 +++---- api/server/handlers/billing/ingest.go | 6 ++--- api/server/handlers/billing/list.go | 12 ++-------- api/server/handlers/billing/plan.go | 24 ++++++++++---------- api/server/handlers/cluster/install_agent.go | 2 +- api/server/handlers/project/create.go | 4 ++-- api/server/handlers/project/delete.go | 4 ++-- api/server/handlers/project/referrals.go | 6 ++--- api/server/shared/config/env/envconfs.go | 2 +- api/server/shared/config/loader/loader.go | 20 ++++++++-------- internal/billing/billing.go | 8 +++---- internal/models/project.go | 7 ------ 12 files changed, 44 insertions(+), 59 deletions(-) diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index 881d8856d8..ca436cdf4a 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -136,7 +136,7 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr return telemetry.Error(ctx, span, err, "failed to get referral count by referrer id") } - maxReferralRewards := c.Config().BillingManager.MetronomeClient.MaxReferralRewards + maxReferralRewards := c.Config().BillingManager.LagoClient.MaxReferralRewards if referralCount >= maxReferralRewards { return nil } @@ -151,9 +151,9 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr // practice will mean the credits will most likely run out before expiring expiresAt := time.Now().AddDate(5, 0, 0).Format(time.RFC3339) reason := "Referral reward" - rewardAmount := c.Config().BillingManager.MetronomeClient.DefaultRewardAmountCents - paidAmount := c.Config().BillingManager.MetronomeClient.DefaultPaidAmountCents - err := c.Config().BillingManager.MetronomeClient.CreateCreditsGrant(ctx, referrerProject.UsageID, reason, rewardAmount, paidAmount, expiresAt) + rewardAmount := c.Config().BillingManager.LagoClient.DefaultRewardAmountCents + paidAmount := c.Config().BillingManager.LagoClient.DefaultPaidAmountCents + err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.UsageID, reason, rewardAmount, paidAmount, expiresAt) if err != nil { return telemetry.Error(ctx, span, err, "failed to grand credits reward") } diff --git a/api/server/handlers/billing/ingest.go b/api/server/handlers/billing/ingest.go index 697984c152..1f383dfe1c 100644 --- a/api/server/handlers/billing/ingest.go +++ b/api/server/handlers/billing/ingest.go @@ -39,11 +39,11 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded}, + telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox}, ) @@ -76,7 +76,7 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } } - err := c.Config().BillingManager.MetronomeClient.IngestEvents(ctx, ingestEventsRequest.Events) + err := c.Config().BillingManager.LagoClient.IngestEvents(ctx, ingestEventsRequest.Events) if err != nil { err := telemetry.Error(ctx, span, err, "error ingesting events") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/server/handlers/billing/list.go b/api/server/handlers/billing/list.go index b344749072..6a98a965ad 100644 --- a/api/server/handlers/billing/list.go +++ b/api/server/handlers/billing/list.go @@ -193,11 +193,11 @@ func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.C ctx, span := telemetry.NewSpan(ctx, "ensure-metronome-customer-exists") defer span.End() - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || proj.UsageID != uuid.Nil { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || proj.UsageID != uuid.Nil { return nil } - customerID, customerPlanID, err := c.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) + err = c.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) if err != nil { return telemetry.Error(ctx, span, err, "error creating Metronome customer") } @@ -207,13 +207,5 @@ func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.C telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID}, ) - proj.UsageID = customerID - proj.UsagePlanID = customerPlanID - - _, err = c.Repo().Project().UpdateProject(proj) - if err != nil { - return telemetry.Error(ctx, span, err, "error updating project") - } - return nil } diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 4d72a6a434..7f62b94ca6 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -33,11 +33,11 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded}, + telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, ) return @@ -48,7 +48,7 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) - plan, err := c.Config().BillingManager.MetronomeClient.ListCustomerPlan(ctx, proj.UsageID) + plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, proj.UsageID) if err != nil { err := telemetry.Error(ctx, span, err, "error listing plans") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -79,17 +79,17 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded}, + telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, ) return } - credits, err := c.Config().BillingManager.MetronomeClient.ListCustomerCredits(ctx, proj.UsageID) + credits, err := c.Config().BillingManager.LagoClient.ListCustomerCredits(ctx, proj.UsageID) if err != nil { err := telemetry.Error(ctx, span, err, "error listing credits") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -127,12 +127,12 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ proj, _ := ctx.Value(types.ProjectScope).(*models.Project) telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded}, + telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") return } @@ -145,7 +145,7 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ return } - usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerUsage(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.WindowSize, req.CurrentPeriod) + usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.WindowSize, req.CurrentPeriod) if err != nil { err := telemetry.Error(ctx, span, err, "error listing customer usage") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -177,12 +177,12 @@ func (c *ListCustomerCostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ proj, _ := ctx.Value(types.ProjectScope).(*models.Project) telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded}, + telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") return } @@ -195,7 +195,7 @@ func (c *ListCustomerCostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ return } - usage, err := c.Config().BillingManager.MetronomeClient.ListCustomerCosts(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.Limit) + usage, err := c.Config().BillingManager.LagoClient.ListCustomerCosts(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.Limit) if err != nil { err := telemetry.Error(ctx, span, err, "error listing customer costs") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/server/handlers/cluster/install_agent.go b/api/server/handlers/cluster/install_agent.go index a8870c46f4..728390a76f 100644 --- a/api/server/handlers/cluster/install_agent.go +++ b/api/server/handlers/cluster/install_agent.go @@ -112,7 +112,7 @@ func (c *InstallAgentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) "clusterID": fmt.Sprintf("%d", cluster.ID), "projectID": fmt.Sprintf("%d", proj.ID), "prometheusURL": c.Config().ServerConf.PrometheusUrl, - "metronomeKey": c.Config().ServerConf.MetronomeAPIKey, + "metronomeKey": c.Config().ServerConf.LagoAPIKey, }, "loki": map[string]interface{}{}, } diff --git a/api/server/handlers/project/create.go b/api/server/handlers/project/create.go index af891b2cec..46566698b4 100644 --- a/api/server/handlers/project/create.go +++ b/api/server/handlers/project/create.go @@ -101,8 +101,8 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } // Create Metronome customer and add to starter plan - if p.Config().BillingManager.MetronomeConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { - customerID, customerPlanID, err := p.Config().BillingManager.MetronomeClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) + if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { + customerID, customerPlanID, err := p.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) if err != nil { err = telemetry.Error(ctx, span, err, "error creating Metronome customer") p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/server/handlers/project/delete.go b/api/server/handlers/project/delete.go index f6fca3b937..8d99fa5f10 100644 --- a/api/server/handlers/project/delete.go +++ b/api/server/handlers/project/delete.go @@ -92,8 +92,8 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - if p.Config().BillingManager.MetronomeConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { - err = p.Config().BillingManager.MetronomeClient.EndCustomerPlan(ctx, proj.UsageID, proj.UsagePlanID) + if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { + err = p.Config().BillingManager.LagoClient.EndCustomerPlan(ctx, proj.UsageID, proj.UsagePlanID) if err != nil { e := "error ending billing plan" err = telemetry.Error(ctx, span, err, e) diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go index 80669a2f95..61e76f3d7c 100644 --- a/api/server/handlers/project/referrals.go +++ b/api/server/handlers/project/referrals.go @@ -34,12 +34,12 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.MetronomeConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || proj.UsageID == uuid.Nil || !proj.EnableSandbox { c.WriteResult(w, r, "") telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.MetronomeConfigLoaded}, + telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, ) return @@ -74,7 +74,7 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h }{ Code: proj.ReferralCode, ReferralCount: referralCount, - MaxAllowedRewards: c.Config().BillingManager.MetronomeClient.MaxReferralRewards, + MaxAllowedRewards: c.Config().BillingManager.LagoClient.MaxReferralRewards, } c.WriteResult(w, r, referralCodeResponse) diff --git a/api/server/shared/config/env/envconfs.go b/api/server/shared/config/env/envconfs.go index 683fdc1f03..d2ada56821 100644 --- a/api/server/shared/config/env/envconfs.go +++ b/api/server/shared/config/env/envconfs.go @@ -71,7 +71,7 @@ type ServerConf struct { StripeSecretKey string `env:"STRIPE_SECRET_KEY"` StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"` - MetronomeAPIKey string `env:"METRONOME_API_KEY"` + LagoAPIKey string `env:"LAGO_API_KEY"` PorterCloudPlanID string `env:"PORTER_CLOUD_PLAN_ID"` PorterStandardPlanID string `env:"PORTER_STANDARD_PLAN_ID"` diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index eac2abded7..e010a04825 100644 --- a/api/server/shared/config/loader/loader.go +++ b/api/server/shared/config/loader/loader.go @@ -360,7 +360,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { var ( stripeClient billing.StripeClient stripeEnabled bool - metronomeClient billing.MetronomeClient + lagoClient billing.LagoClient metronomeEnabled bool ) if sc.StripeSecretKey != "" { @@ -371,23 +371,23 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { res.Logger.Info().Msg("STRIPE_SECRET_KEY not set, all Stripe functionality will be disabled") } - if sc.MetronomeAPIKey != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" { - metronomeClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.MetronomeAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID) + if sc.LagoAPIKey != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" { + lagoClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.LagoAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID) if err != nil { - return nil, fmt.Errorf("unable to create metronome client: %w", err) + return nil, fmt.Errorf("unable to create Lago client: %w", err) } metronomeEnabled = true - res.Logger.Info().Msg("Metronome configuration loaded") + res.Logger.Info().Msg("Lago configuration loaded") } else { - res.Logger.Info().Msg("METRONOME_API_KEY, PORTER_CLOUD_PLAN_ID, or PORTER_STANDARD_PLAN_ID not set, all Metronome functionality will be disabled") + res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_ID, or PORTER_STANDARD_PLAN_ID not set, all Metronome functionality will be disabled") } res.Logger.Info().Msg("Creating billing manager") res.BillingManager = billing.Manager{ - StripeClient: stripeClient, - StripeConfigLoaded: stripeEnabled, - MetronomeClient: metronomeClient, - MetronomeConfigLoaded: metronomeEnabled, + StripeClient: stripeClient, + StripeConfigLoaded: stripeEnabled, + LagoClient: lagoClient, + LagoConfigLoaded: metronomeEnabled, } res.Logger.Info().Msg("Created billing manager") diff --git a/internal/billing/billing.go b/internal/billing/billing.go index 15df5223b3..e07b58d384 100644 --- a/internal/billing/billing.go +++ b/internal/billing/billing.go @@ -2,8 +2,8 @@ package billing // Manager contains methods for managing billing for a project type Manager struct { - StripeClient StripeClient - StripeConfigLoaded bool - MetronomeClient MetronomeClient - MetronomeConfigLoaded bool + StripeClient StripeClient + StripeConfigLoaded bool + LagoClient LagoClient + LagoConfigLoaded bool } diff --git a/internal/models/project.go b/internal/models/project.go index 13c902f3ef..c45946f89a 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -5,7 +5,6 @@ import ( "gorm.io/gorm" - "github.com/google/uuid" "github.com/launchdarkly/go-sdk-common/v3/ldcontext" "github.com/porter-dev/porter/api/types" "github.com/porter-dev/porter/internal/features" @@ -150,12 +149,6 @@ type Project struct { BillingID string BillingEnabled bool - // UsageID is the id corresponding to the customer in Metronome - UsageID uuid.UUID - // UsagePlanID is the id of the customer-plan relationship. Do not confuse with the actual plan ID. - // This exists as long as a user is part of a plan. - UsagePlanID uuid.UUID - // linked repos GitRepos []GitRepo `json:"git_repos,omitempty"` From 511cba3681e461c52fc6bf11db68e95de3a4cc4d Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 7 May 2024 18:36:39 -0400 Subject: [PATCH 03/16] Finish lago migration --- api/server/handlers/billing/create.go | 5 +- api/server/handlers/billing/ingest.go | 3 +- api/server/handlers/billing/list.go | 19 +- api/server/handlers/billing/plan.go | 71 +----- api/server/handlers/project/create.go | 11 +- api/server/handlers/project/delete.go | 7 +- api/server/handlers/project/referrals.go | 4 +- api/server/router/project.go | 28 --- internal/billing/usage.go | 262 +++++++---------------- 9 files changed, 106 insertions(+), 304 deletions(-) diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index ca436cdf4a..87adbc4f1a 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -150,10 +150,9 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr // Metronome requires an expiration to be passed in, so we set it to 5 years which in // practice will mean the credits will most likely run out before expiring expiresAt := time.Now().AddDate(5, 0, 0).Format(time.RFC3339) - reason := "Referral reward" + name := "Referral reward" rewardAmount := c.Config().BillingManager.LagoClient.DefaultRewardAmountCents - paidAmount := c.Config().BillingManager.LagoClient.DefaultPaidAmountCents - err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.UsageID, reason, rewardAmount, paidAmount, expiresAt) + err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.ID, name, rewardAmount, expiresAt, referrerProject.EnableSandbox) if err != nil { return telemetry.Error(ctx, span, err, "failed to grand credits reward") } diff --git a/api/server/handlers/billing/ingest.go b/api/server/handlers/billing/ingest.go index 1f383dfe1c..8926a93fe6 100644 --- a/api/server/handlers/billing/ingest.go +++ b/api/server/handlers/billing/ingest.go @@ -52,7 +52,6 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) ingestEventsRequest := struct { @@ -76,7 +75,7 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } } - err := c.Config().BillingManager.LagoClient.IngestEvents(ctx, ingestEventsRequest.Events) + err := c.Config().BillingManager.LagoClient.IngestEvents(ctx, ingestEventsRequest.Events, proj.EnableSandbox) if err != nil { err := telemetry.Error(ctx, span, err, "error ingesting events") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/server/handlers/billing/list.go b/api/server/handlers/billing/list.go index 6a98a965ad..a7edbd8249 100644 --- a/api/server/handlers/billing/list.go +++ b/api/server/handlers/billing/list.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" - "github.com/google/uuid" "github.com/porter-dev/porter/api/server/handlers" "github.com/porter-dev/porter/api/server/shared" "github.com/porter-dev/porter/api/server/shared/apierrors" @@ -100,10 +99,9 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "billing-id", Value: proj.BillingID}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) - if proj.BillingID == "" || proj.UsageID == uuid.Nil { + if proj.BillingID == "" { adminUser, err := c.getAdminUser(ctx, proj.ID) if err != nil { return telemetry.Error(ctx, span, err, "error getting admin user") @@ -119,6 +117,14 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro if err != nil { return telemetry.Error(ctx, span, err, "error ensuring Stripe customer exists") } + } + + lagoCustomerExists := false + if !lagoCustomerExists { + adminUser, err := c.getAdminUser(ctx, proj.ID) + if err != nil { + return telemetry.Error(ctx, span, err, "error getting admin user") + } // Create usage customer for project and set the usage ID if it doesn't exist err = c.ensureMetronomeCustomerExists(ctx, adminUser.Email, proj) @@ -193,7 +199,7 @@ func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.C ctx, span := telemetry.NewSpan(ctx, "ensure-metronome-customer-exists") defer span.End() - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || proj.UsageID != uuid.Nil { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { return nil } @@ -202,10 +208,5 @@ func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.C return telemetry.Error(ctx, span, err, "error creating Metronome customer") } - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, - telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID}, - ) - return nil } diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 7f62b94ca6..36039759ee 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -45,10 +45,9 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) - plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, proj.UsageID) + plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, proj.ID, proj.EnableSandbox) if err != nil { err := telemetry.Error(ctx, span, err, "error listing plans") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -89,19 +88,18 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - credits, err := c.Config().BillingManager.LagoClient.ListCustomerCredits(ctx, proj.UsageID) - if err != nil { - err := telemetry.Error(ctx, span, err, "error listing credits") - c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - return - } + // credits, err := c.Config().BillingManager.LagoClient.ListCustomerCredits(ctx, proj.ID, proj.EnableSandbox) + // if err != nil { + // err := telemetry.Error(ctx, span, err, "error listing credits") + // c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + // return + // } telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) - c.WriteResult(w, r, credits) + c.WriteResult(w, r, "") } // ListCustomerUsageHandler returns customer usage aggregations like CPU and RAM hours. @@ -129,7 +127,6 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, ) if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { @@ -145,7 +142,7 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ return } - usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.WindowSize, req.CurrentPeriod) + usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, proj.ID, true, proj.EnableSandbox) if err != nil { err := telemetry.Error(ctx, span, err, "error listing customer usage") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -153,53 +150,3 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ } c.WriteResult(w, r, usage) } - -// ListCustomerCostsHandler returns customer usage aggregations like CPU and RAM hours. -type ListCustomerCostsHandler struct { - handlers.PorterHandlerReadWriter -} - -// NewListCustomerCostsHandler returns a new ListCustomerCostsHandler -func NewListCustomerCostsHandler( - config *config.Config, - decoderValidator shared.RequestDecoderValidator, - writer shared.ResultWriter, -) *ListCustomerCostsHandler { - return &ListCustomerCostsHandler{ - PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), - } -} - -func (c *ListCustomerCostsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, span := telemetry.NewSpan(r.Context(), "serve-list-customer-costs") - defer span.End() - - proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, - telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, - ) - - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { - c.WriteResult(w, r, "") - return - } - - req := &types.ListCustomerCostsRequest{} - - if ok := c.DecodeAndValidate(w, r, req); !ok { - err := telemetry.Error(ctx, span, nil, "error decoding list customer costs request") - c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) - return - } - - usage, err := c.Config().BillingManager.LagoClient.ListCustomerCosts(ctx, proj.UsageID, req.StartingOn, req.EndingBefore, req.Limit) - if err != nil { - err := telemetry.Error(ctx, span, err, "error listing customer costs") - c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - return - } - c.WriteResult(w, r, usage) -} diff --git a/api/server/handlers/project/create.go b/api/server/handlers/project/create.go index 46566698b4..1a42c48744 100644 --- a/api/server/handlers/project/create.go +++ b/api/server/handlers/project/create.go @@ -3,7 +3,6 @@ package project import ( "net/http" - "github.com/google/uuid" "github.com/porter-dev/porter/api/server/handlers" "github.com/porter-dev/porter/api/server/shared" "github.com/porter-dev/porter/api/server/shared/apierrors" @@ -102,21 +101,15 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) // Create Metronome customer and add to starter plan if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { - customerID, customerPlanID, err := p.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) + err := p.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) if err != nil { err = telemetry.Error(ctx, span, err, "error creating Metronome customer") p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) return } - proj.UsageID = customerID - proj.UsagePlanID = customerPlanID - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, - telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID}, - ) } - if proj.BillingID != "" || proj.UsageID != uuid.Nil { + if proj.BillingID != "" { _, err = p.Repo().Project().UpdateProject(proj) if err != nil { err := telemetry.Error(ctx, span, err, "error updating project") diff --git a/api/server/handlers/project/delete.go b/api/server/handlers/project/delete.go index 8d99fa5f10..d71266569e 100644 --- a/api/server/handlers/project/delete.go +++ b/api/server/handlers/project/delete.go @@ -93,18 +93,13 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { - err = p.Config().BillingManager.LagoClient.EndCustomerPlan(ctx, proj.UsageID, proj.UsagePlanID) + err = p.Config().BillingManager.LagoClient.EndCustomerPlan(ctx, proj.ID) if err != nil { e := "error ending billing plan" err = telemetry.Error(ctx, span, err, e) p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) return } - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "project-id", Value: proj.ID}, - telemetry.AttributeKV{Key: "usage-id", Value: proj.UsageID}, - telemetry.AttributeKV{Key: "usage-plan-id", Value: proj.UsagePlanID}, - ) } deletedProject, err := p.Repo().Project().DeleteProject(proj) diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go index 61e76f3d7c..51aaf1534f 100644 --- a/api/server/handlers/project/referrals.go +++ b/api/server/handlers/project/referrals.go @@ -3,7 +3,6 @@ package project import ( "net/http" - "github.com/google/uuid" "github.com/porter-dev/porter/api/server/handlers" "github.com/porter-dev/porter/api/server/shared" "github.com/porter-dev/porter/api/server/shared/apierrors" @@ -34,8 +33,7 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || - proj.UsageID == uuid.Nil || !proj.EnableSandbox { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox { c.WriteResult(w, r, "") telemetry.WithAttributes(span, diff --git a/api/server/router/project.go b/api/server/router/project.go index 5a922f33bb..06c186f59a 100644 --- a/api/server/router/project.go +++ b/api/server/router/project.go @@ -454,34 +454,6 @@ func getProjectRoutes( Router: r, }) - // GET /api/projects/{project_id}/billing/costs -> project.NewListCustomerCostsHandler - listCustomerCostsEndpoint := factory.NewAPIEndpoint( - &types.APIRequestMetadata{ - Verb: types.APIVerbGet, - Method: types.HTTPVerbGet, - Path: &types.Path{ - Parent: basePath, - RelativePath: relPath + "/billing/costs", - }, - Scopes: []types.PermissionScope{ - types.UserScope, - types.ProjectScope, - }, - }, - ) - - listCustomerCostsHandler := billing.NewListCustomerCostsHandler( - config, - factory.GetDecoderValidator(), - factory.GetResultWriter(), - ) - - routes = append(routes, &router.Route{ - Endpoint: listCustomerCostsEndpoint, - Handler: listCustomerCostsHandler, - Router: r, - }) - // GET /api/projects/{project_id}/billing/invoices -> project.NewListCustomerInvoicesHandler listCustomerInvoicesEndpoint := factory.NewAPIEndpoint( &types.APIRequestMetadata{ diff --git a/internal/billing/usage.go b/internal/billing/usage.go index ee052943fd..e2346dc793 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -3,28 +3,33 @@ package billing import ( "context" "fmt" - "net/http" + "strconv" "time" "github.com/getlago/lago-go-client" - "github.com/google/uuid" "github.com/porter-dev/porter/api/types" "github.com/porter-dev/porter/internal/telemetry" ) const ( - defaultMaxRetries = 10 - porterStandardTrialDays = 15 defaultRewardAmountCents = 1000 - defaultPaidAmountCents = 0 maxReferralRewards = 10 + defaultMaxRetries = 10 maxIngestEventLimit = 100 + + // These prefixes are used to build the customer and subscription IDs + // in Lago. This way we can reuse the project IDs instead of storing + // the Lago IDs in the database. + + // SubscriptionIDPrefix is the prefix for the subscription ID + SubscriptionIDPrefix = "sub" + // CustomerIDPrefix is the prefix for the customer ID + CustomerIDPrefix = "cus" ) // LagoClient is the client used to call the Lago API type LagoClient struct { client lago.Client - billableMetrics []types.BillableMetric PorterCloudPlanID string PorterStandardPlanID string @@ -64,7 +69,7 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string return telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID)) } - subscriptionID := m.generateSubscriptionID(projectID, sandboxEnabled) + subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) err = m.addCustomerPlan(ctx, customerID, planID, subscriptionID) @@ -76,7 +81,7 @@ func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projec ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer") defer span.End() - customerID = m.generateCustomerID(projectID, sandboxEnabled) + customerID = m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) customerInput := &lago.CustomerInput{ ExternalID: customerID, @@ -122,14 +127,15 @@ func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planI } // ListCustomerPlan will return the current active plan to which the user is subscribed -func (m LagoClient) ListCustomerPlan(ctx context.Context, subscriptionID string) (plan types.Plan, err error) { +func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) { ctx, span := telemetry.NewSpan(ctx, "list-customer-plans") defer span.End() - if subscriptionID == "" { - return plan, telemetry.Error(ctx, span, err, "subscription id empty") + if projectID == 0 { + return plan, telemetry.Error(ctx, span, err, "project id empty") } + subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) subscription, lagoErr := m.client.Subscription().Get(ctx, subscriptionID) if err != nil { return plan, telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription") @@ -143,14 +149,15 @@ func (m LagoClient) ListCustomerPlan(ctx context.Context, subscriptionID string) } // EndCustomerPlan will immediately end the plan for the given customer -func (m LagoClient) EndCustomerPlan(ctx context.Context, subscriptionID string) (err error) { +func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err error) { ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan") defer span.End() - if subscriptionID == "" { + if projectID == 0 { return telemetry.Error(ctx, span, err, "subscription id empty") } + subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, false) subscriptionTerminateInput := lago.SubscriptionTerminateInput{ ExternalID: subscriptionID, } @@ -164,41 +171,42 @@ func (m LagoClient) EndCustomerPlan(ctx context.Context, subscriptionID string) } // ListCustomerCredits will return the total number of credits for the customer -func (m LagoClient) ListCustomerCredits(ctx context.Context, customerID string) (credits types.ListCreditGrantsResponse, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-customer-credits") - defer span.End() +// func (m LagoClient) ListCustomerCredits(ctx context.Context, customerID string) (credits types.ListCreditGrantsResponse, err error) { +// ctx, span := telemetry.NewSpan(ctx, "list-customer-credits") +// defer span.End() - if customerID == "" { - return credits, telemetry.Error(ctx, span, err, "customer id empty") - } +// if customerID == "" { +// return credits, telemetry.Error(ctx, span, err, "customer id empty") +// } - walletListInput := &lago.WalletListInput{ - ExternalCustomerID: customerID, - } +// walletListInput := &lago.WalletListInput{ +// ExternalCustomerID: customerID, +// } - walletList, lagoErr := m.client.Wallet().GetList(ctx, walletListInput) - if lagoErr.Err != nil { - return credits, telemetry.Error(ctx, span, lagoErr.Err, "failed to get wallet") - } +// walletList, lagoErr := m.client.Wallet().GetList(ctx, walletListInput) +// if lagoErr.Err != nil { +// return credits, telemetry.Error(ctx, span, lagoErr.Err, "failed to get wallet") +// } - var response types.ListCreditGrantsResponse - for _, wallet := range walletList.Wallets { - response.GrantedBalanceCents += wallet.BalanceCents - response.RemainingBalanceCents += wallet.OngoingUsageBalanceCents - } +// var response types.ListCreditGrantsResponse +// for _, wallet := range walletList.Wallets { +// response.GrantedBalanceCents += wallet.BalanceCents +// response.RemainingBalanceCents += wallet.OngoingUsageBalanceCents +// } - return response, nil -} +// return response, nil +// } // CreateCreditsGrant will create a new credit grant for the customer with the specified amount -func (m LagoClient) CreateCreditsGrant(ctx context.Context, customerID string, reason string, grantAmount float64, expiresAt string) (err error) { +func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt string, sandboxEnabled bool) (err error) { ctx, span := telemetry.NewSpan(ctx, "create-credits-grant") defer span.End() - if customerID == "" { - return telemetry.Error(ctx, span, err, "customer id empty") + if projectID == 0 { + return telemetry.Error(ctx, span, err, "project id empty") } + customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) expiresAtTime, err := time.Parse(time.RFC3339, expiresAt) if err != nil { return telemetry.Error(ctx, span, err, "failed to parse credit expiration timestamp") @@ -206,8 +214,10 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, customerID string, r walletInput := &lago.WalletInput{ ExternalCustomerID: customerID, + Name: name, Currency: lago.USD, - RateAmount: fmt.Sprintf("%.2f", grantAmount), + GrantedCredits: strconv.FormatInt(grantAmount, 10), + RateAmount: "1", ExpirationAt: &expiresAtTime, } @@ -220,110 +230,30 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, customerID string, r } // ListCustomerUsage will return the aggregated usage for a customer -func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, windowsSize string, currentPeriod bool) (usage []types.Usage, err error) { +func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, currentPeriod bool, sandboxEnabled bool) (usage []types.Usage, err error) { ctx, span := telemetry.NewSpan(ctx, "list-customer-usage") defer span.End() - if customerID == uuid.Nil { - return usage, telemetry.Error(ctx, span, err, "customer id empty") - } - - if len(m.billableMetrics) == 0 { - billableMetrics, err := m.listBillableMetricIDs(ctx, customerID) - if err != nil { - return nil, telemetry.Error(ctx, span, err, "failed to list billable metrics") - } - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "billable-metric-count", Value: len(billableMetrics)}, - ) - - // Cache billable metric ids for future calls - m.billableMetrics = append(m.billableMetrics, billableMetrics...) - } - - path := "usage/groups" - - startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore) - if err != nil { - return nil, telemetry.Error(ctx, span, err, err.Error()) + if projectID == 0 { + return usage, telemetry.Error(ctx, span, err, "project id empty") } - baseReq := types.ListCustomerUsageRequest{ - CustomerID: customerID, - WindowSize: windowsSize, - StartingOn: startingOnTimestamp, - EndingBefore: endingBeforeTimestamp, - CurrentPeriod: currentPeriod, + subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) + customerUsageInput := &lago.CustomerUsageInput{ + ExternalSubscriptionID: subscriptionID, } - for _, billableMetric := range m.billableMetrics { - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "billable-metric-id", Value: billableMetric.ID}, - ) - - var result struct { - Data []types.CustomerUsageMetric `json:"data"` - } - - baseReq.BillableMetricID = billableMetric.ID - _, err = m.do(http.MethodPost, path, "", baseReq, &result) - if err != nil { - return usage, telemetry.Error(ctx, span, err, "failed to get customer usage") - } - - usage = append(usage, types.Usage{ - MetricName: billableMetric.Name, - UsageMetrics: result.Data, - }) + customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) + _, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput) + if lagoErr.Err != nil { + return usage, telemetry.Error(ctx, span, lagoErr.Err, "failed to get customer usage") } return usage, nil } -// ListCustomerCosts will return the costs for a customer over a time period -func (m LagoClient) ListCustomerCosts(ctx context.Context, customerID uuid.UUID, startingOn string, endingBefore string, limit int) (costs []types.FormattedCost, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-customer-costs") - defer span.End() - - if customerID == uuid.Nil { - return costs, telemetry.Error(ctx, span, err, "customer id empty") - } - - path := fmt.Sprintf("customers/%s/costs", customerID) - - var result struct { - Data []types.Cost `json:"data"` - } - - startingOnTimestamp, endingBeforeTimestamp, err := parseAndCheckTimestamps(startingOn, endingBefore) - if err != nil { - return nil, telemetry.Error(ctx, span, err, err.Error()) - } - - queryParams := fmt.Sprintf("starting_on=%s&ending_before=%s&limit=%d", startingOnTimestamp, endingBeforeTimestamp, limit) - - _, err = m.do(http.MethodGet, path, queryParams, nil, &result) - if err != nil { - return costs, telemetry.Error(ctx, span, err, "failed to create credits grant") - } - - for _, customerCost := range result.Data { - formattedCost := types.FormattedCost{ - StartTimestamp: customerCost.StartTimestamp, - EndTimestamp: customerCost.EndTimestamp, - } - for _, creditType := range customerCost.CreditTypes { - formattedCost.Cost += creditType.Cost - } - costs = append(costs, formattedCost) - } - - return costs, nil -} - // IngestEvents sends a list of billing events to Metronome's ingest endpoint -func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEvent) (err error) { +func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEvent, enableSandbox bool) (err error) { ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events") defer span.End() @@ -331,8 +261,6 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven return nil } - path := "ingest" - for i := 0; i < len(events); i += maxIngestEventLimit { end := i + maxIngestEventLimit if end > len(events) { @@ -340,29 +268,29 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven } batch := events[i:end] + batchInput := make([]lago.EventInput, len(batch)) - // Retry each batch to make sure all events are ingested - var currentAttempts int - for currentAttempts < defaultMaxRetries { - statusCode, err := m.do(http.MethodPost, path, "", batch, nil) - // Check errors that are not from error http codes - if statusCode == 0 && err != nil { - return telemetry.Error(ctx, span, err, "failed to ingest billing events") + for i := range batch { + customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64) + if err != nil { + return telemetry.Error(ctx, span, err, "failed to parse customer ID") } - - if statusCode == http.StatusForbidden || statusCode == http.StatusUnauthorized { - return telemetry.Error(ctx, span, err, "unauthorized") - } - - // 400 responses should not be retried - if statusCode == http.StatusBadRequest { - return telemetry.Error(ctx, span, err, "malformed billing events") + externalSubscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox) + + event := lago.EventInput{ + TransactionID: batch[i].TransactionID, + ExternalSubscriptionID: externalSubscriptionID, + Code: batch[i].EventType, + Timestamp: batch[i].Timestamp, + Properties: batch[i].Properties, } + batchInput = append(batchInput, event) + } - // Any other status code can be safely retried - if statusCode == http.StatusOK { - break - } + // Retry each batch to make sure all events are ingested + var currentAttempts int + for currentAttempts < defaultMaxRetries { + m.client.Event().Batch(ctx, &batchInput) currentAttempts++ } @@ -374,40 +302,10 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven return nil } -func (m LagoClient) listBillableMetricIDs(ctx context.Context, customerID uuid.UUID) (billableMetrics []types.BillableMetric, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-billable-metrics") - defer span.End() - - if customerID == uuid.Nil { - return billableMetrics, telemetry.Error(ctx, span, err, "customer id empty") - } - - path := fmt.Sprintf("/customers/%s/billable-metrics", customerID) - - var result struct { - Data []types.BillableMetric `json:"data"` - } - - _, err = m.do(http.MethodGet, path, "", nil, &result) - if err != nil { - return billableMetrics, telemetry.Error(ctx, span, err, "failed to retrieve billable metrics from metronome") - } - - return result.Data, nil -} - -func (m LagoClient) generateCustomerID(projectID uint, sandboxEnabled bool) string { - if sandboxEnabled { - return fmt.Sprintf("cloud_cus_%d", projectID) - } - - return fmt.Sprintf("cus_%d", projectID) -} - -func (m LagoClient) generateSubscriptionID(projectID uint, sandboxEnabled bool) string { +func (m LagoClient) GenerateLagoID(prefix string, projectID uint, sandboxEnabled bool) string { if sandboxEnabled { - return fmt.Sprintf("cloud_sub_%d", projectID) + return fmt.Sprintf("cloud_%s_%d", prefix, projectID) } - return fmt.Sprintf("sub_%d", projectID) + return fmt.Sprintf("%s_%d", prefix, projectID) } From 3be49056ec843189ac6d0e149302ee029eb62741 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Wed, 8 May 2024 17:51:56 -0400 Subject: [PATCH 04/16] Add trials logic --- api/server/handlers/billing/invoices.go | 10 +-- api/server/shared/config/env/envconfs.go | 11 +-- api/server/shared/config/loader/loader.go | 6 +- internal/billing/stripe.go | 39 --------- internal/billing/usage.go | 98 +++++++++++++++-------- internal/models/project.go | 6 +- zarf/helm/.serverenv | 15 ++-- 7 files changed, 86 insertions(+), 99 deletions(-) diff --git a/api/server/handlers/billing/invoices.go b/api/server/handlers/billing/invoices.go index 1b153a69cd..5e0018c6c2 100644 --- a/api/server/handlers/billing/invoices.go +++ b/api/server/handlers/billing/invoices.go @@ -1,7 +1,6 @@ package billing import ( - "fmt" "net/http" "github.com/porter-dev/porter/api/server/handlers" @@ -54,13 +53,6 @@ func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.R return } - invoices, err := c.Config().BillingManager.StripeClient.ListCustomerInvoices(ctx, proj.BillingID, req.Status) - if err != nil { - err = telemetry.Error(ctx, span, err, fmt.Sprintf("error listing invoices for customer %s", proj.BillingID)) - c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) - return - } - // Write the response to the frontend - c.WriteResult(w, r, invoices) + c.WriteResult(w, r, "invoices") } diff --git a/api/server/shared/config/env/envconfs.go b/api/server/shared/config/env/envconfs.go index d2ada56821..b388d22614 100644 --- a/api/server/shared/config/env/envconfs.go +++ b/api/server/shared/config/env/envconfs.go @@ -69,11 +69,12 @@ type ServerConf struct { SendgridDeleteProjectTemplateID string `env:"SENDGRID_DELETE_PROJECT_TEMPLATE_ID"` SendgridSenderEmail string `env:"SENDGRID_SENDER_EMAIL"` - StripeSecretKey string `env:"STRIPE_SECRET_KEY"` - StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"` - LagoAPIKey string `env:"LAGO_API_KEY"` - PorterCloudPlanID string `env:"PORTER_CLOUD_PLAN_ID"` - PorterStandardPlanID string `env:"PORTER_STANDARD_PLAN_ID"` + StripeSecretKey string `env:"STRIPE_SECRET_KEY"` + StripePublishableKey string `env:"STRIPE_PUBLISHABLE_KEY"` + LagoAPIKey string `env:"LAGO_API_KEY"` + PorterCloudPlanCode string `env:"PORTER_CLOUD_PLAN_CODE"` + PorterStandardPlanCode string `env:"PORTER_STANDARD_PLAN_CODE"` + PorterTrialCode string `env:"PORTER_TRIAL_CODE"` // The URL of the webhook to verify ingesting events works IngestStatusWebhookUrl string `env:"INGEST_STATUS_WEBHOOK_URL"` diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index e010a04825..17e0f25671 100644 --- a/api/server/shared/config/loader/loader.go +++ b/api/server/shared/config/loader/loader.go @@ -371,15 +371,15 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { res.Logger.Info().Msg("STRIPE_SECRET_KEY not set, all Stripe functionality will be disabled") } - if sc.LagoAPIKey != "" && sc.PorterCloudPlanID != "" && sc.PorterStandardPlanID != "" { - lagoClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.LagoAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanID, InstanceEnvConf.ServerConf.PorterStandardPlanID) + if sc.LagoAPIKey != "" && sc.PorterCloudPlanCode != "" && sc.PorterStandardPlanCode != "" && sc.PorterTrialCode != "" { + lagoClient, err = billing.NewLagoClient(InstanceEnvConf.ServerConf.LagoAPIKey, InstanceEnvConf.ServerConf.PorterCloudPlanCode, InstanceEnvConf.ServerConf.PorterStandardPlanCode, InstanceEnvConf.ServerConf.PorterTrialCode) if err != nil { return nil, fmt.Errorf("unable to create Lago client: %w", err) } metronomeEnabled = true res.Logger.Info().Msg("Lago configuration loaded") } else { - res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_ID, or PORTER_STANDARD_PLAN_ID not set, all Metronome functionality will be disabled") + res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_CODE, PORTER_STANDARD_PLAN_CODE and PORTER_TRIAL_CODE must be set, all Lago functionality will be disabled") } res.Logger.Info().Msg("Creating billing manager") diff --git a/internal/billing/stripe.go b/internal/billing/stripe.go index d23e35a0d6..afdc1eb334 100644 --- a/internal/billing/stripe.go +++ b/internal/billing/stripe.go @@ -4,13 +4,11 @@ import ( "context" "fmt" "strconv" - "time" "github.com/porter-dev/porter/api/types" "github.com/porter-dev/porter/internal/telemetry" "github.com/stripe/stripe-go/v76" "github.com/stripe/stripe-go/v76/customer" - "github.com/stripe/stripe-go/v76/invoice" "github.com/stripe/stripe-go/v76/paymentmethod" "github.com/stripe/stripe-go/v76/setupintent" ) @@ -245,43 +243,6 @@ func (s StripeClient) GetPublishableKey(ctx context.Context) (key string) { return s.PublishableKey } -// ListCustomerInvoices will return all invoices for the customer with the given status -func (s StripeClient) ListCustomerInvoices(ctx context.Context, customerID string, status string) (invoiceList []types.Invoice, err error) { - ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls") - defer span.End() - - if customerID == "" { - return invoiceList, telemetry.Error(ctx, span, err, "customer id cannot be empty") - } - - stripe.Key = s.SecretKey - - params := &stripe.InvoiceListParams{ - Customer: stripe.String(customerID), - Status: stripe.String(status), - } - - result := invoice.List(params) - - for result.Next() { - invoice := result.Current().(*stripe.Invoice) - - if invoice == nil { - continue - } - - createdTimestamp := time.Unix(invoice.Created, 0) - - invoiceList = append(invoiceList, types.Invoice{ - HostedInvoiceURL: invoice.HostedInvoiceURL, - Status: string(invoice.Status), - Created: createdTimestamp.Format(time.RFC3339), - }) - } - - return invoiceList, nil -} - func (s StripeClient) checkDefaultPaymentMethod(customerID string) (defaultPaymentExists bool, defaultPaymentID string, err error) { // Get customer to check default payment method customer, err := customer.Get(customerID, nil) diff --git a/internal/billing/usage.go b/internal/billing/usage.go index e2346dc793..f9cc58f258 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -17,10 +17,15 @@ const ( defaultMaxRetries = 10 maxIngestEventLimit = 100 + // porterStandardTrialDays is the number of days for the trial + porterStandardTrialDays = 15 + // These prefixes are used to build the customer and subscription IDs // in Lago. This way we can reuse the project IDs instead of storing // the Lago IDs in the database. + // TrialIDPrefix is the prefix for the trial ID + TrialIDPrefix = "trial" // SubscriptionIDPrefix is the prefix for the subscription ID SubscriptionIDPrefix = "sub" // CustomerIDPrefix is the prefix for the customer ID @@ -29,9 +34,10 @@ const ( // LagoClient is the client used to call the Lago API type LagoClient struct { - client lago.Client - PorterCloudPlanID string - PorterStandardPlanID string + client lago.Client + PorterCloudPlanCode string + PorterStandardPlanCode string + PorterTrialCode string // DefaultRewardAmountCents is the default amount in USD cents rewarded to users // who successfully refer a new user @@ -40,48 +46,65 @@ type LagoClient struct { MaxReferralRewards int64 } -// NewLagoClient returns a new Metronome client -func NewLagoClient(lagoApiKey string, porterCloudPlanID string, porterStandardPlanID string) (client LagoClient, err error) { +// NewLagoClient returns a new Lago client +func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandardPlanCode string, porterTrialCode string) (client LagoClient, err error) { lagoClient := lago.New(). SetApiKey("__YOU_API_KEY__") return LagoClient{ client: *lagoClient, - PorterCloudPlanID: porterCloudPlanID, - PorterStandardPlanID: porterStandardPlanID, + PorterCloudPlanCode: porterCloudPlanCode, + PorterStandardPlanCode: porterStandardPlanCode, + PorterTrialCode: porterTrialCode, DefaultRewardAmountCents: defaultRewardAmountCents, MaxReferralRewards: maxReferralRewards, }, nil } -// CreateCustomerWithPlan will create the customer in Metronome and immediately add it to the plan +// CreateCustomerWithPlan will create the customer in Lago and immediately add it to the plan func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (err error) { - ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") + ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan") defer span.End() - planID := m.PorterStandardPlanID + customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled) + if err != nil { + return telemetry.Error(ctx, span, err, "error while creating customer") + } + + trialID := m.generateLagoID(TrialIDPrefix, projectID, sandboxEnabled) + subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) + now := time.Now() + trialEndTime := now.Add(time.Hour * 24 * porterStandardTrialDays) + if sandboxEnabled { - planID = m.PorterCloudPlanID + err = m.addCustomerPlan(ctx, customerID, m.PorterCloudPlanCode, subscriptionID, &now, nil) + if err != nil { + return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterCloudPlanCode)) + } + return nil } - customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled) + // First, start the new customer on the trial + err = m.addCustomerPlan(ctx, customerID, m.PorterTrialCode, trialID, &now, &trialEndTime) if err != nil { - return telemetry.Error(ctx, span, err, fmt.Sprintf("error while creating customer with plan %s", planID)) + return telemetry.Error(ctx, span, err, fmt.Sprintf("error while starting customer trial %s", m.PorterTrialCode)) } - subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) - - err = m.addCustomerPlan(ctx, customerID, planID, subscriptionID) + // Then, add the customer to the actual plan. The date of the subscription will be the end of the trial + err = m.addCustomerPlan(ctx, customerID, m.PorterStandardPlanCode, subscriptionID, &trialEndTime, nil) + if err != nil { + return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterStandardPlanCode)) + } return err } -// createCustomer will create the customer in Metronome +// createCustomer will create the customer in Lago func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) { - ctx, span := telemetry.NewSpan(ctx, "create-metronome-customer") + ctx, span := telemetry.NewSpan(ctx, "create-lago-customer") defer span.End() - customerID = m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) + customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) customerInput := &lago.CustomerInput{ ExternalID: customerID, @@ -101,20 +124,20 @@ func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projec } // addCustomerPlan will create a plan subscription for the customer -func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planID string, subscriptionID string) (err error) { - ctx, span := telemetry.NewSpan(ctx, "add-metronome-customer-plan") +func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) { + ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan") defer span.End() if projectID == "" || planID == "" { return telemetry.Error(ctx, span, err, "project and plan id are required") } - now := time.Now() subscriptionInput := &lago.SubscriptionInput{ ExternalCustomerID: projectID, ExternalID: subscriptionID, PlanCode: planID, - SubscriptionAt: &now, + SubscriptionAt: startingAt, + EndingAt: endingAt, BillingTime: lago.Calendar, } @@ -135,7 +158,7 @@ func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandbo return plan, telemetry.Error(ctx, span, err, "project id empty") } - subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) + subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) subscription, lagoErr := m.client.Subscription().Get(ctx, subscriptionID) if err != nil { return plan, telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription") @@ -150,14 +173,14 @@ func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandbo // EndCustomerPlan will immediately end the plan for the given customer func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err error) { - ctx, span := telemetry.NewSpan(ctx, "end-metronome-customer-plan") + ctx, span := telemetry.NewSpan(ctx, "end-lago-customer-plan") defer span.End() if projectID == 0 { return telemetry.Error(ctx, span, err, "subscription id empty") } - subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, false) + subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, false) subscriptionTerminateInput := lago.SubscriptionTerminateInput{ ExternalID: subscriptionID, } @@ -206,7 +229,7 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name return telemetry.Error(ctx, span, err, "project id empty") } - customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) + customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) expiresAtTime, err := time.Parse(time.RFC3339, expiresAt) if err != nil { return telemetry.Error(ctx, span, err, "failed to parse credit expiration timestamp") @@ -238,12 +261,12 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, curre return usage, telemetry.Error(ctx, span, err, "project id empty") } - subscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) + subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) customerUsageInput := &lago.CustomerUsageInput{ ExternalSubscriptionID: subscriptionID, } - customerID := m.GenerateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) + customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) _, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput) if lagoErr.Err != nil { return usage, telemetry.Error(ctx, span, lagoErr.Err, "failed to get customer usage") @@ -252,7 +275,7 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, curre return usage, nil } -// IngestEvents sends a list of billing events to Metronome's ingest endpoint +// IngestEvents sends a list of billing events to Lago's ingest endpoint func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEvent, enableSandbox bool) (err error) { ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events") defer span.End() @@ -275,7 +298,7 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven if err != nil { return telemetry.Error(ctx, span, err, "failed to parse customer ID") } - externalSubscriptionID := m.GenerateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox) + externalSubscriptionID := m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox) event := lago.EventInput{ TransactionID: batch[i].TransactionID, @@ -302,7 +325,18 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven return nil } -func (m LagoClient) GenerateLagoID(prefix string, projectID uint, sandboxEnabled bool) string { +// ListCustomerInvoices will return all invoices for the customer with the given status +func (s StripeClient) ListCustomerInvoices(ctx context.Context, projectID uint) (invoiceList []types.Invoice, err error) { + ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls") + defer span.End() + + if projectID == 0 { + return invoiceList, telemetry.Error(ctx, span, err, "project id cannot be empty") + } + return invoiceList, nil +} + +func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string { if sandboxEnabled { return fmt.Sprintf("cloud_%s_%d", prefix, projectID) } diff --git a/internal/models/project.go b/internal/models/project.go index c45946f89a..e0828fa54d 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -146,8 +146,7 @@ type Project struct { Roles []Role `json:"roles"` // BillingID corresponds to the id generated by the billing provider - BillingID string - BillingEnabled bool + BillingID string // linked repos GitRepos []GitRepo `json:"git_repos,omitempty"` @@ -243,8 +242,6 @@ func (p *Project) GetFeatureFlag(flagName FeatureFlagLabel, launchDarklyClient * return p.AzureEnabled case "capi_provisioner_enabled": return p.CapiProvisionerEnabled - case "billing_enabled": - return p.BillingEnabled case "db_enabled": return false case "enable_reprovision": @@ -357,7 +354,6 @@ func (p *Project) ToProjectListType() *types.ProjectList { // note: all of these fields should be considered deprecated // in an api response Roles: roles, - BillingEnabled: p.BillingEnabled, PreviewEnvsEnabled: p.PreviewEnvsEnabled, RDSDatabasesEnabled: p.RDSDatabasesEnabled, ManagedInfraEnabled: p.ManagedInfraEnabled, diff --git a/zarf/helm/.serverenv b/zarf/helm/.serverenv index ab36932880..b12175e6a6 100644 --- a/zarf/helm/.serverenv +++ b/zarf/helm/.serverenv @@ -73,14 +73,17 @@ STRIPE_SECRET_KEY= # STRIPE_PUBLISHABLE_KEY is used in the frontend to create Stripe Web Elements STRIPE_PUBLISHABLE_KEY= -# METRONOME_API_KEY is used to create customers in Metronome. If empty all functionality will be disabled. -METRONOME_API_KEY= +# LAGO_API_KEY is used to create customers in Lago. If empty all functionality will be disabled. +LAGO_API_KEY= -# PORTER_CLOUD_PLAN_ID is the id of the starter plan in Metronome. Only used if METRONOME_API_KEY is set -PORTER_CLOUD_PLAN_ID= +# PORTER_CLOUD_PLAN_ID is the id of the starter plan in Lago. Only used if LAGO_API_KEY is set +PORTER_CLOUD_PLAN_CODE= -# PORTER_STANDARD_PLAN_ID is the id of the standard plan in Metronome. Only used if METRONOME_API_KEY is set -PORTER_STANDARD_PLAN_ID= +# PORTER_STANDARD_PLAN_CODE is the id of the standard plan in Lago. Only used if LAGO_API_KEY is set +PORTER_STANDARD_PLAN_CODE= + +# PORTER_TRIAL_CODE is the id of the trial plan in Lago. Only used if LAGO_API_KEY is set +PORTER_TRIAL_CODE= # UPSTASH_ENABLED is used to enable the Upstash integration UPSTASH_ENABLED=false From 7a06be0a41e9550f434f931e51f8142dcdb06d80 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Thu, 9 May 2024 12:25:19 -0400 Subject: [PATCH 05/16] Fix customer creation, add method for getting active subscription --- api/server/handlers/billing/create.go | 4 +- api/server/handlers/billing/ingest.go | 21 +- api/server/handlers/billing/list.go | 26 ++- api/server/handlers/billing/plan.go | 43 +++-- api/server/handlers/project/create.go | 4 +- api/server/handlers/project/delete.go | 2 +- api/server/handlers/project/referrals.go | 4 +- api/server/shared/config/loader/loader.go | 12 +- api/types/project.go | 2 +- internal/billing/usage.go | 224 +++++++++++++++------- internal/models/project.go | 8 +- 11 files changed, 233 insertions(+), 117 deletions(-) diff --git a/api/server/handlers/billing/create.go b/api/server/handlers/billing/create.go index 87adbc4f1a..ac0333fc97 100644 --- a/api/server/handlers/billing/create.go +++ b/api/server/handlers/billing/create.go @@ -149,10 +149,10 @@ func (c *CreateBillingHandler) grantRewardIfReferral(ctx context.Context, referr if referral != nil && referral.Status != models.ReferralStatusCompleted { // Metronome requires an expiration to be passed in, so we set it to 5 years which in // practice will mean the credits will most likely run out before expiring - expiresAt := time.Now().AddDate(5, 0, 0).Format(time.RFC3339) + expiresAt := time.Now().AddDate(5, 0, 0) name := "Referral reward" rewardAmount := c.Config().BillingManager.LagoClient.DefaultRewardAmountCents - err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.ID, name, rewardAmount, expiresAt, referrerProject.EnableSandbox) + err := c.Config().BillingManager.LagoClient.CreateCreditsGrant(ctx, referrerProject.ID, name, rewardAmount, &expiresAt, referrerProject.EnableSandbox) if err != nil { return telemetry.Error(ctx, span, err, "failed to grand credits reward") } diff --git a/api/server/handlers/billing/ingest.go b/api/server/handlers/billing/ingest.go index 8926a93fe6..b407bb28e0 100644 --- a/api/server/handlers/billing/ingest.go +++ b/api/server/handlers/billing/ingest.go @@ -39,19 +39,19 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, - telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, + telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, + telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox}, ) return } telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, + telemetry.AttributeKV{Key: "lago-enabled", Value: true}, ) ingestEventsRequest := struct { @@ -75,7 +75,18 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } } - err := c.Config().BillingManager.LagoClient.IngestEvents(ctx, ingestEventsRequest.Events, proj.EnableSandbox) + subscriptionID, err := c.Config().BillingManager.LagoClient.GetCustomeActiveSubscription(ctx, proj.ID, proj.EnableSandbox) + if err != nil { + err := telemetry.Error(ctx, span, err, "error getting active subscription") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "subscription_id", Value: subscriptionID}, + ) + + err = c.Config().BillingManager.LagoClient.IngestEvents(ctx, subscriptionID, ingestEventsRequest.Events, proj.EnableSandbox) if err != nil { err := telemetry.Error(ctx, span, err, "error ingesting events") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/server/handlers/billing/list.go b/api/server/handlers/billing/list.go index a7edbd8249..4dc9b75760 100644 --- a/api/server/handlers/billing/list.go +++ b/api/server/handlers/billing/list.go @@ -127,9 +127,9 @@ func (c *CheckPaymentEnabledHandler) ensureBillingSetup(ctx context.Context, pro } // Create usage customer for project and set the usage ID if it doesn't exist - err = c.ensureMetronomeCustomerExists(ctx, adminUser.Email, proj) + err = c.ensureLagoCustomerExists(ctx, adminUser.Email, proj) if err != nil { - return telemetry.Error(ctx, span, err, "error ensuring Metronome customer exists") + return telemetry.Error(ctx, span, err, "error ensuring Lago customer exists") } } @@ -195,17 +195,31 @@ func (c *CheckPaymentEnabledHandler) ensureStripeCustomerExists(ctx context.Cont return nil } -func (c *CheckPaymentEnabledHandler) ensureMetronomeCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) { - ctx, span := telemetry.NewSpan(ctx, "ensure-metronome-customer-exists") +func (c *CheckPaymentEnabledHandler) ensureLagoCustomerExists(ctx context.Context, adminUserEmail string, proj *models.Project) (err error) { + ctx, span := telemetry.NewSpan(ctx, "ensure-lago-customer-exists") defer span.End() - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) { + return nil + } + + // Check if the customer already exists + exists, err := c.Config().BillingManager.LagoClient.CheckIfCustomerExists(ctx, proj.ID, proj.EnableSandbox) + if err != nil { + return telemetry.Error(ctx, span, err, "error while checking if customer exists") + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "customer-exists", Value: exists}, + ) + + if exists { return nil } err = c.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, adminUserEmail, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) if err != nil { - return telemetry.Error(ctx, span, err, "error creating Metronome customer") + return telemetry.Error(ctx, span, err, "error creating Lago customer") } return nil diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 36039759ee..b485363570 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -33,21 +33,28 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, + telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, + ) + + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") + return + } - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, - telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, - ) + subscriptionID, err := c.Config().BillingManager.LagoClient.GetCustomeActiveSubscription(ctx, proj.ID, proj.EnableSandbox) + if err != nil { + err := telemetry.Error(ctx, span, err, "error getting active subscription") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) return } telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, + telemetry.AttributeKV{Key: "subscription_id", Value: subscriptionID}, ) - plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, proj.ID, proj.EnableSandbox) + plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, subscriptionID) if err != nil { err := telemetry.Error(ctx, span, err, "error listing plans") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -78,13 +85,13 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { - c.WriteResult(w, r, "") + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, + telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, + ) - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, - telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, - ) + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) { + c.WriteResult(w, r, "") return } @@ -95,10 +102,6 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // return // } - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-enabled", Value: true}, - ) - c.WriteResult(w, r, "") } @@ -125,11 +128,11 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ proj, _ := ctx.Value(types.ProjectScope).(*models.Project) telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, - telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, + telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, + telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, ) - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") return } diff --git a/api/server/handlers/project/create.go b/api/server/handlers/project/create.go index 1a42c48744..1c23c96ad2 100644 --- a/api/server/handlers/project/create.go +++ b/api/server/handlers/project/create.go @@ -100,10 +100,10 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } // Create Metronome customer and add to starter plan - if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { + if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) { err := p.Config().BillingManager.LagoClient.CreateCustomerWithPlan(ctx, user.Email, proj.Name, proj.ID, proj.BillingID, proj.EnableSandbox) if err != nil { - err = telemetry.Error(ctx, span, err, "error creating Metronome customer") + err = telemetry.Error(ctx, span, err, "error creating usage customer") p.HandleAPIError(w, r, apierrors.NewErrInternal(err)) return } diff --git a/api/server/handlers/project/delete.go b/api/server/handlers/project/delete.go index d71266569e..d1939a36e2 100644 --- a/api/server/handlers/project/delete.go +++ b/api/server/handlers/project/delete.go @@ -92,7 +92,7 @@ func (p *ProjectDeleteHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) return } - if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.MetronomeEnabled, p.Config().LaunchDarklyClient) { + if p.Config().BillingManager.LagoConfigLoaded && proj.GetFeatureFlag(models.LagoEnabled, p.Config().LaunchDarklyClient) { err = p.Config().BillingManager.LagoClient.EndCustomerPlan(ctx, proj.ID) if err != nil { e := "error ending billing plan" diff --git a/api/server/handlers/project/referrals.go b/api/server/handlers/project/referrals.go index 51aaf1534f..32bc32947c 100644 --- a/api/server/handlers/project/referrals.go +++ b/api/server/handlers/project/referrals.go @@ -33,12 +33,12 @@ func (c *GetProjectReferralDetailsHandler) ServeHTTP(w http.ResponseWriter, r *h proj, _ := ctx.Value(types.ProjectScope).(*models.Project) - if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) || !proj.EnableSandbox { c.WriteResult(w, r, "") telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "metronome-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, - telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.MetronomeEnabled, c.Config().LaunchDarklyClient)}, + telemetry.AttributeKV{Key: "metronome-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, ) return } diff --git a/api/server/shared/config/loader/loader.go b/api/server/shared/config/loader/loader.go index 17e0f25671..be9cf93cf0 100644 --- a/api/server/shared/config/loader/loader.go +++ b/api/server/shared/config/loader/loader.go @@ -358,10 +358,10 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { } var ( - stripeClient billing.StripeClient - stripeEnabled bool - lagoClient billing.LagoClient - metronomeEnabled bool + stripeClient billing.StripeClient + stripeEnabled bool + lagoClient billing.LagoClient + lagoEnabled bool ) if sc.StripeSecretKey != "" { stripeClient = billing.NewStripeClient(InstanceEnvConf.ServerConf.StripeSecretKey, InstanceEnvConf.ServerConf.StripePublishableKey) @@ -376,7 +376,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { if err != nil { return nil, fmt.Errorf("unable to create Lago client: %w", err) } - metronomeEnabled = true + lagoEnabled = true res.Logger.Info().Msg("Lago configuration loaded") } else { res.Logger.Info().Msg("LAGO_API_KEY, PORTER_CLOUD_PLAN_CODE, PORTER_STANDARD_PLAN_CODE and PORTER_TRIAL_CODE must be set, all Lago functionality will be disabled") @@ -387,7 +387,7 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) { StripeClient: stripeClient, StripeConfigLoaded: stripeEnabled, LagoClient: lagoClient, - LagoConfigLoaded: metronomeEnabled, + LagoConfigLoaded: lagoEnabled, } res.Logger.Info().Msg("Created billing manager") diff --git a/api/types/project.go b/api/types/project.go index ecf3c48d13..99cc101231 100644 --- a/api/types/project.go +++ b/api/types/project.go @@ -39,7 +39,7 @@ type Project struct { BetaFeaturesEnabled bool `json:"beta_features_enabled"` CapiProvisionerEnabled bool `json:"capi_provisioner_enabled"` BillingEnabled bool `json:"billing_enabled"` - MetronomeEnabled bool `json:"metronome_enabled"` + LagoEnabled bool `json:"metronome_enabled"` InfisicalEnabled bool `json:"infisical_enabled"` FreezeEnabled bool `json:"freeze_enabled"` DBEnabled bool `json:"db_enabled"` diff --git a/internal/billing/usage.go b/internal/billing/usage.go index f9cc58f258..3f081d990f 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -3,6 +3,7 @@ package billing import ( "context" "fmt" + "log" "strconv" "time" @@ -12,10 +13,11 @@ import ( ) const ( - defaultRewardAmountCents = 1000 - maxReferralRewards = 10 - defaultMaxRetries = 10 - maxIngestEventLimit = 100 + defaultStarterCreditsCents = 500 + defaultRewardAmountCents = 1000 + maxReferralRewards = 10 + defaultMaxRetries = 10 + maxIngestEventLimit = 100 // porterStandardTrialDays is the number of days for the trial porterStandardTrialDays = 15 @@ -48,8 +50,11 @@ type LagoClient struct { // NewLagoClient returns a new Lago client func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandardPlanCode string, porterTrialCode string) (client LagoClient, err error) { - lagoClient := lago.New(). - SetApiKey("__YOU_API_KEY__") + lagoClient := lago.New().SetApiKey(lagoApiKey) + + if lagoClient == nil { + return client, fmt.Errorf("failed to create lago client") + } return LagoClient{ client: *lagoClient, @@ -66,6 +71,10 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan") defer span.End() + if projectID == 0 { + return telemetry.Error(ctx, span, err, "project id empty") + } + customerID, err := m.createCustomer(ctx, userEmail, projectName, projectID, billingID, sandboxEnabled) if err != nil { return telemetry.Error(ctx, span, err, "error while creating customer") @@ -73,14 +82,23 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string trialID := m.generateLagoID(TrialIDPrefix, projectID, sandboxEnabled) subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) - now := time.Now() - trialEndTime := now.Add(time.Hour * 24 * porterStandardTrialDays) + + // The dates need to be at midnight UTC + now := time.Now().UTC() + now = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + trialEndTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).Add(time.Hour * 24 * porterStandardTrialDays).UTC() if sandboxEnabled { err = m.addCustomerPlan(ctx, customerID, m.PorterCloudPlanCode, subscriptionID, &now, nil) if err != nil { return telemetry.Error(ctx, span, err, fmt.Sprintf("error while adding customer to plan %s", m.PorterCloudPlanCode)) } + + starterWalletName := "Free Starter Credits" + expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour) + + err = m.CreateCreditsGrant(ctx, projectID, starterWalletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled) + return nil } @@ -99,75 +117,91 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string return err } -// createCustomer will create the customer in Lago -func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) { - ctx, span := telemetry.NewSpan(ctx, "create-lago-customer") +func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, enableSandbox bool) (exists bool, err error) { + ctx, span := telemetry.NewSpan(ctx, "check-lago-customer-exists") defer span.End() - customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) - - customerInput := &lago.CustomerInput{ - ExternalID: customerID, - Name: projectName, - Email: userEmail, - BillingConfiguration: lago.CustomerBillingConfigurationInput{ - PaymentProvider: "stripe", - ProviderCustomerID: billingID, - }, + if projectID == 0 { + return exists, telemetry.Error(ctx, span, err, "project id empty") } - _, lagoErr := m.client.Customer().Create(ctx, customerInput) - if err != nil { - return customerID, telemetry.Error(ctx, span, lagoErr.Err, "failed to create lago customer") + customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox) + _, lagoErr := m.client.Customer().Get(ctx, customerID) + if lagoErr != nil { + return exists, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer") } - return customerID, nil + + return true, nil } -// addCustomerPlan will create a plan subscription for the customer -func (m LagoClient) addCustomerPlan(ctx context.Context, projectID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) { - ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan") +func (m LagoClient) GetCustomeActiveSubscription(ctx context.Context, projectID uint, sandboxEnabled bool) (subscriptionID string, err error) { + ctx, span := telemetry.NewSpan(ctx, "get-active-subscription") defer span.End() - if projectID == "" || planID == "" { - return telemetry.Error(ctx, span, err, "project and plan id are required") + if projectID == 0 { + return subscriptionID, telemetry.Error(ctx, span, err, "project id empty") } - subscriptionInput := &lago.SubscriptionInput{ - ExternalCustomerID: projectID, - ExternalID: subscriptionID, - PlanCode: planID, - SubscriptionAt: startingAt, - EndingAt: endingAt, - BillingTime: lago.Calendar, + if sandboxEnabled { + subscriptionID = m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) + return subscriptionID, nil } - _, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput) - if err != nil { - return telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription") + customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) + subscriptionListInput := lago.SubscriptionListInput{ + ExternalCustomerID: customerID, + Status: []string{"active"}, } - return nil + activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput) + if lagoErr != nil { + return subscriptionID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription") + } + + if activeSubscriptions == nil { + return subscriptionID, telemetry.Error(ctx, span, err, "no active subscriptions found") + } + + if len(activeSubscriptions.Subscriptions) > 0 { + subscriptionID = activeSubscriptions.Subscriptions[0].ExternalID + } + + return subscriptionID, nil } // ListCustomerPlan will return the current active plan to which the user is subscribed -func (m LagoClient) ListCustomerPlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) { +func (m LagoClient) ListCustomerPlan(ctx context.Context, subscriptionID string) (plan types.Plan, err error) { ctx, span := telemetry.NewSpan(ctx, "list-customer-plans") defer span.End() - if projectID == 0 { + if subscriptionID == "" { return plan, telemetry.Error(ctx, span, err, "project id empty") } - subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) subscription, lagoErr := m.client.Subscription().Get(ctx, subscriptionID) - if err != nil { - return plan, telemetry.Error(ctx, span, lagoErr.Err, "failed to create subscription") + if lagoErr != nil { + return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get subscription") + } + + if subscription == nil { + return plan, nil } - plan.StartingOn = subscription.StartedAt.Format(time.RFC3339) - plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339) - plan.TrialInfo.EndingBefore = subscription.TrialEndedAt.Format(time.RFC3339) + log.Println("subscription", subscription) + if subscription.StartedAt != nil { + plan.StartingOn = subscription.StartedAt.Format(time.RFC3339) + } + + if subscription.EndingAt != nil { + plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339) + } + + if subscription.TrialEndedAt != nil { + plan.TrialInfo.EndingBefore = subscription.TrialEndedAt.Format(time.RFC3339) + } + + log.Println("plan", plan) return plan, nil } @@ -186,8 +220,8 @@ func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err er } _, lagoErr := m.client.Subscription().Terminate(ctx, subscriptionTerminateInput) - if lagoErr.Err != nil { - return telemetry.Error(ctx, span, lagoErr.Err, "failed to terminate subscription") + if lagoErr != nil { + return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to terminate subscription") } return nil @@ -221,7 +255,7 @@ func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err er // } // CreateCreditsGrant will create a new credit grant for the customer with the specified amount -func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt string, sandboxEnabled bool) (err error) { +func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt *time.Time, sandboxEnabled bool) (err error) { ctx, span := telemetry.NewSpan(ctx, "create-credits-grant") defer span.End() @@ -230,23 +264,19 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name } customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) - expiresAtTime, err := time.Parse(time.RFC3339, expiresAt) - if err != nil { - return telemetry.Error(ctx, span, err, "failed to parse credit expiration timestamp") - } - walletInput := &lago.WalletInput{ ExternalCustomerID: customerID, Name: name, Currency: lago.USD, GrantedCredits: strconv.FormatInt(grantAmount, 10), - RateAmount: "1", - ExpirationAt: &expiresAtTime, + // Rate is 1 credit = 1 cent + RateAmount: "0.01", + ExpirationAt: expiresAt, } _, lagoErr := m.client.Wallet().Create(ctx, walletInput) - if lagoErr.Err != nil { - return telemetry.Error(ctx, span, lagoErr.Err, "failed to create credits grant") + if lagoErr != nil { + return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create credits grant") } return nil @@ -268,15 +298,15 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, curre customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) _, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput) - if lagoErr.Err != nil { - return usage, telemetry.Error(ctx, span, lagoErr.Err, "failed to get customer usage") + if lagoErr != nil { + return usage, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage") } return usage, nil } // IngestEvents sends a list of billing events to Lago's ingest endpoint -func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEvent, enableSandbox bool) (err error) { +func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, events []types.BillingEvent, enableSandbox bool) (err error) { ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events") defer span.End() @@ -294,11 +324,17 @@ func (m LagoClient) IngestEvents(ctx context.Context, events []types.BillingEven batchInput := make([]lago.EventInput, len(batch)) for i := range batch { - customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64) - if err != nil { - return telemetry.Error(ctx, span, err, "failed to parse customer ID") + + externalSubscriptionID := subscriptionID + if enableSandbox { + // This hack has to be done because we can't infer the project id from the + // context in Porter Cloud + customerID, err := strconv.ParseUint(batch[i].CustomerID, 10, 64) + if err != nil { + return telemetry.Error(ctx, span, err, "failed to parse customer ID") + } + externalSubscriptionID = m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox) } - externalSubscriptionID := m.generateLagoID(SubscriptionIDPrefix, uint(customerID), enableSandbox) event := lago.EventInput{ TransactionID: batch[i].TransactionID, @@ -336,6 +372,58 @@ func (s StripeClient) ListCustomerInvoices(ctx context.Context, projectID uint) return invoiceList, nil } +// createCustomer will create the customer in Lago +func (m LagoClient) createCustomer(ctx context.Context, userEmail string, projectName string, projectID uint, billingID string, sandboxEnabled bool) (customerID string, err error) { + ctx, span := telemetry.NewSpan(ctx, "create-lago-customer") + defer span.End() + + customerID = m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) + + customerInput := &lago.CustomerInput{ + ExternalID: customerID, + Name: projectName, + Email: userEmail, + BillingConfiguration: lago.CustomerBillingConfigurationInput{ + PaymentProvider: lago.PaymentProviderStripe, + ProviderCustomerID: billingID, + Sync: false, + SyncWithProvider: false, + }, + } + + _, lagoErr := m.client.Customer().Create(ctx, customerInput) + if lagoErr != nil { + return customerID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create lago customer") + } + return customerID, nil +} + +// addCustomerPlan will create a plan subscription for the customer +func (m LagoClient) addCustomerPlan(ctx context.Context, customerID string, planID string, subscriptionID string, startingAt *time.Time, endingAt *time.Time) (err error) { + ctx, span := telemetry.NewSpan(ctx, "add-lago-customer-plan") + defer span.End() + + if customerID == "" || planID == "" { + return telemetry.Error(ctx, span, err, "project and plan id are required") + } + + subscriptionInput := &lago.SubscriptionInput{ + ExternalCustomerID: customerID, + ExternalID: subscriptionID, + PlanCode: planID, + SubscriptionAt: startingAt, + EndingAt: endingAt, + BillingTime: lago.Calendar, + } + + _, lagoErr := m.client.Subscription().Create(ctx, subscriptionInput) + if lagoErr != nil { + return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to create subscription") + } + + return nil +} + func (m LagoClient) generateLagoID(prefix string, projectID uint, sandboxEnabled bool) string { if sandboxEnabled { return fmt.Sprintf("cloud_%s_%d", prefix, projectID) diff --git a/internal/models/project.go b/internal/models/project.go index e0828fa54d..7c313850b3 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -28,8 +28,8 @@ const ( // BillingEnabled enables the "Billing" tab and all Stripe integrations BillingEnabled FeatureFlagLabel = "billing_enabled" - // MetronomeEnabled enables all Metronome business logic - MetronomeEnabled FeatureFlagLabel = "metronome_enabled" + // LagoEnabled enables all Lago business logic. This is kept as "metronome_enabled" for compatibility reasons + LagoEnabled FeatureFlagLabel = "metronome_enabled" // InfisicalEnabled enables the Infisical secrets operator integration InfisicalEnabled FeatureFlagLabel = "infisical_enabled" @@ -106,7 +106,7 @@ var ProjectFeatureFlags = map[FeatureFlagLabel]bool{ BetaFeaturesEnabled: false, CapiProvisionerEnabled: true, BillingEnabled: false, - MetronomeEnabled: false, + LagoEnabled: false, InfisicalEnabled: false, FreezeEnabled: false, DBEnabled: false, @@ -311,7 +311,7 @@ func (p *Project) ToProjectType(launchDarklyClient *features.Client) types.Proje BetaFeaturesEnabled: p.GetFeatureFlag(BetaFeaturesEnabled, launchDarklyClient), CapiProvisionerEnabled: p.GetFeatureFlag(CapiProvisionerEnabled, launchDarklyClient), BillingEnabled: p.GetFeatureFlag(BillingEnabled, launchDarklyClient), - MetronomeEnabled: p.GetFeatureFlag(MetronomeEnabled, launchDarklyClient), + LagoEnabled: p.GetFeatureFlag(LagoEnabled, launchDarklyClient), InfisicalEnabled: p.GetFeatureFlag(InfisicalEnabled, launchDarklyClient), FreezeEnabled: p.GetFeatureFlag(FreezeEnabled, launchDarklyClient), DBEnabled: p.GetFeatureFlag(DBEnabled, launchDarklyClient), From 49431966ef4ef766725bef34608e20687ea512eb Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Thu, 9 May 2024 13:12:48 -0400 Subject: [PATCH 06/16] Working plans --- api/server/handlers/billing/ingest.go | 6 +- api/server/handlers/billing/plan.go | 11 +--- ...{billing_metronome.go => billing_usage.go} | 12 ++-- internal/billing/usage.go | 62 ++++++------------- 4 files changed, 28 insertions(+), 63 deletions(-) rename api/types/{billing_metronome.go => billing_usage.go} (95%) diff --git a/api/server/handlers/billing/ingest.go b/api/server/handlers/billing/ingest.go index b407bb28e0..aed066bc7c 100644 --- a/api/server/handlers/billing/ingest.go +++ b/api/server/handlers/billing/ingest.go @@ -75,7 +75,7 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } } - subscriptionID, err := c.Config().BillingManager.LagoClient.GetCustomeActiveSubscription(ctx, proj.ID, proj.EnableSandbox) + plan, err := c.Config().BillingManager.LagoClient.GetCustomeActivePlan(ctx, proj.ID, proj.EnableSandbox) if err != nil { err := telemetry.Error(ctx, span, err, "error getting active subscription") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -83,10 +83,10 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) } telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "subscription_id", Value: subscriptionID}, + telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID}, ) - err = c.Config().BillingManager.LagoClient.IngestEvents(ctx, subscriptionID, ingestEventsRequest.Events, proj.EnableSandbox) + err = c.Config().BillingManager.LagoClient.IngestEvents(ctx, plan.ID, ingestEventsRequest.Events, proj.EnableSandbox) if err != nil { err := telemetry.Error(ctx, span, err, "error ingesting events") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index b485363570..606f90bf1b 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -43,7 +43,7 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - subscriptionID, err := c.Config().BillingManager.LagoClient.GetCustomeActiveSubscription(ctx, proj.ID, proj.EnableSandbox) + plan, err := c.Config().BillingManager.LagoClient.GetCustomeActivePlan(ctx, proj.ID, proj.EnableSandbox) if err != nil { err := telemetry.Error(ctx, span, err, "error getting active subscription") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) @@ -51,16 +51,9 @@ func (c *ListPlansHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "subscription_id", Value: subscriptionID}, + telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID}, ) - plan, err := c.Config().BillingManager.LagoClient.ListCustomerPlan(ctx, subscriptionID) - if err != nil { - err := telemetry.Error(ctx, span, err, "error listing plans") - c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - return - } - c.WriteResult(w, r, plan) } diff --git a/api/types/billing_metronome.go b/api/types/billing_usage.go similarity index 95% rename from api/types/billing_metronome.go rename to api/types/billing_usage.go index e123f35545..97c9a6c256 100644 --- a/api/types/billing_metronome.go +++ b/api/types/billing_usage.go @@ -151,14 +151,10 @@ type FormattedCost struct { } type Plan struct { - ID uuid.UUID `json:"id"` - PlanID uuid.UUID `json:"plan_id"` - PlanName string `json:"plan_name"` - PlanDescription string `json:"plan_description"` - StartingOn string `json:"starting_on"` - EndingBefore string `json:"ending_before"` - NetPaymentTermsDays int `json:"net_payment_terms_days"` - TrialInfo Trial `json:"trial_info,omitempty"` + ID string `json:"id"` + StartingOn string `json:"starting_on"` + EndingBefore string `json:"ending_before"` + TrialInfo Trial `json:"trial_info,omitempty"` } // Trial contains the information for a trial period diff --git a/internal/billing/usage.go b/internal/billing/usage.go index 3f081d990f..b1a01b56b7 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -3,8 +3,8 @@ package billing import ( "context" "fmt" - "log" "strconv" + "strings" "time" "github.com/getlago/lago-go-client" @@ -55,6 +55,7 @@ func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandard if lagoClient == nil { return client, fmt.Errorf("failed to create lago client") } + // lagoClient.Debug = true return LagoClient{ client: *lagoClient, @@ -134,74 +135,49 @@ func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, e return true, nil } -func (m LagoClient) GetCustomeActiveSubscription(ctx context.Context, projectID uint, sandboxEnabled bool) (subscriptionID string, err error) { +func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) { ctx, span := telemetry.NewSpan(ctx, "get-active-subscription") defer span.End() if projectID == 0 { - return subscriptionID, telemetry.Error(ctx, span, err, "project id empty") + return plan, telemetry.Error(ctx, span, err, "project id empty") } if sandboxEnabled { - subscriptionID = m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) - return subscriptionID, nil + subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) + return types.Plan{ID: subscriptionID}, nil } customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) subscriptionListInput := lago.SubscriptionListInput{ ExternalCustomerID: customerID, - Status: []string{"active"}, } activeSubscriptions, lagoErr := m.client.Subscription().GetList(ctx, subscriptionListInput) if lagoErr != nil { - return subscriptionID, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription") + return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get active subscription") } if activeSubscriptions == nil { - return subscriptionID, telemetry.Error(ctx, span, err, "no active subscriptions found") - } - - if len(activeSubscriptions.Subscriptions) > 0 { - subscriptionID = activeSubscriptions.Subscriptions[0].ExternalID + return plan, telemetry.Error(ctx, span, err, "no active subscriptions found") } - return subscriptionID, nil -} - -// ListCustomerPlan will return the current active plan to which the user is subscribed -func (m LagoClient) ListCustomerPlan(ctx context.Context, subscriptionID string) (plan types.Plan, err error) { - ctx, span := telemetry.NewSpan(ctx, "list-customer-plans") - defer span.End() - - if subscriptionID == "" { - return plan, telemetry.Error(ctx, span, err, "project id empty") - } - - subscription, lagoErr := m.client.Subscription().Get(ctx, subscriptionID) - if lagoErr != nil { - return plan, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get subscription") - } - - if subscription == nil { - return plan, nil - } - - log.Println("subscription", subscription) - - if subscription.StartedAt != nil { - plan.StartingOn = subscription.StartedAt.Format(time.RFC3339) - } + for _, subscription := range activeSubscriptions.Subscriptions { + if subscription.Status != lago.SubscriptionStatusActive { + continue + } - if subscription.EndingAt != nil { + plan.ID = subscription.ExternalID + plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339) plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339) - } - if subscription.TrialEndedAt != nil { - plan.TrialInfo.EndingBefore = subscription.TrialEndedAt.Format(time.RFC3339) + if strings.Contains(subscription.ExternalID, TrialIDPrefix) { + plan.TrialInfo.EndingBefore = subscription.EndingAt.Format(time.RFC3339) + } + + break } - log.Println("plan", plan) return plan, nil } From f521e90110fa6b6f795a91ef38b12c706231e086 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Fri, 10 May 2024 12:38:01 -0400 Subject: [PATCH 07/16] Add list credits api call, remove unnecessary types --- api/server/handlers/billing/plan.go | 29 ++- api/types/billing_usage.go | 209 ++---------------- dashboard/src/lib/billing/types.tsx | 38 ++-- .../lib/hooks/{useMetronome.ts => useLago.ts} | 67 +----- dashboard/src/main/home/Home.tsx | 2 +- .../src/main/home/app-dashboard/apps/Apps.tsx | 2 +- .../home/project-settings/BillingPage.tsx | 3 +- .../main/home/project-settings/UsagePage.tsx | 2 +- dashboard/src/shared/api.tsx | 1 - internal/billing/usage.go | 108 ++++++--- 10 files changed, 147 insertions(+), 314 deletions(-) rename dashboard/src/lib/hooks/{useMetronome.ts => useLago.ts} (77%) diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index 606f90bf1b..b6afb23ad3 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -88,14 +88,14 @@ func (c *ListCreditsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // credits, err := c.Config().BillingManager.LagoClient.ListCustomerCredits(ctx, proj.ID, proj.EnableSandbox) - // if err != nil { - // err := telemetry.Error(ctx, span, err, "error listing credits") - // c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) - // return - // } - - c.WriteResult(w, r, "") + credits, err := c.Config().BillingManager.LagoClient.ListCustomerCredits(ctx, proj.ID, proj.EnableSandbox) + if err != nil { + err := telemetry.Error(ctx, span, err, "error listing credits") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + c.WriteResult(w, r, credits) } // ListCustomerUsageHandler returns customer usage aggregations like CPU and RAM hours. @@ -138,7 +138,18 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ return } - usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, proj.ID, true, proj.EnableSandbox) + plan, err := c.Config().BillingManager.LagoClient.GetCustomeActivePlan(ctx, proj.ID, proj.EnableSandbox) + if err != nil { + err := telemetry.Error(ctx, span, err, "error getting active subscription") + c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) + return + } + + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID}, + ) + + usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, plan.CustomerID, plan.ID, req.CurrentPeriod) if err != nil { err := telemetry.Error(ctx, span, err, "error listing customer usage") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/types/billing_usage.go b/api/types/billing_usage.go index 97c9a6c256..6bcb017159 100644 --- a/api/types/billing_usage.go +++ b/api/types/billing_usage.go @@ -1,85 +1,5 @@ package types -import "github.com/google/uuid" - -// Customer represents a customer in Metronome -type Customer struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - // Aliases are alternative ids that can be used to refer to this customer in usage events - Aliases []string `json:"ingest_aliases"` - BillingConfig BillingConfig `json:"billing_config,omitempty"` - CustomFields map[string]string `json:"custom_fields,omitempty"` -} - -// CustomerArchiveRequest will archive the customer in Metronome. -type CustomerArchiveRequest struct { - CustomerID uuid.UUID `json:"id"` -} - -// BillingConfig is the configuration for the billing provider (Stripe, etc.) -type BillingConfig struct { - // BillingProviderType is the name of the billing provider (e.g. ) - BillingProviderType string `json:"billing_provider_type"` - BillingProviderCustomerID string `json:"billing_provider_customer_id"` - // StripeCollectionMethod defines if invoices are charged automatically or sent to customers - StripeCollectionMethod string `json:"stripe_collection_method"` -} - -// AddCustomerPlanRequest represents a request to add a customer plan with specific details. -type AddCustomerPlanRequest struct { - PlanID uuid.UUID `json:"plan_id"` - // StartingOn is a RFC3339 timestamp for when the plan becomes active for this customer. Must be at 0:00 UTC (midnight) - StartingOnUTC string `json:"starting_on"` - // EndingBeforeUTC is a RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight) - EndingBeforeUTC string `json:"ending_before,omitempty"` - // NetPaymentTermDays is the number of days after issuance of invoice after which the invoice is due - NetPaymentTermDays int `json:"net_payment_terms_days,omitempty"` - // Trial is the trial period for the plan - Trial *TrialSpec `json:"trial_spec,omitempty"` -} - -// TrialSpec is the trial period for the plan -type TrialSpec struct { - LengthInDays int64 `json:"length_in_days"` -} - -// EndCustomerPlanRequest represents a request to end the plan for a specific customer. -type EndCustomerPlanRequest struct { - // EndingBeforeUTC is a RFC 3339 timestamp for when the plan ends (exclusive) for this customer. Must be at 0:00 UTC (midnight). - EndingBeforeUTC string `json:"ending_before,omitempty"` - // VoidInvoices determines if Metronome invoices are voided. If set to true, the plan end date can be before the last finalized invoice date. - // and any invoices generated after the plan end date will be voided. - VoidInvoices bool `json:"void_invoices"` - // VoidStripeInvoices determines if Stripe invoices are void (if VoidInvoices is set to true). Drafts will be deleted. - VoidStripeInvoices bool `json:"void_stripe_invoices"` -} - -// CreateCreditsGrantRequest is the request to create a credit grant for a customer -type CreateCreditsGrantRequest struct { - // CustomerID is the id of the customer - CustomerID uuid.UUID `json:"customer_id"` - UniquenessKey string `json:"uniqueness_key"` - GrantAmount GrantAmountID `json:"grant_amount"` - PaidAmount PaidAmount `json:"paid_amount"` - Name string `json:"name"` - ExpiresAt string `json:"expires_at"` - Priority int `json:"priority"` - Reason string `json:"reason"` -} - -// ListCreditGrantsRequest is the request to list a user's credit grants. Note that only one of -// CreditTypeIDs, CustomerIDs, or CreditGrantIDs must be specified. -type ListCreditGrantsRequest struct { - CreditTypeIDs []uuid.UUID `json:"credit_type_ids,omitempty"` - CustomerIDs []uuid.UUID `json:"customer_ids,omitempty"` - CreditGrantIDs []uuid.UUID `json:"credit_grant_ids,omitempty"` - // NotExpiringBefore will return grants that expire at or after this RFC 3339 timestamp. - NotExpiringBefore string `json:"not_expiring_before,omitempty"` - // EffectiveBefore will return grants that are effective before this RFC 3339 timestamp (exclusive). - EffectiveBefore string `json:"effective_before,omitempty"` -} - // ListCreditGrantsResponse returns the total remaining and granted credits for a customer. type ListCreditGrantsResponse struct { RemainingBalanceCents int `json:"remaining_credits"` @@ -88,70 +8,34 @@ type ListCreditGrantsResponse struct { // ListCustomerUsageRequest is the request to list usage for a customer type ListCustomerUsageRequest struct { - CustomerID uuid.UUID `json:"customer_id"` - BillableMetricID uuid.UUID `json:"billable_metric_id"` - WindowSize string `json:"window_size"` - StartingOn string `json:"starting_on,omitempty"` - EndingBefore string `json:"ending_before,omitempty"` - CurrentPeriod bool `json:"current_period,omitempty"` + CurrentPeriod bool `json:"current_period,omitempty"` } // Usage is the aggregated usage for a customer type Usage struct { - MetricName string `json:"metric_name"` - UsageMetrics []CustomerUsageMetric `json:"usage_metrics"` + FromDatetime string `json:"from_datetime"` + ToDatetime string `json:"to_datetime"` + TotalAmountCents int64 `json:"total_amount_cents"` + ChargesUsage []ChargeUsage `json:"charges_usage"` } -// CustomerUsageMetric is a metric representing usage for a customer -type CustomerUsageMetric struct { - StartingOn string `json:"starting_on"` - EndingBefore string `json:"ending_before"` - Value float64 `json:"value"` +// ChargeUsage is the usage for a charge +type ChargeUsage struct { + Units string `json:"units"` + AmountCents int64 `json:"amount_cents"` + AmountCurrency string `json:"amount_currency"` + BillableMetric BillableMetric `json:"billable_metric"` } -// BillableMetric is defined in Metronome and represents the events that will -// be ingested +// BillableMetric is the metric collected for billing type BillableMetric struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` -} - -// ListCustomerCostsRequest is the request to list costs for a customer -type ListCustomerCostsRequest struct { - StartingOn string `schema:"starting_on"` - EndingBefore string `schema:"ending_before"` - Limit int `schema:"limit"` -} - -// Cost is the cost for a customer in a specific time range -type Cost struct { - StartTimestamp string `json:"start_timestamp"` - EndTimestamp string `json:"end_timestamp"` - CreditTypes map[string]CreditTypeCost `json:"credit_types"` -} - -// CreditTypeCost is the cost for a specific credit type (e.g. CPU hours) -type CreditTypeCost struct { - Name string `json:"name"` - Cost float64 `json:"cost"` - LineItemBreakdown []LineItemBreakdownCost `json:"line_item_breakdown"` -} - -// LineItemBreakdownCost is the cost breakdown by line item -type LineItemBreakdownCost struct { - Name string `json:"name"` - Cost float64 `json:"cost"` -} - -// FormattedCost is the cost for a customer in a specific time range, flattened from the Metronome response -type FormattedCost struct { - StartTimestamp string `json:"start_timestamp"` - EndTimestamp string `json:"end_timestamp"` - Cost float64 `json:"cost"` + Name string `json:"name"` } +// Plan is the plan for a customer type Plan struct { ID string `json:"id"` + CustomerID string `json:"customer_id"` StartingOn string `json:"starting_on"` EndingBefore string `json:"ending_before"` TrialInfo Trial `json:"trial_info,omitempty"` @@ -162,61 +46,6 @@ type Trial struct { EndingBefore string `json:"ending_before"` } -// CreditType is the type of the credit used in the credit grant -type CreditType struct { - Name string `json:"name"` - ID string `json:"id"` -} - -// GrantAmountID represents the amount of credits granted with the credit type ID -// for the create credits grant request -type GrantAmountID struct { - Amount float64 `json:"amount"` - CreditTypeID uuid.UUID `json:"credit_type_id"` -} - -// GrantAmount represents the amount of credits granted with the credit type -// for the list credit grants response -type GrantAmount struct { - Amount float64 `json:"amount"` - CreditType CreditType `json:"credit_type"` -} - -// PaidAmount represents the amount paid by the customer -type PaidAmount struct { - Amount float64 `json:"amount"` - CreditTypeID uuid.UUID `json:"credit_type_id"` -} - -// PricingUnit represents the unit of the pricing (e.g. USD, MXN, CPU hours) -type PricingUnit struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - IsCurrency bool `json:"is_currency"` -} - -// Balance represents the effective balance of the grant as of the end of the customer's -// current billing period. -type Balance struct { - // ExcludingPending is the grant's current balance excluding pending deductions - ExcludingPending float64 `json:"excluding_pending"` - // IncludingPending is the grant's current balance including pending deductions - IncludingPending float64 `json:"including_pending"` - // EffectiveAt is a RFC3339 timestamp that can be used to filter credit grants by effective date - EffectiveAt string `json:"effective_at"` -} - -// CreditGrant is a grant given to a specific user on a specific plan -type CreditGrant struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - GrantAmount GrantAmount `json:"grant_amount"` - Balance Balance `json:"balance"` - Reason string `json:"reason"` - EffectiveAt string `json:"effective_at"` - ExpiresAt string `json:"expires_at"` -} - // BillingEvent represents a Metronome billing event. type BillingEvent struct { CustomerID string `json:"customer_id"` @@ -225,3 +54,11 @@ type BillingEvent struct { TransactionID string `json:"transaction_id"` Timestamp string `json:"timestamp"` } + +// Wallet represents a customer credits wallet +type Wallet struct { + Status string `json:"status"` + BalanceCents int `json:"balance_cents,omitempty"` + OngoingBalanceCents int `json:"ongoing_balance_cents,omitempty"` + OngoingUsageBalanceCents int `json:"ongoing_usage_balance_cents,omitempty"` +} diff --git a/dashboard/src/lib/billing/types.tsx b/dashboard/src/lib/billing/types.tsx index 469ba1c9db..4415f8cbe8 100644 --- a/dashboard/src/lib/billing/types.tsx +++ b/dashboard/src/lib/billing/types.tsx @@ -20,29 +20,31 @@ export type Plan = z.infer; export const PlanValidator = z .object({ id: z.string(), - plan_name: z.string(), - plan_description: z.string(), starting_on: z.string(), + ending_before: z.string(), trial_info: TrialValidator, }) .nullable(); -export type UsageMetric = z.infer; -export const UsageMetricValidator = z.object({ - // starting_on and ending_before are RFC 3339 date strings - // that represent the timeframe where the metric was ingested. - // If the granularity is set per day, the starting_on field - // represents the day the metric was ingested. - starting_on: z.string(), - ending_before: z.string(), - value: z.number(), +export type BillableMetric = z.infer; +export const BillableMetricValidator = z.object({ + name: z.string(), +}); + +export type ChargeUsage = z.infer; +export const ChargeUsageValidator = z.object({ + units: z.string(), + amount_cents: z.number(), + amount_currency: z.string(), + billable_metric: BillableMetricValidator, }); -export type UsageList = Usage[]; export type Usage = z.infer; export const UsageValidator = z.object({ - metric_name: z.string(), - usage_metrics: z.array(UsageMetricValidator), + from_datetime: z.string(), + to_datetime: z.string(), + total_amount_cents: z.number(), + charges_usage: z.array(ChargeUsageValidator), }); export type CreditGrants = z.infer; @@ -51,14 +53,6 @@ export const CreditGrantsValidator = z.object({ remaining_credits: z.number(), }); -export type CostList = Cost[]; -export type Cost = z.infer; -export const CostValidator = z.object({ - start_timestamp: z.string(), - end_timestamp: z.string(), - cost: z.number(), -}); - export type InvoiceList = Invoice[]; export type Invoice = z.infer; export const InvoiceValidator = z.object({ diff --git a/dashboard/src/lib/hooks/useMetronome.ts b/dashboard/src/lib/hooks/useLago.ts similarity index 77% rename from dashboard/src/lib/hooks/useMetronome.ts rename to dashboard/src/lib/hooks/useLago.ts index a2fd40590f..87167c72cf 100644 --- a/dashboard/src/lib/hooks/useMetronome.ts +++ b/dashboard/src/lib/hooks/useLago.ts @@ -2,18 +2,16 @@ import { useContext } from "react"; import { useQuery } from "@tanstack/react-query"; import { - CostValidator, CreditGrantsValidator, InvoiceValidator, PlanValidator, ReferralDetailsValidator, UsageValidator, - type CostList, type CreditGrants, type InvoiceList, type Plan, type ReferralDetails, - type UsageList, + type Usage, } from "lib/billing/types"; import api from "shared/api"; @@ -32,11 +30,7 @@ type TGetInvoices = { }; type TGetUsage = { - usage: UsageList | null; -}; - -type TGetCosts = { - costs: CostList | null; + usage: Usage | null; }; type TGetReferralDetails = { @@ -100,7 +94,6 @@ export const useCustomerPlan = (): TGetPlan => { {}, { project_id: currentProject.id } ); - const plan = PlanValidator.parse(res.data); return plan; } catch (error) { @@ -117,14 +110,14 @@ export const useCustomerPlan = (): TGetPlan => { export const useCustomerUsage = ( startingOn: Date | null, endingBefore: Date | null, - windowSize: string + currentPeriod: boolean ): TGetUsage => { const { currentProject } = useContext(Context); // Fetch customer usage const usageReq = useQuery( ["listCustomerUsage", currentProject?.id], - async (): Promise => { + async (): Promise => { if (!currentProject?.metronome_enabled) { return null; } @@ -143,13 +136,13 @@ export const useCustomerUsage = ( { starting_on: startingOn.toISOString(), ending_before: endingBefore.toISOString(), - window_size: windowSize, + current_period: currentPeriod, }, { project_id: currentProject?.id, } ); - const usage = UsageValidator.array().parse(res.data); + const usage = UsageValidator.parse(res.data); return usage; } catch (error) { return null; @@ -162,54 +155,6 @@ export const useCustomerUsage = ( }; }; -export const useCustomerCosts = ( - startingOn: Date | null, - endingBefore: Date | null, - limit: number -): TGetCosts => { - const { currentProject } = useContext(Context); - - // Fetch customer costs - const usageReq = useQuery( - ["listCustomerCosts", currentProject?.id], - async (): Promise => { - if (!currentProject?.metronome_enabled) { - return null; - } - - if (!currentProject?.id || currentProject.id === -1) { - return null; - } - - if (startingOn === null || endingBefore === null) { - return null; - } - - try { - const res = await api.getCustomerCosts( - "", - {}, - { - project_id: currentProject?.id, - starting_on: startingOn.toISOString(), - ending_before: endingBefore.toISOString(), - limit, - } - ); - - const costs = CostValidator.array().parse(res.data); - return costs; - } catch (error) { - return null; - } - } - ); - - return { - costs: usageReq.data ?? null, - }; -}; - export const useReferralDetails = (): TGetReferralDetails => { const { currentProject } = useContext(Context); diff --git a/dashboard/src/main/home/Home.tsx b/dashboard/src/main/home/Home.tsx index 0f4034e571..b2ff094b4a 100644 --- a/dashboard/src/main/home/Home.tsx +++ b/dashboard/src/main/home/Home.tsx @@ -19,7 +19,7 @@ import Link from "components/porter/Link"; import Modal from "components/porter/Modal"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; -import { useCustomerPlan } from "lib/hooks/useMetronome"; +import { useCustomerPlan } from "lib/hooks/useLago"; import { checkIfProjectHasPayment } from "lib/hooks/useStripe"; import api from "shared/api"; diff --git a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx index df8613f938..1cf262c592 100644 --- a/dashboard/src/main/home/app-dashboard/apps/Apps.tsx +++ b/dashboard/src/main/home/app-dashboard/apps/Apps.tsx @@ -34,7 +34,7 @@ import { useDeploymentTargetList, type DeploymentTarget, } from "lib/hooks/useDeploymentTarget"; -import { useCustomerPlan } from "lib/hooks/useMetronome"; +import { useCustomerPlan } from "lib/hooks/useLago"; import { checkIfProjectHasPayment } from "lib/hooks/useStripe"; import api from "shared/api"; diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index 2ce4b5f49c..75d1c89c43 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/dashboard/src/main/home/project-settings/BillingPage.tsx @@ -18,9 +18,10 @@ import Text from "components/porter/Text"; import { useCustomerInvoices, useCustomerPlan, + useCustomerUsage, usePorterCredits, useReferralDetails, -} from "lib/hooks/useMetronome"; +} from "lib/hooks/useLago"; import { checkIfProjectHasPayment, usePaymentMethods, diff --git a/dashboard/src/main/home/project-settings/UsagePage.tsx b/dashboard/src/main/home/project-settings/UsagePage.tsx index cd92ee0e59..32e31e3f77 100644 --- a/dashboard/src/main/home/project-settings/UsagePage.tsx +++ b/dashboard/src/main/home/project-settings/UsagePage.tsx @@ -12,7 +12,7 @@ import { useCustomerCosts, useCustomerPlan, useCustomerUsage, -} from "lib/hooks/useMetronome"; +} from "lib/hooks/useLago"; import Bars from "./Bars"; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index a7c167471c..fb581478ce 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -3542,7 +3542,6 @@ const getPublishableKey = baseApi< const getCustomerUsage = baseApi< { - window_size: string; starting_on?: string; ending_before?: string; current_period?: boolean; diff --git a/internal/billing/usage.go b/internal/billing/usage.go index b1a01b56b7..c92cbf4ee7 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -2,7 +2,9 @@ package billing import ( "context" + "encoding/json" "fmt" + "net/http" "strconv" "strings" "time" @@ -37,6 +39,7 @@ const ( // LagoClient is the client used to call the Lago API type LagoClient struct { client lago.Client + lagoApiKey string PorterCloudPlanCode string PorterStandardPlanCode string PorterTrialCode string @@ -58,6 +61,7 @@ func NewLagoClient(lagoApiKey string, porterCloudPlanCode string, porterStandard // lagoClient.Debug = true return LagoClient{ + lagoApiKey: lagoApiKey, client: *lagoClient, PorterCloudPlanCode: porterCloudPlanCode, PorterStandardPlanCode: porterStandardPlanCode, @@ -168,6 +172,7 @@ func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sa } plan.ID = subscription.ExternalID + plan.CustomerID = subscription.ExternalCustomerID plan.StartingOn = subscription.SubscriptionAt.Format(time.RFC3339) plan.EndingBefore = subscription.EndingAt.Format(time.RFC3339) @@ -204,31 +209,55 @@ func (m LagoClient) EndCustomerPlan(ctx context.Context, projectID uint) (err er } // ListCustomerCredits will return the total number of credits for the customer -// func (m LagoClient) ListCustomerCredits(ctx context.Context, customerID string) (credits types.ListCreditGrantsResponse, err error) { -// ctx, span := telemetry.NewSpan(ctx, "list-customer-credits") -// defer span.End() +func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, sandboxEnabled bool) (credits types.ListCreditGrantsResponse, err error) { + ctx, span := telemetry.NewSpan(ctx, "list-customer-credits") + defer span.End() + + if projectID == 0 { + return credits, telemetry.Error(ctx, span, err, "project id empty") + } + customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) + + // We manually do the request in this function because the Lago client has an issue + // with types for this specific request + lagoBaseURL := "https://api.getlago.com" + url := fmt.Sprintf("%s/api/v1/wallets?external_customer_id=%s", lagoBaseURL, customerID) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return credits, telemetry.Error(ctx, span, err, "failed to create wallets request") + } -// if customerID == "" { -// return credits, telemetry.Error(ctx, span, err, "customer id empty") -// } + req.Header.Set("Authorization", "Bearer "+m.lagoApiKey) -// walletListInput := &lago.WalletListInput{ -// ExternalCustomerID: customerID, -// } + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return credits, telemetry.Error(ctx, span, err, "failed to get customer credits") + } + defer resp.Body.Close() + + type ListWalletsResponse struct { + Wallets []types.Wallet `json:"wallets"` + } + + var walletList ListWalletsResponse + err = json.NewDecoder(resp.Body).Decode(&walletList) + if err != nil { + return credits, telemetry.Error(ctx, span, err, "failed to decode wallet list response") + } -// walletList, lagoErr := m.client.Wallet().GetList(ctx, walletListInput) -// if lagoErr.Err != nil { -// return credits, telemetry.Error(ctx, span, lagoErr.Err, "failed to get wallet") -// } + var response types.ListCreditGrantsResponse + for _, wallet := range walletList.Wallets { + if wallet.Status != string(lago.Active) { + continue + } -// var response types.ListCreditGrantsResponse -// for _, wallet := range walletList.Wallets { -// response.GrantedBalanceCents += wallet.BalanceCents -// response.RemainingBalanceCents += wallet.OngoingUsageBalanceCents -// } + response.GrantedBalanceCents += wallet.BalanceCents + response.RemainingBalanceCents += wallet.OngoingBalanceCents + } -// return response, nil -// } + return response, nil +} // CreateCreditsGrant will create a new credit grant for the customer with the specified amount func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name string, grantAmount int64, expiresAt *time.Time, sandboxEnabled bool) (err error) { @@ -259,23 +288,40 @@ func (m LagoClient) CreateCreditsGrant(ctx context.Context, projectID uint, name } // ListCustomerUsage will return the aggregated usage for a customer -func (m LagoClient) ListCustomerUsage(ctx context.Context, projectID uint, currentPeriod bool, sandboxEnabled bool) (usage []types.Usage, err error) { +func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, subscriptionID string, currentPeriod bool) (usage types.Usage, err error) { ctx, span := telemetry.NewSpan(ctx, "list-customer-usage") defer span.End() - if projectID == 0 { - return usage, telemetry.Error(ctx, span, err, "project id empty") + if subscriptionID == "" { + return usage, telemetry.Error(ctx, span, err, "subscription id empty") } - subscriptionID := m.generateLagoID(SubscriptionIDPrefix, projectID, sandboxEnabled) - customerUsageInput := &lago.CustomerUsageInput{ - ExternalSubscriptionID: subscriptionID, - } + if currentPeriod { + customerUsageInput := &lago.CustomerUsageInput{ + ExternalSubscriptionID: subscriptionID, + } + + currentUsage, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput) + if lagoErr != nil { + return usage, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage") + } + + usage.FromDatetime = currentUsage.FromDatetime.Format(time.RFC3339) + usage.ToDatetime = currentUsage.ToDatetime.Format(time.RFC3339) + usage.TotalAmountCents = int64(currentUsage.TotalAmountCents) + usage.ChargesUsage = make([]types.ChargeUsage, len(currentUsage.ChargesUsage)) + + for i, charge := range currentUsage.ChargesUsage { + usage.ChargesUsage[i] = types.ChargeUsage{ + Units: charge.Units, + AmountCents: int64(charge.AmountCents), + AmountCurrency: string(charge.AmountCurrency), + BillableMetric: types.BillableMetric{ + Name: charge.BillableMetric.Name, + }, + } + } - customerID := m.generateLagoID(CustomerIDPrefix, projectID, sandboxEnabled) - _, lagoErr := m.client.Customer().CurrentUsage(ctx, customerID, customerUsageInput) - if lagoErr != nil { - return usage, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to get customer usage") } return usage, nil From 337c8b1e6fe1a6dd3c75f315577b79fedfc7edec Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Fri, 10 May 2024 14:30:24 -0400 Subject: [PATCH 08/16] Working invoices --- api/server/handlers/billing/invoices.go | 20 +++++----- api/types/billing_stripe.go | 15 -------- api/types/billing_usage.go | 10 +++++ dashboard/src/lib/hooks/useLago.ts | 4 +- .../home/project-settings/BillingPage.tsx | 1 - dashboard/src/shared/api.tsx | 4 +- internal/billing/usage.go | 38 +++++++++++++++++-- 7 files changed, 56 insertions(+), 36 deletions(-) diff --git a/api/server/handlers/billing/invoices.go b/api/server/handlers/billing/invoices.go index 5e0018c6c2..42fc64a04b 100644 --- a/api/server/handlers/billing/invoices.go +++ b/api/server/handlers/billing/invoices.go @@ -24,7 +24,7 @@ func NewListCustomerInvoicesHandler( writer shared.ResultWriter, ) *ListCustomerInvoicesHandler { return &ListCustomerInvoicesHandler{ - PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer), + PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, nil, writer), } } @@ -35,24 +35,22 @@ func (c *ListCustomerInvoicesHandler) ServeHTTP(w http.ResponseWriter, r *http.R proj, _ := ctx.Value(types.ProjectScope).(*models.Project) telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "billing-config-exists", Value: c.Config().BillingManager.StripeConfigLoaded}, - telemetry.AttributeKV{Key: "billing-enabled", Value: proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient)}, + telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, + telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox}, ) - if !c.Config().BillingManager.StripeConfigLoaded || !proj.GetFeatureFlag(models.BillingEnabled, c.Config().LaunchDarklyClient) { + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") return } - req := &types.ListCustomerInvoicesRequest{} - - if ok := c.DecodeAndValidate(w, r, req); !ok { - err := telemetry.Error(ctx, span, nil, "error decoding list customer usage request") - c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest)) + invoices, err := c.Config().BillingManager.LagoClient.ListCustomerFinalizedInvoices(ctx, proj.ID, proj.EnableSandbox) + if err != nil { + err = telemetry.Error(ctx, span, err, "error listing invoices") + c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError)) return } - // Write the response to the frontend - c.WriteResult(w, r, "invoices") + c.WriteResult(w, r, invoices) } diff --git a/api/types/billing_stripe.go b/api/types/billing_stripe.go index 7238086be0..e50f003ef8 100644 --- a/api/types/billing_stripe.go +++ b/api/types/billing_stripe.go @@ -10,18 +10,3 @@ type PaymentMethod struct { ExpYear int64 `json:"exp_year"` Default bool `json:"is_default"` } - -// Invoice represents an invoice in the billing system. -type Invoice struct { - // The URL to view the hosted invoice. - HostedInvoiceURL string `json:"hosted_invoice_url"` - // The status of the invoice. - Status string `json:"status"` - // RFC 3339 timestamp for when the invoice was created. - Created string `json:"created"` -} - -// ListCustomerInvoicesRequest is the request to list invoices for a customer -type ListCustomerInvoicesRequest struct { - Status string `schema:"status"` -} diff --git a/api/types/billing_usage.go b/api/types/billing_usage.go index 6bcb017159..a709d9a3ea 100644 --- a/api/types/billing_usage.go +++ b/api/types/billing_usage.go @@ -62,3 +62,13 @@ type Wallet struct { OngoingBalanceCents int `json:"ongoing_balance_cents,omitempty"` OngoingUsageBalanceCents int `json:"ongoing_usage_balance_cents,omitempty"` } + +// Invoice represents an invoice in the billing system. +type Invoice struct { + // The URL to view the hosted invoice. + HostedInvoiceURL string `json:"hosted_invoice_url"` + // The status of the invoice. + Status string `json:"status"` + // RFC 3339 timestamp for when the invoice was created. + Created string `json:"created"` +} diff --git a/dashboard/src/lib/hooks/useLago.ts b/dashboard/src/lib/hooks/useLago.ts index 87167c72cf..2b3863209d 100644 --- a/dashboard/src/lib/hooks/useLago.ts +++ b/dashboard/src/lib/hooks/useLago.ts @@ -208,9 +208,7 @@ export const useCustomerInvoices = (): TGetInvoices => { try { const res = await api.getCustomerInvoices( "", - { - status: "paid", - }, + {}, { project_id: currentProject.id } ); diff --git a/dashboard/src/main/home/project-settings/BillingPage.tsx b/dashboard/src/main/home/project-settings/BillingPage.tsx index 75d1c89c43..c0a8d2e530 100644 --- a/dashboard/src/main/home/project-settings/BillingPage.tsx +++ b/dashboard/src/main/home/project-settings/BillingPage.tsx @@ -18,7 +18,6 @@ import Text from "components/porter/Text"; import { useCustomerInvoices, useCustomerPlan, - useCustomerUsage, usePorterCredits, useReferralDetails, } from "lib/hooks/useLago"; diff --git a/dashboard/src/shared/api.tsx b/dashboard/src/shared/api.tsx index fb581478ce..0388e8094d 100644 --- a/dashboard/src/shared/api.tsx +++ b/dashboard/src/shared/api.tsx @@ -3566,9 +3566,7 @@ const getCustomerCosts = baseApi< ); const getCustomerInvoices = baseApi< - { - status: string; - }, + {}, { project_id?: number; } diff --git a/internal/billing/usage.go b/internal/billing/usage.go index c92cbf4ee7..06dcb6fa8f 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -383,14 +383,46 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve return nil } -// ListCustomerInvoices will return all invoices for the customer with the given status -func (s StripeClient) ListCustomerInvoices(ctx context.Context, projectID uint) (invoiceList []types.Invoice, err error) { - ctx, span := telemetry.NewSpan(ctx, "populate-invoice-urls") +// ListCustomerFinalizedInvoices will return all finalized invoices for the customer +func (m LagoClient) ListCustomerFinalizedInvoices(ctx context.Context, projectID uint, enableSandbox bool) (invoiceList []types.Invoice, err error) { + ctx, span := telemetry.NewSpan(ctx, "list-customer-invoices") defer span.End() if projectID == 0 { return invoiceList, telemetry.Error(ctx, span, err, "project id cannot be empty") } + + customerID := m.generateLagoID(CustomerIDPrefix, projectID, enableSandbox) + invoiceListInput := &lago.InvoiceListInput{ + ExternalCustomerID: customerID, + Status: lago.InvoiceStatusFinalized, + } + + invoices, lagoErr := m.client.Invoice().GetList(ctx, invoiceListInput) + if lagoErr != nil { + return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to list invoices") + } + + for _, invoice := range invoices.Invoices { + invoiceReq, lagoErr := m.client.Invoice().Download(ctx, invoice.LagoID.String()) + if lagoErr != nil { + return invoiceList, telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "failed to download invoice") + } + + var fileURL string + if invoiceReq == nil { + fileURL = invoice.FileURL + } else { + fileURL = invoiceReq.FileURL + } + + invoiceList = append(invoiceList, types.Invoice{ + HostedInvoiceURL: fileURL, + Status: string(invoice.Status), + Created: invoice.IssuingDate, + }) + } + return invoiceList, nil } From 096dbad2395f797cd8f0b56dd4ec364cbe1080e9 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Fri, 10 May 2024 14:36:04 -0400 Subject: [PATCH 09/16] Linting --- internal/billing/usage.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/billing/usage.go b/internal/billing/usage.go index 06dcb6fa8f..e8cc0eef76 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -103,7 +103,9 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string expiresAt := time.Now().UTC().AddDate(0, 1, 0).Truncate(24 * time.Hour) err = m.CreateCreditsGrant(ctx, projectID, starterWalletName, defaultStarterCreditsCents, &expiresAt, sandboxEnabled) - + if err != nil { + return telemetry.Error(ctx, span, err, "error while creating starter credits grant") + } return nil } @@ -122,6 +124,7 @@ func (m LagoClient) CreateCustomerWithPlan(ctx context.Context, userEmail string return err } +// CheckIfCustomerExists will check if the customer exists in Lago func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, enableSandbox bool) (exists bool, err error) { ctx, span := telemetry.NewSpan(ctx, "check-lago-customer-exists") defer span.End() @@ -139,6 +142,7 @@ func (m LagoClient) CheckIfCustomerExists(ctx context.Context, projectID uint, e return true, nil } +// GetCustomeActivePlan will return the active plan for the customer func (m LagoClient) GetCustomeActivePlan(ctx context.Context, projectID uint, sandboxEnabled bool) (plan types.Plan, err error) { ctx, span := telemetry.NewSpan(ctx, "get-active-subscription") defer span.End() @@ -234,7 +238,6 @@ func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, san if err != nil { return credits, telemetry.Error(ctx, span, err, "failed to get customer credits") } - defer resp.Body.Close() type ListWalletsResponse struct { Wallets []types.Wallet `json:"wallets"` @@ -256,6 +259,11 @@ func (m LagoClient) ListCustomerCredits(ctx context.Context, projectID uint, san response.RemainingBalanceCents += wallet.OngoingBalanceCents } + err = resp.Body.Close() + if err != nil { + return credits, telemetry.Error(ctx, span, err, "failed to close response body") + } + return response, nil } @@ -321,7 +329,6 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, su }, } } - } return usage, nil @@ -344,9 +351,7 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve batch := events[i:end] batchInput := make([]lago.EventInput, len(batch)) - for i := range batch { - externalSubscriptionID := subscriptionID if enableSandbox { // This hack has to be done because we can't infer the project id from the From fdb36e5f04d573874168a9be0e588ee5991eb0ec Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Sun, 12 May 2024 15:21:59 -0400 Subject: [PATCH 10/16] Add error handling --- api/server/handlers/billing/ingest.go | 16 ++++++---------- internal/billing/usage.go | 8 ++++++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/api/server/handlers/billing/ingest.go b/api/server/handlers/billing/ingest.go index aed066bc7c..329ea8b560 100644 --- a/api/server/handlers/billing/ingest.go +++ b/api/server/handlers/billing/ingest.go @@ -39,21 +39,17 @@ func (c *IngestEventsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) proj, _ := ctx.Value(types.ProjectScope).(*models.Project) + telemetry.WithAttributes(span, + telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, + telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, + telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox}, + ) + if !c.Config().BillingManager.LagoConfigLoaded || !proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient) { c.WriteResult(w, r, "") - - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "lago-config-exists", Value: c.Config().BillingManager.LagoConfigLoaded}, - telemetry.AttributeKV{Key: "lago-enabled", Value: proj.GetFeatureFlag(models.LagoEnabled, c.Config().LaunchDarklyClient)}, - telemetry.AttributeKV{Key: "porter-cloud-enabled", Value: proj.EnableSandbox}, - ) return } - telemetry.WithAttributes(span, - telemetry.AttributeKV{Key: "lago-enabled", Value: true}, - ) - ingestEventsRequest := struct { Events []types.BillingEvent `json:"billing_events"` }{} diff --git a/internal/billing/usage.go b/internal/billing/usage.go index e8cc0eef76..3d07c0541a 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -336,7 +336,7 @@ func (m LagoClient) ListCustomerUsage(ctx context.Context, customerID string, su // IngestEvents sends a list of billing events to Lago's ingest endpoint func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, events []types.BillingEvent, enableSandbox bool) (err error) { - ctx, span := telemetry.NewSpan(ctx, "ingets-billing-events") + ctx, span := telemetry.NewSpan(ctx, "ingest-billing-events") defer span.End() if len(events) == 0 { @@ -376,7 +376,11 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve // Retry each batch to make sure all events are ingested var currentAttempts int for currentAttempts < defaultMaxRetries { - m.client.Event().Batch(ctx, &batchInput) + + _, lagoErr := m.client.Event().Batch(ctx, &batchInput) + if lagoErr == nil { + return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "error sending ingest events to Lago") + } currentAttempts++ } From f03c13263fd0d0c5eec36cd5ace8684a1a34b5c9 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Tue, 14 May 2024 17:09:13 -0400 Subject: [PATCH 11/16] Format --- internal/billing/usage.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/billing/usage.go b/internal/billing/usage.go index 3d07c0541a..4346bdb9e8 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -376,7 +376,6 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve // Retry each batch to make sure all events are ingested var currentAttempts int for currentAttempts < defaultMaxRetries { - _, lagoErr := m.client.Event().Batch(ctx, &batchInput) if lagoErr == nil { return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "error sending ingest events to Lago") From 3842a01ba3b8c89338560d363f398c9b19b5f838 Mon Sep 17 00:00:00 2001 From: Mauricio Araujo Date: Wed, 15 May 2024 09:55:05 -0400 Subject: [PATCH 12/16] Fix batch endpoint call --- go.mod | 1 - go.sum | 2 -- internal/billing/usage.go | 12 ++++++------ 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index a0c0e16432..d6d4b3493d 100644 --- a/go.mod +++ b/go.mod @@ -151,7 +151,6 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-resty/resty/v2 v2.11.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/golang-jwt/jwt v3.2.1+incompatible // indirect github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/google/gnostic v0.6.9 // indirect github.com/google/s2a-go v0.1.4 // indirect diff --git a/go.sum b/go.sum index d48ba732cf..667b77f30d 100644 --- a/go.sum +++ b/go.sum @@ -1570,8 +1570,6 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77 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/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw= -github.com/porter-dev/api-contracts v0.2.159 h1:Ze4K0rm8p6sRMxaFW4Nb3dJuzz4NEMQ+UMXMtOKKRQ4= -github.com/porter-dev/api-contracts v0.2.159/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= github.com/porter-dev/api-contracts v0.2.161 h1:kf1ZcS1032eLabBzjwDs9SVcecXwUxJ2mJUkRl9C8jk= github.com/porter-dev/api-contracts v0.2.161/go.mod h1:VV5BzXd02ZdbWIPLVP+PX3GKawJSGQnxorVT2sUZALU= github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M= diff --git a/internal/billing/usage.go b/internal/billing/usage.go index 4346bdb9e8..1bad7a62c0 100644 --- a/internal/billing/usage.go +++ b/internal/billing/usage.go @@ -350,7 +350,7 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve } batch := events[i:end] - batchInput := make([]lago.EventInput, len(batch)) + var batchInput []lago.EventInput for i := range batch { externalSubscriptionID := subscriptionID if enableSandbox { @@ -367,7 +367,6 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve TransactionID: batch[i].TransactionID, ExternalSubscriptionID: externalSubscriptionID, Code: batch[i].EventType, - Timestamp: batch[i].Timestamp, Properties: batch[i].Properties, } batchInput = append(batchInput, event) @@ -375,12 +374,13 @@ func (m LagoClient) IngestEvents(ctx context.Context, subscriptionID string, eve // Retry each batch to make sure all events are ingested var currentAttempts int - for currentAttempts < defaultMaxRetries { + for currentAttempts := 0; currentAttempts < defaultMaxRetries; currentAttempts++ { _, lagoErr := m.client.Event().Batch(ctx, &batchInput) - if lagoErr == nil { - return telemetry.Error(ctx, span, fmt.Errorf(lagoErr.ErrorCode), "error sending ingest events to Lago") + if lagoErr != nil { + telemetry.Error(ctx, span, err, "failed to send ingest events") + continue } - currentAttempts++ + break } if currentAttempts == defaultMaxRetries { From bbf9b0696fc530978aa57f8834369cb06d55377a Mon Sep 17 00:00:00 2001 From: jusrhee Date: Wed, 15 May 2024 11:32:47 -0400 Subject: [PATCH 13/16] basic usage display (#4634) Co-authored-by: Mauricio Araujo --- api/server/handlers/billing/plan.go | 2 +- api/types/billing_usage.go | 3 + dashboard/src/lib/hooks/useLago.ts | 20 +- .../home/project-settings/ProjectSettings.tsx | 18 +- .../main/home/project-settings/UsagePage.tsx | 245 +++++++----------- dashboard/src/shared/api.tsx | 3 +- internal/billing/usage.go | 87 +++++-- 7 files changed, 175 insertions(+), 203 deletions(-) diff --git a/api/server/handlers/billing/plan.go b/api/server/handlers/billing/plan.go index b6afb23ad3..b321e77772 100644 --- a/api/server/handlers/billing/plan.go +++ b/api/server/handlers/billing/plan.go @@ -149,7 +149,7 @@ func (c *ListCustomerUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ telemetry.AttributeKV{Key: "subscription_id", Value: plan.ID}, ) - usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, plan.CustomerID, plan.ID, req.CurrentPeriod) + usage, err := c.Config().BillingManager.LagoClient.ListCustomerUsage(ctx, plan.CustomerID, plan.ID, req.CurrentPeriod, req.PreviousPeriods) if err != nil { err := telemetry.Error(ctx, span, err, "error listing customer usage") c.HandleAPIError(w, r, apierrors.NewErrInternal(err)) diff --git a/api/types/billing_usage.go b/api/types/billing_usage.go index a709d9a3ea..50714564b5 100644 --- a/api/types/billing_usage.go +++ b/api/types/billing_usage.go @@ -8,6 +8,9 @@ type ListCreditGrantsResponse struct { // ListCustomerUsageRequest is the request to list usage for a customer type ListCustomerUsageRequest struct { + // PreviousPeriods is the number of previous periods to include in the response. + PreviousPeriods int `json:"previous_periods,omitempty"` + // CurrentPeriod is whether to return only usage for the current billing period. CurrentPeriod bool `json:"current_period,omitempty"` } diff --git a/dashboard/src/lib/hooks/useLago.ts b/dashboard/src/lib/hooks/useLago.ts index 2b3863209d..6e7e45677f 100644 --- a/dashboard/src/lib/hooks/useLago.ts +++ b/dashboard/src/lib/hooks/useLago.ts @@ -30,7 +30,7 @@ type TGetInvoices = { }; type TGetUsage = { - usage: Usage | null; + usageList: Usage[] | null; }; type TGetReferralDetails = { @@ -108,16 +108,15 @@ export const useCustomerPlan = (): TGetPlan => { }; export const useCustomerUsage = ( - startingOn: Date | null, - endingBefore: Date | null, + previousPeriods: number, currentPeriod: boolean ): TGetUsage => { const { currentProject } = useContext(Context); // Fetch customer usage const usageReq = useQuery( - ["listCustomerUsage", currentProject?.id], - async (): Promise => { + ["listCustomerUsage", currentProject?.id, previousPeriods, currentPeriod], + async (): Promise => { if (!currentProject?.metronome_enabled) { return null; } @@ -126,23 +125,18 @@ export const useCustomerUsage = ( return null; } - if (startingOn === null || endingBefore === null) { - return null; - } - try { const res = await api.getCustomerUsage( "", { - starting_on: startingOn.toISOString(), - ending_before: endingBefore.toISOString(), + previous_periods: previousPeriods, current_period: currentPeriod, }, { project_id: currentProject?.id, } ); - const usage = UsageValidator.parse(res.data); + const usage = UsageValidator.array().parse(res.data); return usage; } catch (error) { return null; @@ -151,7 +145,7 @@ export const useCustomerUsage = ( ); return { - usage: usageReq.data ?? null, + usageList: usageReq.data ?? null, }; }; diff --git a/dashboard/src/main/home/project-settings/ProjectSettings.tsx b/dashboard/src/main/home/project-settings/ProjectSettings.tsx index 7b20c3391b..3c73f0fc93 100644 --- a/dashboard/src/main/home/project-settings/ProjectSettings.tsx +++ b/dashboard/src/main/home/project-settings/ProjectSettings.tsx @@ -96,15 +96,15 @@ function ProjectSettings(props: any) { }); } - // if ( - // currentProject?.billing_enabled && - // currentProject?.metronome_enabled - // ) { - // tabOpts.push({ - // value: "usage", - // label: "Usage", - // }); - // } + if ( + currentProject?.billing_enabled && + currentProject?.metronome_enabled + ) { + tabOpts.push({ + value: "usage", + label: "Usage", + }); + } tabOpts.push({ value: "additional-settings", diff --git a/dashboard/src/main/home/project-settings/UsagePage.tsx b/dashboard/src/main/home/project-settings/UsagePage.tsx index 32e31e3f77..78384445b7 100644 --- a/dashboard/src/main/home/project-settings/UsagePage.tsx +++ b/dashboard/src/main/home/project-settings/UsagePage.tsx @@ -1,169 +1,137 @@ -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; -import styled from "styled-components"; +import Container from "components/porter/Container"; import Fieldset from "components/porter/Fieldset"; import Select from "components/porter/Select"; import Spacer from "components/porter/Spacer"; import Text from "components/porter/Text"; -import { type CostList } from "lib/billing/types"; -import { - useCustomerCosts, - useCustomerPlan, - useCustomerUsage, -} from "lib/hooks/useLago"; - -import Bars from "./Bars"; +import { useCustomerPlan, useCustomerUsage } from "lib/hooks/useLago"; dayjs.extend(utc); function UsagePage(): JSX.Element { const { plan } = useCustomerPlan(); + const planStartDate = dayjs.utc(plan?.starting_on).startOf("month"); - const startDate = dayjs.utc(plan?.starting_on); - const endDate = dayjs().utc().startOf("day"); - const numberOfDays = startDate.daysInMonth(); - - const [currentPeriodStart, setCurrentPeriodStart] = useState( - startDate.toDate() + const [currentPeriod, setCurrentPeriod] = useState( + dayjs().utc().startOf("month") ); - const [currentPeriodEnd, setCurrentPeriodEnd] = useState(endDate.toDate()); - const [currentPeriodDuration, setCurrentPeriodDuration] = - useState(numberOfDays); - - const { usage } = useCustomerUsage( - currentPeriodStart, - currentPeriodEnd, - "day" - ); - const { costs } = useCustomerCosts( - currentPeriodStart, - currentPeriodEnd, - currentPeriodDuration + const [options, setOptions] = useState< + Array<{ value: string; label: string }> + >([]); + const [previousPeriodCount, setPreviousPeriodCount] = useState(0); + const [showCurrentPeriod, setShowCurrentPeriod] = useState(true); + + const { usageList } = useCustomerUsage( + previousPeriodCount, + showCurrentPeriod ); - const computeTotalCost = (costs: CostList): number => { - const total = costs.reduce((acc, curr) => acc + curr.cost, 0); - return parseFloat(total.toFixed(2)); - }; - - const processedUsage = useMemo(() => { - const before = usage; - const resultMap = new Map(); - - before?.forEach( - (metric: { - metric_name: string; - usage_metrics: Array<{ starting_on: string; value: number }>; - }) => { - const metricName = metric.metric_name.toLowerCase().replace(" ", "_"); - metric.usage_metrics.forEach(({ starting_on: startingOn, value }) => { - if (resultMap.has(startingOn)) { - resultMap.get(startingOn)[metricName] = value; - } else { - resultMap.set(startingOn, { - starting_on: new Date(startingOn).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }), - [metricName]: value, - }); - } - }); - } - ); - - // Convert the map to an array of values - const x = Array.from(resultMap.values()); - return x; - }, [usage]); - - const processedCosts = useMemo(() => { - return costs - ?.map((dailyCost) => { - dailyCost.start_timestamp = new Date( - dailyCost.start_timestamp - ).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - dailyCost.cost = parseFloat((dailyCost.cost / 100).toFixed(4)); - return dailyCost; - }) - .filter((dailyCost) => dailyCost.cost > 0); - }, [costs]); + useEffect(() => { + const newOptions = generateOptions(); + setOptions(newOptions); + }, [previousPeriodCount, showCurrentPeriod]); const generateOptions = (): Array<{ value: string; label: string }> => { const options = []; + const monthsElapsed = dayjs + .utc() + .startOf("month") + .diff(planStartDate, "month"); - let startDate = dayjs.utc(currentPeriodStart); - const endDate = dayjs.utc(currentPeriodEnd); - - while (startDate.isBefore(endDate)) { - const nextDate = startDate.add(1, "month"); + if (monthsElapsed <= 0) { options.push({ - value: startDate.toISOString(), - label: `${startDate.format("M/D/YY")} - ${nextDate.format("M/D/YY")}`, + value: currentPeriod.month().toString(), + label: dayjs().utc().format("MMMM YYYY"), }); + setShowCurrentPeriod(true); + return options; + } - startDate = startDate.add(1, "month"); + setPreviousPeriodCount(monthsElapsed); + for (let i = 0; i <= monthsElapsed; i++) { + const optionDate = planStartDate.add(i, "month"); + options.push({ + value: optionDate.month().toString(), + label: optionDate.format("MMMM YYYY"), + }); } + return options; }; - const options = generateOptions(); + const processedUsage = useMemo(() => { + if (!usageList?.length) { + return null; + } + + const periodUsage = usageList.find( + (usage) => + dayjs(usage.from_datetime).utc().month() === currentPeriod.month() + ); + + if (!periodUsage) { + return null; + } + + const totalCost = periodUsage?.total_amount_cents + ? (periodUsage.total_amount_cents / 100).toFixed(4) + : ""; + const totalCpuHours = + periodUsage?.charges_usage.find((x) => + x.billable_metric.name.includes("CPU") + )?.units ?? ""; + const totalGibHours = + periodUsage?.charges_usage.find((x) => + x.billable_metric.name.includes("GiB") + )?.units ?? ""; + const currency = periodUsage?.charges_usage[0].amount_currency ?? ""; + return { + total_cost: totalCost, + total_cpu_hours: totalCpuHours, + total_gib_hours: totalGibHours, + currency, + }; + }, [usageList]); return ( <>