From 6701dd980ac0095dbc460f1e47609783e4b39973 Mon Sep 17 00:00:00 2001 From: Simon Klinkert Date: Mon, 20 Jul 2026 11:14:15 +0200 Subject: [PATCH 1/3] Money: Rename cents to minor units, make String() exponent-aware ISO 4217 minor-unit exponents are not always 2 (JPY has 0, BHD has 3). The cents naming invites a divide-by-100 bug the day a non-exponent-2 currency lands in the whitelist. Field, accessor, JSON, wire format, DB column and docs now say minor units, and formatting derives from a per-currency exponent table. --- README.md | 7 +- README.zh-CN.md | 5 +- api/openapi.yaml | 12 +-- docs/reference/architecture.md | 2 +- docs/tutorial/02-entities.md | 8 +- docs/tutorial/03-value-objects.md | 48 +++++++----- docs/tutorial/05-repositories.md | 6 +- docs/tutorial/06-cqrs.md | 6 +- docs/tutorial/07-domain-events-outbox.md | 2 +- docs/tutorial/08-idempotency.md | 2 +- .../command/create_product_command.go | 12 +-- .../command/update_product_command.go | 12 +-- .../services/failure_paths_test.go | 78 +++++++++---------- .../application/services/product_service.go | 4 +- .../services/product_service_test.go | 10 +-- internal/domain/entities/money.go | 49 ++++++++---- internal/domain/entities/money_test.go | 61 +++++++++++---- internal/domain/entities/product.go | 4 +- .../entities/product_business_logic_test.go | 16 ++-- internal/domain/events/events_test.go | 2 +- internal/domain/events/product_events.go | 20 ++--- .../infrastructure/db/postgres/outbox_test.go | 8 +- .../sqlc_idempotency_repository_test.go | 2 +- .../db/postgres/sqlc_product_repository.go | 36 ++++----- .../postgres/sqlc_product_repository_test.go | 6 +- internal/infrastructure/db/sqlc/models.go | 16 ++-- .../infrastructure/db/sqlc/products.sql.go | 74 +++++++++--------- .../dto/mapper/product_response_mapper.go | 14 ++-- .../rest/dto/mapper/response_mapper_test.go | 10 +-- .../dto/request/create_product_request.go | 20 ++--- .../api/rest/dto/request/request_test.go | 30 +++---- .../dto/request/update_product_request.go | 22 +++--- .../api/rest/dto/response/product_response.go | 14 ++-- .../rest_test/mock_product_service_test.go | 2 +- .../product_controller_extra_test.go | 10 +-- .../api/rest_test/product_controller_test.go | 28 +++---- .../postgres_test_container_test.go | 2 +- migrations/000004_price_minor_units.down.sql | 1 + migrations/000004_price_minor_units.up.sql | 3 + sql/queries/products.sql | 8 +- 40 files changed, 366 insertions(+), 306 deletions(-) create mode 100644 migrations/000004_price_minor_units.down.sql create mode 100644 migrations/000004_price_minor_units.up.sql diff --git a/README.md b/README.md index aed1d3a..ccece80 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ migrations/ ├── 000001_initial_schema.down.sql # Rollback for initial schema ├── 000002_price_as_money.up.sql # Money as integer cents + currency ├── 000003_outbox.up.sql # Transactional outbox table +├── 000004_price_minor_units.up.sql # Rename to ISO 4217 minor units └── ... ``` @@ -235,16 +236,16 @@ curl -s -X POST http://localhost:8080/api/v1/sellers \ {"id":"0197a3c2-...","name":"Acme Corp","created_at":"2026-07-14T09:00:00Z","updated_at":"2026-07-14T09:00:00Z"} ``` -Create a product for that seller (prices are integer cents — never floats): +Create a product for that seller (prices are integer minor units — never floats): ```bash curl -s -X POST http://localhost:8080/api/v1/products \ -H 'Content-Type: application/json' \ - -d '{"name": "Wooden Chair", "price_cents": 4999, "currency": "EUR", "seller_id": ""}' + -d '{"name": "Wooden Chair", "price_minor_units": 4999, "currency": "EUR", "seller_id": ""}' ``` ```json -{"id":"0197a3c3-...","name":"Wooden Chair","price_cents":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."} +{"id":"0197a3c3-...","name":"Wooden Chair","price_minor_units":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."} ``` Replay a request with the same `idempotency_key` — you get the cached response back instead of a duplicate seller: diff --git a/README.zh-CN.md b/README.zh-CN.md index e6034d4..bfd5736 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -163,6 +163,7 @@ migrations/ ├── 000001_initial_schema.down.sql # 初始模式的回滚 ├── 000002_price_as_money.up.sql # 金额改为整数分 + 货币 ├── 000003_outbox.up.sql # 事务性发件箱表 +├── 000004_price_minor_units.up.sql # 重命名为 ISO 4217 最小货币单位 └── ... ``` @@ -249,11 +250,11 @@ curl -s -X POST http://localhost:8080/api/v1/sellers \ ```bash curl -s -X POST http://localhost:8080/api/v1/products \ -H 'Content-Type: application/json' \ - -d '{"name": "Wooden Chair", "price_cents": 4999, "currency": "EUR", "seller_id": "<上一步返回的 seller-id>"}' + -d '{"name": "Wooden Chair", "price_minor_units": 4999, "currency": "EUR", "seller_id": "<上一步返回的 seller-id>"}' ``` ```json -{"id":"0197a3c3-...","name":"Wooden Chair","price_cents":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."} +{"id":"0197a3c3-...","name":"Wooden Chair","price_minor_units":4999,"currency":"EUR","seller_id":"0197a3c2-...","created_at":"...","updated_at":"..."} ``` 用相同的 `idempotency_key` 重放请求——返回缓存的响应,而不是创建重复卖家: diff --git a/api/openapi.yaml b/api/openapi.yaml index cedbee7..85fbaca 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -263,17 +263,17 @@ components: $ref: "#/components/schemas/Seller" CreateProductRequest: type: object - required: [name, price_cents, currency, seller_id] + required: [name, price_minor_units, currency, seller_id] properties: idempotency_key: type: string name: type: string example: Wooden Chair - price_cents: + price_minor_units: type: integer format: int64 - description: Price in minor units (cents) — no floats for money. + description: Price in ISO 4217 minor units (cents for EUR/USD) — no floats for money. example: 4999 currency: type: string @@ -283,13 +283,13 @@ components: format: uuid UpdateProductRequest: type: object - required: [name, price_cents, currency, seller_id] + required: [name, price_minor_units, currency, seller_id] properties: idempotency_key: type: string name: type: string - price_cents: + price_minor_units: type: integer format: int64 currency: @@ -306,7 +306,7 @@ components: format: uuid name: type: string - price_cents: + price_minor_units: type: integer format: int64 currency: diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index c6e0e59..dd737d8 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -33,7 +33,7 @@ The wiring point where everything meets is [`cmd/marketplace/main.go`](https://g **Infrastructure** ([tutorial 5](../tutorial/05-repositories.md), [7](../tutorial/07-domain-events-outbox.md)) — the details. sqlc-generated, type-safe SQL behind the domain's repository interfaces; row-to-entity mapping routes through validating constructors; the aggregate write and its outbox events share one transaction; the relay polls and publishes with at-least-once semantics. -**Interface** — the edge. Echo controllers parse DTOs, call services, and map sentinel errors to status codes in [`errors.go`](https://github.com/sklinkert/go-ddd/blob/main/internal/interface/api/rest/errors.go): `ErrProductNotFound` → 404, `ErrValidation` → 400, `ErrRequestInFlight` → 409, `ErrIdempotencyKeyReuse` → 422. DTOs use explicit primitives (`price_cents`, not `price`) so the wire format can never be ambiguous. +**Interface** — the edge. Echo controllers parse DTOs, call services, and map sentinel errors to status codes in [`errors.go`](https://github.com/sklinkert/go-ddd/blob/main/internal/interface/api/rest/errors.go): `ErrProductNotFound` → 404, `ErrValidation` → 400, `ErrRequestInFlight` → 409, `ErrIdempotencyKeyReuse` → 422. DTOs use explicit primitives (`price_minor_units`, not `price`) so the wire format can never be ambiguous. ## The request flows diff --git a/docs/tutorial/02-entities.md b/docs/tutorial/02-entities.md index 1283bbc..ffac133 100644 --- a/docs/tutorial/02-entities.md +++ b/docs/tutorial/02-entities.md @@ -31,7 +31,7 @@ func NewProduct(name string, price Money, seller ValidatedSeller) *Product { } product.recordEvent(events.NewProductCreated( - product.Id, name, price.Cents(), string(price.Currency()), seller.Id)) + product.Id, name, price.MinorUnits(), string(price.Currency()), seller.Id)) return product } @@ -76,7 +76,7 @@ func (p *Product) validate() error { if p.Name == "" { return fmt.Errorf("%w: name must not be empty", ErrValidation) } - if p.Price.Cents() == 0 { + if p.Price.MinorUnits() == 0 { return fmt.Errorf("%w: price must be greater than 0", ErrValidation) } if p.SellerId == uuid.Nil { @@ -122,14 +122,14 @@ Is this bulletproof? No — `Product` fields are exported (the persistence layer You might object: my HTTP layer already validates requests. Keep it! Edge validation and domain validation answer different questions: -- The edge asks: *is this request well-formed?* (Is `price_cents` a number? Is `seller_id` a UUID?) +- The edge asks: *is this request well-formed?* (Is `price_minor_units` a number? Is `seller_id` a UUID?) - The domain asks: *is this a valid product?* (Is the price positive? Does the seller exist and pass validation?) The edge check is about protocol; it produces friendly 400s fast. The domain check is about business truth, and it runs no matter where the call came from — HTTP today, a message consumer tomorrow, a backfill script at 2am. The scattered-validation problem isn't solved by choosing one location; it's solved by giving each rule its *one correct* location. ## Try it -1. Delete the `Price.Cents() == 0` check from `validate()` and run `go test ./internal/...` — watch which tests fail and read what they assert. The test suite documents the invariants. +1. Delete the `Price.MinorUnits() == 0` check from `validate()` and run `go test ./internal/...` — watch which tests fail and read what they assert. The test suite documents the invariants. 2. Add a new rule: product names must be at most 200 characters. Notice you touch exactly two files — the entity and its test. 3. Try to call `productRepository.Create` with a plain `*Product`. Enjoy the compile error; that error is the pattern working. diff --git a/docs/tutorial/03-value-objects.md b/docs/tutorial/03-value-objects.md index 95c60b5..9124451 100644 --- a/docs/tutorial/03-value-objects.md +++ b/docs/tutorial/03-value-objects.md @@ -33,53 +33,59 @@ const ( USD Currency = "USD" ) -var supportedCurrencies = map[Currency]struct{}{ - EUR: {}, - USD: {}, +// supportedCurrencies maps each supported currency to its ISO 4217 +// minor-unit exponent. Not every currency has two decimals: JPY, KRW, +// CLP and ISK have 0; BHD, JOD, KWD, OMR and TND have 3. +var supportedCurrencies = map[Currency]int{ + EUR: 2, + USD: 2, } -// Money is an immutable value object storing an amount in minor units -// (cents) to avoid floating-point rounding errors. +// Money is an immutable value object storing an amount in ISO 4217 minor +// units (cents for EUR/USD, yen for JPY, fils for BHD) to avoid +// floating-point rounding errors. type Money struct { - cents int64 - currency Currency + minorUnits int64 + currency Currency } -func NewMoney(cents int64, currency Currency) (Money, error) { - if cents < 0 { +func NewMoney(minorUnits int64, currency Currency) (Money, error) { + if minorUnits < 0 { return Money{}, fmt.Errorf("%w: amount must not be negative", ErrValidation) } if _, ok := supportedCurrencies[currency]; !ok { return Money{}, fmt.Errorf("%w: unsupported currency %q", ErrValidation, currency) } - return Money{cents: cents, currency: currency}, nil + return Money{minorUnits: minorUnits, currency: currency}, nil } -func (m Money) Cents() int64 { return m.cents } +func (m Money) MinorUnits() int64 { return m.minorUnits } func (m Money) Currency() Currency { return m.currency } ``` The design decisions, spelled out: -**Unexported fields, one constructor.** Because `cents` and `currency` are unexported, the only way to build a `Money` outside the package is `NewMoney`. Every `Money` in the entire system has passed the negative check and the currency whitelist. There is no "construct raw, validate later" path to forget. This is [chapter 2's](02-entities.md) idea taken to its logical end: for a value object, we *can* make invalid states fully unrepresentable, because nothing needs to mutate it. +**Unexported fields, one constructor.** Because `minorUnits` and `currency` are unexported, the only way to build a `Money` outside the package is `NewMoney`. Every `Money` in the entire system has passed the negative check and the currency whitelist. There is no "construct raw, validate later" path to forget. This is [chapter 2's](02-entities.md) idea taken to its logical end: for a value object, we *can* make invalid states fully unrepresentable, because nothing needs to mutate it. -**Integer minor units, not `big.Rat` or a decimal library.** `int64` cents gives exact addition and comparison for free, sorts and indexes trivially in the database, and covers about ±92 quadrillion dollars. For prices and balances, minor units are the boring answer that works. (For FX rates or interest math, reach for a decimal type — different problem.) +**"Minor units", not "cents".** The first version of this type called the field `cents`, and a sharp reader pointed out the trap: "cents" is only correct for currencies with two decimal places. ISO 4217 defines a *minor-unit exponent* per currency — JPY, KRW, CLP and ISK have 0 (there is no sub-yen), BHD, JOD, KWD, OMR and TND have 3. A field named `cents` invites whoever adds JPY to divide by 100 and turn ¥5000 into ¥50. The currency-neutral name plus the exponent stored in the whitelist keeps the type honest, and `String()` formats from the exponent instead of a hardcoded 100. + +**Integer minor units, not `big.Rat` or a decimal library.** `int64` minor units give exact addition and comparison for free, sort and index trivially in the database, and cover about ±92 quadrillion dollars. For prices and balances, minor units are the boring answer that works. (For FX rates or interest math, reach for a decimal type — different problem.) **Currency is part of equality.** `Money` is a comparable struct, so `==` compares amount *and* currency: `NewMoney(1000, EUR) != NewMoney(1000, USD)`. The euros-versus-cents class of bug now fails at the type level instead of on an accountant's spreadsheet. -**A whitelist of currencies.** Two entries looks restrictive; that's the point. The *domain* decides which currencies the business supports. Adding one is a one-line change that forces a moment of thought — which is what you want when the alternative is silently accepting `"BTC"` or `"EURO"` from a client. +**A whitelist of currencies.** Two entries looks restrictive; that's the point. The *domain* decides which currencies the business supports. Adding one is a one-line change that forces a moment of thought — including looking up the right exponent — which is what you want when the alternative is silently accepting `"BTC"` or `"EURO"` from a client. ## Immutability changes how arithmetic looks -There's no `SetCents`. If the template grew an `Add`, it would return a *new* value: +There's no `SetMinorUnits`. If the template grew an `Add`, it would return a *new* value: ```go func (m Money) Add(other Money) (Money, error) { if m.currency != other.currency { return Money{}, fmt.Errorf("%w: cannot add %s to %s", ErrValidation, other.currency, m.currency) } - return NewMoney(m.cents+other.cents, m.currency) + return NewMoney(m.minorUnits+other.minorUnits, m.currency) } ``` @@ -98,7 +104,7 @@ func (m *Money) UnmarshalJSON(data []byte) error { return err } - money, err := NewMoney(raw.Cents, raw.Currency) + money, err := NewMoney(raw.MinorUnits, raw.Currency) if err != nil { return err } @@ -110,12 +116,12 @@ func (m *Money) UnmarshalJSON(data []byte) error { Why bother, when the value was valid at serialization time? Because JSON doesn't only come from you. It comes from an outbox row written by last year's code, a queue message from another service, a fixture someone hand-edited. Revalidating on the way in costs two comparisons and closes the whole category. -At the REST edge, the DTO doesn't expose the domain type at all — it carries `price_cents` and `currency` as explicit primitive fields. The name `price_cents` does real work: no client developer will ever wonder whether to send `19.99` or `1999`. +At the REST edge, the DTO doesn't expose the domain type at all — it carries `price_minor_units` and `currency` as explicit primitive fields. The explicit name does real work: no client developer will ever wonder whether to send `19.99` or `1999`. ## The sharp edges I'll tell you about myself -- **`String()` assumes two decimal places.** Correct for EUR and USD, wrong for JPY (0 decimals) or KWD (3). The whitelist keeps the assumption safe *by construction* — but if you go properly multi-currency, "cents" itself becomes a misleading word. The accurate term is *minor units*, and you'll need per-currency exponents from ISO 4217. -- **Division still rounds.** Integer cents make addition exact, but 100 cents split three ways is 33+33+34 no matter what. You need an allocation strategy (largest-remainder works), and if you allocate, *persist the allocation* — a refund must mirror the original split, not recompute it. +- **Formatting is only as good as the exponent table.** `String()` derives its divisor from the whitelist's per-currency exponent, so JPY (0 decimals) and KWD (3) format correctly the day they're added — but only if whoever adds them looks up the right exponent. The table is the contract; a wrong entry is a wrong display everywhere. +- **Division still rounds.** Integer minor units make addition exact, but 100 split three ways is still 33+33+34. You need an allocation strategy (largest-remainder works), and if you allocate, *persist the allocation* — a refund must mirror the original split, not recompute it. - **Parsing at ingestion boundaries is where money bugs actually live.** A bank API sending `"5000"` JPY means 5000 yen, not 50.00 of anything. Parse currency-aware, before the value ever becomes an integer in your system. ## Beyond Money @@ -124,7 +130,7 @@ The pattern generalizes to anything defined by its value and burdened with rules ## Try it -1. Add `GBP` to the whitelist and write the test. One line plus one assertion — feel how cheap extending a value object is. +1. Add `GBP` to the whitelist (exponent 2) and write the test. One line plus one assertion — feel how cheap extending a value object is. Then imagine adding `JPY` instead: the exponent table already handles the formatting. 2. Write `Sub(other Money)` and decide what happens when the result would be negative. (There's no universally right answer: an account balance may go negative, a price may not. Your domain decides.) 3. Round-trip a `Money` through `json.Marshal`/`json.Unmarshal`, then hand-edit the JSON to `"currency": "XXX"` and unmarshal again. Watch the constructor reject it. diff --git a/docs/tutorial/05-repositories.md b/docs/tutorial/05-repositories.md index 7c33dde..251760b 100644 --- a/docs/tutorial/05-repositories.md +++ b/docs/tutorial/05-repositories.md @@ -38,7 +38,7 @@ The concrete side, [`SqlcProductRepository`](https://github.com/sklinkert/go-ddd ```sql -- name: GetProductById :one -SELECT p.id, p.name, p.price_cents, p.currency, p.seller_id, p.created_at, p.updated_at +SELECT p.id, p.name, p.price_minor_units, p.currency, p.seller_id, p.created_at, p.updated_at FROM products p JOIN sellers s ON p.seller_id = s.id WHERE p.id = $1 AND p.deleted_at IS NULL AND s.deleted_at IS NULL; @@ -53,8 +53,8 @@ I prefer this over an ORM in a DDD codebase for a specific reason: **the mapping Reading a row back is a boundary crossing, and the same rule from [chapter 3](03-value-objects.md) applies — reconstruction routes through the validating constructors: ```go -func productFromRow(id uuid.UUID, name string, priceCents int64, currency string, /* ... */) (*entities.Product, error) { - price, err := entities.NewMoney(priceCents, entities.Currency(currency)) +func productFromRow(id uuid.UUID, name string, priceMinorUnits int64, currency string, /* ... */) (*entities.Product, error) { + price, err := entities.NewMoney(priceMinorUnits, entities.Currency(currency)) if err != nil { return nil, err } diff --git a/docs/tutorial/06-cqrs.md b/docs/tutorial/06-cqrs.md index e152529..99b30ab 100644 --- a/docs/tutorial/06-cqrs.md +++ b/docs/tutorial/06-cqrs.md @@ -22,13 +22,13 @@ type CreateProductCommand struct { IdempotencyKey string Id uuid.UUID Name string - PriceCents int64 + PriceMinorUnits int64 Currency entities.Currency SellerId uuid.UUID } ``` -It carries raw-ish data (`PriceCents int64`, not `Money`) because the command is the *request* to do something — turning its data into validated domain objects is the application service's job: +It carries raw-ish data (`PriceMinorUnits int64`, not `Money`) because the command is the *request* to do something — turning its data into validated domain objects is the application service's job: ```go func (s *ProductService) CreateProduct(ctx context.Context, cmd *command.CreateProductCommand) (*command.CreateProductCommandResult, error) { @@ -38,7 +38,7 @@ func (s *ProductService) CreateProduct(ctx context.Context, cmd *command.CreateP return nil, err } - price, err := entities.NewMoney(cmd.PriceCents, cmd.Currency) + price, err := entities.NewMoney(cmd.PriceMinorUnits, cmd.Currency) if err != nil { return nil, err } diff --git a/docs/tutorial/07-domain-events-outbox.md b/docs/tutorial/07-domain-events-outbox.md index df333b1..bfd17de 100644 --- a/docs/tutorial/07-domain-events-outbox.md +++ b/docs/tutorial/07-domain-events-outbox.md @@ -27,7 +27,7 @@ func NewProduct(name string, price Money, seller ValidatedSeller) *Product { product := &Product{ /* ... */ } product.recordEvent(events.NewProductCreated( - product.Id, name, price.Cents(), string(price.Currency()), seller.Id)) + product.Id, name, price.MinorUnits(), string(price.Currency()), seller.Id)) return product } diff --git a/docs/tutorial/08-idempotency.md b/docs/tutorial/08-idempotency.md index 5d5954d..77ca12b 100644 --- a/docs/tutorial/08-idempotency.md +++ b/docs/tutorial/08-idempotency.md @@ -142,7 +142,7 @@ Against the live stack, the behavior in four commands: for i in $(seq 5); do curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8080/api/v1/products \ -H 'Content-Type: application/json' \ - -d '{"idempotency_key":"k-42","name":"Widget","price_cents":999,"currency":"EUR","seller_id":"..."}' & + -d '{"idempotency_key":"k-42","name":"Widget","price_minor_units":999,"currency":"EUR","seller_id":"..."}' & done; wait ``` diff --git a/internal/application/command/create_product_command.go b/internal/application/command/create_product_command.go index 8782574..1c257bd 100644 --- a/internal/application/command/create_product_command.go +++ b/internal/application/command/create_product_command.go @@ -7,12 +7,12 @@ import ( ) type CreateProductCommand struct { - IdempotencyKey string - Id uuid.UUID - Name string - PriceCents int64 - Currency entities.Currency - SellerId uuid.UUID + IdempotencyKey string + Id uuid.UUID + Name string + PriceMinorUnits int64 + Currency entities.Currency + SellerId uuid.UUID } type CreateProductCommandResult struct { diff --git a/internal/application/command/update_product_command.go b/internal/application/command/update_product_command.go index bac0dde..f174dd2 100644 --- a/internal/application/command/update_product_command.go +++ b/internal/application/command/update_product_command.go @@ -7,12 +7,12 @@ import ( ) type UpdateProductCommand struct { - IdempotencyKey string - Id uuid.UUID - Name string - PriceCents int64 - Currency entities.Currency - SellerId uuid.UUID + IdempotencyKey string + Id uuid.UUID + Name string + PriceMinorUnits int64 + Currency entities.Currency + SellerId uuid.UUID } type UpdateProductCommandResult struct { diff --git a/internal/application/services/failure_paths_test.go b/internal/application/services/failure_paths_test.go index 9717fbb..9ec7ded 100644 --- a/internal/application/services/failure_paths_test.go +++ b/internal/application/services/failure_paths_test.go @@ -18,10 +18,10 @@ func TestProductService_CreateProduct_SellerNotFound(t *testing.T) { service := NewProductService(&MockProductRepository{}, &MockSellerRepository{}, NewMockIdempotencyRepository()) _, err := service.CreateProduct(context.Background(), &command.CreateProductCommand{ - Name: "Widget", - PriceCents: 999, - Currency: entities.USD, - SellerId: uuid.New(), // no such seller persisted + Name: "Widget", + PriceMinorUnits: 999, + Currency: entities.USD, + SellerId: uuid.New(), // no such seller persisted }) assert.Error(t, err) @@ -35,10 +35,10 @@ func TestProductService_CreateProduct_InvalidCurrency(t *testing.T) { seller := createPersistedSeller(t, sellerRepo) _, err := service.CreateProduct(context.Background(), &command.CreateProductCommand{ - Name: "Widget", - PriceCents: 999, - Currency: "XXX", - SellerId: seller.Id, + Name: "Widget", + PriceMinorUnits: 999, + Currency: "XXX", + SellerId: seller.Id, }) assert.ErrorIs(t, err, entities.ErrValidation) @@ -49,11 +49,11 @@ func TestProductService_UpdateProduct_NotFound(t *testing.T) { service := NewProductService(&MockProductRepository{}, &MockSellerRepository{}, NewMockIdempotencyRepository()) _, err := service.UpdateProduct(context.Background(), &command.UpdateProductCommand{ - Id: uuid.New(), - Name: "Widget", - PriceCents: 999, - Currency: entities.USD, - SellerId: uuid.New(), + Id: uuid.New(), + Name: "Widget", + PriceMinorUnits: 999, + Currency: entities.USD, + SellerId: uuid.New(), }) assert.EqualError(t, err, "product not found") @@ -70,11 +70,11 @@ func TestProductService_UpdateProduct_ValidationError(t *testing.T) { // Empty name must fail domain validation. _, err = service.UpdateProduct(context.Background(), &command.UpdateProductCommand{ - Id: created.Result.Id, - Name: "", - PriceCents: 999, - Currency: entities.USD, - SellerId: seller.Id, + Id: created.Result.Id, + Name: "", + PriceMinorUnits: 999, + Currency: entities.USD, + SellerId: seller.Id, }) assert.ErrorIs(t, err, entities.ErrValidation) @@ -91,11 +91,11 @@ func TestProductService_UpdateProduct_Success(t *testing.T) { assert.NoError(t, err) updated, err := service.UpdateProduct(context.Background(), &command.UpdateProductCommand{ - Id: created.Result.Id, - Name: "Widget v2", - PriceCents: 1999, - Currency: entities.USD, - SellerId: seller.Id, + Id: created.Result.Id, + Name: "Widget v2", + PriceMinorUnits: 1999, + Currency: entities.USD, + SellerId: seller.Id, }) assert.NoError(t, err) @@ -163,11 +163,11 @@ func TestProductService_UpdateProduct_SellerChangedNotFound(t *testing.T) { // A different (non-existent) seller must fail the seller lookup branch. _, err = service.UpdateProduct(context.Background(), &command.UpdateProductCommand{ - Id: created.Result.Id, - Name: "Widget", - PriceCents: 999, - Currency: entities.USD, - SellerId: uuid.New(), + Id: created.Result.Id, + Name: "Widget", + PriceMinorUnits: 999, + Currency: entities.USD, + SellerId: uuid.New(), }) assert.EqualError(t, err, "seller not found") @@ -189,11 +189,11 @@ func TestProductService_UpdateProduct_SellerChangedSuccess(t *testing.T) { assert.NoError(t, err) updated, err := service.UpdateProduct(context.Background(), &command.UpdateProductCommand{ - Id: created.Result.Id, - Name: "Widget", - PriceCents: 999, - Currency: entities.USD, - SellerId: sellerB.Id, + Id: created.Result.Id, + Name: "Widget", + PriceMinorUnits: 999, + Currency: entities.USD, + SellerId: sellerB.Id, }) assert.NoError(t, err) @@ -210,12 +210,12 @@ func TestProductService_UpdateProduct_IdempotentReplay(t *testing.T) { assert.NoError(t, err) cmd := &command.UpdateProductCommand{ - IdempotencyKey: "upd-key", - Id: created.Result.Id, - Name: "Widget v2", - PriceCents: 1999, - Currency: entities.USD, - SellerId: seller.Id, + IdempotencyKey: "upd-key", + Id: created.Result.Id, + Name: "Widget v2", + PriceMinorUnits: 1999, + Currency: entities.USD, + SellerId: seller.Id, } first, err := service.UpdateProduct(context.Background(), cmd) diff --git a/internal/application/services/product_service.go b/internal/application/services/product_service.go index 4b2703e..429e0dc 100644 --- a/internal/application/services/product_service.go +++ b/internal/application/services/product_service.go @@ -37,7 +37,7 @@ func (s *ProductService) CreateProduct(ctx context.Context, productCommand *comm return nil, err } - price, err := entities.NewMoney(productCommand.PriceCents, productCommand.Currency) + price, err := entities.NewMoney(productCommand.PriceMinorUnits, productCommand.Currency) if err != nil { return nil, err } @@ -116,7 +116,7 @@ func (s *ProductService) UpdateProduct(ctx context.Context, productCommand *comm return nil, err } - price, err := entities.NewMoney(productCommand.PriceCents, productCommand.Currency) + price, err := entities.NewMoney(productCommand.PriceMinorUnits, productCommand.Currency) if err != nil { return nil, err } diff --git a/internal/application/services/product_service_test.go b/internal/application/services/product_service_test.go index 64ddb6a..e72eb34 100644 --- a/internal/application/services/product_service_test.go +++ b/internal/application/services/product_service_test.go @@ -202,12 +202,12 @@ func TestProductService_FindProductById(t *testing.T) { } } -func getCreateProductCommand(name string, priceCents int64, sellerId uuid.UUID) *command.CreateProductCommand { +func getCreateProductCommand(name string, priceMinorUnits int64, sellerId uuid.UUID) *command.CreateProductCommand { return &command.CreateProductCommand{ - Name: name, - PriceCents: priceCents, - Currency: entities.USD, - SellerId: sellerId, + Name: name, + PriceMinorUnits: priceMinorUnits, + Currency: entities.USD, + SellerId: sellerId, } } diff --git a/internal/domain/entities/money.go b/internal/domain/entities/money.go index 6df7c80..417fe67 100644 --- a/internal/domain/entities/money.go +++ b/internal/domain/entities/money.go @@ -13,31 +13,36 @@ const ( USD Currency = "USD" ) -var supportedCurrencies = map[Currency]struct{}{ - EUR: {}, - USD: {}, +// supportedCurrencies maps each supported currency to its ISO 4217 +// minor-unit exponent. Not every currency has two decimals: JPY, KRW, +// CLP and ISK have 0; BHD, JOD, KWD, OMR and TND have 3. When adding a +// currency here, use its real exponent so String() stays correct. +var supportedCurrencies = map[Currency]int{ + EUR: 2, + USD: 2, } -// Money is an immutable value object storing an amount in minor units -// (cents) to avoid floating-point rounding errors. +// Money is an immutable value object storing an amount in ISO 4217 minor +// units (cents for EUR/USD, yen for JPY, fils for BHD) to avoid +// floating-point rounding errors. type Money struct { - cents int64 - currency Currency + minorUnits int64 + currency Currency } -func NewMoney(cents int64, currency Currency) (Money, error) { - if cents < 0 { +func NewMoney(minorUnits int64, currency Currency) (Money, error) { + if minorUnits < 0 { return Money{}, fmt.Errorf("%w: amount must not be negative", ErrValidation) } if _, ok := supportedCurrencies[currency]; !ok { return Money{}, fmt.Errorf("%w: unsupported currency %q", ErrValidation, currency) } - return Money{cents: cents, currency: currency}, nil + return Money{minorUnits: minorUnits, currency: currency}, nil } -func (m Money) Cents() int64 { - return m.cents +func (m Money) MinorUnits() int64 { + return m.minorUnits } func (m Money) Currency() Currency { @@ -49,16 +54,26 @@ func (m Money) IsZero() bool { } func (m Money) String() string { - return fmt.Sprintf("%d.%02d %s", m.cents/100, m.cents%100, m.currency) + exponent := supportedCurrencies[m.currency] + if exponent == 0 { + return fmt.Sprintf("%d %s", m.minorUnits, m.currency) + } + + divisor := int64(1) + for range exponent { + divisor *= 10 + } + + return fmt.Sprintf("%d.%0*d %s", m.minorUnits/divisor, exponent, m.minorUnits%divisor, m.currency) } type moneyJSON struct { - Cents int64 `json:"cents"` - Currency Currency `json:"currency"` + MinorUnits int64 `json:"minor_units"` + Currency Currency `json:"currency"` } func (m Money) MarshalJSON() ([]byte, error) { - return json.Marshal(moneyJSON{Cents: m.cents, Currency: m.currency}) + return json.Marshal(moneyJSON{MinorUnits: m.minorUnits, Currency: m.currency}) } // UnmarshalJSON goes through NewMoney so a Money can never be deserialized @@ -69,7 +84,7 @@ func (m *Money) UnmarshalJSON(data []byte) error { return err } - money, err := NewMoney(raw.Cents, raw.Currency) + money, err := NewMoney(raw.MinorUnits, raw.Currency) if err != nil { return err } diff --git a/internal/domain/entities/money_test.go b/internal/domain/entities/money_test.go index 25525fa..8e7ffd8 100644 --- a/internal/domain/entities/money_test.go +++ b/internal/domain/entities/money_test.go @@ -12,20 +12,20 @@ func TestNewMoney(t *testing.T) { money, err := NewMoney(1234, USD) require.NoError(t, err) - assert.Equal(t, int64(1234), money.Cents()) + assert.Equal(t, int64(1234), money.MinorUnits()) assert.Equal(t, USD, money.Currency()) assert.False(t, money.IsZero()) } -func TestNewMoney_ZeroCentsAllowed(t *testing.T) { +func TestNewMoney_ZeroAmountAllowed(t *testing.T) { money, err := NewMoney(0, EUR) require.NoError(t, err) - assert.Equal(t, int64(0), money.Cents()) + assert.Equal(t, int64(0), money.MinorUnits()) assert.Equal(t, EUR, money.Currency()) } -func TestNewMoney_NegativeCents(t *testing.T) { +func TestNewMoney_NegativeAmount(t *testing.T) { _, err := NewMoney(-1, USD) assert.Error(t, err) assert.Contains(t, err.Error(), "must not be negative") @@ -50,7 +50,7 @@ func TestMoney_IsZero(t *testing.T) { require.NoError(t, err) assert.False(t, money.IsZero()) - // Zero cents with a currency is not the zero value + // A zero amount with a currency is not the zero value zeroUsd, err := NewMoney(0, USD) require.NoError(t, err) assert.False(t, zeroUsd.IsZero()) @@ -58,9 +58,9 @@ func TestMoney_IsZero(t *testing.T) { func TestMoney_String(t *testing.T) { testCases := []struct { - cents int64 - currency Currency - expected string + minorUnits int64 + currency Currency + expected string }{ {1234, USD, "12.34 USD"}, {5, EUR, "0.05 EUR"}, @@ -70,7 +70,40 @@ func TestMoney_String(t *testing.T) { for _, tc := range testCases { t.Run(tc.expected, func(t *testing.T) { - money, err := NewMoney(tc.cents, tc.currency) + money, err := NewMoney(tc.minorUnits, tc.currency) + require.NoError(t, err) + assert.Equal(t, tc.expected, money.String()) + }) + } +} + +// TestMoney_String_ExponentAware pins the formatting contract for +// non-exponent-2 currencies without adding them to the whitelist: the +// divisor comes from the exponent table, not a hardcoded 100. +func TestMoney_String_ExponentAware(t *testing.T) { + const ( + testJPY Currency = "JPY" // exponent 0 + testBHD Currency = "BHD" // exponent 3 + ) + supportedCurrencies[testJPY] = 0 + supportedCurrencies[testBHD] = 3 + defer delete(supportedCurrencies, testJPY) + defer delete(supportedCurrencies, testBHD) + + testCases := []struct { + minorUnits int64 + currency Currency + expected string + }{ + {5000, testJPY, "5000 JPY"}, + {0, testJPY, "0 JPY"}, + {12345, testBHD, "12.345 BHD"}, + {5, testBHD, "0.005 BHD"}, + } + + for _, tc := range testCases { + t.Run(tc.expected, func(t *testing.T) { + money, err := NewMoney(tc.minorUnits, tc.currency) require.NoError(t, err) assert.Equal(t, tc.expected, money.String()) }) @@ -83,7 +116,7 @@ func TestMoney_JSONRoundTrip(t *testing.T) { data, err := json.Marshal(original) require.NoError(t, err) - assert.JSONEq(t, `{"cents":1234,"currency":"USD"}`, string(data)) + assert.JSONEq(t, `{"minor_units":1234,"currency":"USD"}`, string(data)) var decoded Money require.NoError(t, json.Unmarshal(data, &decoded)) @@ -95,10 +128,10 @@ func TestMoney_UnmarshalJSON_InvalidState(t *testing.T) { name string json string }{ - {"negative cents", `{"cents":-100,"currency":"USD"}`}, - {"unsupported currency", `{"cents":100,"currency":"GBP"}`}, - {"missing currency", `{"cents":100}`}, - {"malformed json", `{"cents":`}, + {"negative amount", `{"minor_units":-100,"currency":"USD"}`}, + {"unsupported currency", `{"minor_units":100,"currency":"GBP"}`}, + {"missing currency", `{"minor_units":100}`}, + {"malformed json", `{"minor_units":`}, } for _, tc := range testCases { diff --git a/internal/domain/entities/product.go b/internal/domain/entities/product.go index a1c8dd3..81f8365 100644 --- a/internal/domain/entities/product.go +++ b/internal/domain/entities/product.go @@ -23,7 +23,7 @@ func (p *Product) validate() error { if p.Name == "" { return fmt.Errorf("%w: name must not be empty", ErrValidation) } - if p.Price.Cents() == 0 { + if p.Price.MinorUnits() == 0 { return fmt.Errorf("%w: price must be greater than 0", ErrValidation) } if p.SellerId == uuid.Nil { @@ -49,7 +49,7 @@ func NewProduct(name string, price Money, seller ValidatedSeller) *Product { SellerId: seller.Id, } - product.recordEvent(events.NewProductCreated(product.Id, name, price.Cents(), string(price.Currency()), seller.Id)) + product.recordEvent(events.NewProductCreated(product.Id, name, price.MinorUnits(), string(price.Currency()), seller.Id)) return product } diff --git a/internal/domain/entities/product_business_logic_test.go b/internal/domain/entities/product_business_logic_test.go index 661fb1f..ae6ac63 100644 --- a/internal/domain/entities/product_business_logic_test.go +++ b/internal/domain/entities/product_business_logic_test.go @@ -9,9 +9,9 @@ import ( "github.com/stretchr/testify/require" ) -func mustMoney(t *testing.T, cents int64, currency Currency) Money { +func mustMoney(t *testing.T, minorUnits int64, currency Currency) Money { t.Helper() - money, err := NewMoney(cents, currency) + money, err := NewMoney(minorUnits, currency) require.NoError(t, err) return money } @@ -129,11 +129,11 @@ func TestProduct_Validate_AllEdgeCases(t *testing.T) { require.NoError(t, err) testCases := []struct { - name string - productName string - priceCents int64 - sellerId uuid.UUID - expectedError string + name string + productName string + priceMinorUnits int64 + sellerId uuid.UUID + expectedError string }{ {"empty name", "", 1000, validatedSeller.Id, "name must not be empty"}, {"zero price", "Valid Product", 0, validatedSeller.Id, "price must be greater than 0"}, @@ -145,7 +145,7 @@ func TestProduct_Validate_AllEdgeCases(t *testing.T) { t.Run(tc.name, func(t *testing.T) { product := &Product{ Name: tc.productName, - Price: mustMoney(t, tc.priceCents, USD), + Price: mustMoney(t, tc.priceMinorUnits, USD), SellerId: tc.sellerId, CreatedAt: time.Now(), UpdatedAt: time.Now(), diff --git a/internal/domain/events/events_test.go b/internal/domain/events/events_test.go index c3173fa..fdc28b2 100644 --- a/internal/domain/events/events_test.go +++ b/internal/domain/events/events_test.go @@ -17,7 +17,7 @@ func TestNewProductCreated(t *testing.T) { assert.Equal(t, "product.created", event.EventName()) assert.Equal(t, productId, event.AggregateId()) assert.Equal(t, sellerId, event.SellerId) - assert.Equal(t, int64(999), event.PriceCents) + assert.Equal(t, int64(999), event.PriceMinorUnits) assert.Equal(t, "USD", event.Currency) assert.NotEqual(t, uuid.Nil, event.EventId()) assert.WithinDuration(t, time.Now(), event.OccurredAt(), time.Second) diff --git a/internal/domain/events/product_events.go b/internal/domain/events/product_events.go index c1ab076..893fead 100644 --- a/internal/domain/events/product_events.go +++ b/internal/domain/events/product_events.go @@ -6,19 +6,19 @@ const ProductCreatedEventName = "product.created" type ProductCreated struct { BaseEvent - Name string - PriceCents int64 - Currency string - SellerId uuid.UUID + Name string + PriceMinorUnits int64 + Currency string + SellerId uuid.UUID } -func NewProductCreated(productId uuid.UUID, name string, priceCents int64, currency string, sellerId uuid.UUID) ProductCreated { +func NewProductCreated(productId uuid.UUID, name string, priceMinorUnits int64, currency string, sellerId uuid.UUID) ProductCreated { return ProductCreated{ - BaseEvent: NewBaseEvent(productId), - Name: name, - PriceCents: priceCents, - Currency: currency, - SellerId: sellerId, + BaseEvent: NewBaseEvent(productId), + Name: name, + PriceMinorUnits: priceMinorUnits, + Currency: currency, + SellerId: sellerId, } } diff --git a/internal/infrastructure/db/postgres/outbox_test.go b/internal/infrastructure/db/postgres/outbox_test.go index a8f5458..03db5f1 100644 --- a/internal/infrastructure/db/postgres/outbox_test.go +++ b/internal/infrastructure/db/postgres/outbox_test.go @@ -37,13 +37,13 @@ func TestSqlcProductRepository_Create_WritesOutboxEvent(t *testing.T) { assert.False(t, event.PublishedAt.Valid) var payload struct { - Name string `json:"Name"` - PriceCents int64 `json:"PriceCents"` - Currency string `json:"Currency"` + Name string `json:"Name"` + PriceMinorUnits int64 `json:"PriceMinorUnits"` + Currency string `json:"Currency"` } require.NoError(t, json.Unmarshal(event.Payload, &payload)) assert.Equal(t, "Outbox Product", payload.Name) - assert.Equal(t, int64(1299), payload.PriceCents) + assert.Equal(t, int64(1299), payload.PriceMinorUnits) assert.Equal(t, "EUR", payload.Currency) // Marking published removes it from the unpublished set (relay behavior). diff --git a/internal/infrastructure/db/postgres/sqlc_idempotency_repository_test.go b/internal/infrastructure/db/postgres/sqlc_idempotency_repository_test.go index 2f12dd9..a970d6b 100644 --- a/internal/infrastructure/db/postgres/sqlc_idempotency_repository_test.go +++ b/internal/infrastructure/db/postgres/sqlc_idempotency_repository_test.go @@ -218,7 +218,7 @@ func TestSqlcIdempotencyRepository_Integration_Workflow(t *testing.T) { ctx := context.Background() key := "workflow-test-key" - requestData := `{"product": "test", "price_cents": 9999, "currency": "USD"}` + requestData := `{"product": "test", "price_minor_units": 9999, "currency": "USD"}` // Step 1: Key does not exist yet existingRecord, err := repo.FindByKey(ctx, key) diff --git a/internal/infrastructure/db/postgres/sqlc_product_repository.go b/internal/infrastructure/db/postgres/sqlc_product_repository.go index 20db758..bf3aab4 100644 --- a/internal/infrastructure/db/postgres/sqlc_product_repository.go +++ b/internal/infrastructure/db/postgres/sqlc_product_repository.go @@ -37,13 +37,13 @@ func (repo *SqlcProductRepository) Create(ctx context.Context, product *entities qtx := repo.queries.WithTx(tx) if _, err := qtx.CreateProduct(ctx, db.CreateProductParams{ - ID: product.Id, - Name: product.Name, - PriceCents: product.Price.Cents(), - Currency: string(product.Price.Currency()), - SellerID: product.SellerId, - CreatedAt: timestamptzFromTime(product.CreatedAt), - UpdatedAt: timestamptzFromTime(product.UpdatedAt), + ID: product.Id, + Name: product.Name, + PriceMinorUnits: product.Price.MinorUnits(), + Currency: string(product.Price.Currency()), + SellerID: product.SellerId, + CreatedAt: timestamptzFromTime(product.CreatedAt), + UpdatedAt: timestamptzFromTime(product.UpdatedAt), }); err != nil { return nil, err } @@ -57,7 +57,7 @@ func (repo *SqlcProductRepository) Create(ctx context.Context, product *entities return nil, err } - created, err := productFromRow(row.ID, row.Name, row.PriceCents, row.Currency, row.SellerID, row.CreatedAt, row.UpdatedAt) + created, err := productFromRow(row.ID, row.Name, row.PriceMinorUnits, row.Currency, row.SellerID, row.CreatedAt, row.UpdatedAt) if err != nil { return nil, err } @@ -80,7 +80,7 @@ func (repo *SqlcProductRepository) FindById(ctx context.Context, id uuid.UUID) ( return nil, err } - return productFromRow(row.ID, row.Name, row.PriceCents, row.Currency, row.SellerID, row.CreatedAt, row.UpdatedAt) + return productFromRow(row.ID, row.Name, row.PriceMinorUnits, row.Currency, row.SellerID, row.CreatedAt, row.UpdatedAt) } func (repo *SqlcProductRepository) FindAll(ctx context.Context) ([]*entities.Product, error) { @@ -91,7 +91,7 @@ func (repo *SqlcProductRepository) FindAll(ctx context.Context) ([]*entities.Pro products := make([]*entities.Product, len(rows)) for i, row := range rows { - product, err := productFromRow(row.ID, row.Name, row.PriceCents, row.Currency, row.SellerID, row.CreatedAt, row.UpdatedAt) + product, err := productFromRow(row.ID, row.Name, row.PriceMinorUnits, row.Currency, row.SellerID, row.CreatedAt, row.UpdatedAt) if err != nil { return nil, err } @@ -103,12 +103,12 @@ func (repo *SqlcProductRepository) FindAll(ctx context.Context) ([]*entities.Pro func (repo *SqlcProductRepository) Update(ctx context.Context, product *entities.ValidatedProduct) (*entities.Product, error) { rows, err := repo.queries.UpdateProduct(ctx, db.UpdateProductParams{ - ID: product.Id, - Name: product.Name, - PriceCents: product.Price.Cents(), - Currency: string(product.Price.Currency()), - SellerID: product.SellerId, - UpdatedAt: timestamptzFromTime(product.UpdatedAt), + ID: product.Id, + Name: product.Name, + PriceMinorUnits: product.Price.MinorUnits(), + Currency: string(product.Price.Currency()), + SellerID: product.SellerId, + UpdatedAt: timestamptzFromTime(product.UpdatedAt), }) if err != nil { return nil, err @@ -125,8 +125,8 @@ func (repo *SqlcProductRepository) Delete(ctx context.Context, id uuid.UUID) err return repo.queries.DeleteProduct(ctx, id) } -func productFromRow(id uuid.UUID, name string, priceCents int64, currency string, sellerId uuid.UUID, createdAt, updatedAt pgtype.Timestamptz) (*entities.Product, error) { - price, err := entities.NewMoney(priceCents, entities.Currency(currency)) +func productFromRow(id uuid.UUID, name string, priceMinorUnits int64, currency string, sellerId uuid.UUID, createdAt, updatedAt pgtype.Timestamptz) (*entities.Product, error) { + price, err := entities.NewMoney(priceMinorUnits, entities.Currency(currency)) if err != nil { return nil, err } diff --git a/internal/infrastructure/db/postgres/sqlc_product_repository_test.go b/internal/infrastructure/db/postgres/sqlc_product_repository_test.go index 1aafdcb..ec18555 100644 --- a/internal/infrastructure/db/postgres/sqlc_product_repository_test.go +++ b/internal/infrastructure/db/postgres/sqlc_product_repository_test.go @@ -13,9 +13,9 @@ import ( "github.com/sklinkert/go-ddd/internal/testhelpers" ) -func mustMoney(t *testing.T, cents int64, currency entities.Currency) entities.Money { +func mustMoney(t *testing.T, minorUnits int64, currency entities.Currency) entities.Money { t.Helper() - money, err := entities.NewMoney(cents, currency) + money, err := entities.NewMoney(minorUnits, currency) require.NoError(t, err) return money } @@ -53,7 +53,7 @@ func TestSqlcProductRepository_Create(t *testing.T) { require.NotNil(t, createdProduct) assert.Equal(t, validatedProduct.Name, createdProduct.Name) assert.Equal(t, validatedProduct.Price, createdProduct.Price) - assert.Equal(t, int64(9999), createdProduct.Price.Cents()) + assert.Equal(t, int64(9999), createdProduct.Price.MinorUnits()) assert.Equal(t, entities.USD, createdProduct.Price.Currency()) assert.Equal(t, validatedSeller.Id, createdProduct.SellerId) assert.NotEqual(t, uuid.Nil, createdProduct.Id) diff --git a/internal/infrastructure/db/sqlc/models.go b/internal/infrastructure/db/sqlc/models.go index 4c9bb01..fb5d9d1 100644 --- a/internal/infrastructure/db/sqlc/models.go +++ b/internal/infrastructure/db/sqlc/models.go @@ -28,14 +28,14 @@ type OutboxEvent struct { } type Product struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - SellerID uuid.UUID `db:"seller_id" json:"seller_id"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` - DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` - PriceCents int64 `db:"price_cents" json:"price_cents"` - Currency string `db:"currency" json:"currency"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + SellerID uuid.UUID `db:"seller_id" json:"seller_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` + PriceMinorUnits int64 `db:"price_minor_units" json:"price_minor_units"` + Currency string `db:"currency" json:"currency"` } type Seller struct { diff --git a/internal/infrastructure/db/sqlc/products.sql.go b/internal/infrastructure/db/sqlc/products.sql.go index 1ffd3d3..4a9e25a 100644 --- a/internal/infrastructure/db/sqlc/products.sql.go +++ b/internal/infrastructure/db/sqlc/products.sql.go @@ -13,26 +13,26 @@ import ( ) const createProduct = `-- name: CreateProduct :one -INSERT INTO products (id, name, price_cents, currency, seller_id, created_at, updated_at) +INSERT INTO products (id, name, price_minor_units, currency, seller_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, name, seller_id, created_at, updated_at, deleted_at, price_cents, currency +RETURNING id, name, seller_id, created_at, updated_at, deleted_at, price_minor_units, currency ` type CreateProductParams struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - PriceCents int64 `db:"price_cents" json:"price_cents"` - Currency string `db:"currency" json:"currency"` - SellerID uuid.UUID `db:"seller_id" json:"seller_id"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + PriceMinorUnits int64 `db:"price_minor_units" json:"price_minor_units"` + Currency string `db:"currency" json:"currency"` + SellerID uuid.UUID `db:"seller_id" json:"seller_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } func (q *Queries) CreateProduct(ctx context.Context, arg CreateProductParams) (Product, error) { row := q.db.QueryRow(ctx, createProduct, arg.ID, arg.Name, - arg.PriceCents, + arg.PriceMinorUnits, arg.Currency, arg.SellerID, arg.CreatedAt, @@ -46,7 +46,7 @@ func (q *Queries) CreateProduct(ctx context.Context, arg CreateProductParams) (P &i.CreatedAt, &i.UpdatedAt, &i.DeletedAt, - &i.PriceCents, + &i.PriceMinorUnits, &i.Currency, ) return i, err @@ -62,7 +62,7 @@ func (q *Queries) DeleteProduct(ctx context.Context, id uuid.UUID) error { } const getAllProducts = `-- name: GetAllProducts :many -SELECT p.id, p.name, p.price_cents, p.currency, p.seller_id, p.created_at, p.updated_at +SELECT p.id, p.name, p.price_minor_units, p.currency, p.seller_id, p.created_at, p.updated_at FROM products p JOIN sellers s ON p.seller_id = s.id WHERE p.deleted_at IS NULL AND s.deleted_at IS NULL @@ -70,13 +70,13 @@ ORDER BY p.created_at DESC ` type GetAllProductsRow struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - PriceCents int64 `db:"price_cents" json:"price_cents"` - Currency string `db:"currency" json:"currency"` - SellerID uuid.UUID `db:"seller_id" json:"seller_id"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + PriceMinorUnits int64 `db:"price_minor_units" json:"price_minor_units"` + Currency string `db:"currency" json:"currency"` + SellerID uuid.UUID `db:"seller_id" json:"seller_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } func (q *Queries) GetAllProducts(ctx context.Context) ([]GetAllProductsRow, error) { @@ -91,7 +91,7 @@ func (q *Queries) GetAllProducts(ctx context.Context) ([]GetAllProductsRow, erro if err := rows.Scan( &i.ID, &i.Name, - &i.PriceCents, + &i.PriceMinorUnits, &i.Currency, &i.SellerID, &i.CreatedAt, @@ -108,20 +108,20 @@ func (q *Queries) GetAllProducts(ctx context.Context) ([]GetAllProductsRow, erro } const getProductById = `-- name: GetProductById :one -SELECT p.id, p.name, p.price_cents, p.currency, p.seller_id, p.created_at, p.updated_at +SELECT p.id, p.name, p.price_minor_units, p.currency, p.seller_id, p.created_at, p.updated_at FROM products p JOIN sellers s ON p.seller_id = s.id WHERE p.id = $1 AND p.deleted_at IS NULL AND s.deleted_at IS NULL ` type GetProductByIdRow struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - PriceCents int64 `db:"price_cents" json:"price_cents"` - Currency string `db:"currency" json:"currency"` - SellerID uuid.UUID `db:"seller_id" json:"seller_id"` - CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + PriceMinorUnits int64 `db:"price_minor_units" json:"price_minor_units"` + Currency string `db:"currency" json:"currency"` + SellerID uuid.UUID `db:"seller_id" json:"seller_id"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } func (q *Queries) GetProductById(ctx context.Context, id uuid.UUID) (GetProductByIdRow, error) { @@ -130,7 +130,7 @@ func (q *Queries) GetProductById(ctx context.Context, id uuid.UUID) (GetProductB err := row.Scan( &i.ID, &i.Name, - &i.PriceCents, + &i.PriceMinorUnits, &i.Currency, &i.SellerID, &i.CreatedAt, @@ -141,24 +141,24 @@ func (q *Queries) GetProductById(ctx context.Context, id uuid.UUID) (GetProductB const updateProduct = `-- name: UpdateProduct :execrows UPDATE products -SET name = $2, price_cents = $3, currency = $4, seller_id = $5, updated_at = $6 +SET name = $2, price_minor_units = $3, currency = $4, seller_id = $5, updated_at = $6 WHERE id = $1 AND deleted_at IS NULL ` type UpdateProductParams struct { - ID uuid.UUID `db:"id" json:"id"` - Name string `db:"name" json:"name"` - PriceCents int64 `db:"price_cents" json:"price_cents"` - Currency string `db:"currency" json:"currency"` - SellerID uuid.UUID `db:"seller_id" json:"seller_id"` - UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + ID uuid.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + PriceMinorUnits int64 `db:"price_minor_units" json:"price_minor_units"` + Currency string `db:"currency" json:"currency"` + SellerID uuid.UUID `db:"seller_id" json:"seller_id"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } func (q *Queries) UpdateProduct(ctx context.Context, arg UpdateProductParams) (int64, error) { result, err := q.db.Exec(ctx, updateProduct, arg.ID, arg.Name, - arg.PriceCents, + arg.PriceMinorUnits, arg.Currency, arg.SellerID, arg.UpdatedAt, diff --git a/internal/interface/api/rest/dto/mapper/product_response_mapper.go b/internal/interface/api/rest/dto/mapper/product_response_mapper.go index f3dbff4..5f68cc9 100644 --- a/internal/interface/api/rest/dto/mapper/product_response_mapper.go +++ b/internal/interface/api/rest/dto/mapper/product_response_mapper.go @@ -7,13 +7,13 @@ import ( func ToProductResponse(product *common.ProductResult) *response.ProductResponse { return &response.ProductResponse{ - Id: product.Id.String(), - Name: product.Name, - PriceCents: product.Price.Cents(), - Currency: string(product.Price.Currency()), - SellerId: product.SellerId.String(), - CreatedAt: product.CreatedAt, - UpdatedAt: product.UpdatedAt, + Id: product.Id.String(), + Name: product.Name, + PriceMinorUnits: product.Price.MinorUnits(), + Currency: string(product.Price.Currency()), + SellerId: product.SellerId.String(), + CreatedAt: product.CreatedAt, + UpdatedAt: product.UpdatedAt, } } diff --git a/internal/interface/api/rest/dto/mapper/response_mapper_test.go b/internal/interface/api/rest/dto/mapper/response_mapper_test.go index f28e99c..0da6f8f 100644 --- a/internal/interface/api/rest/dto/mapper/response_mapper_test.go +++ b/internal/interface/api/rest/dto/mapper/response_mapper_test.go @@ -12,9 +12,9 @@ import ( "github.com/stretchr/testify/require" ) -func mustMoney(t *testing.T, cents int64, currency entities.Currency) entities.Money { +func mustMoney(t *testing.T, minorUnits int64, currency entities.Currency) entities.Money { t.Helper() - money, err := entities.NewMoney(cents, currency) + money, err := entities.NewMoney(minorUnits, currency) require.NoError(t, err) return money } @@ -66,7 +66,7 @@ func TestToProductResponse(t *testing.T) { assert.Equal(t, id.String(), resp.Id) assert.Equal(t, "Widget", resp.Name) - assert.Equal(t, int64(999), resp.PriceCents) + assert.Equal(t, int64(999), resp.PriceMinorUnits) assert.Equal(t, "USD", resp.Currency) assert.Equal(t, sellerId.String(), resp.SellerId) assert.Equal(t, now, resp.CreatedAt) @@ -86,7 +86,7 @@ func TestToProductResponse_JsonShape(t *testing.T) { var payload map[string]any require.NoError(t, json.Unmarshal(data, &payload)) - assert.Equal(t, float64(1234), payload["price_cents"]) + assert.Equal(t, float64(1234), payload["price_minor_units"]) assert.Equal(t, "EUR", payload["currency"]) assert.Equal(t, result.SellerId.String(), payload["seller_id"]) } @@ -101,7 +101,7 @@ func TestToProductListResponse(t *testing.T) { assert.Len(t, resp.Products, 2) assert.Equal(t, "Widget", resp.Products[0].Name) - assert.Equal(t, int64(100), resp.Products[0].PriceCents) + assert.Equal(t, int64(100), resp.Products[0].PriceMinorUnits) assert.Equal(t, "Gadget", resp.Products[1].Name) assert.Equal(t, "EUR", resp.Products[1].Currency) } diff --git a/internal/interface/api/rest/dto/request/create_product_request.go b/internal/interface/api/rest/dto/request/create_product_request.go index bcd7547..5d5d149 100644 --- a/internal/interface/api/rest/dto/request/create_product_request.go +++ b/internal/interface/api/rest/dto/request/create_product_request.go @@ -7,11 +7,11 @@ import ( ) type CreateProductRequest struct { - IdempotencyKey string `json:"idempotency_key"` - Name string `json:"name"` - PriceCents int64 `json:"price_cents"` - Currency string `json:"currency"` - SellerId string `json:"seller_id"` + IdempotencyKey string `json:"idempotency_key"` + Name string `json:"name"` + PriceMinorUnits int64 `json:"price_minor_units"` + Currency string `json:"currency"` + SellerId string `json:"seller_id"` } func (req *CreateProductRequest) ToCreateProductCommand() (*command.CreateProductCommand, error) { @@ -21,10 +21,10 @@ func (req *CreateProductRequest) ToCreateProductCommand() (*command.CreateProduc } return &command.CreateProductCommand{ - IdempotencyKey: req.IdempotencyKey, - Name: req.Name, - PriceCents: req.PriceCents, - Currency: entities.Currency(req.Currency), - SellerId: sellerId, + IdempotencyKey: req.IdempotencyKey, + Name: req.Name, + PriceMinorUnits: req.PriceMinorUnits, + Currency: entities.Currency(req.Currency), + SellerId: sellerId, }, nil } diff --git a/internal/interface/api/rest/dto/request/request_test.go b/internal/interface/api/rest/dto/request/request_test.go index db8636f..778ac1d 100644 --- a/internal/interface/api/rest/dto/request/request_test.go +++ b/internal/interface/api/rest/dto/request/request_test.go @@ -13,11 +13,11 @@ import ( func TestCreateProductRequest_ToCreateProductCommand(t *testing.T) { sellerId := uuid.New() req := &CreateProductRequest{ - IdempotencyKey: "key-1", - Name: "Widget", - PriceCents: 999, - Currency: "USD", - SellerId: sellerId.String(), + IdempotencyKey: "key-1", + Name: "Widget", + PriceMinorUnits: 999, + Currency: "USD", + SellerId: sellerId.String(), } cmd, err := req.ToCreateProductCommand() @@ -25,13 +25,13 @@ func TestCreateProductRequest_ToCreateProductCommand(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "key-1", cmd.IdempotencyKey) assert.Equal(t, "Widget", cmd.Name) - assert.Equal(t, int64(999), cmd.PriceCents) + assert.Equal(t, int64(999), cmd.PriceMinorUnits) assert.Equal(t, entities.USD, cmd.Currency) assert.Equal(t, sellerId, cmd.SellerId) } func TestCreateProductRequest_ToCreateProductCommand_InvalidSellerId(t *testing.T) { - req := &CreateProductRequest{Name: "Widget", PriceCents: 999, Currency: "USD", SellerId: "not-a-uuid"} + req := &CreateProductRequest{Name: "Widget", PriceMinorUnits: 999, Currency: "USD", SellerId: "not-a-uuid"} cmd, err := req.ToCreateProductCommand() @@ -41,14 +41,14 @@ func TestCreateProductRequest_ToCreateProductCommand_InvalidSellerId(t *testing. func TestCreateProductRequest_JsonTags(t *testing.T) { sellerId := uuid.New() - body := `{"idempotency_key":"key-1","name":"Widget","price_cents":1234,"currency":"EUR","seller_id":"` + sellerId.String() + `"}` + body := `{"idempotency_key":"key-1","name":"Widget","price_minor_units":1234,"currency":"EUR","seller_id":"` + sellerId.String() + `"}` var req CreateProductRequest require.NoError(t, json.Unmarshal([]byte(body), &req)) assert.Equal(t, "key-1", req.IdempotencyKey) assert.Equal(t, "Widget", req.Name) - assert.Equal(t, int64(1234), req.PriceCents) + assert.Equal(t, int64(1234), req.PriceMinorUnits) assert.Equal(t, "EUR", req.Currency) assert.Equal(t, sellerId.String(), req.SellerId) } @@ -57,11 +57,11 @@ func TestUpdateProductRequest_ToUpdateProductCommand(t *testing.T) { sellerId := uuid.New() productId := uuid.New() req := &UpdateProductRequest{ - IdempotencyKey: "key-2", - Name: "Widget v2", - PriceCents: 1999, - Currency: "EUR", - SellerId: sellerId.String(), + IdempotencyKey: "key-2", + Name: "Widget v2", + PriceMinorUnits: 1999, + Currency: "EUR", + SellerId: sellerId.String(), } cmd, err := req.ToUpdateProductCommand(productId) @@ -70,7 +70,7 @@ func TestUpdateProductRequest_ToUpdateProductCommand(t *testing.T) { assert.Equal(t, productId, cmd.Id) assert.Equal(t, "key-2", cmd.IdempotencyKey) assert.Equal(t, "Widget v2", cmd.Name) - assert.Equal(t, int64(1999), cmd.PriceCents) + assert.Equal(t, int64(1999), cmd.PriceMinorUnits) assert.Equal(t, entities.EUR, cmd.Currency) assert.Equal(t, sellerId, cmd.SellerId) } diff --git a/internal/interface/api/rest/dto/request/update_product_request.go b/internal/interface/api/rest/dto/request/update_product_request.go index b2fec9b..b6119e2 100644 --- a/internal/interface/api/rest/dto/request/update_product_request.go +++ b/internal/interface/api/rest/dto/request/update_product_request.go @@ -7,11 +7,11 @@ import ( ) type UpdateProductRequest struct { - IdempotencyKey string `json:"idempotency_key"` - Name string `json:"name"` - PriceCents int64 `json:"price_cents"` - Currency string `json:"currency"` - SellerId string `json:"seller_id"` + IdempotencyKey string `json:"idempotency_key"` + Name string `json:"name"` + PriceMinorUnits int64 `json:"price_minor_units"` + Currency string `json:"currency"` + SellerId string `json:"seller_id"` } // ToUpdateProductCommand builds the command. The product Id comes from the @@ -23,11 +23,11 @@ func (req *UpdateProductRequest) ToUpdateProductCommand(id uuid.UUID) (*command. } return &command.UpdateProductCommand{ - IdempotencyKey: req.IdempotencyKey, - Id: id, - Name: req.Name, - PriceCents: req.PriceCents, - Currency: entities.Currency(req.Currency), - SellerId: sellerId, + IdempotencyKey: req.IdempotencyKey, + Id: id, + Name: req.Name, + PriceMinorUnits: req.PriceMinorUnits, + Currency: entities.Currency(req.Currency), + SellerId: sellerId, }, nil } diff --git a/internal/interface/api/rest/dto/response/product_response.go b/internal/interface/api/rest/dto/response/product_response.go index ccb8af5..5acc30c 100644 --- a/internal/interface/api/rest/dto/response/product_response.go +++ b/internal/interface/api/rest/dto/response/product_response.go @@ -3,13 +3,13 @@ package response import "time" type ProductResponse struct { - Id string `json:"id"` - Name string `json:"name"` - PriceCents int64 `json:"price_cents"` - Currency string `json:"currency"` - SellerId string `json:"seller_id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id string `json:"id"` + Name string `json:"name"` + PriceMinorUnits int64 `json:"price_minor_units"` + Currency string `json:"currency"` + SellerId string `json:"seller_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListProductsResponse struct { diff --git a/internal/interface/api/rest_test/mock_product_service_test.go b/internal/interface/api/rest_test/mock_product_service_test.go index 87206a2..02f43ff 100644 --- a/internal/interface/api/rest_test/mock_product_service_test.go +++ b/internal/interface/api/rest_test/mock_product_service_test.go @@ -32,7 +32,7 @@ func (m *MockProductService) CreateProduct(ctx context.Context, productCommand * return nil, err } - price, err := entities.NewMoney(productCommand.PriceCents, productCommand.Currency) + price, err := entities.NewMoney(productCommand.PriceMinorUnits, productCommand.Currency) if err != nil { return nil, err } diff --git a/internal/interface/api/rest_test/product_controller_extra_test.go b/internal/interface/api/rest_test/product_controller_extra_test.go index 51c385a..2560a3f 100644 --- a/internal/interface/api/rest_test/product_controller_extra_test.go +++ b/internal/interface/api/rest_test/product_controller_extra_test.go @@ -23,7 +23,7 @@ func TestCreateProduct_ServiceError(t *testing.T) { mockService := new(MockProductService) ctrl := rest.NewProductController(e, mockService) - body, _ := json.Marshal(map[string]any{"name": "X", "price_cents": 100, "currency": "EUR", "seller_id": uuid.NewString()}) + body, _ := json.Marshal(map[string]any{"name": "X", "price_minor_units": 100, "currency": "EUR", "seller_id": uuid.NewString()}) req := httptest.NewRequest(http.MethodPost, "/api/v1/products", bytes.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() @@ -40,7 +40,7 @@ func TestCreateProduct_InvalidSellerId(t *testing.T) { e := echo.New() ctrl := rest.NewProductController(e, new(MockProductService)) - body, _ := json.Marshal(map[string]any{"name": "X", "price_cents": 100, "currency": "EUR", "seller_id": "not-a-uuid"}) + body, _ := json.Marshal(map[string]any{"name": "X", "price_minor_units": 100, "currency": "EUR", "seller_id": "not-a-uuid"}) req := httptest.NewRequest(http.MethodPost, "/api/v1/products", bytes.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() @@ -91,7 +91,7 @@ func TestUpdateProduct_Success(t *testing.T) { id := uuid.New() sellerId := uuid.New() - body, _ := json.Marshal(map[string]any{"name": "Widget v2", "price_cents": 1999, "currency": "USD", "seller_id": sellerId.String()}) + body, _ := json.Marshal(map[string]any{"name": "Widget v2", "price_minor_units": 1999, "currency": "USD", "seller_id": sellerId.String()}) req := httptest.NewRequest(http.MethodPut, "/api/v1/products/"+id.String(), bytes.NewReader(body)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) rec := httptest.NewRecorder() @@ -100,7 +100,7 @@ func TestUpdateProduct_Success(t *testing.T) { c.SetParamValues(id.String()) mockService.On("UpdateProduct", mock.MatchedBy(func(cmd *command.UpdateProductCommand) bool { - return cmd.Id == id && cmd.PriceCents == 1999 && cmd.Currency == entities.USD && cmd.SellerId == sellerId + return cmd.Id == id && cmd.PriceMinorUnits == 1999 && cmd.Currency == entities.USD && cmd.SellerId == sellerId })).Return(&command.UpdateProductCommandResult{ Result: &common.ProductResult{ Id: id, @@ -116,7 +116,7 @@ func TestUpdateProduct_Success(t *testing.T) { var responseBody map[string]any assert.NoError(t, json.Unmarshal(rec.Body.Bytes(), &responseBody)) assert.Equal(t, "Widget v2", responseBody["name"]) - assert.Equal(t, float64(1999), responseBody["price_cents"]) + assert.Equal(t, float64(1999), responseBody["price_minor_units"]) assert.Equal(t, "USD", responseBody["currency"]) assert.Equal(t, sellerId.String(), responseBody["seller_id"]) mockService.AssertExpectations(t) diff --git a/internal/interface/api/rest_test/product_controller_test.go b/internal/interface/api/rest_test/product_controller_test.go index 8c4558b..d73e395 100644 --- a/internal/interface/api/rest_test/product_controller_test.go +++ b/internal/interface/api/rest_test/product_controller_test.go @@ -19,9 +19,9 @@ import ( "github.com/stretchr/testify/assert" ) -func mustMoney(t *testing.T, cents int64, currency entities.Currency) entities.Money { +func mustMoney(t *testing.T, minorUnits int64, currency entities.Currency) entities.Money { t.Helper() - money, err := entities.NewMoney(cents, currency) + money, err := entities.NewMoney(minorUnits, currency) require.NoError(t, err) return money } @@ -32,11 +32,11 @@ func TestCreateProduct(t *testing.T) { mockService := new(MockProductService) sellerId := "123e4567-e89b-12d3-a456-426614174000" reqBody := map[string]interface{}{ - "name": "TestProduct", - "price_cents": 999, - "currency": "USD", - "seller_id": sellerId, - "idempotency_key": "idem-123", + "name": "TestProduct", + "price_minor_units": 999, + "currency": "USD", + "seller_id": sellerId, + "idempotency_key": "idem-123", } reqBodyBytes, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/v1/products", bytes.NewReader(reqBodyBytes)) @@ -47,7 +47,7 @@ func TestCreateProduct(t *testing.T) { mockService.On("CreateProduct", mock.MatchedBy(func(cmd *command.CreateProductCommand) bool { return cmd.Name == "TestProduct" && - cmd.PriceCents == 999 && + cmd.PriceMinorUnits == 999 && cmd.Currency == entities.USD && cmd.SellerId.String() == sellerId && cmd.IdempotencyKey == "idem-123" @@ -63,7 +63,7 @@ func TestCreateProduct(t *testing.T) { // Assertions assert.Equal(t, http.StatusCreated, rec.Code) assert.Equal(t, "TestProduct", responseBody["name"]) - assert.Equal(t, float64(999), responseBody["price_cents"]) + assert.Equal(t, float64(999), responseBody["price_minor_units"]) assert.Equal(t, "USD", responseBody["currency"]) assert.Equal(t, sellerId, responseBody["seller_id"]) assert.NotEmpty(t, responseBody["id"]) @@ -101,11 +101,11 @@ func TestGetAllProducts(t *testing.T) { for _, product := range expectedProducts { expectedListResponse.Products = append(expectedListResponse.Products, &response.ProductResponse{ - Id: product.Id.String(), - Name: product.Name, - PriceCents: product.Price.Cents(), - Currency: string(product.Price.Currency()), - SellerId: product.SellerId.String(), + Id: product.Id.String(), + Name: product.Name, + PriceMinorUnits: product.Price.MinorUnits(), + Currency: string(product.Price.Currency()), + SellerId: product.SellerId.String(), }) } diff --git a/internal/testhelpers/postgres_test_container_test.go b/internal/testhelpers/postgres_test_container_test.go index 60183ba..cbbd36a 100644 --- a/internal/testhelpers/postgres_test_container_test.go +++ b/internal/testhelpers/postgres_test_container_test.go @@ -41,7 +41,7 @@ func TestPostgresTestContainer_TruncateTables(t *testing.T) { // Insert test product _, err = testDB.Pool.Exec(ctx, ` - INSERT INTO products (id, name, price_cents, currency, seller_id, created_at, updated_at) + INSERT INTO products (id, name, price_minor_units, currency, seller_id, created_at, updated_at) VALUES (gen_random_uuid(), 'Test Product', 9999, 'USD', (SELECT id FROM sellers LIMIT 1), NOW(), NOW()) `) diff --git a/migrations/000004_price_minor_units.down.sql b/migrations/000004_price_minor_units.down.sql new file mode 100644 index 0000000..86e86c5 --- /dev/null +++ b/migrations/000004_price_minor_units.down.sql @@ -0,0 +1 @@ +ALTER TABLE products RENAME COLUMN price_minor_units TO price_cents; diff --git a/migrations/000004_price_minor_units.up.sql b/migrations/000004_price_minor_units.up.sql new file mode 100644 index 0000000..99c5831 --- /dev/null +++ b/migrations/000004_price_minor_units.up.sql @@ -0,0 +1,3 @@ +-- Rename the amount column to ISO 4217 terminology: "cents" is only +-- correct for exponent-2 currencies, "minor units" is currency-neutral. +ALTER TABLE products RENAME COLUMN price_cents TO price_minor_units; diff --git a/sql/queries/products.sql b/sql/queries/products.sql index 4508ace..1871e16 100644 --- a/sql/queries/products.sql +++ b/sql/queries/products.sql @@ -1,16 +1,16 @@ -- name: CreateProduct :one -INSERT INTO products (id, name, price_cents, currency, seller_id, created_at, updated_at) +INSERT INTO products (id, name, price_minor_units, currency, seller_id, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *; -- name: GetProductById :one -SELECT p.id, p.name, p.price_cents, p.currency, p.seller_id, p.created_at, p.updated_at +SELECT p.id, p.name, p.price_minor_units, p.currency, p.seller_id, p.created_at, p.updated_at FROM products p JOIN sellers s ON p.seller_id = s.id WHERE p.id = $1 AND p.deleted_at IS NULL AND s.deleted_at IS NULL; -- name: GetAllProducts :many -SELECT p.id, p.name, p.price_cents, p.currency, p.seller_id, p.created_at, p.updated_at +SELECT p.id, p.name, p.price_minor_units, p.currency, p.seller_id, p.created_at, p.updated_at FROM products p JOIN sellers s ON p.seller_id = s.id WHERE p.deleted_at IS NULL AND s.deleted_at IS NULL @@ -18,7 +18,7 @@ ORDER BY p.created_at DESC; -- name: UpdateProduct :execrows UPDATE products -SET name = $2, price_cents = $3, currency = $4, seller_id = $5, updated_at = $6 +SET name = $2, price_minor_units = $3, currency = $4, seller_id = $5, updated_at = $6 WHERE id = $1 AND deleted_at IS NULL; -- name: DeleteProduct :exec From 862011accdec32d5a1fbca2ec75ec88621a8bd64 Mon Sep 17 00:00:00 2001 From: Simon Klinkert Date: Mon, 20 Jul 2026 11:40:57 +0200 Subject: [PATCH 2/3] Migrate pending outbox payloads, reject legacy Money JSON Review follow-ups: migration 000004 now rewrites PriceCents to PriceMinorUnits in unpublished product.created payloads so consumers never read a silent zero price, and Money.UnmarshalJSON fails loudly on the old cents field instead of decoding it as zero. --- internal/domain/entities/money.go | 9 +++ internal/domain/entities/money_test.go | 11 +++ .../db/postgres/outbox_migration_test.go | 70 +++++++++++++++++++ .../testhelpers/postgres_test_container.go | 48 ++++++++----- migrations/000004_price_minor_units.down.sql | 4 ++ migrations/000004_price_minor_units.up.sql | 6 ++ 6 files changed, 132 insertions(+), 16 deletions(-) create mode 100644 internal/infrastructure/db/postgres/outbox_migration_test.go diff --git a/internal/domain/entities/money.go b/internal/domain/entities/money.go index 417fe67..70ea337 100644 --- a/internal/domain/entities/money.go +++ b/internal/domain/entities/money.go @@ -70,6 +70,11 @@ func (m Money) String() string { type moneyJSON struct { MinorUnits int64 `json:"minor_units"` Currency Currency `json:"currency"` + + // LegacyCents catches payloads from before the minor-units rename. + // Ignoring it would decode an old value as zero and NewMoney would + // happily accept that, so its presence is an explicit error instead. + LegacyCents *int64 `json:"cents,omitempty"` } func (m Money) MarshalJSON() ([]byte, error) { @@ -84,6 +89,10 @@ func (m *Money) UnmarshalJSON(data []byte) error { return err } + if raw.LegacyCents != nil { + return fmt.Errorf("%w: legacy field %q is no longer supported, use %q", ErrValidation, "cents", "minor_units") + } + money, err := NewMoney(raw.MinorUnits, raw.Currency) if err != nil { return err diff --git a/internal/domain/entities/money_test.go b/internal/domain/entities/money_test.go index 8e7ffd8..68a6d7f 100644 --- a/internal/domain/entities/money_test.go +++ b/internal/domain/entities/money_test.go @@ -142,3 +142,14 @@ func TestMoney_UnmarshalJSON_InvalidState(t *testing.T) { }) } } + +// A pre-rename payload must fail loudly: silently ignoring "cents" would +// decode as a zero amount, which NewMoney accepts. +func TestMoney_UnmarshalJSON_RejectsLegacyCentsField(t *testing.T) { + var money Money + err := json.Unmarshal([]byte(`{"cents":1234,"currency":"USD"}`), &money) + + require.Error(t, err) + assert.ErrorIs(t, err, ErrValidation) + assert.Contains(t, err.Error(), "minor_units") +} diff --git a/internal/infrastructure/db/postgres/outbox_migration_test.go b/internal/infrastructure/db/postgres/outbox_migration_test.go new file mode 100644 index 0000000..f70629a --- /dev/null +++ b/internal/infrastructure/db/postgres/outbox_migration_test.go @@ -0,0 +1,70 @@ +package postgres + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + db "github.com/sklinkert/go-ddd/internal/infrastructure/db/sqlc" + "github.com/sklinkert/go-ddd/internal/testhelpers" +) + +// The minor-units migration rewrites pending product.created payloads from +// PriceCents to PriceMinorUnits. Without the rewrite, a consumer of the new +// schema would read a silent zero price from pre-rename outbox rows. The +// test seeds a legacy payload and re-runs the migration's UPDATE from the +// actual migration file, so the two cannot drift apart. +func TestMigration000004_RewritesPendingOutboxPayloads(t *testing.T) { + testDB := testhelpers.SetupTestDB(t) + defer testDB.Close(t) + + ctx := context.Background() + + legacyPayload := `{"Id":"0198c0de-0000-7000-8000-000000000001","Aggregate":"0198c0de-0000-7000-8000-000000000002","OccurredAtT":"2026-07-01T00:00:00Z","Name":"Legacy Product","PriceCents":4999,"Currency":"EUR","SellerId":"0198c0de-0000-7000-8000-000000000003"}` + eventId := uuid.Must(uuid.NewV7()) + require.NoError(t, testDB.Queries.InsertOutboxEvent(ctx, db.InsertOutboxEventParams{ + ID: eventId, + AggregateID: uuid.Must(uuid.NewV7()), + EventName: "product.created", + Payload: []byte(legacyPayload), + OccurredAt: timestamptzFromTime(time.Now()), + })) + + migration, err := os.ReadFile(filepath.Join(testhelpers.ProjectRoot(t), "migrations", "000004_price_minor_units.up.sql")) + require.NoError(t, err) + // The column rename already ran during schema setup; re-running just the + // payload UPDATE is idempotent thanks to its `payload ? 'PriceCents'` guard. + _, err = testDB.Pool.Exec(ctx, extractOutboxUpdate(t, string(migration))) + require.NoError(t, err) + + events, err := testDB.Queries.GetUnpublishedOutboxEvents(ctx, 10) + require.NoError(t, err) + require.Len(t, events, 1) + + var payload map[string]json.RawMessage + require.NoError(t, json.Unmarshal(events[0].Payload, &payload)) + + assert.NotContains(t, payload, "PriceCents") + require.Contains(t, payload, "PriceMinorUnits") + assert.Equal(t, "4999", string(payload["PriceMinorUnits"])) + assert.Equal(t, `"Legacy Product"`, string(payload["Name"])) +} + +// extractOutboxUpdate pulls the UPDATE statement out of the migration file +// so the test always runs what the migration actually ships. +func extractOutboxUpdate(t *testing.T, migration string) string { + t.Helper() + + idx := strings.Index(migration, "UPDATE") + require.GreaterOrEqual(t, idx, 0, "migration must contain the outbox UPDATE statement") + + return migration[idx:] +} diff --git a/internal/testhelpers/postgres_test_container.go b/internal/testhelpers/postgres_test_container.go index c1943d6..c478ba2 100644 --- a/internal/testhelpers/postgres_test_container.go +++ b/internal/testhelpers/postgres_test_container.go @@ -84,23 +84,9 @@ func (p *PostgresTestContainer) Close(t *testing.T) { // applySchema applies all up-migrations in order func applySchema(ctx context.Context, pool *pgxpool.Pool) error { - // Get current working directory and find project root - cwd, err := os.Getwd() + projectRoot, err := findProjectRoot() if err != nil { - return fmt.Errorf("failed to get working directory: %w", err) - } - - // Find project root by looking for go.mod file - projectRoot := cwd - for { - if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil { - break - } - parent := filepath.Dir(projectRoot) - if parent == projectRoot { - return fmt.Errorf("could not find project root (go.mod not found)") - } - projectRoot = parent + return err } migrationPaths, err := filepath.Glob(filepath.Join(projectRoot, "migrations", "*.up.sql")) @@ -126,6 +112,36 @@ func applySchema(ctx context.Context, pool *pgxpool.Pool) error { return nil } +// findProjectRoot walks up from the working directory to the go.mod. +func findProjectRoot() (string, error) { + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get working directory: %w", err) + } + + projectRoot := cwd + for { + if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil { + return projectRoot, nil + } + parent := filepath.Dir(projectRoot) + if parent == projectRoot { + return "", fmt.Errorf("could not find project root (go.mod not found)") + } + projectRoot = parent + } +} + +// ProjectRoot returns the repository root for tests that need real files, +// like migration files. +func ProjectRoot(t *testing.T) string { + t.Helper() + + root, err := findProjectRoot() + require.NoError(t, err) + return root +} + // TruncateTables cleans all test data from the database tables func (p *PostgresTestContainer) TruncateTables(t *testing.T) { ctx := context.Background() diff --git a/migrations/000004_price_minor_units.down.sql b/migrations/000004_price_minor_units.down.sql index 86e86c5..0008e1d 100644 --- a/migrations/000004_price_minor_units.down.sql +++ b/migrations/000004_price_minor_units.down.sql @@ -1 +1,5 @@ ALTER TABLE products RENAME COLUMN price_minor_units TO price_cents; + +UPDATE outbox_events +SET payload = (payload - 'PriceMinorUnits') || jsonb_build_object('PriceCents', payload->'PriceMinorUnits') +WHERE event_name = 'product.created' AND payload ? 'PriceMinorUnits'; diff --git a/migrations/000004_price_minor_units.up.sql b/migrations/000004_price_minor_units.up.sql index 99c5831..c44129a 100644 --- a/migrations/000004_price_minor_units.up.sql +++ b/migrations/000004_price_minor_units.up.sql @@ -1,3 +1,9 @@ -- Rename the amount column to ISO 4217 terminology: "cents" is only -- correct for exponent-2 currencies, "minor units" is currency-neutral. ALTER TABLE products RENAME COLUMN price_cents TO price_minor_units; + +-- Rewrite stored event payloads to the renamed field so pending outbox +-- rows publish with the new schema instead of a silently-zero price. +UPDATE outbox_events +SET payload = (payload - 'PriceCents') || jsonb_build_object('PriceMinorUnits', payload->'PriceCents') +WHERE event_name = 'product.created' AND payload ? 'PriceCents'; From 2bc0b8ccc8faffd044c98817e268f14200d9eb04 Mon Sep 17 00:00:00 2001 From: Simon Klinkert Date: Mon, 20 Jul 2026 11:59:20 +0200 Subject: [PATCH 3/3] Money: Drop legacy cents detection from JSON unmarshaling The migration rewrites all stored payloads, so no old-format JSON can exist. Code stays free of legacy awareness. --- internal/domain/entities/money.go | 9 --------- internal/domain/entities/money_test.go | 11 ----------- 2 files changed, 20 deletions(-) diff --git a/internal/domain/entities/money.go b/internal/domain/entities/money.go index 70ea337..417fe67 100644 --- a/internal/domain/entities/money.go +++ b/internal/domain/entities/money.go @@ -70,11 +70,6 @@ func (m Money) String() string { type moneyJSON struct { MinorUnits int64 `json:"minor_units"` Currency Currency `json:"currency"` - - // LegacyCents catches payloads from before the minor-units rename. - // Ignoring it would decode an old value as zero and NewMoney would - // happily accept that, so its presence is an explicit error instead. - LegacyCents *int64 `json:"cents,omitempty"` } func (m Money) MarshalJSON() ([]byte, error) { @@ -89,10 +84,6 @@ func (m *Money) UnmarshalJSON(data []byte) error { return err } - if raw.LegacyCents != nil { - return fmt.Errorf("%w: legacy field %q is no longer supported, use %q", ErrValidation, "cents", "minor_units") - } - money, err := NewMoney(raw.MinorUnits, raw.Currency) if err != nil { return err diff --git a/internal/domain/entities/money_test.go b/internal/domain/entities/money_test.go index 68a6d7f..8e7ffd8 100644 --- a/internal/domain/entities/money_test.go +++ b/internal/domain/entities/money_test.go @@ -142,14 +142,3 @@ func TestMoney_UnmarshalJSON_InvalidState(t *testing.T) { }) } } - -// A pre-rename payload must fail loudly: silently ignoring "cents" would -// decode as a zero amount, which NewMoney accepts. -func TestMoney_UnmarshalJSON_RejectsLegacyCentsField(t *testing.T) { - var money Money - err := json.Unmarshal([]byte(`{"cents":1234,"currency":"USD"}`), &money) - - require.Error(t, err) - assert.ErrorIs(t, err, ErrValidation) - assert.Contains(t, err.Error(), "minor_units") -}