Skip to content

Commit 83d7f1f

Browse files
feat(currencies): refactor currencies package
1 parent cdbf530 commit 83d7f1f

9 files changed

Lines changed: 750 additions & 38 deletions

File tree

.agents/skills/currencyx/SKILL.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
---
2+
name: currencyx
3+
description: Work on OpenMeter currency primitives in pkg/currencyx for fiat and custom currency codes, shared currency interfaces, rounding modes, calculators, allocation precision, fiat/custom boundaries, and callers in billing, charges, ledger, product catalog, subscriptions, API, or currency registry code.
4+
---
5+
6+
# Currencyx
7+
8+
Use this skill when changes touch `pkg/currencyx` or any caller that depends on currency code shape, fiat/custom classification, calculator behavior, rounding, allocation, or invoice/ledger currency boundaries.
9+
10+
Also load the domain skill for each touched caller area: `billing`, `charges`, `ledger`, `subscription`, `api`, `ent`, `db-migration`, and `test`.
11+
12+
## Source Of Truth
13+
14+
- Source code: `pkg/currencyx/*.go`.
15+
- Primary tests: `pkg/currencyx/*_test.go`.
16+
- Package usage examples: `pkg/currencyx/README.md`.
17+
- This skill is a how-to/reference for agents. Update it whenever `Currency`, `Code`, `CustomCurrency`, `Calculator`, rounding, allocation, or validation behavior changes.
18+
- One canonical repo skill lives at `.agents/skills/currencyx`; do not create duplicate currencyx guidance elsewhere.
19+
20+
## Package Layout
21+
22+
- `currency.go`: currency type constants, the shared `Currency` interface, `Code`, `CustomCurrency`, and fiat/custom constructors.
23+
- `validation.go`: code format validation, fiat collision checks, precision validation, `PostgresCodeSchemaType`, and `CustomCurrency.Validate`.
24+
- `fiat.go`: rounding modes, `Calculator`, and precision helpers.
25+
- `allocation.go`: deterministic largest-remainder allocation using calculator precision.
26+
- `README.md`: short examples for fiat, custom, and allocation usage.
27+
28+
## Boundary Model
29+
30+
- **Currency code**: durable identifier. Fiat and custom codes use `currencyx.Code`, but validation differs by boundary.
31+
- **Currency interface**: shared behavior contract. Callers that know a configured currency should expose `CurrencyCode()`, `CurrencyType()`, `CurrencyPrecision()`, and `CurrencyRoundingMode()`.
32+
- **Fiat currency**: `currencyx.Code` implements `currencyx.Currency` as fiat. `Code.Calculator()` preserves existing fiat behavior and derives precision from GOBL/ISO definitions.
33+
- **Custom currency**: `currencyx.CustomCurrency` implements `currencyx.Currency`. Custom currencies carry configured precision and rounding mode; missing rounding mode defaults to bankers rounding.
34+
- **Calculator**: construct with `currencyx.NewCalculator(currencyx.Currency)`. The calculator must branch from `CurrencyType()` and use `CurrencyPrecision()` / `CurrencyRoundingMode()` from the interface for custom currencies.
35+
- **Allocation**: use calculator precision for units and largest-remainder distribution. Do not reach into fiat-only `Def.Subunits` for allocation logic.
36+
- **Validation**: `Code.Validate()` remains fiat semantic for existing callers. Use `ValidateFormat()` for structural code checks and `ValidateCustom()` for custom currency codes. Custom codes must not contain the `|` route delimiter.
37+
- **Registry boundary**: owns custom currency definition, fiat-code collision checks, archive/activation rules, and future persisted rounding configuration.
38+
- **Finance boundary**: snapshots fiat basis and applies fiat rounding when custom units become fiat amounts.
39+
- **Ledger boundary**: records durable currency codes and balanced single-currency legs. Round before posting only when the upstream domain owns normalization.
40+
- **Invoice boundary**: invoice currency stays fiat. Custom units must be materialized to fiat before invoice artifacts.
41+
42+
## Rounding Rules
43+
44+
- Preserve fiat rounding unless the task explicitly changes fiat money behavior.
45+
- Custom currency default rounding is `RoundingModeBankers` (`RoundBank`, half-even).
46+
- Custom currencies can opt into `RoundingModeHalfAwayFromZero` through `NewCustomCurrencyWithRounding`.
47+
- `Calculator.RoundToPrecision` is the single place that applies the effective rounding mode.
48+
- `Calculator.RoundDown` and `Calculator.Unit` are precision helpers; they should not apply banker/half-away rounding.
49+
- `Calculator.IsRoundedToPrecision` must use `RoundToPrecision`, so it follows the configured rounding mode.
50+
51+
## Process
52+
53+
1. Name the surface before editing: code validation, type/interface, rounding, calculator, allocation, registry, ledger fact, fiat materialization, or invoice boundary.
54+
2. Keep `pkg/currencyx` free of imports from `openmeter/...`; callers can implement `currencyx.Currency` to supply registry-backed custom settings.
55+
3. Prefer `CurrencyType()` at the boundary that truly requires fiat or custom. Do not add broad split helpers unless the caller boundary needs a named domain rule.
56+
4. Preserve `currencyx.Code(...).Calculator()` for existing fiat callers.
57+
5. For custom currencies, validate structural code, route delimiter exclusion, fiat-code collisions, precision, and rounding mode.
58+
6. Keep allocation deterministic: precision defines units, largest remainder distributes residual units, and tie-breakers remain stable.
59+
7. After editing, run focused `pkg/currencyx` tests, `go vet`, and caller tests or compile checks for every touched boundary.
60+
61+
## Test Checklist
62+
63+
Cover the named risk introduced by the change:
64+
65+
- Fiat regression behavior: code validation, precision from ISO definition, and existing rounding.
66+
- Validation boundaries: `Code.Validate()` stays fiat-only while `ValidateFormat()` accepts structurally valid custom codes.
67+
- Custom interface behavior: code, type, precision, and rounding mode all flow through `currencyx.Currency`.
68+
- Banker ties: positive, negative, and zero-precision custom rounding tie to even.
69+
- Alternate custom rounding: half-away-from-zero remains selectable and tested.
70+
- Invalid config: bad precision or rounding mode fails validation.
71+
- Allocation precision: custom precision affects units and largest-remainder allocation.
72+
- Boundary tests: billing/invoice rejects custom invoice currency explicitly; ledger accepts structurally valid custom codes only when that domain supports them.
73+
74+
Focused commands:
75+
76+
```bash
77+
env GOCACHE=/private/tmp/openmeter-go-build go test ./pkg/currencyx
78+
env GOCACHE=/private/tmp/openmeter-go-build go vet ./pkg/currencyx
79+
```
80+
81+
For caller compile checks, keep the package list scoped to touched boundaries and include `-tags=dynamic` when billing/ledger paths require it.
82+
83+
## Review Checks
84+
85+
- Fiat and custom currencies share the `currencyx.Currency` interface.
86+
- `Calculator.RoundToPrecision` applies the effective rounding rule.
87+
- `Calculator` does not require fiat definitions for custom currencies.
88+
- Allocation code uses calculator methods, not fiat-only definition fields.
89+
- Invalid rounding precision or mode fails validation.
90+
- Tests cover banker ties, configured custom precision, fiat regression behavior, invalid rounding config, and allocation precision.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface:
2+
display_name: "Currencyx"
3+
short_description: "Work on fiat and custom currency primitives"
4+
default_prompt: "Use $currencyx to update OpenMeter currency code, rounding, calculator, allocation, or fiat/custom boundary behavior."

pkg/currencyx/README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# currencyx
2+
3+
`currencyx` contains OpenMeter's shared currency primitives. It keeps fiat
4+
behavior compatible with GOBL/ISO currency definitions while allowing product
5+
and ledger code to pass configured custom currencies through the same
6+
`Currency` interface.
7+
8+
## Fiat Currency
9+
10+
Use `Code` directly for fiat currencies. Fiat precision comes from the GOBL
11+
currency definition and fiat rounding remains half-away-from-zero.
12+
13+
```go
14+
calculator, err := currencyx.Code("USD").Calculator()
15+
if err != nil {
16+
return err
17+
}
18+
19+
amount := calculator.RoundToPrecision(alpacadecimal.RequireFromString("1.235"))
20+
// amount == 1.24
21+
```
22+
23+
## Custom Currency
24+
25+
Use `NewCustomCurrency` when the currency is not a known fiat code. Custom
26+
currencies carry explicit precision and default to bankers rounding
27+
(half-even). Use `NewCustomCurrencyWithRounding` to opt into half-away-from-zero.
28+
29+
```go
30+
credits, err := currencyx.NewCustomCurrency(currencyx.Code("CREDITS"), 6)
31+
if err != nil {
32+
return err
33+
}
34+
35+
calculator, err := currencyx.NewCalculator(credits)
36+
if err != nil {
37+
return err
38+
}
39+
40+
amount := calculator.RoundToPrecision(alpacadecimal.RequireFromString("1.2345678"))
41+
// amount == 1.234568
42+
```
43+
44+
## Allocation
45+
46+
Allocation helpers use the calculator's precision and distribute residual units
47+
with a deterministic largest-remainder method. Provide a `CompareKey` function
48+
when equal remainders need a stable domain-specific tie-breaker.

pkg/currencyx/allocation.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ func AllocateByWeight[T any](calculator Calculator, input WeightedAllocationInpu
8585
allocated := alpacadecimal.Zero
8686
for i, item := range input.Items {
8787
share := input.Amount.Mul(item.Weight).Div(totalWeight)
88-
amount := share.RoundDown(int32(calculator.Def.Subunits))
88+
amount := calculator.RoundDown(share)
8989

9090
candidates = append(candidates, allocationCandidate{
9191
index: i,
@@ -169,7 +169,7 @@ func AllocateByAmount[T any](calculator Calculator, input AmountAllocationInput[
169169
allocated := alpacadecimal.Zero
170170
for i, item := range input.Items {
171171
share := input.Amount.Mul(item.Amount).Div(totalAmount)
172-
floor := share.RoundDown(int32(calculator.Def.Subunits))
172+
floor := calculator.RoundDown(share)
173173

174174
candidates = append(candidates, allocationCandidate{
175175
index: i,
@@ -312,5 +312,5 @@ func validateAmountAllocationInput[T any](calculator Calculator, input AmountAll
312312
}
313313

314314
func currencyUnit(calculator Calculator) alpacadecimal.Decimal {
315-
return alpacadecimal.NewFromInt(1).Shift(-int32(calculator.Def.Subunits))
315+
return calculator.Unit()
316316
}

pkg/currencyx/allocation_test.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ func TestAllocateByWeight(t *testing.T) {
8787
}, allocations)
8888
})
8989

90+
t.Run("uses custom currency precision", func(t *testing.T) {
91+
custom := testCustomCalculator(t, "CREDITS", 4)
92+
93+
allocations, err := currencyx.AllocateByWeight(custom, currencyx.WeightedAllocationInput[string]{
94+
Amount: dec("0.0005"),
95+
Items: []currencyx.WeightedAllocationItem[string]{
96+
{Key: "A", Weight: dec("1")},
97+
{Key: "B", Weight: dec("1")},
98+
},
99+
})
100+
require.NoError(t, err)
101+
102+
requireAllocationsEqual(t, []currencyx.WeightedAllocation[string]{
103+
{Key: "A", Amount: dec("0.0003")},
104+
{Key: "B", Amount: dec("0.0002")},
105+
}, allocations)
106+
})
107+
90108
t.Run("omits zero allocations", func(t *testing.T) {
91109
allocations, err := currencyx.AllocateByWeight(usd, currencyx.WeightedAllocationInput[string]{
92110
Amount: dec("0.01"),
@@ -361,6 +379,18 @@ func testCalculator(t *testing.T, code string) currencyx.Calculator {
361379
return calculator
362380
}
363381

382+
func testCustomCalculator(t *testing.T, code string, precision int32) currencyx.Calculator {
383+
t.Helper()
384+
385+
currency, err := currencyx.NewCustomCurrency(currencyx.Code(code), precision)
386+
require.NoError(t, err)
387+
388+
calculator, err := currencyx.NewCalculator(currency)
389+
require.NoError(t, err)
390+
391+
return calculator
392+
}
393+
364394
func requireAllocationsEqual[T comparable](t *testing.T, expected, actual []currencyx.WeightedAllocation[T]) {
365395
t.Helper()
366396

pkg/currencyx/currency.go

Lines changed: 97 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,122 @@
11
package currencyx
22

33
import (
4-
"errors"
4+
"fmt"
55

6-
"github.com/alpacahq/alpacadecimal"
76
"github.com/invopop/gobl/currency"
87
)
98

10-
// Currency represents a currency code.
11-
// Three-letter [ISO4217](https://www.iso.org/iso-4217-currency-codes.html) currency code.
12-
type Code currency.Code
9+
type CurrencyType string
10+
11+
const (
12+
CurrencyTypeFiat CurrencyType = "fiat"
13+
CurrencyTypeCustom CurrencyType = "custom"
14+
)
1315

14-
func (c Code) Validate() error {
15-
if c == "" {
16-
return errors.New("currency code is required")
16+
func (t CurrencyType) Validate() error {
17+
switch t {
18+
case CurrencyTypeFiat, CurrencyTypeCustom:
19+
return nil
20+
default:
21+
return fmt.Errorf("invalid currency type: %s", t)
1722
}
23+
}
24+
25+
type Currency interface {
26+
CurrencyCode() Code
27+
CurrencyType() CurrencyType
28+
CurrencyPrecision() int32
29+
CurrencyRoundingMode() RoundingMode
30+
}
31+
32+
// Code represents a fiat or custom currency code. Code values used directly as
33+
// Currency values are treated as fiat currencies for backwards compatibility.
34+
type Code currency.Code
1835

19-
return currency.Code(c).Validate()
36+
func (c Code) String() string {
37+
return string(c)
2038
}
2139

22-
// Calculator provides a currency calculator object. This allows us to not to resolve def a lot of times, plus
23-
// we can assume that def is always valid, thus we can avoid a lot of error handling.
24-
func (c Code) Calculator() (Calculator, error) {
25-
if err := c.Validate(); err != nil {
26-
return Calculator{}, err
40+
func (c Code) CurrencyCode() Code {
41+
return c
42+
}
43+
44+
func (c Code) CurrencyType() CurrencyType {
45+
return CurrencyTypeFiat
46+
}
47+
48+
func (c Code) CurrencyPrecision() int32 {
49+
def := currency.Get(currency.Code(c))
50+
if def == nil {
51+
return 0
2752
}
2853

29-
return Calculator{
30-
Currency: c,
31-
Def: currency.Get(currency.Code(c)),
32-
}, nil
54+
return int32(def.Subunits)
3355
}
3456

35-
// TODO: Better name?!
36-
type Calculator struct {
37-
Currency Code
38-
Def *currency.Def
57+
func (c Code) CurrencyRoundingMode() RoundingMode {
58+
return RoundingModeHalfAwayFromZero
3959
}
4060

41-
func (c Calculator) RoundToPrecision(amount alpacadecimal.Decimal) alpacadecimal.Decimal {
42-
// TODO: For now we are skipping the smallestDenomination, as that is a reference to the coins
43-
// in circulation, but should not be an issue for online payments.
44-
return amount.Round(int32(c.Def.Subunits))
61+
type CustomCurrency struct {
62+
Code Code
63+
Precision int32
64+
RoundingMode RoundingMode
4565
}
4666

47-
func (c Calculator) Validate() error {
48-
var errs []error
49-
if err := c.Currency.Validate(); err != nil {
50-
errs = append(errs, err)
67+
func NewCurrency(code Code, currencyType CurrencyType, precision int32) (Currency, error) {
68+
switch currencyType {
69+
case CurrencyTypeFiat:
70+
return NewFiatCurrency(code)
71+
case CurrencyTypeCustom:
72+
return NewCustomCurrency(code, precision)
73+
default:
74+
return nil, currencyType.Validate()
5175
}
52-
if c.Def == nil {
53-
errs = append(errs, errors.New("currency definition is required"))
76+
}
77+
78+
func NewFiatCurrency(code Code) (Code, error) {
79+
if err := code.Validate(); err != nil {
80+
return "", err
81+
}
82+
83+
if currency.Get(currency.Code(code)) == nil {
84+
return "", fmt.Errorf("fiat currency definition is required for %s", code)
5485
}
55-
return errors.Join(errs...)
86+
87+
return code, nil
88+
}
89+
90+
func NewCustomCurrency(code Code, precision int32) (CustomCurrency, error) {
91+
return NewCustomCurrencyWithRounding(code, precision, RoundingModeBankers)
92+
}
93+
94+
func NewCustomCurrencyWithRounding(code Code, precision int32, roundingMode RoundingMode) (CustomCurrency, error) {
95+
out := CustomCurrency{
96+
Code: code,
97+
Precision: precision,
98+
RoundingMode: roundingMode,
99+
}
100+
101+
return out, out.Validate()
56102
}
57103

58-
func (c Calculator) IsRoundedToPrecision(amount alpacadecimal.Decimal) bool {
59-
return amount.Equal(c.RoundToPrecision(amount))
104+
func (c CustomCurrency) CurrencyCode() Code {
105+
return c.Code
106+
}
107+
108+
func (c CustomCurrency) CurrencyType() CurrencyType {
109+
return CurrencyTypeCustom
110+
}
111+
112+
func (c CustomCurrency) CurrencyPrecision() int32 {
113+
return c.Precision
114+
}
115+
116+
func (c CustomCurrency) CurrencyRoundingMode() RoundingMode {
117+
if c.RoundingMode == "" {
118+
return RoundingModeBankers
119+
}
120+
121+
return c.RoundingMode
60122
}

0 commit comments

Comments
 (0)