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

Add documentation for billing customers #827

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
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
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 compose-up-dev
.DEFAULT_GOAL := build
PROTON_COMMIT := "8c05fcc798a7893761c9df0410c8d92cc926f68a"
PROTON_COMMIT := "5e5f98bafba2a218b35a9130d75bd68980151d00"

ui:
@echo " > generating ui build"
Expand Down
70 changes: 29 additions & 41 deletions core/role/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,42 +97,38 @@ func (s Service) createRolePermissionRelation(ctx context.Context, roleID string
return nil
}

func (s Service) deleteRolePermissionRelation(ctx context.Context, roleID string, permissions []string) error {
func (s Service) deleteRolePermissionRelations(ctx context.Context, roleID string) error {
// delete relation between role and permissions
// for example for each permission:
// app/role:org_owner#organization_delete@app/user:*
// app/role:org_owner#organization_update@app/user:*
// this needs to be created for each type of principles
for _, perm := range permissions {
err := s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{
ID: roleID,
Namespace: schema.RoleNamespace,
},
Subject: relation.Subject{
ID: "*", // all principles who have role will have access
Namespace: schema.UserPrincipal,
},
RelationName: perm,
})
if err != nil {
return err
}
// do the same with service user
err = s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{
ID: roleID,
Namespace: schema.RoleNamespace,
},
Subject: relation.Subject{
ID: "*", // all principles who have role will have access
Namespace: schema.ServiceUserPrincipal,
},
RelationName: perm,
})
if err != nil {
return err
}
err := s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{
ID: roleID,
Namespace: schema.RoleNamespace,
},
Subject: relation.Subject{
ID: "*", // all principles who have role will have access
Namespace: schema.UserPrincipal,
},
})
if err != nil {
return err
}
// do the same with service user
err = s.relationService.Delete(ctx, relation.Relation{
Object: relation.Object{
ID: roleID,
Namespace: schema.RoleNamespace,
},
Subject: relation.Subject{
ID: "*", // all principles who have role will have access
Namespace: schema.ServiceUserPrincipal,
},
})
if err != nil {
return err
}
return nil
}
Expand Down Expand Up @@ -165,16 +161,8 @@ func (s Service) Update(ctx context.Context, toUpdate Role) (Role, error) {
return Role{}, err
}

// figure out what to delete from permission relation
var permissionsToDelete []string
for _, perm := range existingRole.Permissions {
if !utils.Contains(toUpdate.Permissions, perm) {
permissionsToDelete = append(permissionsToDelete, perm)
}
}

// delete relation between role and permissions
if err := s.deleteRolePermissionRelation(ctx, existingRole.ID, permissionsToDelete); err != nil {
// delete all existing relation between role and permissions
if err := s.deleteRolePermissionRelations(ctx, existingRole.ID); err != nil {
return Role{}, err
}

Expand Down
51 changes: 51 additions & 0 deletions docs/docs/billing/billing_customers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Billing Customers

Billing customers represent a customer entity with fields for storing billing related customer data like ID, organization ID (OrgID), currency etc. It also includes a field called `provider_id` which represents the ID of the customer in a billing engine (Frontier supports Stripe as the default billing engine).

## Configuration

This configuration section streamlines the customer onboarding process by automating account creation, plan assignment, and credit allocation. It provides flexibility in managing customer accounts and billing preferences.

### Key Settings:

- `auto_create_with_org`: Determines whether a default customer account should be automatically created when a new organization is created.
- Value: true or false
- Default: true

- `default_plan`: Specifies the default plan that should be automatically subscribed to when a new organization is created. This also triggers the creation of an empty billing account under the organization.
- Value: Plan name (string)
- Default: Empty string

- `default_offline`: Controls the default offline status for customer accounts. If set to true, the customer account will not be registered with the billing provider.
- Value: true or false
- Default: false

- `onboard_credits_with_org`: Specifies the amount of free credits to be added to a customer account when it's created as part of an organization.
- Value: Integer (number of credits)
- Default: 0


### Billing Customer Creation

Using the configurations described above, the creation process of a billing account can be customized. Billing customers are billing counterparts to the "organization" entity in Frontier, and are created as soon as an organization is created.
During billing account creation, we check for existing active billing accounts within the same organization. If they exist, the new account is not created. New billing accounts are also not created when the organization has existing accounts with negative credit balance.
In order to ensure that billing accounts are created on Stripe only when a customer actually tries to purchase something, we can set the `default_offline` flag to true in the config. This makes sure that billing accounts are created in Frontier without a counterpart in Stripe. A Stripe account is created during the checkout flow in such cases.

## Syncing Billing Customer Data

In order to sync changes that have been made directly on Stripe (instead of via Frontier), Frontier has a background syncer that priodically syncs customer data to ensure that the data on Frontier is consistent with that on Stripe.
This is done by initialising a background worker that runs periodically. The frequency of this worker is configurable in the `refresh_interval` configuration parameter of Frontier

The working of the syncer is as follows:

1. Acquire a lock (s.mu) on the syncer's run (in case a previous syncer is still running) in Frontier to prevent race conditions when accessing shared resources.
2. Fetch customer details from Stripe using the provided customer ID.
3. Check if the customer is marked as deleted in Stripe. If so, and the local customer is active, disable the local customer.
4. Selective Updates (Active Customers Only): Process updates only if the local customer is active. Various customer fields are compared between the local customr object and the retrieved Stripe customer object such as:
- Tax data (using a custom comparison function)
- Phone number
- Email (if not empty)
- Name
- Currency
- Address details (city, country, address lines, postal code, state)
5. If any discrepancies are found in the customer data between Stripe and Frontier, the necessary changes are synced and saved to the database, and the lock acquired in (1) is released so that the syncer can run again in the next iteration
1 change: 1 addition & 0 deletions docs/sidebars.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ module.exports = {
},
items: [
"billing/introduction",
"billing/billing_customers"
],
},
{
Expand Down
2 changes: 1 addition & 1 deletion internal/api/v1beta1/billing_checkout.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ func (h Handler) CreateCheckout(ctx context.Context, request *frontierv1beta1.Cr
})
if err != nil {
if errors.Is(err, product.ErrPerSeatLimitReached) {
return nil, status.Errorf(codes.InvalidArgument, err.Error())
return nil, status.Errorf(codes.InvalidArgument, "%v", err)
}
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/v1beta1/billing_customer.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func (h Handler) CreateBillingAccount(ctx context.Context, request *frontierv1be
}, request.GetOffline())
if err != nil {
if errors.Is(err, customer.ErrActiveConflict) {
return nil, status.Errorf(codes.FailedPrecondition, err.Error())
return nil, status.Errorf(codes.FailedPrecondition, "%v", err)
}
return nil, err
}
Expand Down
4 changes: 2 additions & 2 deletions internal/api/v1beta1/deleter.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ type CascadeDeleter interface {

func (h Handler) DeleteProject(ctx context.Context, request *frontierv1beta1.DeleteProjectRequest) (*frontierv1beta1.DeleteProjectResponse, error) {
if err := h.deleterService.DeleteProject(ctx, request.GetId()); err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Errorf(codes.Internal, "%v", err)
}
return &frontierv1beta1.DeleteProjectResponse{}, nil
}

func (h Handler) DeleteOrganization(ctx context.Context, request *frontierv1beta1.DeleteOrganizationRequest) (*frontierv1beta1.DeleteOrganizationResponse, error) {
if err := h.deleterService.DeleteOrganization(ctx, request.GetId()); err != nil {
return nil, status.Errorf(codes.Internal, err.Error())
return nil, status.Errorf(codes.Internal, "%v", err)
}
return &frontierv1beta1.DeleteOrganizationResponse{}, nil
}
2 changes: 2 additions & 0 deletions internal/store/postgres/relation_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ func (r RelationRepository) Upsert(ctx context.Context, relationToCreate relatio
"object_namespace_name": relationToCreate.Object.Namespace,
"object_id": relationToCreate.Object.ID,
"relation_name": relationToCreate.RelationName,
"created_at": goqu.L("now()"),
"updated_at": goqu.L("now()"),
}).OnConflict(
goqu.DoUpdate("subject_namespace_name, subject_id, object_namespace_name, object_id, relation_name", goqu.Record{
"subject_namespace_name": relationToCreate.Subject.Namespace,
Expand Down
4 changes: 2 additions & 2 deletions internal/store/postgres/relation_repository_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ func (s *RelationRepositoryTestSuite) TestUpsert() {
"ID",
"CreatedAt",
"UpdatedAt")) {
s.T().Fatalf(cmp.Diff(got, tc.ExpectedRelation))
s.T().Fatal(cmp.Diff(got, tc.ExpectedRelation))
}
})
}
Expand Down Expand Up @@ -272,7 +272,7 @@ func (s *RelationRepositoryTestSuite) TestList() {
sort.Slice(tc.ExpectedRelations, func(i, j int) bool {
return tc.ExpectedRelations[i].RelationName < tc.ExpectedRelations[j].RelationName
})
s.T().Fatalf(cmp.Diff(got, tc.ExpectedRelations))
s.T().Fatal(cmp.Diff(got, tc.ExpectedRelations))
}
})
}
Expand Down
10 changes: 6 additions & 4 deletions internal/store/postgres/user_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,12 @@ func (r UserRepository) Create(ctx context.Context, usr user.User) (user.User, e
}

insertRow := goqu.Record{
"name": strings.ToLower(usr.Name),
"email": strings.ToLower(usr.Email),
"title": usr.Title,
"avatar": usr.Avatar,
"name": strings.ToLower(usr.Name),
"email": strings.ToLower(usr.Email),
"title": usr.Title,
"avatar": usr.Avatar,
"created_at": goqu.L("now()"),
"updated_at": goqu.L("now()"),
}
if usr.Metadata != nil {
marshaledMetadata, err := json.Marshal(usr.Metadata)
Expand Down
Loading
Loading