Skip to content

Commit

Permalink
cache plan name
Browse files Browse the repository at this point in the history
  • Loading branch information
pjain1 committed Jan 2, 2025
1 parent 9fdb50b commit e50d262
Show file tree
Hide file tree
Showing 13 changed files with 3,868 additions and 3,721 deletions.
3 changes: 3 additions & 0 deletions admin/billing/biller.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ type Biller interface {

// WebhookHandlerFunc returns a http.HandlerFunc that can be used to handle incoming webhooks from the payment provider. Return nil if you don't want to register any webhook handlers. jobs is used to enqueue jobs for processing the webhook events.
WebhookHandlerFunc(ctx context.Context, jobs jobs.Client) httputil.Handler

// GetCurrentPlanDisplayName this is specifically added for the UI to show the current plan name
GetCurrentPlanDisplayName(ctx context.Context, customerID string) (string, error)
}

type PlanType int
Expand Down
4 changes: 4 additions & 0 deletions admin/billing/noop.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,7 @@ func (n noop) GetReportingWorkerCron() string {
func (n noop) WebhookHandlerFunc(ctx context.Context, jc jobs.Client) httputil.Handler {
return nil
}

func (n noop) GetCurrentPlanDisplayName(ctx context.Context, customerID string) (string, error) {
return "", nil
}
41 changes: 40 additions & 1 deletion admin/billing/orb.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ const (

var ErrCustomerIDRequired = errors.New("customer id is required")

var planCache = make(map[string]planCacheEntry)

type planCacheEntry struct {
planDisplayName string
lastUpdated time.Time
}

var _ Biller = &Orb{}

type Orb struct {
Expand Down Expand Up @@ -190,7 +197,12 @@ func (o *Orb) DeleteCustomer(ctx context.Context, customerID string) error {
}

func (o *Orb) CreateSubscription(ctx context.Context, customerID string, plan *Plan) (*Subscription, error) {
return o.createSubscription(ctx, customerID, plan)
sub, err := o.createSubscription(ctx, customerID, plan)
if err != nil {
return nil, err
}
planCache[customerID] = planCacheEntry{planDisplayName: sub.Plan.DisplayName, lastUpdated: time.Now()}
return sub, nil
}

func (o *Orb) GetActiveSubscription(ctx context.Context, customerID string) (*Subscription, error) {
Expand All @@ -200,13 +212,16 @@ func (o *Orb) GetActiveSubscription(ctx context.Context, customerID string) (*Su
}

if len(subs) == 0 {
planCache[customerID] = planCacheEntry{planDisplayName: "", lastUpdated: time.Now()}
return nil, ErrNotFound
}

if len(subs) > 1 {
return nil, fmt.Errorf("multiple active subscriptions (%d) found for customer %s", len(subs), customerID)
}

planCache[customerID] = planCacheEntry{planDisplayName: subs[0].Plan.DisplayName, lastUpdated: time.Now()}

return subs[0], nil
}

Expand All @@ -218,6 +233,9 @@ func (o *Orb) ChangeSubscriptionPlan(ctx context.Context, subscriptionID string,
if err != nil {
return nil, err
}
// NOTE - since change option is SubscriptionSchedulePlanChangeParamsChangeOptionImmediate, the plan change should be immediate
// if adding any other option then don't do this rather rely on webhook to update the cache
planCache[s.Customer.ExternalCustomerID] = planCacheEntry{planDisplayName: plan.DisplayName, lastUpdated: time.Now()}
return &Subscription{
ID: s.ID,
Customer: getBillingCustomerFromOrbCustomer(&s.Customer),
Expand Down Expand Up @@ -281,6 +299,12 @@ func (o *Orb) CancelSubscriptionsForCustomer(ctx context.Context, customerID str
cancelDate = sub.EndDate
}
}

if cancelOption == SubscriptionCancellationOptionImmediate {
delete(planCache, customerID)
// for end of subscription term rely on webhook
}

return cancelDate, nil
}

Expand Down Expand Up @@ -424,6 +448,21 @@ func (o *Orb) WebhookHandlerFunc(ctx context.Context, jc jobs.Client) httputil.H
return ow.handleWebhook
}

func (o *Orb) GetCurrentPlanDisplayName(ctx context.Context, customerID string) (string, error) {
if p, ok := planCache[customerID]; ok {
return p.planDisplayName, nil
}
sub, err := o.GetActiveSubscription(ctx, customerID)
if err != nil {
if errors.Is(err, ErrNotFound) || errors.Is(err, ErrCustomerIDRequired) {
return "", nil
}
return "", err
}

return sub.Plan.DisplayName, nil
}

func (o *Orb) createSubscription(ctx context.Context, customerID string, plan *Plan) (*Subscription, error) {
sub, err := o.client.Subscriptions.New(ctx, orb.SubscriptionNewParams{
ExternalCustomerID: orb.String(customerID),
Expand Down
70 changes: 69 additions & 1 deletion admin/billing/orb_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const (
maxBodyBytes = int64(65536)
)

var interestingEvents = []string{"invoice.payment_succeeded", "invoice.payment_failed", "invoice.issue_failed"}
var interestingEvents = []string{"invoice.payment_succeeded", "invoice.payment_failed", "invoice.issue_failed", "subscription.started", "subscription.ended", "subscription.plan_changed"}

type orbWebhook struct {
orb *Orb
Expand Down Expand Up @@ -86,6 +86,27 @@ func (o *orbWebhook) handleWebhook(w http.ResponseWriter, r *http.Request) error
}
// inefficient one time conversion to named logger as its rare event and no need to log every thing else with named logger
o.orb.logger.Named("billing").Warn("invoice issue failed", zap.String("customer_id", ie.OrbInvoice.Customer.ExternalCustomerID), zap.String("invoice_id", ie.OrbInvoice.ID), zap.String("props", fmt.Sprintf("%v", ie.Properties)))
case "subscription.started":
var se subscriptionEvent
err = json.Unmarshal(payload, &se)
if err != nil {
return httputil.Errorf(http.StatusBadRequest, "error parsing event data: %w", err)
}
o.handleSubscriptionStarted(se) // as of now we are just using this to update plan cache
case "subscription.ended":
var se subscriptionEvent
err = json.Unmarshal(payload, &se)
if err != nil {
return httputil.Errorf(http.StatusBadRequest, "error parsing event data: %w", err)
}
o.handleSubscriptionEnded(se) // as of now we are just using this to update plan cache
case "subscription.plan_changed":
var se subscriptionEvent
err = json.Unmarshal(payload, &se)
if err != nil {
return httputil.Errorf(http.StatusBadRequest, "error parsing event data: %w", err)
}
o.handleSubscriptionPlanChanged(se) // as of now we are just using this to update plan cache
default:
// do nothing
}
Expand Down Expand Up @@ -125,6 +146,45 @@ func (o *orbWebhook) handleInvoicePaymentFailed(ctx context.Context, ie invoiceE
return nil
}

func (o *orbWebhook) handleSubscriptionStarted(se subscriptionEvent) {
if se.OrbSubscription.Customer.ExternalCustomerID == "" {
return
}
if pe, ok := planCache[se.OrbSubscription.Customer.ExternalCustomerID]; !ok || pe.lastUpdated.After(se.CreatedAt) {
// don't have plan cached for this customer, ignore as we are not handling this events in persistent job
return
}
planCache[se.OrbSubscription.Customer.ExternalCustomerID] = planCacheEntry{
planDisplayName: getPlanDisplayName(se.OrbSubscription.Plan.ExternalPlanID),
lastUpdated: se.CreatedAt,
}
}

func (o *orbWebhook) handleSubscriptionEnded(se subscriptionEvent) {
if se.OrbSubscription.Customer.ExternalCustomerID == "" {
return
}
if pe, ok := planCache[se.OrbSubscription.Customer.ExternalCustomerID]; !ok || pe.lastUpdated.After(se.CreatedAt) {
// don't have plan cached for this customer, ignore as we are not handling this events in persistent job
return
}
delete(planCache, se.OrbSubscription.Customer.ExternalCustomerID)
}

func (o *orbWebhook) handleSubscriptionPlanChanged(se subscriptionEvent) {
if se.OrbSubscription.Customer.ExternalCustomerID == "" {
return
}
if pe, ok := planCache[se.OrbSubscription.Customer.ExternalCustomerID]; !ok || pe.lastUpdated.After(se.CreatedAt) {
// don't have plan cached for this customer, ignore as we are not handling this events in persistent job
return
}
planCache[se.OrbSubscription.Customer.ExternalCustomerID] = planCacheEntry{
planDisplayName: getPlanDisplayName(se.OrbSubscription.Plan.ExternalPlanID),
lastUpdated: se.CreatedAt,
}
}

// Validates whether or not the webhook payload was sent by Orb.
func (o *orbWebhook) verifySignature(payload []byte, headers http.Header, now time.Time) error {
if o.orb.webhookSecret == "" {
Expand Down Expand Up @@ -192,3 +252,11 @@ type invoiceEvent struct {
Properties interface{} `json:"properties"`
OrbInvoice orb.Invoice `json:"invoice"`
}

type subscriptionEvent struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Type string `json:"type"`
Properties interface{} `json:"properties"`
OrbSubscription orb.Subscription `json:"subscription"`
}
6 changes: 5 additions & 1 deletion admin/jobs/river/org_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ func (w *StartTrialWorker) Work(ctx context.Context, job *river.Job[StartTrialAr

org, sub, err := w.admin.StartTrial(ctx, org)
if err != nil {
w.logger.Error("failed to start trial for organization", zap.String("org_id", job.Args.OrgID), zap.Error(err))
if job.Attempt < job.MaxAttempts {
w.logger.Info("retrying to start trial for organization", zap.String("org_id", job.Args.OrgID), zap.String("error", err.Error()))
} else {
w.logger.Error("failed to start trial for organization", zap.String("org_id", job.Args.OrgID), zap.Error(err))
}
return err
}

Expand Down
10 changes: 8 additions & 2 deletions admin/server/organizations.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,15 @@ func (s *Server) GetOrganization(ctx context.Context, req *adminv1.GetOrganizati
perms.ReadProjects = true
}

planDisplayName, err := s.admin.Biller.GetCurrentPlanDisplayName(ctx, org.BillingCustomerID)
if err != nil {
return nil, err
}

return &adminv1.GetOrganizationResponse{
Organization: s.organizationToDTO(org, perms.ManageOrg),
Permissions: perms,
Organization: s.organizationToDTO(org, perms.ManageOrg),
Permissions: perms,
PlanDisplayName: planDisplayName,
}, nil
}

Expand Down
1 change: 1 addition & 0 deletions cli/cmd/sudo/org/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func ShowCmd(ch *cmdutil.Helper) *cobra.Command {
}

fmt.Println(string(data))
fmt.Printf("\nPlan: %s\n", res.PlanDisplayName)

return nil
},
Expand Down
2 changes: 2 additions & 0 deletions proto/gen/rill/admin/v1/admin.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4249,6 +4249,8 @@ definitions:
$ref: '#/definitions/v1Organization'
permissions:
$ref: '#/definitions/v1OrganizationPermissions'
planDisplayName:
type: string
v1GetPaymentsPortalURLResponse:
type: object
properties:
Expand Down
Loading

0 comments on commit e50d262

Please sign in to comment.