Skip to content
Open
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
42 changes: 42 additions & 0 deletions openmeter/billing/charges/chargecreditpurchase.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package charges

import (
"context"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -374,3 +375,44 @@ func (s CreditPurchaseState) Validate() error {

return errors.Join(errs...)
}

// CreditPurchaseHandler is the interface for handling credit purchase charges.
// It is used to handle the different types of credit purchase charges (promotional, external, invoice).
//
// Promotional credit purchases are handled by the OnPromotionalCreditPurchase method only.
//
// Cost basis > 0 credit purchases are handled by the OnCreditPurchase method, which is the initial call.
// Happy path:
// - OnCreditPurchase is called
// - OnCreditPurchasePaymentAuthorized is called
// - OnCreditPurchasePaymentSettled is called
//
// Failed payment can occur either after the OnCreditPurchase or after the OnCreditPurchasePaymentAuthorized call.

type CreditPurchaseHandler interface {
// Promotional credit handler methods (cost basis == 0)
// ----------------------------------------------------

// OnPromotionalCreditPurchase is called when a promotional credit purchase is created (e.g. costbasis is 0)
// For promotional credit purchases we don't call any of the payment handler methods.
OnPromotionalCreditPurchase(ctx context.Context, charge CreditPurchaseCharge) ([]CreditRealizationCreateInput, error)

// Credit purchase handler methods (cost basis > 0)
// ------------------------------------------------

// OnCreditPurchase is called when a credit purchase is initiated that is going to be settled by
// a payment (either external or a standard invoice)
// Initial call
OnCreditPurchase(ctx context.Context, charge CreditPurchaseCharge) ([]CreditRealizationCreateInput, error)

// OnCreditPurchasePaymentAuthorized is called when a credit purchase payment is authorized for a credit
// purchase.
OnCreditPurchasePaymentAuthorized(ctx context.Context, charge CreditPurchaseCharge) (LedgerTransactionGroupReference, error)

// OnCreditPurchasePaymentSettled is called when a credit purchase payment is settled for a credit
// purchase.
OnCreditPurchasePaymentSettled(ctx context.Context, charge CreditPurchaseCharge) (LedgerTransactionGroupReference, error)

// OnCreditPurchasePaymentUncollectible is called when a credit purchase payment is uncollectible
OnCreditPurchasePaymentUncollectible(ctx context.Context, charge CreditPurchaseCharge) (LedgerTransactionGroupReference, error)
}
24 changes: 24 additions & 0 deletions openmeter/billing/charges/chargeflatfee.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package charges

import (
"context"
"errors"
"fmt"
"slices"
Expand Down Expand Up @@ -107,3 +108,26 @@ func (s FlatFeeState) Validate() error {

return errors.Join(errs...)
}

type OnFlatFeeAssignedToInvoiceInput struct {
Charge FlatFeeCharge `json:"charge"`
PreTaxTotalAmount alpacadecimal.Decimal `json:"totalAmount"`
}
Comment on lines +112 to +115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

JSON tag "totalAmount" doesn't match the field name PreTaxTotalAmount.

The Go field name communicates "pre-tax" semantics, but the JSON tag just says "totalAmount". This mismatch could confuse consumers of the serialized form — they'd see totalAmount and might not realize it's specifically the pre-tax amount. Consider aligning them, e.g., json:"preTaxTotalAmount".

Proposed fix
 type OnFlatFeeAssignedToInvoiceInput struct {
 	Charge            FlatFeeCharge         `json:"charge"`
-	PreTaxTotalAmount alpacadecimal.Decimal `json:"totalAmount"`
+	PreTaxTotalAmount alpacadecimal.Decimal `json:"preTaxTotalAmount"`
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
type OnFlatFeeAssignedToInvoiceInput struct {
Charge FlatFeeCharge `json:"charge"`
PreTaxTotalAmount alpacadecimal.Decimal `json:"totalAmount"`
}
type OnFlatFeeAssignedToInvoiceInput struct {
Charge FlatFeeCharge `json:"charge"`
PreTaxTotalAmount alpacadecimal.Decimal `json:"preTaxTotalAmount"`
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/chargeflatfee.go` around lines 112 - 115, The JSON
tag for PreTaxTotalAmount on the OnFlatFeeAssignedToInvoiceInput struct is
inconsistent (json:"totalAmount") — update the tag to accurately reflect the
field's semantics (for example json:"preTaxTotalAmount") so serialized JSON
matches the Go field name; modify the struct definition for
OnFlatFeeAssignedToInvoiceInput and its PreTaxTotalAmount field to use the new
tag and run tests/linting to ensure no other code relies on the old
"totalAmount" key.


type FlatFeeHandler interface {
// OnFlatFeeAssignedToInvoice is called when a flat fee is being assigned to an invoice
OnFlatFeeAssignedToInvoice(ctx context.Context, input OnFlatFeeAssignedToInvoiceInput) ([]CreditRealizationCreateInput, error)

// OnFlatFeeLineDeleted is called when a flat fee line is deleted before the invoice has been issued, should revert
// the credit realizations that were created.
OnFlatFeeLineDeleted(ctx context.Context, charge FlatFeeCharge) ([]ReversedCreditRealization, error)

// OnFlatFeePaymentAuthorized is called when a flat fee payment is authorized
OnFlatFeePaymentAuthorized(ctx context.Context, charge FlatFeeCharge) (LedgerTransactionGroupReference, error)

// OnFlatFeePaymentSettled is called when a flat fee payment is settled
OnFlatFeePaymentSettled(ctx context.Context, charge FlatFeeCharge) (LedgerTransactionGroupReference, error)

// OnFlatFeePaymentUncollectible is called when a flat fee payment is uncollectible
OnFlatFeePaymentUncollectible(ctx context.Context, charge FlatFeeCharge) (LedgerTransactionGroupReference, error)
}
119 changes: 119 additions & 0 deletions openmeter/billing/charges/credits.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package charges

import (
"errors"
"fmt"

"github.com/alpacahq/alpacadecimal"

"github.com/openmeterio/openmeter/pkg/models"
"github.com/openmeterio/openmeter/pkg/timeutil"
)

type CreditRealizationCreateInput struct {
Annotations models.Annotations `json:"annotations"`
ServicePeriod timeutil.ClosedPeriod `json:"servicePeriod"`

// TODO: let's add ledger transaction id(s) here

Amount alpacadecimal.Decimal `json:"amount"`
}

func (i CreditRealizationCreateInput) Validate() error {
var errs []error

if err := i.ServicePeriod.Validate(); err != nil {
errs = append(errs, fmt.Errorf("service period: %w", err))
}

if i.Amount.IsNegative() {
errs = append(errs, fmt.Errorf("amount must be positive"))
}
Comment on lines +29 to +31
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error message says "must be positive" but zero is allowed.

The check i.Amount.IsNegative() permits zero amounts, but the error message "amount must be positive" implies zero would be rejected. If zero is intentionally valid, the message should say "amount cannot be negative" (or similar) to match the actual behavior.

Proposed fix
 	if i.Amount.IsNegative() {
-		errs = append(errs, fmt.Errorf("amount must be positive"))
+		errs = append(errs, fmt.Errorf("amount cannot be negative"))
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if i.Amount.IsNegative() {
errs = append(errs, fmt.Errorf("amount must be positive"))
}
if i.Amount.IsNegative() {
errs = append(errs, fmt.Errorf("amount cannot be negative"))
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@openmeter/billing/charges/credits.go` around lines 29 - 31, Update the error
text to reflect that zero is allowed: where the code checks
i.Amount.IsNegative() and appends fmt.Errorf("amount must be positive"), change
the message to something like "amount cannot be negative" so the error
accurately describes the validation (reference the i.Amount.IsNegative() check
and the appended errs entry).


return errors.Join(errs...)
}

type CreditRealization struct {
models.NamespacedID
models.ManagedModel
CreditRealizationCreateInput

// AllocatedToStandardInvoiceRealizationID is the standard invoice realization ID that the credit was allocated to.
// If nil, the credit is not allocated to any invoice line (e.g. line is still in gathering,
// credit_only mode without invoicing, etc.)
AllocatedToStandardInvoiceRealizationID *string `json:"allocatedToStandardInvoiceRealizationID"`
}

func (r CreditRealization) Validate() error {
var errs []error

if err := r.CreditRealizationCreateInput.Validate(); err != nil {
errs = append(errs, fmt.Errorf("credit realization input: %w", err))
}

if r.AllocatedToStandardInvoiceRealizationID != nil && *r.AllocatedToStandardInvoiceRealizationID == "" {
errs = append(errs, fmt.Errorf("allocated to standard invoice realization ID must be set"))
}

return errors.Join(errs...)
}

type CreditRealizations []CreditRealization

func (r CreditRealizations) Validate() error {
var errs []error

for idx, realization := range r {
if err := realization.Validate(); err != nil {
errs = append(errs, fmt.Errorf("credit realization[%d]: %w", idx, err))
}
}

return errors.Join(errs...)
}

func (r CreditRealizations) RealizedUnsettledAmount() alpacadecimal.Decimal {
sum := alpacadecimal.Zero
for _, realization := range r {
sum = sum.Add(realization.Amount)
}
return sum
}

func (r CreditRealizations) LastRealizedPeriod() *timeutil.ClosedPeriod {
// TODO: we might want to filter for period realizations only
if len(r) == 0 {
return nil
}

lastRealizedPeriod := r[0].ServicePeriod
for _, realization := range r {
if realization.ServicePeriod.To.After(lastRealizedPeriod.To) {
lastRealizedPeriod = realization.ServicePeriod
}
}

return &lastRealizedPeriod
}

type ReversedCreditRealization struct {
RealizationID models.NamespacedID `json:"realizationID"`

TransactionGroupReferences []LedgerTransactionGroupReference `json:"transactionGroupReference"`
}

func (r ReversedCreditRealization) Validate() error {
var errs []error

if err := r.RealizationID.Validate(); err != nil {
errs = append(errs, fmt.Errorf("realization ID: %w", err))
}

for idx, transactionGroupReference := range r.TransactionGroupReferences {
if err := transactionGroupReference.Validate(); err != nil {
errs = append(errs, fmt.Errorf("transaction group reference[%d]: %w", idx, err))
}
}

return errors.Join(errs...)
}
24 changes: 24 additions & 0 deletions openmeter/billing/charges/ledger.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package charges

import (
"errors"
"fmt"

"github.com/openmeterio/openmeter/pkg/models"
)

// LedgerTransactionGroupReference is a reference to a ledger transaction group.
// It is used to track payment settlement transactions.
type LedgerTransactionGroupReference struct {
TransactionGroupID models.NamespacedID `json:"transactionGroupID"`
}

func (r LedgerTransactionGroupReference) Validate() error {
var errs []error

if err := r.TransactionGroupID.Validate(); err != nil {
errs = append(errs, fmt.Errorf("transaction group ID: %w", err))
}

return errors.Join(errs...)
}
Loading