Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Next Next commit
feat(billing): Add Stripe Checkout integration for better payment UX
- Add CreateCheckoutSession to payment provider interface
- Implement Stripe Checkout session creation with billing address collection
- Add API endpoint for creating checkout sessions (/v1/orgs/{org}/billing/payments/checkout-session)
- Handle checkout.session.completed webhook events
- Update frontend to use Checkout for payment issues:
  - StartTeamPlanDialog
  - Payment.svelte (manage button)
  - BillingCTAHandler
  - upgrade-callback page
  - billing/upgrade route

This provides a better UX similar to Lovable's payment page with:
- Multiple payment method options (cards, Amazon Pay, Cash App Pay, etc.)
- Integrated billing address collection
- Quick checkout flow

Resolves APP-706

Co-authored-by: eric.okuma <eric.okuma@rilldata.com>
  • Loading branch information
cursoragent and ericokuma committed Feb 3, 2026
commit e18183504df6f12574485e1cf031c8ad61b987b4
4 changes: 4 additions & 0 deletions admin/billing/payment/noop.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ func (n noop) GetBillingPortalURL(ctx context.Context, customerID, returnURL str
return "", nil
}

func (n noop) CreateCheckoutSession(ctx context.Context, opts *CheckoutSessionOptions) (*CheckoutSession, error) {
return &CheckoutSession{URL: ""}, nil
}

func (n noop) WebhookHandlerFunc(ctx context.Context, jc jobs.Client) httputil.Handler {
return nil
}
15 changes: 15 additions & 0 deletions admin/billing/payment/payment.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,26 @@ type Provider interface {
DeleteCustomer(ctx context.Context, customerID string) error
// GetBillingPortalURL returns the payment portal URL to collect payment information from the customer.
GetBillingPortalURL(ctx context.Context, customerID, returnURL string) (string, error)
// CreateCheckoutSession creates a Stripe Checkout session for collecting payment method and billing information.
// This provides a better UX with multiple payment method options (cards, Amazon Pay, Cash App Pay, etc.)
CreateCheckoutSession(ctx context.Context, opts *CheckoutSessionOptions) (*CheckoutSession, error)

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

// CheckoutSessionOptions contains options for creating a Stripe Checkout session
type CheckoutSessionOptions struct {
CustomerID string
SuccessURL string
CancelURL string
}

// CheckoutSession represents a Stripe Checkout session
type CheckoutSession struct {
URL string
}

type Customer struct {
ID string
Name string
Expand Down
27 changes: 27 additions & 0 deletions admin/billing/payment/stripe.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/rilldata/rill/runtime/pkg/httputil"
"github.com/stripe/stripe-go/v79"
"github.com/stripe/stripe-go/v79/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v79/checkout/session"
"github.com/stripe/stripe-go/v79/customer"
"go.uber.org/zap"
)
Expand Down Expand Up @@ -107,6 +108,32 @@ func (s *Stripe) GetBillingPortalURL(ctx context.Context, customerID, returnURL
return sess.URL, nil
}

func (s *Stripe) CreateCheckoutSession(ctx context.Context, opts *CheckoutSessionOptions) (*CheckoutSession, error) {
params := &stripe.CheckoutSessionParams{
Customer: stripe.String(opts.CustomerID),
Mode: stripe.String(string(stripe.CheckoutSessionModeSetup)),
// Setup mode allows collecting payment method without charging
PaymentMethodTypes: stripe.StringSlice([]string{
"card",
}),
SuccessURL: stripe.String(opts.SuccessURL),
CancelURL: stripe.String(opts.CancelURL),
// Collect billing address
BillingAddressCollection: stripe.String(string(stripe.CheckoutSessionBillingAddressCollectionRequired)),
// Update customer with the collected payment method
PaymentMethodCollection: stripe.String(string(stripe.CheckoutSessionPaymentMethodCollectionAlways)),
}

sess, err := checkoutsession.New(params)
if err != nil {
return nil, err
}

return &CheckoutSession{
URL: sess.URL,
}, nil
}

func (s *Stripe) WebhookHandlerFunc(ctx context.Context, jc jobs.Client) httputil.Handler {
if s.webhookSecret == "" {
return nil
Expand Down
53 changes: 53 additions & 0 deletions admin/billing/payment/stripe_webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ func (s *stripeWebhook) handleWebhook(w http.ResponseWriter, r *http.Request) er

// Handle the event based on its type
switch event.Type {
case "checkout.session.completed":
var checkoutSession stripe.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &checkoutSession); err != nil {
return httputil.Errorf(http.StatusBadRequest, "error parsing checkout session data: %w", err)
}
if checkoutSession.Customer == nil {
s.stripe.logger.Warn("no customer info sent for checkout.session.completed event", zap.String("event_id", event.ID), zap.Time("event_time", time.UnixMilli(event.Created*1000)))
} else {
err = s.handleCheckoutSessionCompleted(r.Context(), event.ID, time.UnixMilli(event.Created*1000), &checkoutSession)
if err != nil {
return httputil.Errorf(http.StatusInternalServerError, "error handling checkout.session.completed event: %w", err)
}
}
case "payment_method.attached":
var paymentMethod stripe.PaymentMethod
if err := json.Unmarshal(event.Data.Raw, &paymentMethod); err != nil {
Expand Down Expand Up @@ -131,3 +144,43 @@ func (s *stripeWebhook) handleCustomerAddressUpdated(ctx context.Context, eventI
}
return nil
}

func (s *stripeWebhook) handleCheckoutSessionCompleted(ctx context.Context, eventID string, eventTime time.Time, session *stripe.CheckoutSession) error {
// When a checkout session is completed, the payment method is automatically attached to the customer
// and the billing address is collected. We need to trigger the same jobs as when payment method is added
// and customer address is updated.

s.stripe.logger.Info("checkout session completed",
zap.String("event_id", eventID),
zap.String("customer_id", session.Customer.ID),
zap.String("session_id", session.ID),
observability.ZapCtx(ctx),
)

// Handle payment method setup - the SetupIntent will have attached the payment method
if session.SetupIntent != nil && session.SetupIntent.PaymentMethod != nil {
pm := session.SetupIntent.PaymentMethod
res, err := s.jobs.PaymentMethodAdded(ctx, pm.ID, session.Customer.ID, string(pm.Type), eventTime)
if err != nil {
s.stripe.logger.Error("failed to add payment method added job from checkout session", zap.String("payment_customer_id", session.Customer.ID), zap.Error(err), observability.ZapCtx(ctx))
return err
}
if res.Duplicate {
s.stripe.logger.Debug("duplicate payment method from checkout session", zap.String("event_id", eventID))
}
}

// Handle customer address update - billing address is collected during checkout
if session.CustomerDetails != nil && session.CustomerDetails.Address != nil {
res, err := s.jobs.CustomerAddressUpdated(ctx, session.Customer.ID, eventTime)
if err != nil {
s.stripe.logger.Error("failed to add customer address updated job from checkout session", zap.String("payment_customer_id", session.Customer.ID), zap.Error(err), observability.ZapCtx(ctx))
return err
}
if res.Duplicate {
s.stripe.logger.Debug("duplicate customer address update from checkout session", zap.String("event_id", eventID))
}
}

return nil
}
41 changes: 41 additions & 0 deletions admin/server/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/rilldata/rill/admin/billing"
"github.com/rilldata/rill/admin/billing/payment"
"github.com/rilldata/rill/admin/database"
"github.com/rilldata/rill/admin/server/auth"
adminv1 "github.com/rilldata/rill/proto/gen/rill/admin/v1"
Expand Down Expand Up @@ -497,6 +498,46 @@ func (s *Server) GetPaymentsPortalURL(ctx context.Context, req *adminv1.GetPayme
return &adminv1.GetPaymentsPortalURLResponse{Url: url}, nil
}

func (s *Server) CreatePaymentCheckoutSession(ctx context.Context, req *adminv1.CreatePaymentCheckoutSessionRequest) (*adminv1.CreatePaymentCheckoutSessionResponse, error) {
observability.AddRequestAttributes(ctx, attribute.String("args.org", req.Org))
observability.AddRequestAttributes(ctx, attribute.String("args.success_url", req.SuccessUrl))
observability.AddRequestAttributes(ctx, attribute.String("args.cancel_url", req.CancelUrl))

org, err := s.admin.DB.FindOrganizationByName(ctx, req.Org)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}

claims := auth.GetClaims(ctx)
forceAccess := claims.Superuser(ctx) && req.SuperuserForceAccess
if !claims.OrganizationPermissions(ctx, org.ID).ManageOrg && !forceAccess {
return nil, status.Error(codes.PermissionDenied, "not allowed to manage org billing")
}

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

// Default URLs if not provided
if req.SuccessUrl == "" {
req.SuccessUrl = s.admin.URLs.Billing(org.Name, false)
}
if req.CancelUrl == "" {
req.CancelUrl = s.admin.URLs.Billing(org.Name, false)
}

session, err := s.admin.PaymentProvider.CreateCheckoutSession(ctx, &payment.CheckoutSessionOptions{
CustomerID: org.PaymentCustomerID,
SuccessURL: req.SuccessUrl,
CancelURL: req.CancelUrl,
})
if err != nil {
return nil, status.Error(codes.Internal, fmt.Sprintf("failed to create checkout session: %v", err))
}

return &adminv1.CreatePaymentCheckoutSessionResponse{Url: session.URL}, nil
}

// SudoUpdateOrganizationBillingCustomer updates the billing customer id for an organization. May be useful if customer is initialized manually in billing system
func (s *Server) SudoUpdateOrganizationBillingCustomer(ctx context.Context, req *adminv1.SudoUpdateOrganizationBillingCustomerRequest) (*adminv1.SudoUpdateOrganizationBillingCustomerResponse, error) {
observability.AddRequestAttributes(ctx,
Expand Down
37 changes: 37 additions & 0 deletions proto/gen/rill/admin/v1/admin.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,38 @@ paths:
in: query
required: false
type: boolean
/v1/orgs/{org}/billing/payments/checkout-session:
post:
summary: |-
CreatePaymentCheckoutSession creates a Stripe Checkout session for collecting payment method and billing address
This provides a better UX with multiple payment method options (cards, Amazon Pay, Cash App Pay, etc.)
operationId: AdminService_CreatePaymentCheckoutSession
responses:
"200":
description: A successful response.
schema:
$ref: '#/definitions/v1CreatePaymentCheckoutSessionResponse'
default:
description: An unexpected error response.
schema:
$ref: '#/definitions/rpcStatus'
parameters:
- name: org
in: path
required: true
type: string
- name: body
in: body
required: true
schema:
type: object
properties:
successUrl:
type: string
cancelUrl:
type: string
superuserForceAccess:
type: boolean
/v1/orgs/{org}/billing/payments/portal-url:
get:
summary: GetPaymentsPortalURL returns the URL for the billing session to collect payment method
Expand Down Expand Up @@ -4851,6 +4883,11 @@ definitions:
properties:
organization:
$ref: '#/definitions/v1Organization'
v1CreatePaymentCheckoutSessionResponse:
type: object
properties:
url:
type: string
v1CreateProjectResponse:
type: object
properties:
Expand Down
Loading
Loading