Skip to content

Commit 9a09c0a

Browse files
authored
feat(customcurrencies): support flat fee credit then invoice (#4794)
1 parent 1210e1a commit 9a09c0a

18 files changed

Lines changed: 1783 additions & 288 deletions

openmeter/billing/charges/flatfee/charge.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/openmeterio/openmeter/openmeter/billing"
1313
"github.com/openmeterio/openmeter/openmeter/billing/charges/meta"
1414
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis"
15+
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
1516
"github.com/openmeterio/openmeter/openmeter/currencies"
1617
"github.com/openmeterio/openmeter/openmeter/customer"
1718
"github.com/openmeterio/openmeter/openmeter/productcatalog"
@@ -149,6 +150,15 @@ func (c Charge) Validate() error {
149150
return models.NewNillableGenericValidationError(errors.Join(errs...))
150151
}
151152

153+
func (c Charge) ConvertCustomCurrencyOverageToFiat(creditCurrencyTotals totals.Totals) (meta.FiatOverage, error) {
154+
return meta.ConvertCustomCurrencyOverageToFiat(meta.ConvertCustomCurrencyOverageToFiatInput{
155+
Currency: c.Intent.GetCurrency(),
156+
CostBasisIntent: c.Intent.GetCostBasisIntent(),
157+
ResolvedCostBasis: c.State.ResolvedCostBasis,
158+
Totals: creditCurrencyTotals,
159+
})
160+
}
161+
152162
type Intent struct {
153163
meta.Intent
154164
IntentMutableFields `json:"intentMutableFields"`

openmeter/billing/charges/flatfee/handler.go

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/creditrealization"
1313
"github.com/openmeterio/openmeter/openmeter/billing/charges/models/ledgertransaction"
1414
"github.com/openmeterio/openmeter/openmeter/billing/models/totals"
15+
"github.com/openmeterio/openmeter/openmeter/currencies"
1516
"github.com/openmeterio/openmeter/pkg/currencyx"
1617
"github.com/openmeterio/openmeter/pkg/models"
1718
"github.com/openmeterio/openmeter/pkg/timeutil"
@@ -77,6 +78,85 @@ func (i OnInvoiceUsageAccruedInput) Validate() error {
7778
return models.NewNillableGenericValidationError(errors.Join(errs...))
7879
}
7980

81+
type OnCustomCurrencyOverageAccruedInput struct {
82+
Charge Charge `json:"charge"`
83+
Run RealizationRun `json:"run"`
84+
}
85+
86+
func (i OnCustomCurrencyOverageAccruedInput) CustomCurrency() currencies.Currency {
87+
return i.Charge.Intent.GetCurrency()
88+
}
89+
90+
func (i OnCustomCurrencyOverageAccruedInput) GetFiatCurrency() (*currencyx.FiatCurrency, error) {
91+
costBasisIntent := i.Charge.Intent.GetCostBasisIntent()
92+
if costBasisIntent == nil {
93+
return nil, fmt.Errorf("cost basis intent is required")
94+
}
95+
96+
return costBasisIntent.GetFiatCurrency()
97+
}
98+
99+
func (i OnCustomCurrencyOverageAccruedInput) GetCostBasis() (alpacadecimal.Decimal, error) {
100+
if i.Charge.State.ResolvedCostBasis == nil {
101+
return alpacadecimal.Decimal{}, fmt.Errorf("cost basis is not resolved")
102+
}
103+
104+
return i.Charge.State.ResolvedCostBasis.CostBasis, nil
105+
}
106+
107+
func (i OnCustomCurrencyOverageAccruedInput) GetCustomCurrencyAmountAccrued() alpacadecimal.Decimal {
108+
return i.Run.Totals.Total
109+
}
110+
111+
func (i OnCustomCurrencyOverageAccruedInput) Validate() error {
112+
var errs []error
113+
114+
if err := i.Charge.Validate(); err != nil {
115+
errs = append(errs, fmt.Errorf("charge: %w", err))
116+
}
117+
118+
if err := i.Run.Validate(); err != nil {
119+
errs = append(errs, fmt.Errorf("run: %w", err))
120+
}
121+
122+
if err := i.CustomCurrency().Validate(); err != nil {
123+
errs = append(errs, fmt.Errorf("custom currency: %w", err))
124+
}
125+
126+
if !i.CustomCurrency().IsCustom() {
127+
errs = append(errs, fmt.Errorf("custom currency must be custom typed currency"))
128+
}
129+
130+
if !i.GetCustomCurrencyAmountAccrued().IsPositive() {
131+
errs = append(errs, fmt.Errorf("amount must be positive"))
132+
}
133+
134+
if _, err := i.GetCostBasis(); err != nil {
135+
errs = append(errs, fmt.Errorf("cost basis: %w", err))
136+
}
137+
138+
return models.NewNillableGenericValidationError(errors.Join(errs...))
139+
}
140+
141+
type OnCustomCurrencyOverageAccruedResult struct {
142+
TransactionGroup ledgertransaction.GroupReference `json:"transactionGroup"`
143+
TotalFiatAmount alpacadecimal.Decimal `json:"totalFiatAmount"`
144+
}
145+
146+
func (r OnCustomCurrencyOverageAccruedResult) Validate() error {
147+
var errs []error
148+
149+
if err := r.TransactionGroup.Validate(); err != nil {
150+
errs = append(errs, fmt.Errorf("transaction group: %w", err))
151+
}
152+
153+
if r.TotalFiatAmount.IsNegative() {
154+
errs = append(errs, fmt.Errorf("total fiat amount cannot be negative"))
155+
}
156+
157+
return models.NewNillableGenericValidationError(errors.Join(errs...))
158+
}
159+
80160
type CorrectCreditAllocationsInput struct {
81161
Charge Charge `json:"charge"`
82162
BookedAt time.Time `json:"bookedAt"`
@@ -114,9 +194,10 @@ func (i CorrectCreditAllocationsInput) ValidateWith(currencyCalculator currencyx
114194
}
115195

116196
type PaymentEventInput struct {
117-
Charge Charge `json:"charge"`
118-
EventAt time.Time `json:"eventAt"`
119-
Amount alpacadecimal.Decimal `json:"amount"`
197+
Charge Charge `json:"charge"`
198+
Run RealizationRun `json:"run"`
199+
EventAt time.Time `json:"eventAt"`
200+
FiatAmount alpacadecimal.Decimal `json:"fiatAmount"`
120201
}
121202

122203
func (i PaymentEventInput) Validate() error {
@@ -126,12 +207,16 @@ func (i PaymentEventInput) Validate() error {
126207
errs = append(errs, fmt.Errorf("charge: %w", err))
127208
}
128209

210+
if err := i.Run.Validate(); err != nil {
211+
errs = append(errs, fmt.Errorf("run: %w", err))
212+
}
213+
129214
if i.EventAt.IsZero() {
130215
errs = append(errs, fmt.Errorf("event at is required"))
131216
}
132217

133-
if i.Amount.IsNegative() {
134-
errs = append(errs, fmt.Errorf("amount cannot be negative"))
218+
if !i.FiatAmount.IsPositive() {
219+
errs = append(errs, fmt.Errorf("fiat amount must be positive"))
135220
}
136221

137222
return models.NewNillableGenericValidationError(errors.Join(errs...))
@@ -149,6 +234,10 @@ type Handler interface {
149234
// OnFlatFeeStandardInvoiceUsageAccrued is called when the remaining usage is sent to the customer on a standard invoice.
150235
OnInvoiceUsageAccrued(ctx context.Context, input OnInvoiceUsageAccruedInput) (ledgertransaction.GroupReference, error)
151236

237+
// OnCustomCurrencyOverageAccrued is called when uncovered custom-currency flat-fee value is accrued in fiat.
238+
// This must be modeled as a credit purchase flow from the ledger point of view.
239+
OnCustomCurrencyOverageAccrued(ctx context.Context, input OnCustomCurrencyOverageAccruedInput) (OnCustomCurrencyOverageAccruedResult, error)
240+
152241
// OnCorrectCreditAllocations is called when a credit allocation needs to be corrected.
153242
OnCorrectCreditAllocations(ctx context.Context, input CorrectCreditAllocationsInput) (creditrealization.CreateCorrectionInputs, error)
154243

openmeter/billing/charges/flatfee/service/create.go

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"time"
77

8+
"github.com/alpacahq/alpacadecimal"
89
"github.com/samber/lo"
910

1011
"github.com/openmeterio/openmeter/openmeter/billing"
@@ -176,6 +177,27 @@ func buildFlatFeeGatheringLine(input buildFlatFeeGatheringLineInput) (billing.Ga
176177
return billing.GatheringLine{}, fmt.Errorf("cloning annotations: %w", err)
177178
}
178179

180+
lineName := lineIntent.Name
181+
linePrice := lo.FromPtr(rateableIntent.GetPrice())
182+
lineDiscounts := rateableIntent.GetRateCardDiscounts()
183+
if lineIntent.Currency.IsCustom() {
184+
if clonedAnnotations == nil {
185+
clonedAnnotations = models.Annotations{}
186+
}
187+
clonedAnnotations[billing.AnnotationKeyReason] = lo.ToPtr(billing.AnnotationValueReasonOveragePlaceholder)
188+
189+
lineName = "overage"
190+
if lineIntent.Name != "" {
191+
lineName = fmt.Sprintf("%s (overage)", lineIntent.Name)
192+
}
193+
194+
linePrice = lo.FromPtr(productcatalog.NewPriceFrom(productcatalog.FlatPrice{
195+
Amount: alpacadecimal.Zero,
196+
PaymentTerm: productcatalog.InArrearsPaymentTerm,
197+
}))
198+
lineDiscounts = billing.Discounts{}
199+
}
200+
179201
managedBy := lineIntent.ManagedBy
180202
if flatFee.Intent.HasOverrideLayer() {
181203
managedBy = billing.ManuallyManagedLine
@@ -190,15 +212,15 @@ func buildFlatFeeGatheringLine(input buildFlatFeeGatheringLineInput) (billing.Ga
190212
GatheringLineBase: billing.GatheringLineBase{
191213
ManagedResource: models.NewManagedResource(models.ManagedResourceInput{
192214
Namespace: flatFee.Namespace,
193-
Name: lineIntent.Name,
215+
Name: lineName,
194216
Description: lineIntent.Description,
195217
}),
196218

197219
Metadata: lineIntent.Metadata.Clone(),
198220
Annotations: clonedAnnotations,
199221
ManagedBy: managedBy,
200222

201-
Price: lo.FromPtr(rateableIntent.GetPrice()),
223+
Price: linePrice,
202224
FeatureKey: lo.FromPtr(lineIntent.FeatureKey),
203225

204226
Currency: invoiceCurrency,
@@ -213,7 +235,7 @@ func buildFlatFeeGatheringLine(input buildFlatFeeGatheringLineInput) (billing.Ga
213235
},
214236
}
215237

216-
gatheringLine.RateCardDiscounts = rateableIntent.GetRateCardDiscounts()
238+
gatheringLine.RateCardDiscounts = lineDiscounts
217239

218240
return gatheringLine, nil
219241
}

openmeter/billing/charges/flatfee/service/creditheninvoice.go

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import (
1919
"github.com/openmeterio/openmeter/pkg/clock"
2020
"github.com/openmeterio/openmeter/pkg/models"
2121
"github.com/openmeterio/openmeter/pkg/statelessx"
22-
"github.com/openmeterio/openmeter/pkg/timeutil"
2322
)
2423

2524
type CreditThenInvoiceStateMachine struct {
@@ -329,7 +328,6 @@ func (s *CreditThenInvoiceStateMachine) LineManualEdit(ctx context.Context, patc
329328

330329
return s.reconcileInvoicingState(ctx, reconcileInvoicingStateInput{
331330
Op: meta.PatchTypeLineManualEdit,
332-
Period: s.Charge.Intent.GetEffectiveServicePeriod(),
333331
Intent: s.Charge.Intent,
334332
OldAmountAfterProration: oldAmountAfterProration,
335333
NewAmountAfterProration: amountAfterProration,
@@ -372,7 +370,6 @@ func (s *CreditThenInvoiceStateMachine) applyPeriodPatch(patch periodPatch) (rec
372370

373371
return reconcileInvoicingStateInput{
374372
Op: patch.Op(),
375-
Period: intent.GetEffectiveServicePeriod(),
376373
Intent: intent,
377374
OldAmountAfterProration: s.Charge.State.AmountAfterProration,
378375
NewAmountAfterProration: amountAfterProration,
@@ -562,7 +559,6 @@ func (s *CreditThenInvoiceStateMachine) AreAllPaymentsSettled() bool {
562559

563560
type reconcileInvoicingStateInput struct {
564561
Op meta.PatchType
565-
Period timeutil.ClosedPeriod
566562
Intent flatfee.OverridableIntent
567563
OldAmountAfterProration alpacadecimal.Decimal
568564
NewAmountAfterProration alpacadecimal.Decimal
@@ -699,17 +695,16 @@ func (s *CreditThenInvoiceStateMachine) reconcileInvoicingState(ctx context.Cont
699695

700696
line.ID = *currentRun.LineID
701697

702-
// The invoice updater rebuilt the mutable standard line from the new
703-
// charge intent, but the charge realization run still describes the old
704-
// line amount and credit allocations. Reconcile them before handing the
705-
// updated line back to billing.
706-
result, err := s.Realizations.ReconcileStandardLineToIntent(ctx, flatfeerealizations.ReconcileStandardLineToIntentInput{
698+
// The mutable run still describes the previous effective intent.
699+
// Rerate and reconcile it first, then project the resulting charge state
700+
// onto the billing-owned line identity.
701+
result, err := s.Realizations.ReconcileRunToIntent(ctx, flatfeerealizations.ReconcileRunToIntentInput{
707702
Charge: s.Charge,
708703
Run: *currentRun,
709704
AllocateAt: flatfee.UsageBookedAt(s.Charge.Intent.GetEffectivePaymentTerm(), s.Charge.Intent.GetEffectiveServicePeriod()),
710705
})
711706
if err != nil {
712-
return fmt.Errorf("reconcile standard line to intent for %s flat-fee charge[%s]: %w", input.Op, s.Charge.ID, err)
707+
return fmt.Errorf("reconcile run to intent for %s flat-fee charge[%s]: %w", input.Op, s.Charge.ID, err)
713708
}
714709

715710
s.Charge.Realizations.CurrentRun = &result.Run

openmeter/billing/charges/flatfee/service/lineengine.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ func (e *LineEngine) BuildStandardLinesForGatheringPreview(ctx context.Context,
8181
return nil, fmt.Errorf("flat fee charge[%s] not found for gathering preview line[%s]", *stdLine.ChargeID, stdLine.ID)
8282
}
8383

84+
// Custom-currency gathering lines are scheduling placeholders. They
85+
// remain zero until an actual draft invoice realization has resolved
86+
// and persisted the charge's cost basis.
87+
if charge.Intent.GetCurrency().IsCustom() {
88+
if err := stdLine.Validate(); err != nil {
89+
return nil, fmt.Errorf("validating custom currency gathering preview line[%s]: %w", stdLine.ID, err)
90+
}
91+
92+
continue
93+
}
94+
8495
previewResult, err := e.service.realizations.BuildCreditThenInvoiceGatheringPreviewRun(flatfeerealizations.BuildCreditThenInvoiceGatheringPreviewRunInput{
8596
Charge: charge,
8697
LineID: stdLine.ID,
@@ -225,6 +236,14 @@ func (e *LineEngine) OnMutableInvoiceLinesEditedViaAPI(ctx context.Context, inpu
225236
)
226237
}
227238

239+
if charge.Intent.GetCurrency().IsCustom() {
240+
return nil, fmt.Errorf(
241+
"custom-currency flat fee line[%s] cannot be edited: %w",
242+
override.ExistingLine.GetID(),
243+
billing.ErrCannotUpdateChargeManagedLine,
244+
)
245+
}
246+
228247
stateMachine, err := e.service.newStateMachineForCharge(charge)
229248
if err != nil {
230249
return nil, fmt.Errorf("new state machine for flat fee charge[%s]: %w", charge.ID, err)
@@ -316,6 +335,14 @@ func (e *LineEngine) OnMutableInvoiceLinesEditedViaAPI(ctx context.Context, inpu
316335
)
317336
}
318337

338+
if charge.Intent.GetCurrency().IsCustom() {
339+
return billing.OnMutableInvoiceUpdateResult{}, fmt.Errorf(
340+
"custom-currency flat fee line[%s] cannot be deleted: %w",
341+
line.GetID(),
342+
billing.ErrCannotUpdateChargeManagedLine,
343+
)
344+
}
345+
319346
if err := validateManualDeleteLine(charge, line); err != nil {
320347
return billing.OnMutableInvoiceUpdateResult{}, err
321348
}
@@ -405,6 +432,14 @@ func (e *LineEngine) validateManualUpdateLineViaAPI(ctx context.Context, overrid
405432
)
406433
}
407434

435+
if charge.Intent.GetCurrency().IsCustom() {
436+
return fmt.Errorf(
437+
"custom-currency flat fee line[%s] cannot be edited: %w",
438+
override.ExistingLine.GetID(),
439+
billing.ErrCannotUpdateChargeManagedLine,
440+
)
441+
}
442+
408443
if err := override.ExistingLine.AsInvoiceLine().Type().Require(billing.InvoiceLineTypeStandard, billing.InvoiceLineTypeGathering); err != nil {
409444
return fmt.Errorf("flat fee line[%s]: unsupported line type for API edit: %s", override.ExistingLine.GetID(), override.ExistingLine.AsInvoiceLine().Type())
410445
}
@@ -446,6 +481,14 @@ func (e *LineEngine) validateManualDeleteLineViaAPI(ctx context.Context, line bi
446481
)
447482
}
448483

484+
if charge.Intent.GetCurrency().IsCustom() {
485+
return fmt.Errorf(
486+
"custom-currency flat fee line[%s] cannot be deleted: %w",
487+
line.GetID(),
488+
billing.ErrCannotUpdateChargeManagedLine,
489+
)
490+
}
491+
449492
return validateManualDeleteLine(charge, line)
450493
}
451494

0 commit comments

Comments
 (0)