Skip to content

Commit

Permalink
feat: allow adding payment methods for a billing customer
Browse files Browse the repository at this point in the history
- while creating a checkout session, now an additional parameter
can be passed "setup_body.payment_method" as "true" which returns
a url used to setup customer default PM.
- raystack/proton#332

Signed-off-by: Kush Sharma <[email protected]>
  • Loading branch information
kushsharma committed Jan 14, 2024
1 parent a1aa7ad commit 093443a
Show file tree
Hide file tree
Showing 11 changed files with 6,578 additions and 6,090 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ TAG := $(shell git rev-list --tags --max-count=1)
VERSION := $(shell git describe --tags ${TAG})
.PHONY: build check fmt lint test test-race vet test-cover-html help install proto ui
.DEFAULT_GOAL := build
PROTON_COMMIT := "209e8f0f4c8068644817404738a170522545359d"
PROTON_COMMIT := "70c01935bc75115a794eedcad102c77e57d4cbf9"

ui:
@echo " > generating ui build"
Expand Down
185 changes: 169 additions & 16 deletions billing/checkout/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,28 +214,30 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
Enabled: stripe.Bool(s.stripeAutoTax),
},
Currency: &billingCustomer.Currency,
Customer: &billingCustomer.ProviderID,
Currency: stripe.String(billingCustomer.Currency),
Customer: stripe.String(billingCustomer.ProviderID),
LineItems: subsItems,
Metadata: map[string]string{
"org_id": billingCustomer.OrgID,
"plan_id": ch.PlanID,
"managed_by": "frontier",
"org_id": billingCustomer.OrgID,
"plan_id": ch.PlanID,
"checkout_id": checkoutID,
"managed_by": "frontier",
},
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{
Description: stripe.String(fmt.Sprintf("Checkout for %s", plan.Name)),
Metadata: map[string]string{
"org_id": billingCustomer.OrgID,
"plan_id": ch.PlanID,
"plan_name": plan.Name,
"interval": plan.Interval,
"managed_by": "frontier",
"org_id": billingCustomer.OrgID,
"plan_id": ch.PlanID,
"plan_name": plan.Name,
"interval": plan.Interval,
"checkout_id": checkoutID,
"managed_by": "frontier",
},
},
AllowPromotionCodes: stripe.Bool(true),
CancelURL: &ch.CancelUrl,
SuccessURL: &ch.SuccessUrl,
CancelURL: stripe.String(ch.CancelUrl),
SuccessURL: stripe.String(ch.SuccessUrl),
ExpiresAt: stripe.Int64(time.Now().Add(SessionValidity).Unix()),
})
if err != nil {
Expand Down Expand Up @@ -287,18 +289,19 @@ func (s *Service) Create(ctx context.Context, ch Checkout) (Checkout, error) {
AutomaticTax: &stripe.CheckoutSessionAutomaticTaxParams{
Enabled: stripe.Bool(s.stripeAutoTax),
},
Currency: &billingCustomer.Currency,
Customer: &billingCustomer.ProviderID,
Currency: stripe.String(billingCustomer.Currency),
Customer: stripe.String(billingCustomer.ProviderID),
LineItems: subsItems,
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
Metadata: map[string]string{
"org_id": billingCustomer.OrgID,
"product_name": chFeature.Name,
"credit_amount": fmt.Sprintf("%d", chFeature.CreditAmount),
"checkout_id": checkoutID,
"managed_by": "frontier",
},
CancelURL: &ch.CancelUrl,
SuccessURL: &ch.SuccessUrl,
CancelURL: stripe.String(ch.CancelUrl),
SuccessURL: stripe.String(ch.SuccessUrl),
ExpiresAt: stripe.Int64(time.Now().Add(SessionValidity).Unix()),
})
if err != nil {
Expand Down Expand Up @@ -551,3 +554,153 @@ func (s *Service) List(ctx context.Context, filter Filter) ([]Checkout, error) {

return s.repository.List(ctx, filter)
}

func (s *Service) CreateSessionForPaymentMethod(ctx context.Context, ch Checkout) (Checkout, error) {
// get billing
billingCustomer, err := s.customerService.GetByID(ctx, ch.CustomerID)
if err != nil {
return Checkout{}, err
}

checkoutID := uuid.New().String()
ch, err = s.templatizeUrls(ch, checkoutID)
if err != nil {
return Checkout{}, err
}

// create payment method setup checkout link
stripeCheckout, err := s.stripeClient.CheckoutSessions.New(&stripe.CheckoutSessionParams{
Params: stripe.Params{
Context: ctx,
},
Customer: stripe.String(billingCustomer.ProviderID),
Currency: stripe.String(billingCustomer.Currency),
Mode: stripe.String(string(stripe.CheckoutSessionModeSetup)),
CancelURL: stripe.String(ch.CancelUrl),
SuccessURL: stripe.String(ch.SuccessUrl),
ExpiresAt: stripe.Int64(time.Now().Add(SessionValidity).Unix()),
Metadata: map[string]string{
"org_id": billingCustomer.OrgID,
"checkout_id": checkoutID,
"managed_by": "frontier",
},
})
if err != nil {
return Checkout{}, fmt.Errorf("failed to create checkout at billing provider: %w", err)
}

return s.repository.Create(ctx, Checkout{
ID: checkoutID,
ProviderID: stripeCheckout.ID,
CustomerID: billingCustomer.ID,
CancelUrl: ch.CancelUrl,
SuccessUrl: ch.SuccessUrl,
CheckoutUrl: stripeCheckout.URL,
State: string(stripeCheckout.Status),
ExpireAt: time.Unix(stripeCheckout.ExpiresAt, 0),
Metadata: map[string]any{
"mode": "setup",
},
})
}

// Apply applies the actual request directly without creating a checkout session
// for example when a request is created for a plan, it will be directly subscribe without
// actually paying for it
func (s *Service) Apply(ctx context.Context, ch Checkout) (*subscription.Subscription, *product.Product, error) {
// get billing
billingCustomer, err := s.customerService.GetByID(ctx, ch.CustomerID)
if err != nil {
return nil, nil, err
}

// checkout could be for a plan or a product
if ch.PlanID != "" {
// if already subscribed to the plan, return
if subID, err := s.checkIfAlreadySubscribed(ctx, ch); err != nil {
return nil, nil, err
} else if subID != "" {
return nil, nil, fmt.Errorf("already subscribed to the plan")
}

// create subscription items
plan, err := s.planService.GetByID(ctx, ch.PlanID)
if err != nil {
return nil, nil, err
}
var subsItems []*stripe.SubscriptionItemsParams
for _, planFeature := range plan.Products {
// if it's credit, skip, they are handled separately
if planFeature.Behavior == product.CreditBehavior {
continue
}

for _, productPrice := range planFeature.Prices {
// only work with plan interval prices
if productPrice.Interval != plan.Interval {
continue
}

itemParams := &stripe.SubscriptionItemsParams{
Price: stripe.String(productPrice.ProviderID),
}
if productPrice.UsageType == product.PriceUsageTypeLicensed {
itemParams.Quantity = stripe.Int64(1)

if planFeature.Behavior == product.UserCountBehavior {
count, err := s.orgService.MemberCount(ctx, billingCustomer.OrgID)
if err != nil {
return nil, nil, fmt.Errorf("failed to get member count: %w", err)
}
itemParams.Quantity = stripe.Int64(count)
}
}
subsItems = append(subsItems, itemParams)
}
}
// create subscription directly
stripeSubscription, err := s.stripeClient.Subscriptions.New(&stripe.SubscriptionParams{
Params: stripe.Params{
Context: ctx,
},
Customer: stripe.String(billingCustomer.ProviderID),
Currency: stripe.String(billingCustomer.Currency),
Items: subsItems,
Metadata: map[string]string{
"org_id": billingCustomer.OrgID,
"plan_id": ch.PlanID,
"managed_by": "frontier",
},
})
if err != nil {
return nil, nil, fmt.Errorf("failed to create subscription at billing provider: %w", err)
}

// register subscription in frontier
subs, err := s.subscriptionService.Create(ctx, subscription.Subscription{
ID: uuid.New().String(),
ProviderID: stripeSubscription.ID,
CustomerID: billingCustomer.ID,
PlanID: plan.ID,
Metadata: map[string]any{
"org_id": billingCustomer.OrgID,
"delegated": "true",
},
})
if err != nil {
return nil, nil, fmt.Errorf("failed to create subscription: %w", err)
}
ch.ID = subs.ID

// subscription can also be complimented with free credits
if err := s.ensureCreditsForPlan(ctx, ch); err != nil {
return nil, nil, fmt.Errorf("ensureCreditsForPlan: %w", err)
}
return &subs, nil, nil
} else if ch.ProductID != "" {
// TODO(kushsharma): not implemented yet
return nil, nil, fmt.Errorf("not supported yet")
}

return nil, nil, fmt.Errorf("invalid checkout request")
}
64 changes: 64 additions & 0 deletions internal/api/v1beta1/billing_checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package v1beta1
import (
"context"

"github.com/raystack/frontier/billing/product"
"github.com/raystack/frontier/billing/subscription"

"github.com/raystack/frontier/billing/checkout"
"google.golang.org/protobuf/types/known/timestamppb"

Expand All @@ -13,11 +16,72 @@ import (
type CheckoutService interface {
Create(ctx context.Context, ch checkout.Checkout) (checkout.Checkout, error)
List(ctx context.Context, filter checkout.Filter) ([]checkout.Checkout, error)
Apply(ctx context.Context, ch checkout.Checkout) (*subscription.Subscription, *product.Product, error)
CreateSessionForPaymentMethod(ctx context.Context, ch checkout.Checkout) (checkout.Checkout, error)
}

func (h Handler) DelegatedCheckout(ctx context.Context, request *frontierv1beta1.DelegatedCheckoutRequest) (*frontierv1beta1.DelegatedCheckoutResponse, error) {
logger := grpczap.Extract(ctx)

planID := ""
if request.GetSubscriptionBody() != nil {
planID = request.GetSubscriptionBody().GetPlan()
}
featureID := ""
if request.GetProductBody() != nil {
featureID = request.GetProductBody().GetProduct()
}
subs, prod, err := h.checkoutService.Apply(ctx, checkout.Checkout{
CustomerID: request.GetBillingId(),
PlanID: planID,
ProductID: featureID,
})
if err != nil {
logger.Error(err.Error())
return nil, grpcInternalServerError
}

var subsPb *frontierv1beta1.Subscription
if subs != nil {
if subsPb, err = transformSubscriptionToPB(*subs); err != nil {
logger.Error(err.Error())
return nil, grpcInternalServerError
}
}
var productPb *frontierv1beta1.Product
if prod != nil {
if productPb, err = transformProductToPB(*prod); err != nil {
logger.Error(err.Error())
return nil, grpcInternalServerError
}
}

return &frontierv1beta1.DelegatedCheckoutResponse{
Subscription: subsPb,
Product: productPb,
}, nil
}

func (h Handler) CreateCheckout(ctx context.Context, request *frontierv1beta1.CreateCheckoutRequest) (*frontierv1beta1.CreateCheckoutResponse, error) {
logger := grpczap.Extract(ctx)

// check if setup requested
if request.GetSetupBody() != nil {
newCheckout, err := h.checkoutService.CreateSessionForPaymentMethod(ctx, checkout.Checkout{
CustomerID: request.GetBillingId(),
SuccessUrl: request.GetSuccessUrl(),
CancelUrl: request.GetCancelUrl(),
})
if err != nil {
logger.Error(err.Error())
return nil, grpcInternalServerError
}
return &frontierv1beta1.CreateCheckoutResponse{
CheckoutSession: transformCheckoutToPB(newCheckout),
}, nil
}

// check if checkout requested
planID := ""
if request.GetSubscriptionBody() != nil {
planID = request.GetSubscriptionBody().GetPlan()
Expand Down
3 changes: 3 additions & 0 deletions pkg/server/interceptors/authorization.go
Original file line number Diff line number Diff line change
Expand Up @@ -733,4 +733,7 @@ var authorizationValidationMap = map[string]func(ctx context.Context, handler *v
"/raystack.frontier.v1beta1.AdminService/CheckFederatedResourcePermission": func(ctx context.Context, handler *v1beta1.Handler, req any) error {
return handler.IsAuthorized(ctx, relation.Object{Namespace: schema.PlatformNamespace, ID: schema.PlatformID}, schema.PlatformCheckPermission)
},
"/raystack.frontier.v1beta1.AdminService/DelegatedCheckout": func(ctx context.Context, handler *v1beta1.Handler, req any) error {
return handler.IsSuperUser(ctx)
},
}
17 changes: 14 additions & 3 deletions proto/apidocs.swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2868,6 +2868,9 @@ paths:
product_body:
$ref: '#/definitions/v1beta1CheckoutProductBody'
title: Product to buy
setup_body:
$ref: '#/definitions/v1beta1CheckoutSetupBody'
title: Payment method setup
tags:
- Checkout
/v1beta1/organizations/{org_id}/billing/{billing_id}/invoices:
Expand Down Expand Up @@ -8164,6 +8167,11 @@ definitions:
expire_at:
type: string
format: date-time
v1beta1CheckoutSetupBody:
type: object
properties:
payment_method:
type: boolean
v1beta1CheckoutSubscriptionBody:
type: object
properties:
Expand Down Expand Up @@ -8383,9 +8391,12 @@ definitions:
v1beta1DelegatedCheckoutResponse:
type: object
properties:
checkout_session:
$ref: '#/definitions/v1beta1CheckoutSession'
title: Checkout session
subscription:
$ref: '#/definitions/v1beta1Subscription'
title: subscription if created
product:
$ref: '#/definitions/v1beta1Product'
title: product if bought
v1beta1DeleteBillingAccountResponse:
type: object
v1beta1DeleteGroupResponse:
Expand Down
Loading

0 comments on commit 093443a

Please sign in to comment.