For AI agents: the complete documentation index is available at /llms.txt, the full documentation bundle is available at /llms-full.txt, and this page is available as Markdown at /reference/services/beto/contexts/marketplace/README.md.

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 plans the tasks, and Place an order designs the first one.

It has its domain, the PlaceOrder and ListProjectOrders contracts and handlers, its database 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.

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 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:

CodeWhen
already_existsThe Order exists.
pack_not_in_catalogueThe catalogue has no pack with that ID.
not_in_projectThe actor is empty, or isn't a member of the Project.
no_buyer_rightThe 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 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:

CodeWhen
invalid_inputAn ID isn't canonical, or the page is larger than 100.
not_in_projectThe caller is empty, or isn't a member of the Project.
no_buyer_rightThe 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:

  • 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:

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.

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:

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.

Page source

Source snapshot ยท Revision f928834c8.