Skip to content

Commit

Permalink
feat: allow adding payment methods for a billing customer (#457)
Browse files Browse the repository at this point in the history
* feat: allow adding payment methods for a billing customer

- 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]>

* build docs

Signed-off-by: Kush Sharma <[email protected]>

---------

Signed-off-by: Kush Sharma <[email protected]>
  • Loading branch information
kushsharma authored Jan 14, 2024
1 parent a1aa7ad commit 739b5da
Show file tree
Hide file tree
Showing 159 changed files with 8,093 additions and 7,441 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")
}
8 changes: 4 additions & 4 deletions docs/docs/apis/admin-service-add-platform-user.api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,12 +209,12 @@ api:
"type": "object",
"properties":
{
"userId":
"user_id":
{
"type": "string",
"description": "The user id to add to the platform.",
},
"serviceuserId":
"serviceuser_id":
{
"type": "string",
"description": "The service user id to add to the platform.",
Expand Down Expand Up @@ -255,7 +255,7 @@ api:
},
},
"jsonRequestBodyExample":
{ "userId": "string", "serviceuserId": "string", "relation": "string" },
{ "user_id": "string", "serviceuser_id": "string", "relation": "string" },
"info":
{
"title": "Frontier Administration API",
Expand Down Expand Up @@ -317,7 +317,7 @@ import TabItem from "@theme/TabItem";

Adds a user to the platform.

<MimeTabs><TabItem label={"application/json"} value={"application/json-schema"}><details style={{}} data-collapsed={false} open={true}><summary style={{"textAlign":"left"}}><strong>Request Body</strong><strong style={{"fontSize":"var(--ifm-code-font-size)","color":"var(--openapi-required)"}}> required</strong></summary><div style={{"textAlign":"left","marginLeft":"1rem"}}></div><ul style={{"marginLeft":"1rem"}}><SchemaItem collapsible={false} name={"userId"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The user id to add to the platform."}}></SchemaItem><SchemaItem collapsible={false} name={"serviceuserId"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The service user id to add to the platform."}}></SchemaItem><SchemaItem collapsible={false} name={"relation"} required={true} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The relation to add as in the platform. It can be admin or member."}}></SchemaItem></ul></details></TabItem></MimeTabs><div><ApiTabs><TabItem label={"200"} value={"200"}><div>
<MimeTabs><TabItem label={"application/json"} value={"application/json-schema"}><details style={{}} data-collapsed={false} open={true}><summary style={{"textAlign":"left"}}><strong>Request Body</strong><strong style={{"fontSize":"var(--ifm-code-font-size)","color":"var(--openapi-required)"}}> required</strong></summary><div style={{"textAlign":"left","marginLeft":"1rem"}}></div><ul style={{"marginLeft":"1rem"}}><SchemaItem collapsible={false} name={"user_id"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The user id to add to the platform."}}></SchemaItem><SchemaItem collapsible={false} name={"serviceuser_id"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The service user id to add to the platform."}}></SchemaItem><SchemaItem collapsible={false} name={"relation"} required={true} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","description":"The relation to add as in the platform. It can be admin or member."}}></SchemaItem></ul></details></TabItem></MimeTabs><div><ApiTabs><TabItem label={"200"} value={"200"}><div>

A successful response.

Expand Down
6 changes: 3 additions & 3 deletions docs/docs/apis/admin-service-create-permission.api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,14 @@ api:
"id": { "type": "string" },
"name": { "type": "string" },
"title": { "type": "string" },
"createdAt":
"created_at":
{
"type": "string",
"format": "date-time",
"example": "2023-06-07T05:39:56.961Z",
"description": "The time the permission was created.",
},
"updatedAt":
"updated_at":
{
"type": "string",
"format": "date-time",
Expand Down Expand Up @@ -405,7 +405,7 @@ Creates a permission. It can be used to grant permissions to all the resources i

A successful response.

</div><div><MimeTabs schemaType={"response"}><TabItem label={"application/json"} value={"application/json"}><SchemaTabs><TabItem label={"Schema"} value={"Schema"}><details style={{}} data-collapsed={false} open={true}><summary style={{"textAlign":"left"}}><strong>Schema</strong></summary><div style={{"textAlign":"left","marginLeft":"1rem"}}></div><ul style={{"marginLeft":"1rem"}}><SchemaItem collapsible={true} className={"schemaItem"}><details style={{}}><summary style={{}}><strong>permissions</strong><span style={{"opacity":"0.6"}}> object[]</span></summary><div style={{"marginLeft":"1rem"}}><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem","paddingBottom":".5rem"}}>Array [</div></li><SchemaItem collapsible={false} name={"id"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"name"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"title"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"createdAt"} required={false} schemaName={"date-time"} qualifierMessage={undefined} schema={{"type":"string","format":"date-time","example":"2023-06-07T05:39:56.961Z","description":"The time the permission was created."}}></SchemaItem><SchemaItem collapsible={false} name={"updatedAt"} required={false} schemaName={"date-time"} qualifierMessage={undefined} schema={{"type":"string","format":"date-time","example":"2023-06-07T05:39:56.961Z","description":"The time the permission was last updated."}}></SchemaItem><SchemaItem collapsible={false} name={"namespace"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"metadata"} required={false} schemaName={"object"} qualifierMessage={undefined} schema={{"type":"object"}}></SchemaItem><SchemaItem collapsible={false} name={"key"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","example":"compute.instance.get","description":"Permission path key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'."}}></SchemaItem><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem"}}>]</div></li></div></details></SchemaItem></ul></details></TabItem><TabItem label={"Example (from schema)"} value={"Example (from schema)"}><ResponseSamples responseExample={"{\n \"permissions\": [\n {\n \"id\": \"string\",\n \"name\": \"string\",\n \"title\": \"string\",\n \"createdAt\": \"2023-06-07T05:39:56.961Z\",\n \"updatedAt\": \"2023-06-07T05:39:56.961Z\",\n \"namespace\": \"string\",\n \"metadata\": {},\n \"key\": \"compute.instance.get\"\n }\n ]\n}"} language={"json"}></ResponseSamples></TabItem></SchemaTabs></TabItem></MimeTabs></div></TabItem><TabItem label={"400"} value={"400"}><div>
</div><div><MimeTabs schemaType={"response"}><TabItem label={"application/json"} value={"application/json"}><SchemaTabs><TabItem label={"Schema"} value={"Schema"}><details style={{}} data-collapsed={false} open={true}><summary style={{"textAlign":"left"}}><strong>Schema</strong></summary><div style={{"textAlign":"left","marginLeft":"1rem"}}></div><ul style={{"marginLeft":"1rem"}}><SchemaItem collapsible={true} className={"schemaItem"}><details style={{}}><summary style={{}}><strong>permissions</strong><span style={{"opacity":"0.6"}}> object[]</span></summary><div style={{"marginLeft":"1rem"}}><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem","paddingBottom":".5rem"}}>Array [</div></li><SchemaItem collapsible={false} name={"id"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"name"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"title"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"created_at"} required={false} schemaName={"date-time"} qualifierMessage={undefined} schema={{"type":"string","format":"date-time","example":"2023-06-07T05:39:56.961Z","description":"The time the permission was created."}}></SchemaItem><SchemaItem collapsible={false} name={"updated_at"} required={false} schemaName={"date-time"} qualifierMessage={undefined} schema={{"type":"string","format":"date-time","example":"2023-06-07T05:39:56.961Z","description":"The time the permission was last updated."}}></SchemaItem><SchemaItem collapsible={false} name={"namespace"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string"}}></SchemaItem><SchemaItem collapsible={false} name={"metadata"} required={false} schemaName={"object"} qualifierMessage={undefined} schema={{"type":"object"}}></SchemaItem><SchemaItem collapsible={false} name={"key"} required={false} schemaName={"string"} qualifierMessage={undefined} schema={{"type":"string","example":"compute.instance.get","description":"Permission path key is composed of three parts, 'service.resource.verb'. Where 'service.resource' works as a namespace for the 'verb'."}}></SchemaItem><li><div style={{"fontSize":"var(--ifm-code-font-size)","opacity":"0.6","marginLeft":"-.5rem"}}>]</div></li></div></details></SchemaItem></ul></details></TabItem><TabItem label={"Example (from schema)"} value={"Example (from schema)"}><ResponseSamples responseExample={"{\n \"permissions\": [\n {\n \"id\": \"string\",\n \"name\": \"string\",\n \"title\": \"string\",\n \"created_at\": \"2023-06-07T05:39:56.961Z\",\n \"updated_at\": \"2023-06-07T05:39:56.961Z\",\n \"namespace\": \"string\",\n \"metadata\": {},\n \"key\": \"compute.instance.get\"\n }\n ]\n}"} language={"json"}></ResponseSamples></TabItem></SchemaTabs></TabItem></MimeTabs></div></TabItem><TabItem label={"400"} value={"400"}><div>

Bad Request - The request was malformed or contained invalid parameters.

Expand Down
Loading

0 comments on commit 739b5da

Please sign in to comment.