Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add self serve billing management UI #5431

Merged
merged 80 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
2440f32
Add basic plan info
AdityaHegde Aug 12, 2024
acb2da5
Add actions for switching plans
AdityaHegde Aug 19, 2024
44a38ab
Add payment section
AdityaHegde Aug 19, 2024
475dbd0
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Aug 21, 2024
441e0bf
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Aug 26, 2024
662bfee
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Sep 19, 2024
418e180
Use new APIs
AdityaHegde Sep 19, 2024
376cbaa
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Sep 23, 2024
96d26a0
Add banners for trial and billing issues
AdityaHegde Sep 23, 2024
72e6508
Improve trial end math
AdityaHegde Sep 24, 2024
1f71f66
Add TRIAL_ENDING_SOON state
AdityaHegde Sep 24, 2024
fade30f
Remove TRIAL_ENDING_SOON
AdityaHegde Sep 30, 2024
83ae354
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Sep 30, 2024
e5af9dd
Add plan pill
AdityaHegde Sep 30, 2024
c337e64
Add start team plan variants
AdityaHegde Sep 30, 2024
a16c1f7
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 1, 2024
6ba3913
Handle payment issues
AdityaHegde Oct 1, 2024
b60caca
Handle some edge cases
AdityaHegde Oct 1, 2024
368dd77
Open stripe page before plan upgrade
AdityaHegde Oct 2, 2024
ebc8e6e
Use billing issue to show trial dates
AdityaHegde Oct 3, 2024
7730520
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 3, 2024
5a3105c
Avoid using ListPlans API
AdityaHegde Oct 3, 2024
c4ea1c3
Add null checks
AdityaHegde Oct 3, 2024
8310b96
Fix trial grace period end check
AdityaHegde Oct 4, 2024
df1f20b
Handle cancelled subscriptions
AdityaHegde Oct 4, 2024
9752b05
UXQA fixes - pass 1
AdityaHegde Oct 5, 2024
fc7e934
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 7, 2024
3dabb6a
Use RenewBillingSubscription
AdityaHegde Oct 7, 2024
1d61d74
Handle no subscription case
AdityaHegde Oct 8, 2024
e993973
Always fetch stripe page
AdityaHegde Oct 8, 2024
1e5f1dd
Embed Orb customer portal
AdityaHegde Oct 8, 2024
d4b790c
Open upgrader in same page
AdityaHegde Oct 8, 2024
79f4874
Tweaks
AdityaHegde Oct 8, 2024
883738d
Fix misc issues
AdityaHegde Oct 8, 2024
2198ea1
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 9, 2024
599d568
UXQA - Pass 2
AdityaHegde Oct 9, 2024
7560fa8
Handle viewer cases
AdityaHegde Oct 9, 2024
3613cc3
Refactor to use billing issues message in hibernating message
AdityaHegde Oct 10, 2024
9c2b883
Support PAYMENT_FAILED_ISSUE
AdityaHegde Oct 10, 2024
cfe2460
Add wake all projects
AdityaHegde Oct 10, 2024
98e75c8
UXQA - Pass 3
AdityaHegde Oct 11, 2024
187bb17
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 11, 2024
d2148b1
Hibernating message improvements
AdityaHegde Oct 14, 2024
c4d546b
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 14, 2024
e382acc
Fix hibernate sub cancel check
AdityaHegde Oct 14, 2024
c6389c2
minor changes (#5890)
pjain1 Oct 14, 2024
e49a3d8
Revert asset deletion
AdityaHegde Oct 15, 2024
24acbb9
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 16, 2024
d65af0d
UXQA - Pass 4
AdityaHegde Oct 16, 2024
3d0718c
Add back quotas
AdityaHegde Oct 16, 2024
de504fe
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 17, 2024
f6fc368
Hardcode plan names
AdityaHegde Oct 17, 2024
ab7e0f1
Add CTAs to existing email formats
AdityaHegde Oct 17, 2024
323699b
UI Review comments
AdityaHegde Oct 18, 2024
472e5de
PR comments pass 2
AdityaHegde Oct 21, 2024
dfd796b
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 22, 2024
1be4252
Fix upgrade callback url to stripe
AdityaHegde Oct 22, 2024
4c04d87
PR comments pass 3
AdityaHegde Oct 23, 2024
a671dd4
Styling updates
AdityaHegde Oct 24, 2024
5674229
Add spinner to iframe loading
AdityaHegde Oct 24, 2024
8ec01f5
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 24, 2024
6d57a5c
Add POC plan support
AdityaHegde Oct 24, 2024
4b87ed9
Tweaks
AdityaHegde Oct 24, 2024
8dcd55b
PR comments pass 4
AdityaHegde Oct 25, 2024
f4301e5
fix start trial race condition (#5971)
pjain1 Oct 25, 2024
a8e41c2
web-auth: use svelte and vite-plugin-singlefile (#5965)
briangregoryholmes Oct 24, 2024
d6ce8c4
Share Project Popover (#5887)
lovincyrus Oct 24, 2024
0d774ea
disable preview button when explore resource is reconciling (#5962)
briangregoryholmes Oct 24, 2024
06c444c
fix: display null values with italics in TDD (#5945)
briangregoryholmes Oct 25, 2024
c535b01
completely remove org from orb and stripe (#5955)
pjain1 Oct 25, 2024
035155b
Don't log request cancellation as internal errors in the Github APIs …
begelundmuller Oct 25, 2024
87b95ae
trigger org repair manually (#5936)
pjain1 Oct 25, 2024
2af7c7d
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 28, 2024
4b14bf3
Fix public URLs
AdityaHegde Oct 28, 2024
7c5ba24
Fix upgrade emails
AdityaHegde Oct 28, 2024
52df6b5
Merge branch 'main' into adityahegde/billing-ui
AdityaHegde Oct 28, 2024
0dc2a39
Fix lint
AdityaHegde Oct 28, 2024
691c99a
PR comments pass 5
AdityaHegde Oct 28, 2024
1deb7fb
check org billing init in start trial job (#5983)
pjain1 Oct 28, 2024
15132a4
Add back commented email
AdityaHegde Oct 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions admin/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ func (s *Service) StartTrial(ctx context.Context, org *database.Organization) (*
sub, err := s.Biller.GetActiveSubscription(ctx, org.BillingCustomerID)
if err != nil {
if !errors.Is(err, billing.ErrNotFound) {
if errors.Is(err, billing.ErrCustomerIDRequired) {
return nil, nil, fmt.Errorf("org billing not initialized yet, retry")
}
return nil, nil, fmt.Errorf("failed to get subscriptions for customer: %w", err)
}
}
Expand Down
8 changes: 7 additions & 1 deletion admin/billing/orb.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const (
avalaraTaxExemptionCode = "R" // code for NON-RESIDENT
)

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

var _ Biller = &Orb{}

type Orb struct {
Expand Down Expand Up @@ -202,7 +204,7 @@ func (o *Orb) GetActiveSubscription(ctx context.Context, customerID string) (*Su
}

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

return subs[0], nil
Expand Down Expand Up @@ -445,6 +447,10 @@ func (o *Orb) createSubscription(ctx context.Context, customerID string, plan *P
}

func (o *Orb) getSubscriptions(ctx context.Context, customerID string, status orb.SubscriptionListParamsStatus) ([]*Subscription, error) {
if customerID == "" { // weird behaviour but empty external customer id returns all active subscriptions
return nil, ErrCustomerIDRequired
}

sub, err := o.client.Subscriptions.List(ctx, orb.SubscriptionListParams{
ExternalCustomerID: orb.String(customerID),
Status: orb.F(status),
Expand Down
1 change: 1 addition & 0 deletions admin/database/postgres/migrations/0052.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE assets ALTER COLUMN org_id DROP NOT NULL;
8 changes: 5 additions & 3 deletions admin/jobs/river/biller_event_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ func (w *PaymentFailedWorker) Work(ctx context.Context, job *river.Job[PaymentFa
OrgName: org.Name,
Currency: job.Args.Currency,
Amount: job.Args.Amount,
PaymentURL: w.admin.URLs.PaymentPortal(org.Name),
GracePeriodEndDate: gracePeriodEndDate,
})
if err != nil {
Expand Down Expand Up @@ -242,9 +243,10 @@ func (w *PaymentFailedGracePeriodCheckWorker) paymentFailedGracePeriodCheck(ctx

// send email
err = w.admin.Email.SendInvoiceUnpaid(&email.InvoiceUnpaid{
ToEmail: org.BillingEmail,
ToName: org.Name,
OrgName: org.Name,
ToEmail: org.BillingEmail,
ToName: org.Name,
OrgName: org.Name,
PaymentURL: w.admin.URLs.PaymentPortal(org.Name),
})
if err != nil {
return fmt.Errorf("failed to send project hibernated due to payment overdue email for org %q: %w", org.Name, err)
Expand Down
2 changes: 1 addition & 1 deletion admin/jobs/river/org_jobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ 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_name", org.Name), zap.String("org_id", org.ID), zap.Error(err))
w.logger.Error("failed to start trial for organization", zap.String("org_id", job.Args.OrgID), zap.Error(err))
return err
}

Expand Down
1 change: 1 addition & 0 deletions admin/jobs/river/river.go
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ func (c *Client) StartOrgTrial(ctx context.Context, orgID string) (*jobs.InsertR
UniqueOpts: river.UniqueOpts{
ByArgs: true,
},
MaxAttempts: 5, // override default retries as init org billing job should complete before this if org creation and project deployment were done in single flow
})
if err != nil {
return nil, err
Expand Down
9 changes: 6 additions & 3 deletions admin/jobs/river/trial_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func (w *TrialEndingSoonWorker) trialEndingSoon(ctx context.Context) error {
ToEmail: org.BillingEmail,
ToName: org.Name,
OrgName: org.Name,
UpgradeURL: w.admin.URLs.UpgradePlan(org.Name),
TrialEndDate: m.EndDate,
})
if err != nil {
Expand Down Expand Up @@ -162,6 +163,7 @@ func (w *TrialEndCheckWorker) trialEndCheck(ctx context.Context) error {
ToEmail: org.BillingEmail,
ToName: org.Name,
OrgName: org.Name,
UpgradeURL: w.admin.URLs.UpgradePlan(org.Name),
GracePeriodEndDate: m.GracePeriodEndDate,
})
if err != nil {
Expand Down Expand Up @@ -284,9 +286,10 @@ func (w *TrialGracePeriodCheckWorker) trialGracePeriodCheck(ctx context.Context)

// send email
err = w.admin.Email.SendTrialGracePeriodEnded(&email.TrialGracePeriodEnded{
ToEmail: org.BillingEmail,
ToName: org.Name,
OrgName: org.Name,
ToEmail: org.BillingEmail,
ToName: org.Name,
OrgName: org.Name,
UpgradeURL: w.admin.URLs.UpgradePlan(org.Name),
})
if err != nil {
return fmt.Errorf("failed to send trial grace period ended email for org %q: %w", org.Name, err)
Expand Down
59 changes: 43 additions & 16 deletions admin/server/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ func (s *Server) CancelBillingSubscription(ctx context.Context, req *adminv1.Can
return nil, status.Error(codes.PermissionDenied, "not allowed to cancel org subscription")
}

if org.BillingCustomerID == "" {
return nil, status.Error(codes.FailedPrecondition, "billing not yet initialized for the organization")
}

endDate, err := s.admin.Biller.CancelSubscriptionsForCustomer(ctx, org.BillingCustomerID, billing.SubscriptionCancellationOptionEndOfSubscriptionTerm)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
Expand All @@ -233,6 +237,16 @@ func (s *Server) CancelBillingSubscription(ctx context.Context, req *adminv1.Can
return nil, status.Error(codes.Internal, err.Error())
}

err = s.admin.Email.SendSubscriptionCancelled(&email.SubscriptionCancelled{
ToEmail: org.BillingEmail,
ToName: org.Name,
OrgName: org.Name,
EndDate: endDate,
})
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}

s.logger.Named("billing").Warn("subscription cancelled", zap.String("org_id", org.ID), zap.String("org_name", org.Name))

return &adminv1.CancelBillingSubscriptionResponse{}, nil
Expand Down Expand Up @@ -366,6 +380,11 @@ func (s *Server) GetPaymentsPortalURL(ctx context.Context, req *adminv1.GetPayme
return nil, status.Error(codes.FailedPrecondition, "payment customer not initialized yet for the organization")
}

// returnUrl is mandatory so if not passed default to home page
if req.ReturnUrl == "" {
req.ReturnUrl = s.admin.URLs.Frontend()
}

url, err := s.admin.PaymentProvider.GetBillingPortalURL(ctx, org.PaymentCustomerID, req.ReturnUrl)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
Expand Down Expand Up @@ -784,11 +803,11 @@ func subscriptionToDTO(sub *billing.Subscription) *adminv1.Subscription {
return &adminv1.Subscription{
Id: sub.ID,
Plan: billingPlanToDTO(sub.Plan),
StartDate: timestamppb.New(sub.StartDate),
EndDate: timestamppb.New(sub.EndDate),
CurrentBillingCycleStartDate: timestamppb.New(sub.CurrentBillingCycleStartDate),
CurrentBillingCycleEndDate: timestamppb.New(sub.CurrentBillingCycleEndDate),
TrialEndDate: timestamppb.New(sub.TrialEndDate),
StartDate: valOrNullTime(sub.StartDate),
EndDate: valOrNullTime(sub.EndDate),
CurrentBillingCycleStartDate: valOrNullTime(sub.CurrentBillingCycleStartDate),
CurrentBillingCycleEndDate: valOrNullTime(sub.CurrentBillingCycleEndDate),
TrialEndDate: valOrNullTime(sub.TrialEndDate),
}
}

Expand Down Expand Up @@ -871,17 +890,17 @@ func billingIssueMetadataToDTO(t database.BillingIssueType, m database.BillingIs
return &adminv1.BillingIssueMetadata{
Metadata: &adminv1.BillingIssueMetadata_OnTrial{
OnTrial: &adminv1.BillingIssueMetadataOnTrial{
EndDate: timestamppb.New(m.(*database.BillingIssueMetadataOnTrial).EndDate),
GracePeriodEndDate: timestamppb.New(m.(*database.BillingIssueMetadataOnTrial).GracePeriodEndDate),
EndDate: valOrNullTime(m.(*database.BillingIssueMetadataOnTrial).EndDate),
GracePeriodEndDate: valOrNullTime(m.(*database.BillingIssueMetadataOnTrial).GracePeriodEndDate),
},
},
}
case database.BillingIssueTypeTrialEnded:
return &adminv1.BillingIssueMetadata{
Metadata: &adminv1.BillingIssueMetadata_TrialEnded{
TrialEnded: &adminv1.BillingIssueMetadataTrialEnded{
EndDate: timestamppb.New(m.(*database.BillingIssueMetadataTrialEnded).EndDate),
GracePeriodEndDate: timestamppb.New(m.(*database.BillingIssueMetadataTrialEnded).GracePeriodEndDate),
EndDate: valOrNullTime(m.(*database.BillingIssueMetadataTrialEnded).EndDate),
GracePeriodEndDate: valOrNullTime(m.(*database.BillingIssueMetadataTrialEnded).GracePeriodEndDate),
},
},
}
Expand All @@ -902,12 +921,13 @@ func billingIssueMetadataToDTO(t database.BillingIssueType, m database.BillingIs
invoices := make([]*adminv1.BillingIssueMetadataPaymentFailedMeta, 0)
for k := range paymentFailed.Invoices {
invoices = append(invoices, &adminv1.BillingIssueMetadataPaymentFailedMeta{
InvoiceId: paymentFailed.Invoices[k].ID,
InvoiceNumber: paymentFailed.Invoices[k].Number,
InvoiceUrl: paymentFailed.Invoices[k].URL,
AmountDue: paymentFailed.Invoices[k].Amount,
Currency: paymentFailed.Invoices[k].Currency,
DueDate: timestamppb.New(paymentFailed.Invoices[k].DueDate),
InvoiceId: paymentFailed.Invoices[k].ID,
InvoiceNumber: paymentFailed.Invoices[k].Number,
InvoiceUrl: paymentFailed.Invoices[k].URL,
AmountDue: paymentFailed.Invoices[k].Amount,
Currency: paymentFailed.Invoices[k].Currency,
DueDate: valOrNullTime(paymentFailed.Invoices[k].DueDate),
GracePeriodEndDate: valOrNullTime(paymentFailed.Invoices[k].GracePeriodEndDate),
})
}
return &adminv1.BillingIssueMetadata{
Expand All @@ -921,7 +941,7 @@ func billingIssueMetadataToDTO(t database.BillingIssueType, m database.BillingIs
return &adminv1.BillingIssueMetadata{
Metadata: &adminv1.BillingIssueMetadata_SubscriptionCancelled{
SubscriptionCancelled: &adminv1.BillingIssueMetadataSubscriptionCancelled{
EndDate: timestamppb.New(m.(*database.BillingIssueMetadataSubscriptionCancelled).EndDate),
EndDate: valOrNullTime(m.(*database.BillingIssueMetadataSubscriptionCancelled).EndDate),
},
},
}
Expand Down Expand Up @@ -973,6 +993,13 @@ func comparableInt64(v *int64) int64 {
return *v
}

func valOrNullTime(v time.Time) *timestamppb.Timestamp {
if v.IsZero() {
return nil
}
return timestamppb.New(v)
}

func biggerOfInt(ptr *int, def int) int {
if ptr == nil {
return def
Expand Down
11 changes: 11 additions & 0 deletions admin/urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,3 +336,14 @@ func (u *URLs) AlertOpen(org, project, alert string) string {
func (u *URLs) AlertEdit(org, project, alert string) string {
return urlutil.MustJoinURL(u.Frontend(), org, project, "-", "alerts", alert)
}

// UpgradePlan returns the landing page URL to either upgrade to plan or redirect to payment portal if there are any issues.
func (u *URLs) UpgradePlan(org string) string {
return urlutil.MustWithQuery(urlutil.MustJoinURL(u.Frontend(), org, "-", "settings", "billing"), map[string]string{"upgrade": "true"})
}

// PaymentPortal returns the landing page url that redirects user to payment portal
// Since the payment link can expire it is generated in this landing page on demand.
func (u *URLs) PaymentPortal(org string) string {
return urlutil.MustJoinURL(u.Frontend(), org, "-", "settings", "billing", "payment")
}
13 changes: 13 additions & 0 deletions admin/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,19 @@ func (s *Service) CreateOrganizationForUser(ctx context.Context, userID, email,

s.Logger.Info("created org", zap.String("name", orgName), zap.String("user_id", userID))

// raise never subscribed billing issue in sync to prevent race condition where first project is deployed before issue is raised and thus start trial job not submitted
if s.Biller.Name() != "noop" {
_, err := s.DB.UpsertBillingIssue(ctx, &database.UpsertBillingIssueOptions{
OrgID: org.ID,
Type: database.BillingIssueTypeNeverSubscribed,
Metadata: database.BillingIssueMetadataNeverSubscribed{},
EventTime: org.CreatedOn,
})
if err != nil {
return nil, fmt.Errorf("failed to upsert billing error: %w", err)
}
}

// Submit job to init org billing // TODO modify river client to allow job submission as part of transaction
_, err = s.Jobs.InitOrgBilling(ctx, org.ID)
if err != nil {
Expand Down
Loading
Loading