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:
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:
Cataloguehas one pack,H200TenHours: ten H200 hours at 1500.00 EUR.ProjectBuyerstreats 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:
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 onlyproject_idandpack_id. The path's{id}is the Order ID, theIdempotency-Keyis the command ID,If-Matchis0, and the buyer is the session's subject. A placed Order answers 200, a refusal 422, andversion_conflictoridempotency_conflict409.GET /api/v1/marketplace/projects/{projectId}/orderslists one page, with optionalafterandpage_size(1 to 100). It answersitemsandnext_after, which is null on the last page.invalid_inputanswers 400, andnot_in_projectandno_buyer_rightanswer 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:
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:
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.