Skip to content

Commit 0686dde

Browse files
authored
feat(controlplane): cloud and self-hosted billing trials with dashboard UX (#2701)
* feat(billing): cloud and self-hosted trials with billing UX Trial start, cap activation, checkout flows, and dashboard billing pages. Provisional license_data seeding with project_limit enforcement during trial propagation. Removes local convoy-qa-ui.sh from tracking. * feat(dashboard): map served self-hosted plan keys for billing catalog alias overwatch catalog keys (business_annual, enterprise) onto canonical presentation keys so feature cards and trial filtering work with the env-driven maple plan allowlist. * fix(dashboard): drop unused trial-offer lint imports * fix(billing): address bugbot trial poll, 4xx status, org pagination Stop trial-cap polling on paid authoritative payloads that omit daily_event_limit, forward definitive billing 4xx to clients, and paginate all user orgs when resolving org_limit. * fix(billing): resolve asynq licenser at request time and trial subscribe label RequireAsynqMonitoring reads the shared licenser per request so a self-hosted trial refresh unlocks queue monitoring without restart. During trial, billing CTA shows Subscribe now instead of Resubscribe when checkout_license_key exists. * fix(dashboard): resolve catalog plan for self-hosted trial conversion Trial convert sends the purchasable catalog plan id and cadence to upgrade checkout instead of the trial subscription legacy plan row. * fix(dashboard): normalize subscription plan for plansMatch in trial conversion Add optional key on SubscriptionPlan and pass a normalized match object so convertTrialToPaid resolves the catalog plan without TS2345. * fix(controlplane): skip asynqmon routes when queue is not RedisQueue Keep per-request RequireAsynqMonitoring for runtime trial unlock, but use a safe type assertion so mock or alternate Queuer implementations can boot. * fix(billing): defer trial supersede until mint succeeds Call billing before mutating checkout state, record orphan failed trial attempts without touching an active paid checkout, and treat post-write license refresh as best-effort so mint retries are not triggered. * fix(billing): persist checkout supersession before trial finalize Write superseded attempt bookkeeping and active trial attempt id before CompleteCheckoutIfActive so a pending paid checkout is cleared when trial mint succeeds. * fix(billing): recover locally when trial mint settles after billing If Overwatch returns a trial license but checkout settlement fails, persist the minted key via UpdateInstanceBillingConfig instead of returning an error that leaves an orphan trial and blocks retry.
1 parent 844b217 commit 0686dde

116 files changed

Lines changed: 22467 additions & 14403 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/api.go

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/frain-dev/convoy/internal/organisation_members"
2828
"github.com/frain-dev/convoy/internal/organisations"
2929
"github.com/frain-dev/convoy/internal/pkg/billing"
30+
"github.com/frain-dev/convoy/internal/pkg/license"
3031
"github.com/frain-dev/convoy/internal/pkg/metrics"
3132
"github.com/frain-dev/convoy/internal/pkg/middleware"
3233
redisqueue "github.com/frain-dev/convoy/queue/redis"
@@ -194,6 +195,10 @@ func NewApplicationHandler(a *types.APIOptions) (*ApplicationHandler, error) {
194195
}
195196
ensureAPIRepositories(a)
196197

198+
if a.TrialEvents == nil {
199+
a.TrialEvents = license.NewTrialEventLimiter(a.Redis, a.Logger)
200+
}
201+
197202
appHandler := &ApplicationHandler{A: a}
198203

199204
cfg, err := config.Get()
@@ -203,7 +208,7 @@ func NewApplicationHandler(a *types.APIOptions) (*ApplicationHandler, error) {
203208

204209
appHandler.cfg = cfg
205210

206-
// Resolve the billing service URL: OSS/self-hosted default to prod Overwatch
211+
// Resolve the billing service URL: OSS/self-hosted default to prod billing
207212
// so catalog/checkout/license management work out of the box, while cloud
208213
// (API key set) keeps requiring an explicit URL (Billing.Validate fails closed
209214
// otherwise). The API key, not the URL, remains the cloud/org-billing signal
@@ -475,7 +480,10 @@ func (a *ApplicationHandler) mountControlPlaneRoutes(router chi.Router, handler
475480
uiRouter.Use(middleware.JsonResponse)
476481
uiRouter.Use(chiMiddleware.Maybe(middleware.RequireAuth(handler.A.Logger), shouldAuthRoute))
477482

478-
uiRouter.Get("/license/features", handler.GetLicenseFeatures)
483+
// Guest-listed (login/signup fetch instance features pre-auth), but a signed-in
484+
// dashboard call carries a bearer token; resolve it so the handler can count the
485+
// caller's orgs and gate org_limit. Without this the org gate always fails open.
486+
uiRouter.With(middleware.OptionalAuth(handler.A.Logger)).Get("/license/features", handler.GetLicenseFeatures)
479487

480488
uiRouter.Post("/users/forgot-password", handler.ForgotPassword)
481489
uiRouter.Post("/users/reset-password", handler.ResetPassword)
@@ -721,7 +729,9 @@ func (a *ApplicationHandler) mountControlPlaneRoutes(router chi.Router, handler
721729
billingRouter.Get("/tax_id_types", billingHandler.GetTaxIDTypes)
722730
billingRouter.Post("/sh_checkout/start", billingHandler.StartSelfHostedCheckout)
723731
billingRouter.Post("/sh_checkout/complete", billingHandler.CompleteSelfHostedCheckout)
732+
billingRouter.Post("/sh_trial/start", billingHandler.StartSelfHostedTrial)
724733
billingRouter.Get("/sh_subscription", billingHandler.GetSelfHostedSubscription)
734+
billingRouter.Put("/sh_subscription/upgrade", billingHandler.UpgradeSelfHostedSubscription)
725735
billingRouter.Delete("/sh_subscription", billingHandler.DeleteSelfHostedSubscription)
726736
billingRouter.Get("/sh_organisation", billingHandler.GetSelfHostedOrganisation)
727737
billingRouter.Put("/sh_tax_id", billingHandler.UpdateSelfHostedOrganisationTaxID)
@@ -751,6 +761,7 @@ func (a *ApplicationHandler) mountControlPlaneRoutes(router chi.Router, handler
751761
billingRouter.Route("/organisations/{orgID}/subscriptions", func(billingSubRouter chi.Router) {
752762
billingSubRouter.Get("/", billingHandler.GetSubscriptions)
753763
billingSubRouter.Post("/onboard", billingHandler.OnboardSubscription)
764+
billingSubRouter.Post("/trial", billingHandler.StartTrial)
754765
billingSubRouter.Put("/{subscriptionID}/upgrade", billingHandler.UpgradeSubscription)
755766
billingSubRouter.Delete("/{subscriptionID}", billingHandler.DeleteSubscription)
756767
})
@@ -863,25 +874,27 @@ func (a *ApplicationHandler) mountControlPlaneRoutes(router chi.Router, handler
863874
})
864875
})
865876

866-
if a.A.Licenser.AsynqMonitoring() {
867-
router.Route("/queue", func(asynqRouter chi.Router) {
868-
asynqRouter.Group(func(sessionRouter chi.Router) {
869-
sessionRouter.Use(middleware.RequireAuth(handler.A.Logger))
870-
sessionRouter.Post("/monitoring/session", handler.CreateQueueMonitoringSession)
871-
sessionRouter.Delete("/monitoring/session", handler.RevokeQueueMonitoringSession)
872-
})
877+
router.Route("/queue", func(asynqRouter chi.Router) {
878+
asynqRouter.Use(middleware.RequireAsynqMonitoring(func() license.Licenser { return a.A.Licenser }, handler.A.Logger))
879+
asynqRouter.Group(func(sessionRouter chi.Router) {
880+
sessionRouter.Use(middleware.RequireAuth(handler.A.Logger))
881+
sessionRouter.Post("/monitoring/session", handler.CreateQueueMonitoringSession)
882+
sessionRouter.Delete("/monitoring/session", handler.RevokeQueueMonitoringSession)
883+
})
873884

874-
rq := a.A.Queue.(*redisqueue.RedisQueue)
875-
asynqRouter.Group(func(embedRouter chi.Router) {
876-
embedRouter.Use(middleware.RequireQueueSessionCookie(handlers.ValidateQueueSessionCookie(handler.A.Redis, handler.A.Cache)))
877-
embedRouter.Handle("/monitoring/embed/*", rq.MonitorWithRootPath("/queue/monitoring/embed"))
878-
})
879-
asynqRouter.Group(func(monitorRouter chi.Router) {
880-
monitorRouter.Use(middleware.RequireAuth(handler.A.Logger))
881-
monitorRouter.Handle("/monitoring/*", rq.Monitor())
882-
})
885+
rq, ok := a.A.Queue.(*redisqueue.RedisQueue)
886+
if !ok {
887+
return
888+
}
889+
asynqRouter.Group(func(embedRouter chi.Router) {
890+
embedRouter.Use(middleware.RequireQueueSessionCookie(handlers.ValidateQueueSessionCookie(handler.A.Redis, handler.A.Cache)))
891+
embedRouter.Handle("/monitoring/embed/*", rq.MonitorWithRootPath("/queue/monitoring/embed"))
883892
})
884-
}
893+
asynqRouter.Group(func(monitorRouter chi.Router) {
894+
monitorRouter.Use(middleware.RequireAuth(handler.A.Logger))
895+
monitorRouter.Handle("/monitoring/*", rq.Monitor())
896+
})
897+
})
885898

886899
router.HandleFunc("/metrics", a.metricsHandler())
887900
}

0 commit comments

Comments
 (0)