Skip to content

Commit e82d034

Browse files
feat(custom-currencies): prepare it to ledger support
1 parent 277f8d3 commit e82d034

29 files changed

Lines changed: 731 additions & 108 deletions
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
name: custom-currencies
3+
description: Custom currency coordination for OpenMeter. Use when changes touch custom currency codes or fiat/custom boundaries in `pkg/currencyx`, `openmeter/currencies`, ledger routes/transactions/balances, charge settlement, product catalog or subscription mapping, schema migrations, or credit tests.
4+
---
5+
6+
# Custom Currencies
7+
8+
Use this skill to keep custom-currency work boundary-driven. Use only public repository sources in code, tests, comments, migrations, committed docs, and review notes; do not cite uncommitted planning notes, branch URLs, or non-public business context.
9+
10+
Also load the package skill for each touched area: `ledger`, `charges`, `billing`, `subscription`, `api`, `ent`, `db-migration`, and `test`.
11+
12+
## Boundary Model
13+
14+
- **Currency code**: can be fiat or namespace-scoped custom. Custom codes are durable business identifiers, so historical finance facts must stay readable after display metadata changes or archive.
15+
- **Product boundary**: validates registry semantics before new effects are created, including active definition, archive rules, ISO collision rejection, and product-facing errors.
16+
- **Finance boundary**: resolves fiat basis, snapshots/copies basis context, and performs fiat materialization rounding.
17+
- **Ledger boundary**: preserves durable text currency, posted decimal amount, route dimensions, and balanced transaction invariants without live registry lookup.
18+
- **Invoice boundary**: keeps invoice money-of-account fiat. Custom units may appear as metadata or description, not as invoice currency.
19+
- **Charge boundary**: keeps each charge denominated in exactly one currency. Multi-currency subscriptions are mapping and validation work, not a new charge lifecycle.
20+
21+
## Package Surfaces
22+
23+
- `pkg/currencyx`: code shape, fiat validation/calculator, allocation helpers, schema constants.
24+
- `openmeter/currencies`: custom currency definitions, cost basis, service, adapter.
25+
- `openmeter/ledger`: route validation, routing keys, account dimensions, historical ledger, transactions, balances, breakage.
26+
- `openmeter/ledger/chargeadapter`: ledger-backed credit purchase, flat fee, usage-based handlers.
27+
- `openmeter/billing/charges`: charge lifecycle, settlement modes, realization runs, allocation/correction, line engines.
28+
- `openmeter/billing`, `openmeter/billing/models/stddetailedline`: invoice and detailed-line currency storage, fiat materialization.
29+
- `openmeter/productcatalog`, `openmeter/subscription`, `openmeter/billing/worker/subscriptionsync`: rate-card currency and subscription-to-charge mapping.
30+
- `openmeter/ent/schema`, `tools/migrate/migrations`: schema source and migrations for currency fields and finance context.
31+
- `test/credits`: cross-stack sanity tests for ledger-backed customer credit behavior.
32+
33+
## Process
34+
35+
1. **Name the surface before editing.** Pick one primary surface: registry, cost basis, ledger fact, funding, catalog/subscription mapping, charge settlement, balance visibility, or breakage. Continue only when the scope can be stated in one sentence.
36+
2. **Classify every changed path by boundary.** For each package you will edit, write down whether it is product, finance, ledger, invoice, or charge/subscription boundary work. Continue only when validation and rounding ownership are clear.
37+
3. **Keep unrelated surfaces out.** If a path merely carries `currencyx.Code`, do not expand scope unless the request requires that boundary. Continue only when each edited file is justified by the named surface.
38+
4. **Preserve fiat-only behavior.** Keep existing fiat calculator, precision, invoice currency, tax, payment, and lifecycle behavior unless the request explicitly changes it. Continue only when fiat regression coverage is selected or the reason for omitting it is clear.
39+
5. **Verify the slice.** Run focused unit tests for validation/math and integration tests for ledger-backed credit behavior when balances, transactions, or charge adapters change. Finish only after reporting commands run or why they could not run.
40+
41+
## Ledger Checks
42+
43+
- Accept structurally valid custom codes in `ledger.Route` and routing key generation.
44+
- Reject invalid codes: empty, too short, too long, whitespace-padded, or containing the route delimiter.
45+
- Preserve fiat precision checks for fiat codes.
46+
- Preserve exact posted decimal amounts for custom codes unless an upstream materialization boundary normalized them.
47+
- Keep transaction groups balanced per currency.
48+
- Use linked single-currency legs for fiat-to-custom funding; never put two currencies into one entry.
49+
- Verify balance queries filter by custom currency and discover custom currencies with activity.
50+
- Add replay/idempotency coverage when the same funding event can run more than once.
51+
52+
## Charges And Billing Checks
53+
54+
- Keep charge lifecycle generic over currency where possible.
55+
- Burn same-currency customer credits for covered custom usage.
56+
- Convert uncovered custom usage to fiat only at invoice materialization, using captured basis context.
57+
- Do not use fiat calculators for custom-unit rounding unless the code is materializing fiat.
58+
- Keep invoice line totals, tax, payment, and external invoicing fiat-denominated.
59+
- Preserve charge status transitions and meta status synchronization when adding custom branches.
60+
61+
## Schema Checks
62+
63+
- Reuse shared currency code constants instead of hard-coding column widths.
64+
- Widen every durable field that can store custom codes; do not widen only the first failing table.
65+
- Generate Ent code and migrations from schema sources.
66+
- Do not hand-edit generated Ent code.
67+
- State narrowing/data-loss assumptions honestly in down migrations that reduce currency column width.
68+
69+
## Testing
70+
71+
Use direct commands. For Postgres-backed tests, set `POSTGRES_HOST=127.0.0.1`.
72+
73+
```bash
74+
go test -count=1 -tags=dynamic ./pkg/currencyx ./openmeter/currencies
75+
go test -count=1 -tags=dynamic ./openmeter/ledger
76+
env POSTGRES_HOST=127.0.0.1 go test -count=1 -tags=dynamic ./openmeter/ledger/...
77+
env POSTGRES_HOST=127.0.0.1 go test -count=1 -tags=dynamic ./openmeter/ledger/chargeadapter ./openmeter/ledger/customerbalance
78+
env POSTGRES_HOST=127.0.0.1 go test -count=1 -tags=dynamic ./test/credits
79+
```
80+
81+
For schema work, also follow the `ent` and `db-migration` skills.
82+
83+
## Review Phrases
84+
85+
- "Custom currency codes are accepted as durable ledger route values."
86+
- "Custom balances are visible through ledger-backed balance reads."
87+
- "Paid fiat-to-custom funding is implemented" only when linked fiat/custom ledger legs, basis context, and idempotency are covered.
88+
- "Custom charge settlement is implemented" only when covered custom usage and uncovered fiat materialization are both covered.
89+
- "Storage supports long custom codes" only means persistence width is ready; it does not imply registry-backed precision or product archive rules.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface:
2+
display_name: "Custom Currencies"
3+
short_description: "Work on OpenMeter custom currency implementation"
4+
default_prompt: "Use the custom-currencies skill to review or implement custom currency support."

openmeter/billing/charges/models/chargemeta/mixin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (metaMixin) Fields() []ent.Field {
6262
NotEmpty().
6363
Immutable().
6464
SchemaType(map[string]string{
65-
dialect.Postgres: "varchar(3)",
65+
dialect.Postgres: currencyx.PostgresCodeSchemaType,
6666
}),
6767

6868
field.Enum("managed_by").

openmeter/billing/models/stddetailedline/mixin.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func (mixinBase) Fields() []ent.Field {
4040
NotEmpty().
4141
Immutable().
4242
SchemaType(map[string]string{
43-
dialect.Postgres: "varchar(3)",
43+
dialect.Postgres: currencyx.PostgresCodeSchemaType,
4444
}),
4545

4646
// TODO: remove these deprecated detailed-line tax fields after the parent-line

openmeter/currencies/models.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"github.com/alpacahq/alpacadecimal"
99

10+
"github.com/openmeterio/openmeter/pkg/currencyx"
1011
"github.com/openmeterio/openmeter/pkg/filter"
1112
"github.com/openmeterio/openmeter/pkg/models"
1213
"github.com/openmeterio/openmeter/pkg/pagination"
@@ -114,6 +115,8 @@ func (i CreateCurrencyInput) Validate() error {
114115

115116
if i.Code == "" {
116117
errs = append(errs, errors.New("code is required"))
118+
} else if err := currencyx.Code(i.Code).ValidateCustom(); err != nil {
119+
errs = append(errs, fmt.Errorf("code: %w", err))
117120
}
118121

119122
if i.Name == "" {
@@ -159,6 +162,8 @@ func (i CreateCostBasisInput) Validate() error {
159162

160163
if i.FiatCode == "" {
161164
errs = append(errs, errors.New("fiat_code is required"))
165+
} else if err := currencyx.Code(i.FiatCode).ValidateFiat(); err != nil {
166+
errs = append(errs, fmt.Errorf("fiat_code: %w", err))
162167
}
163168

164169
if !i.Rate.IsPositive() {
@@ -189,5 +194,13 @@ func (i ListCostBasesInput) Validate() error {
189194
errs = append(errs, errors.New("currency_id is required"))
190195
}
191196

197+
if i.FilterFiatCode != nil {
198+
if *i.FilterFiatCode == "" {
199+
errs = append(errs, errors.New("filter_fiat_code is required"))
200+
} else if err := currencyx.Code(*i.FilterFiatCode).ValidateFiat(); err != nil {
201+
errs = append(errs, fmt.Errorf("filter_fiat_code: %w", err))
202+
}
203+
}
204+
192205
return errors.Join(errs...)
193206
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package currencies_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
8+
"github.com/openmeterio/openmeter/openmeter/currencies"
9+
)
10+
11+
func TestCreateCurrencyInputValidate(t *testing.T) {
12+
valid := currencies.CreateCurrencyInput{
13+
Namespace: "ns",
14+
Code: "CREDITS",
15+
Name: "Credits",
16+
Symbol: "cr",
17+
}
18+
19+
tests := []struct {
20+
name string
21+
input currencies.CreateCurrencyInput
22+
wantErr string
23+
}{
24+
{
25+
name: "valid",
26+
input: valid,
27+
},
28+
{
29+
name: "fiat code collision",
30+
input: currencies.CreateCurrencyInput{
31+
Namespace: "ns",
32+
Code: "USD",
33+
Name: "Credits",
34+
Symbol: "cr",
35+
},
36+
wantErr: "custom currency code cannot conflict with fiat currency code",
37+
},
38+
{
39+
name: "invalid structural code",
40+
input: currencies.CreateCurrencyInput{
41+
Namespace: "ns",
42+
Code: "BAD|CODE",
43+
Name: "Credits",
44+
Symbol: "cr",
45+
},
46+
wantErr: "currency code cannot contain route delimiter",
47+
},
48+
{
49+
name: "missing code",
50+
input: currencies.CreateCurrencyInput{
51+
Namespace: "ns",
52+
Name: "Credits",
53+
Symbol: "cr",
54+
},
55+
wantErr: "code is required",
56+
},
57+
}
58+
59+
for _, tt := range tests {
60+
t.Run(tt.name, func(t *testing.T) {
61+
err := tt.input.Validate()
62+
if tt.wantErr == "" {
63+
require.NoError(t, err)
64+
return
65+
}
66+
67+
require.ErrorContains(t, err, tt.wantErr)
68+
})
69+
}
70+
}

0 commit comments

Comments
 (0)