Skip to content

Commit

Permalink
feat(invoice): create
Browse files Browse the repository at this point in the history
  • Loading branch information
hekike committed Dec 18, 2024
1 parent 6c5bef5 commit dffeffa
Show file tree
Hide file tree
Showing 9 changed files with 599 additions and 331 deletions.
37 changes: 37 additions & 0 deletions openmeter/app/stripe/entity/app/calculator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package appstripeentityapp

import (
"fmt"

"github.com/alpacahq/alpacadecimal"
"github.com/openmeterio/openmeter/pkg/currencyx"
)

// NewStripeCalculator creates a new StripeCalculator.
func NewStripeCalculator(currency currencyx.Code) (StripeCalculator, error) {
calculator, err := currencyx.Code(currency).Calculator()
if err != nil {
return StripeCalculator{}, fmt.Errorf("failed to get stripe calculator: %w", err)
}

return StripeCalculator{
calculator: calculator,
multiplier: alpacadecimal.NewFromInt(10).Pow(alpacadecimal.NewFromInt(int64(calculator.Def.Subunits))),
}, nil
}

// StripeCalculator provides a currency calculator object.
type StripeCalculator struct {
calculator currencyx.Calculator
multiplier alpacadecimal.Decimal
}

// RoundToAmount rounds the amount to the precision of the Stripe currency in Stripe amount.
func (c StripeCalculator) RoundToAmount(amount alpacadecimal.Decimal) int64 {
return amount.Mul(c.multiplier).Round(0).IntPart()
}

// IsInteger checks if the amount is an integer in the Stripe currency.
func (c StripeCalculator) IsInteger(amount alpacadecimal.Decimal) bool {
return amount.Mul(c.multiplier).IsInteger()
}
107 changes: 71 additions & 36 deletions openmeter/app/stripe/entity/app/invoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package appstripeentityapp
import (
"context"
"fmt"
"sort"

appentitybase "github.com/openmeterio/openmeter/openmeter/app/entity/base"
stripeclient "github.com/openmeterio/openmeter/openmeter/app/stripe/client"
Expand All @@ -13,6 +14,8 @@ import (
"github.com/stripe/stripe-go/v80"
)

const invoiceLineMetadataID = "om_line_id"

var _ billing.InvoicingApp = (*App)(nil)

// ValidateInvoice validates the invoice for the app
Expand Down Expand Up @@ -73,6 +76,12 @@ func (a App) FinalizeInvoice(ctx context.Context, invoice billing.Invoice) (*bil

// createInvoice creates the invoice for the app
func (a App) createInvoice(ctx context.Context, invoice billing.Invoice) (*billing.UpsertInvoiceResult, error) {
// Get the currency calculator
calculator, err := NewStripeCalculator(invoice.Currency)
if err != nil {
return nil, fmt.Errorf("failed to get currency calculator: %w", err)
}

customerID := customerentity.CustomerID{
Namespace: invoice.Namespace,
ID: invoice.Customer.CustomerID,
Expand Down Expand Up @@ -112,29 +121,26 @@ func (a App) createInvoice(ctx context.Context, invoice billing.Invoice) (*billi
// Add lines to the Stripe invoice
var stripeLineAdd []*stripe.InvoiceAddLinesLineParams

// Walk the tree
var queue []*billing.Line
lines := invoice.FlattenLinesByID()

// Feed the queue with the root lines
invoice.Lines.ForEach(func(lines []*billing.Line) {
queue = append(queue, lines...)
})
// Check if we have any non integer amount or quantity
// We use this to determinate if we add alreay calculated total or per unit amount and quantity to the Stripe line item
isInteger := true

// We collect the line IDs to match them with the Stripe line items from the response
var lineIDs []string

for len(queue) > 0 {
line := queue[0]
queue = queue[1:]
for _, line := range lines {
if line.Type != billing.InvoiceLineTypeFee {
continue
}

// Add children to the queue
childrens := line.Children.OrEmpty()
for _, l := range childrens {
queue = append(queue, l)
if !calculator.IsInteger(line.FlatFee.PerUnitAmount) || !line.FlatFee.Quantity.IsInteger() {
isInteger = false
break
}
}

// Only add line items for leaf nodes
if len(childrens) > 0 {
// Walk the tree
for _, line := range lines {
if line.Type != billing.InvoiceLineTypeFee {
continue
}

Expand All @@ -146,42 +152,71 @@ func (a App) createInvoice(ctx context.Context, invoice billing.Invoice) (*billi
// Add discounts
line.Discounts.ForEach(func(discounts []billing.LineDiscount) {
for _, discount := range discounts {
lineIDs = append(lineIDs, line.ID)
name := line.Name
if discount.Description != nil {
name = fmt.Sprintf("%s (%s)", name, *discount.Description)
}

stripeLineAdd = append(stripeLineAdd, &stripe.InvoiceAddLinesLineParams{
Description: discount.Description,
Amount: lo.ToPtr(-discount.Amount.GetFixed()),
Description: lo.ToPtr(name),
Amount: lo.ToPtr(-calculator.RoundToAmount(discount.Amount)),
Quantity: lo.ToPtr(int64(1)),
Period: period,
Metadata: map[string]string{
// TODO: is a discount ID a line id?
invoiceLineMetadataID: discount.ID,
},
})
}
})

// Add line item
switch line.Type {
case billing.InvoiceLineTypeFee:
lineIDs = append(lineIDs, line.ID)
// Add line
name := line.Name
if line.Description != nil {
name = fmt.Sprintf("%s (%s)", name, *line.Description)
}

// If the per unit amount and quantity can be represented in stripe as integer we add the line item
if isInteger {
stripeLineAdd = append(stripeLineAdd, &stripe.InvoiceAddLinesLineParams{
Description: line.Description,
Amount: lo.ToPtr(line.Totals.Amount.GetFixed()),
Quantity: lo.ToPtr(int64(1)),
Description: lo.ToPtr(name),
Amount: lo.ToPtr(calculator.RoundToAmount(line.FlatFee.PerUnitAmount)),
Quantity: lo.ToPtr(line.FlatFee.Quantity.IntPart()),
Period: period,
Metadata: map[string]string{
invoiceLineMetadataID: line.ID,
},
})
case billing.InvoiceLineTypeUsageBased:
lineIDs = append(lineIDs, line.ID)

} else {
// Otherwise we add the calcualted total with with quantity one
stripeLineAdd = append(stripeLineAdd, &stripe.InvoiceAddLinesLineParams{
Description: line.Description,
Amount: lo.ToPtr(line.Totals.Amount.GetFixed()),
Quantity: lo.ToPtr(line.UsageBased.Quantity.GetFixed()),
Description: lo.ToPtr(name),
Amount: lo.ToPtr(calculator.RoundToAmount(line.Totals.Amount)),
Quantity: lo.ToPtr(int64(1)),
Period: period,
Metadata: map[string]string{
invoiceLineMetadataID: line.ID,
},
})
default:
return result, fmt.Errorf("unsupported line type: %s", line.Type)
}
}

// Sort the line items by description
sort.Slice(stripeLineAdd, func(i, j int) bool {
descA := lo.FromPtrOr(stripeLineAdd[i].Description, "")
descB := lo.FromPtrOr(stripeLineAdd[j].Description, "")

return descA < descB
})

// We collect the line IDs to match them with the Stripe line items from the response
var lineIDs []string

for _, stripeLine := range stripeLineAdd {
lineIDs = append(lineIDs, stripeLine.Metadata[invoiceLineMetadataID])
stripeLine.Metadata = nil
}

// Add Stripe line items to the Stripe invoice
stripeInvoice, err = stripeClient.AddInvoiceLines(ctx, stripeclient.AddInvoiceLinesInput{
StripeInvoiceID: stripeInvoice.ID,
Expand Down
6 changes: 3 additions & 3 deletions test/app/stripe/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ func TestAppStripe(t *testing.T) {
testSuite.TestCreateCheckoutSession(ctx, t)
})

t.Run("TestCreateInvoice", func(t *testing.T) {
testSuite.TestCreateInvoice(ctx, t)
})
// t.Run("TestCreateInvoice", func(t *testing.T) {
// testSuite.TestCreateInvoice(ctx, t)
// })
})
}
65 changes: 6 additions & 59 deletions test/app/stripe/appstripe.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ func (s *AppHandlerTestSuite) TestUninstall(ctx context.Context, t *testing.T) {
func (s *AppHandlerTestSuite) TestCustomerData(ctx context.Context, t *testing.T) {
s.setupNamespace(t)

testApp, customer, _ := s.setupAppWithCustomer(ctx, t)
testApp, customer, _, err := s.Env.Fixture().setupAppWithCustomer(ctx, s.namespace)
require.NoError(t, err, "setup fixture must not return error")

// Get customer data
customerData, err := testApp.GetCustomerData(ctx, appentity.GetAppInstanceCustomerDataInput{
Expand Down Expand Up @@ -208,7 +209,8 @@ func (s *AppHandlerTestSuite) TestCustomerData(ctx context.Context, t *testing.T

// TestCustomerValidate tests stripe app behavior when validating a customer
func (s *AppHandlerTestSuite) TestCustomerValidate(ctx context.Context, t *testing.T) {
app, customer, _ := s.setupAppWithCustomer(ctx, t)
app, customer, _, err := s.Env.Fixture().setupAppWithCustomer(ctx, s.namespace)
require.NoError(t, err, "setup fixture must not return error")

// Create customer without stripe data
customerWithoutStripeData, err := s.Env.Customer().CreateCustomer(ctx, customerentity.CreateCustomerInput{
Expand Down Expand Up @@ -246,7 +248,8 @@ func (s *AppHandlerTestSuite) TestCustomerValidate(ctx context.Context, t *testi

// TestCreateCheckoutSession tests stripe app behavior when creating a new checkout session
func (s *AppHandlerTestSuite) TestCreateCheckoutSession(ctx context.Context, t *testing.T) {
app, customer, _ := s.setupAppWithCustomer(ctx, t)
app, customer, _, err := s.Env.Fixture().setupAppWithCustomer(ctx, s.namespace)
require.NoError(t, err, "setup fixture must not return error")

// Create checkout session
appID := app.GetID()
Expand Down Expand Up @@ -300,59 +303,3 @@ func (s *AppHandlerTestSuite) TestCreateCheckoutSession(ctx context.Context, t *

require.ErrorIs(t, err, customerentity.NotFoundError{CustomerID: customerIdNotFound}, "Create checkout session must return customer not found error")
}

func (s *AppHandlerTestSuite) setupAppWithCustomer(ctx context.Context, t *testing.T) (appentity.App, *customerentity.Customer, appstripeentity.CustomerData) {
app := s.setupApp(ctx, t)
customer := s.setupCustomer(ctx, t)
data := s.setupAppCustomerData(ctx, t, app, customer)

return app, customer, data
}

// Create a stripe app first
func (s *AppHandlerTestSuite) setupApp(ctx context.Context, t *testing.T) appentity.App {
app, err := s.Env.App().InstallMarketplaceListingWithAPIKey(ctx, appentity.InstallAppWithAPIKeyInput{
MarketplaceListingID: appentity.MarketplaceListingID{
Type: appentitybase.AppTypeStripe,
},

Namespace: s.namespace,
APIKey: TestStripeAPIKey,
})

require.NoError(t, err, "Create stripe app must not return error")
require.NotNil(t, app, "Create stripe app must return app")

return app
}

// Create test customers
func (s *AppHandlerTestSuite) setupCustomer(ctx context.Context, t *testing.T) *customerentity.Customer {
customer, err := s.Env.Customer().CreateCustomer(ctx, customerentity.CreateCustomerInput{
Namespace: s.namespace,
CustomerMutate: customerentity.CustomerMutate{
Name: "Test Customer",
},
})

require.NoError(t, err, "Create customer must not return error")
require.NotNil(t, customer, "Create customer must return customer")

return customer
}

// Add customer data to the app
func (s *AppHandlerTestSuite) setupAppCustomerData(ctx context.Context, t *testing.T, app appentity.App, customer *customerentity.Customer) appstripeentity.CustomerData {
data := appstripeentity.CustomerData{
StripeCustomerID: "cus_123",
}

err := app.UpsertCustomerData(ctx, appentity.UpsertAppInstanceCustomerDataInput{
CustomerID: customer.GetID(),
Data: data,
})

require.NoError(t, err, "Upsert customer data must not return error")

return data
}
94 changes: 94 additions & 0 deletions test/app/stripe/fixture.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package appstripe

import (
"context"
"fmt"

"github.com/openmeterio/openmeter/openmeter/app"
appentity "github.com/openmeterio/openmeter/openmeter/app/entity"
appentitybase "github.com/openmeterio/openmeter/openmeter/app/entity/base"
appstripeentity "github.com/openmeterio/openmeter/openmeter/app/stripe/entity"
"github.com/openmeterio/openmeter/openmeter/customer"
customerentity "github.com/openmeterio/openmeter/openmeter/customer/entity"
)

func NewFixture(app app.Service, customer customer.Service) *Fixture {
return &Fixture{
app: app,
customer: customer,
}
}

type Fixture struct {
app app.Service
customer customer.Service
}

// setupAppWithCustomer creates a stripe app and a customer with customer data
func (s *Fixture) setupAppWithCustomer(ctx context.Context, namespace string) (appentity.App, *customerentity.Customer, appstripeentity.CustomerData, error) {
app, err := s.setupApp(ctx, namespace)
if err != nil {
return nil, nil, appstripeentity.CustomerData{}, fmt.Errorf("setup app failed: %w", err)
}

customer, err := s.setupCustomer(ctx, namespace)
if err != nil {
return nil, nil, appstripeentity.CustomerData{}, fmt.Errorf("setup customer failed: %w", err)
}

data, err := s.setupAppCustomerData(ctx, app, customer)
if err != nil {
return nil, nil, appstripeentity.CustomerData{}, fmt.Errorf("setup app customer data failed: %w", err)
}

return app, customer, data, nil
}

// Create a stripe app first
func (s *Fixture) setupApp(ctx context.Context, namespace string) (appentity.App, error) {
app, err := s.app.InstallMarketplaceListingWithAPIKey(ctx, appentity.InstallAppWithAPIKeyInput{
MarketplaceListingID: appentity.MarketplaceListingID{
Type: appentitybase.AppTypeStripe,
},

Namespace: namespace,
APIKey: TestStripeAPIKey,
})
if err != nil {
return nil, fmt.Errorf("install stripe app failed: %w", err)
}

return app, nil
}

// Create test customers
func (s *Fixture) setupCustomer(ctx context.Context, namespace string) (*customerentity.Customer, error) {
customer, err := s.customer.CreateCustomer(ctx, customerentity.CreateCustomerInput{
Namespace: namespace,
CustomerMutate: customerentity.CustomerMutate{
Name: "Test Customer",
},
})
if err != nil {
return nil, fmt.Errorf("create customer failed: %w", err)
}

return customer, nil
}

// Add customer data to the app
func (s *Fixture) setupAppCustomerData(ctx context.Context, app appentity.App, customer *customerentity.Customer) (appstripeentity.CustomerData, error) {
data := appstripeentity.CustomerData{
StripeCustomerID: "cus_123",
}

err := app.UpsertCustomerData(ctx, appentity.UpsertAppInstanceCustomerDataInput{
CustomerID: customer.GetID(),
Data: data,
})
if err != nil {
return data, fmt.Errorf("Upsert customer data failed: %w", err)
}

return data, nil
}
Loading

0 comments on commit dffeffa

Please sign in to comment.