فهرست منبع

Migrate customer creation + subscriptions to lago

Mauricio Araujo 2 سال پیش
والد
کامیت
b5758ae1e4
6فایلهای تغییر یافته به همراه447 افزوده شده و 586 حذف شده
  1. 1 1
      api/server/shared/config/loader/loader.go
  2. 2 2
      api/types/billing_metronome.go
  3. 6 2
      go.mod
  4. 25 3
      go.sum
  5. 0 578
      internal/billing/metronome.go
  6. 413 0
      internal/billing/usage.go

+ 1 - 1
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)
 		}

+ 2 - 2
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

+ 6 - 2
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

+ 25 - 3
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=

+ 0 - 578
internal/billing/metronome.go

@@ -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
-}

+ 413 - 0
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)
+}