|
| 1 | +package creditpurchase |
| 2 | + |
| 3 | +import ( |
| 4 | + "errors" |
| 5 | + "fmt" |
| 6 | + |
| 7 | + "github.com/alpacahq/alpacadecimal" |
| 8 | + "github.com/samber/lo" |
| 9 | + |
| 10 | + "github.com/openmeterio/openmeter/openmeter/billing" |
| 11 | + "github.com/openmeterio/openmeter/openmeter/billing/charges/models/costbasis" |
| 12 | + "github.com/openmeterio/openmeter/openmeter/billing/models/stddetailedline" |
| 13 | + "github.com/openmeterio/openmeter/openmeter/billing/models/totals" |
| 14 | + "github.com/openmeterio/openmeter/openmeter/currencies" |
| 15 | + "github.com/openmeterio/openmeter/openmeter/productcatalog" |
| 16 | + "github.com/openmeterio/openmeter/pkg/currencyx" |
| 17 | + "github.com/openmeterio/openmeter/pkg/models" |
| 18 | + "github.com/openmeterio/openmeter/pkg/timeutil" |
| 19 | +) |
| 20 | + |
| 21 | +const CreditPurchaseChildUniqueReferenceID = "credit-purchase" |
| 22 | + |
| 23 | +type NewDetailedLineInput struct { |
| 24 | + Namespace string |
| 25 | + InvoiceID string |
| 26 | + Name string |
| 27 | + ServicePeriod timeutil.ClosedPeriod |
| 28 | + |
| 29 | + CustomCurrency currencies.Currency |
| 30 | + CustomCurrencyAmount alpacadecimal.Decimal |
| 31 | + ResolvedCostBasis *costbasis.State |
| 32 | + |
| 33 | + FiatCurrency *currencyx.FiatCurrency |
| 34 | + FiatAmount alpacadecimal.Decimal |
| 35 | +} |
| 36 | + |
| 37 | +func (i NewDetailedLineInput) Validate() error { |
| 38 | + var errs []error |
| 39 | + |
| 40 | + if i.Namespace == "" { |
| 41 | + errs = append(errs, errors.New("namespace is required")) |
| 42 | + } |
| 43 | + |
| 44 | + if i.InvoiceID == "" { |
| 45 | + errs = append(errs, errors.New("invoice ID is required")) |
| 46 | + } |
| 47 | + |
| 48 | + if i.Name == "" { |
| 49 | + errs = append(errs, errors.New("name is required")) |
| 50 | + } |
| 51 | + |
| 52 | + if err := i.ServicePeriod.Validate(); err != nil { |
| 53 | + errs = append(errs, fmt.Errorf("service period: %w", err)) |
| 54 | + } |
| 55 | + |
| 56 | + if err := i.CustomCurrency.Validate(); err != nil { |
| 57 | + errs = append(errs, fmt.Errorf("custom currency: %w", err)) |
| 58 | + } |
| 59 | + |
| 60 | + if !i.CustomCurrency.IsCustom() { |
| 61 | + errs = append(errs, errors.New("custom currency must be custom")) |
| 62 | + } |
| 63 | + |
| 64 | + if i.CustomCurrencyAmount.IsNegative() { |
| 65 | + errs = append(errs, errors.New("custom currency amount must be positive or zero")) |
| 66 | + } |
| 67 | + |
| 68 | + if i.ResolvedCostBasis == nil { |
| 69 | + errs = append(errs, errors.New("resolved cost basis is required")) |
| 70 | + } else if err := i.ResolvedCostBasis.Validate(); err != nil { |
| 71 | + errs = append(errs, fmt.Errorf("resolved cost basis: %w", err)) |
| 72 | + } |
| 73 | + |
| 74 | + if err := i.FiatCurrency.Validate(); err != nil { |
| 75 | + errs = append(errs, fmt.Errorf("fiat currency: %w", err)) |
| 76 | + } |
| 77 | + |
| 78 | + if i.FiatAmount.IsNegative() { |
| 79 | + errs = append(errs, errors.New("fiat amount must be positive or zero")) |
| 80 | + } |
| 81 | + |
| 82 | + if i.FiatCurrency != nil && !i.FiatCurrency.IsRoundedToPrecision(i.FiatAmount) { |
| 83 | + errs = append(errs, errors.New("fiat amount must be rounded to fiat currency precision")) |
| 84 | + } |
| 85 | + |
| 86 | + return models.NewNillableGenericValidationError(errors.Join(errs...)) |
| 87 | +} |
| 88 | + |
| 89 | +// NewDetailedLine represents a custom-currency purchase as a fiat invoice |
| 90 | +// line. The quantity preserves the custom-currency amount, the unit amount |
| 91 | +// preserves the exact cost basis, and totals preserve the already-rounded |
| 92 | +// fiat outcome. |
| 93 | +func NewDetailedLine(input NewDetailedLineInput) (billing.DetailedLine, error) { |
| 94 | + if err := input.Validate(); err != nil { |
| 95 | + return billing.DetailedLine{}, err |
| 96 | + } |
| 97 | + |
| 98 | + detailedLine := billing.DetailedLine{ |
| 99 | + DetailedLineBase: billing.DetailedLineBase{ |
| 100 | + Base: stddetailedline.Base{ |
| 101 | + ManagedResource: models.NewManagedResource(models.ManagedResourceInput{ |
| 102 | + Namespace: input.Namespace, |
| 103 | + Name: input.Name, |
| 104 | + }), |
| 105 | + Category: stddetailedline.CategoryRegular, |
| 106 | + ChildUniqueReferenceID: CreditPurchaseChildUniqueReferenceID, |
| 107 | + Index: lo.ToPtr(0), |
| 108 | + PaymentTerm: productcatalog.InArrearsPaymentTerm, |
| 109 | + ServicePeriod: input.ServicePeriod, |
| 110 | + PerUnitAmount: input.ResolvedCostBasis.CostBasis, |
| 111 | + Quantity: input.CustomCurrency.RoundToPrecision(input.CustomCurrencyAmount), |
| 112 | + Totals: totals.Totals{ |
| 113 | + Amount: input.FiatAmount, |
| 114 | + Total: input.FiatAmount, |
| 115 | + }, |
| 116 | + }, |
| 117 | + InvoiceID: input.InvoiceID, |
| 118 | + }, |
| 119 | + } |
| 120 | + |
| 121 | + if err := detailedLine.Validate(); err != nil { |
| 122 | + return billing.DetailedLine{}, fmt.Errorf("detailed line: %w", err) |
| 123 | + } |
| 124 | + |
| 125 | + if err := detailedLine.Totals.Validate(); err != nil { |
| 126 | + return billing.DetailedLine{}, fmt.Errorf("totals: %w", err) |
| 127 | + } |
| 128 | + |
| 129 | + calculatedAmount := input.FiatCurrency.RoundToPrecision( |
| 130 | + detailedLine.Quantity.Mul(detailedLine.PerUnitAmount), |
| 131 | + ) |
| 132 | + if !detailedLine.Totals.Amount.Equal(calculatedAmount) { |
| 133 | + return billing.DetailedLine{}, fmt.Errorf( |
| 134 | + "totals amount does not match quantity and cost basis: expected %s, got %s", |
| 135 | + calculatedAmount, |
| 136 | + detailedLine.Totals.Amount, |
| 137 | + ) |
| 138 | + } |
| 139 | + |
| 140 | + calculatedTotal := detailedLine.Totals.CalculateTotal() |
| 141 | + if !detailedLine.Totals.Total.Equal(calculatedTotal) { |
| 142 | + return billing.DetailedLine{}, fmt.Errorf( |
| 143 | + "totals total does not match its components: expected %s, got %s", |
| 144 | + calculatedTotal, |
| 145 | + detailedLine.Totals.Total, |
| 146 | + ) |
| 147 | + } |
| 148 | + |
| 149 | + return detailedLine, nil |
| 150 | +} |
0 commit comments