# Marketplace Marketplace in Beto lets a Project's buyer buy fixed provider packs, such as ten H200 hours or a block of storage. A provider fulfils each order behind a provider port; today the provider is Waldur. The [epic](../../docs/epics/marketplace/adr.md) plans the tasks, and [Place an order](../../docs/epics/marketplace/place-order.md) designs the first one. It has its domain, the PlaceOrder and ListProjectOrders contracts and handlers, its [database](adaptors/persistence/README.md) and its private `OrderPlaced` event. Beto serves both over RabbitMQ to the API, with stand-in catalogue and buyer ports, and the API exposes them as [routes](#routes). ## Domain authority `Order` is one aggregate keyed by a canonical `OrderId`, which the caller chooses. One Order is one purchase of one pack for one Project. `NewOrder` creates it directly in status `placed`; there is no draft. The Order keeps the buyer's `PartyId` as `orderedBy` and copies the pack's price, amount and provider plan reference from the catalogue as they were when it was placed. A later catalogue change doesn't change the Order. The Order checks only its own values: canonical Order, Project, Party and pack IDs; a price in integer minor units with a three-letter currency, which may be zero and has no upper bound; an amount of at least one; and a bounded, exact provider plan reference. A pack's name, such as `h200-10`, is not its ID. The PlaceOrder handler checks the catalogue, Project membership and the buyer right. ## State and facts The Order exposes a value snapshot for its persistence mapping. Restoring invalid state returns a corruption error, which stays distinct from an expected command rejection. Restoration raises no events. Placing raises `OrderPlaced` with the order, Project, buyer, pack, price and amount. The provider plan reference stays on the Order for the send policy. The messaging adaptor maps it to the private `marketplace.order_placed` event, which the outbox commits with the Order. Its version 1 bytes are frozen in `adaptors/messaging/events/testdata`. Nothing subscribes to it yet. ## PlaceOrder The [PlaceOrder contract](../../../../contracts/marketplace/messaging/README.md) carries `project_id` and `pack_id`. The command's target is the Order ID and its actor is the buyer; the price comes from the catalogue. `PlaceOrderCommandHandler` refuses, in this order: | Code | When | | --- | --- | | `already_exists` | The Order exists. | | `pack_not_in_catalogue` | The catalogue has no pack with that ID. | | `not_in_project` | The actor is empty, or isn't a member of the Project. | | `no_buyer_right` | The member doesn't hold the Project's buyer right. | Otherwise it places the Order with the catalogue pack's price, amount and provider plan, and saves it. The status is `placed`. The handler reads the catalogue pack through the shared `ProjectionPort`, keyed by `pack_id`, so it is read before the transaction opens. It asks `ProjectBuyers` inside the transaction, because the answer depends on the actor. Neither source exists yet: the catalogue copy comes with Task 5 and the buyer source with Task 6. Until then `adaptors/persistence/mock` answers both, for development only: - `Catalogue` has one pack, `H200TenHours`: ten H200 hours at 1500.00 EUR. - `ProjectBuyers` treats every caller as a member and buyer of every Project. Beto's composition (`services/beto/services/beto/di/marketplace.go`) registers PlaceOrder with them through `commandregistration.RegisterProjection`, keyed by `pack_id`. ## ListProjectOrders The [ListProjectOrders contract](../../../../contracts/marketplace/messaging/README.md) carries `project_id`, `after_order_id` and `page_size`. The caller is the request's verified actor, never a field of the query. `ListProjectOrdersQueryHandler` refuses, in this order, and the reply carries the code: | Code | When | | --- | --- | | `invalid_input` | An ID isn't canonical, or the page is larger than 100. | | `not_in_project` | The caller is empty, or isn't a member of the Project. | | `no_buyer_right` | The member doesn't hold the Project's buyer right. | Only a buyer may list, because other members can't see the Project's costs. Otherwise it returns one page of the Project's Orders, 50 unless asked otherwise, ordered by Order ID. Each item has the Order's version as its revision. It is a view over the stored Orders, not a projection. The shared projection holds only facts that never change, and an Order's status will change once it is sent, so `OrderReads` reads the Orders themselves and each list shows them as they are now. The buyer check uses the same `ProjectBuyers` stand-in as PlaceOrder. ## Routes The API serves both through Marketplace's [HTTP contract](../../../../contracts/marketplace/http_api/orders.paths.json): - `POST /api/v1/marketplace/orders/{id}` places an Order. The body holds only `project_id` and `pack_id`. The path's `{id}` is the Order ID, the `Idempotency-Key` is the command ID, `If-Match` is `0`, and the buyer is the session's subject. A placed Order answers 200, a refusal 422, and `version_conflict` or `idempotency_conflict` 409. - `GET /api/v1/marketplace/projects/{projectId}/orders` lists one page, with optional `after` and `page_size` (1 to 100). It answers `items` and `next_after`, which is null on the last page. `invalid_input` answers 400, and `not_in_project` and `no_buyer_right` answer 403. Both are meant for a Party session. A caller without a Party, such as the CLI key or an operator, can still place an Order with its own actor ID, because the buyer stand-in accepts every caller. Its list is refused with `not_in_project`, because the request carries no verified Party. The real buyer source (Task 6) replaces the stand-in; whether such callers may order or list at all is still open. ## Verification From the repository root: ```sh PHAROS_GO_PROJECT=services/beto python3 scripts/go.py test -race -count=1 ./contexts/marketplace/... ``` The domain tests cover placement, refused values and corrupt restores. The handler tests cover a placed order and each refusal with test-only ports. The query tests cover a buyer's page, each refusal and invalid input with test-only ports. The snapshot tests cover the stored Order's round trip and refused JSON. The PostgreSQL tests run in the native lane; see the [persistence README](adaptors/persistence/README.md). The API's `marketplace_test.go` covers both routes: the buyer comes from the session, invalid requests never reach Marketplace, each owner answer gets its status, and every response matches the contract. From the repository root: ```sh PHAROS_GO_PROJECT=services/api python3 scripts/go.py test -race -count=1 ./adaptors/http/... ``` `pnpm test:project:broker` runs `TestPlaceOrderOverRabbitMQReturnsTheRecordedOutcome` against real PostgreSQL and RabbitMQ with Beto's full composition: the API places an Order, a retry returns the same outcome, a second placement with `If-Match: 0` gets `version_conflict` and a pack outside the stand-in catalogue is refused. `TestListProjectOrdersOverRabbitMQShowsTheBuyersOrder` lists the placed Order for its buyer and refuses a request without a verified caller.