# DD — Place an order (Task 1) A buyer orders a catalogue pack for a project. Beto records the Order with the pack's price, amount and provider plan as they were at that moment. ## Baseline and adaptation Café reference `a9d7bf6` creates an order in its Ordering context with CreateOrder. The handler checks that the target order doesn't exist, checks a published copy of the menu, creates the aggregate and saves it. The menu copy is read before the transaction opens. The aggregate records a private domain event. The shared executor commits the aggregate, command receipt, outcome and outbox row in one transaction (café decisions 0011, 0023 and 0028, and steps 1 to 6 of its end-to-end recipe). Café's own PlaceOrder only moves a draft order to placed. Our Order has no draft, so PlaceOrder follows CreateOrder and creates the Order directly in status `placed`. #696 brought the same executor, `RegisterProjection` and aggregate transaction into Beto, and the API's `CommandBinding` (decision 0046). We adapt this in three ways: - The buyer is the authenticated caller, which the API sends as `actor_id` and Beto gives the handler as `CommandContext.ActorID`. Café takes `customerId` from the request body. - The catalogue copy is read through `RegisterProjection`, keyed by `pack_id`, as café reads its menu copy. The buyer check goes through a plain port. Stand-ins answer both until the catalogue copy (Task 5) and the buyer source (Task 6) exist. The Project context uses a replaceable read port in the same way. - The price is copied from the catalogue and never sent by the caller. The money type lives in this context's domain. Café's shared `Money` caps amounts at 10,000.00, which a pack can exceed. ## Order and contracts | Field | Source | | --- | --- | | `order_id` | Generated by the browser and sent as `{id}` in the path. The API puts it in the command metadata as the target. | | `project_id` | Request body. | | `ordered_by` | The authenticated caller, `CommandContext.ActorID`. | | `pack_id` | Request body. | | `price`, `amount`, `provider_plan_ref` | Catalogue, copied when the order is placed. | | `status` | `placed`. | The PlaceOrder payload carries `project_id` and `pack_id` as plain strings marked as required input, which the contract generator supports. The caller is not in the payload: the API sets `actor_id` in the command metadata, and neither headers nor body can choose it. The private Order Placed event carries `order_id`, `project_id`, `ordered_by`, `pack_id`, `price` and `amount`. `provider_plan_ref` stays on the Order for the send policy. ## Flow 1. The browser sends `POST /api/v1/marketplace/orders/{id}` with `{ "project_id": …, "pack_id": … }`, in the shape of café's `POST /api/v1/ordering/orders/{id}`. It generates the order ID and an `Idempotency-Key` for the attempt and sends `If-Match: 0`, as café's browser does when it creates an order. 2. The API authenticates the session, checks the body against the route's schema and sends a typed PlaceOrder command to Marketplace over RabbitMQ through `CommandBinding`. The order ID is the command's target, the key is its command ID and the session's subject is its `actor_id`. A CLI key has its own actor. 3. Before the transaction opens, `RegisterProjection` reads the pack from the catalogue copy. 4. Inside the transaction the shared code checks the version first. If the Order already exists, `If-Match: 0` is stale and the command is refused with `version_conflict`. The handler then refuses with `already_exists` if the Order exists (this covers commands sent without an expected version, as in café), with `pack_not_in_catalogue`, or, after asking the buyer port, with `not_in_project` or `no_buyer_right`. An empty actor counts as no caller and is refused with `not_in_project`. Otherwise the handler creates the Order in status `placed` and saves it. The Order records Order Placed. 5. The executor commits the Order, receipt, outcome and Order Placed outbox row together and replies with the outcome. A refusal commits its outcome too. The API returns 200 for a placed order, 422 for a refusal and 409 for `version_conflict` or `idempotency_conflict`. 6. ListProjectOrders (1D) reads the stored Orders, so the new Order is listed once it commits. The browser reads it through `GET /api/v1/marketplace/projects/{projectId}/orders` (1E). ## Repeated requests and races - The same command ID with the same input returns the recorded outcome and places nothing new. The same command ID with different input, or from a different caller, is refused as `idempotency_conflict`. - A retry after a lost reply reuses the order ID and the `Idempotency-Key` and receives the recorded outcome. Each attempt gets a fresh transport request ID. - A second attempt with the same order ID and a new command ID is refused as `version_conflict`, because the Order exists and `If-Match: 0` is stale. The browser keeps the order ID until it sees an answer, so a double click places one order. - Two concurrent placements of the same order ID are serialised by the transaction's lock on the order ID. One commits and the other is refused as `version_conflict`. - A catalogue price change after placing doesn't change the Order. - An accepted order stands if the caller later loses membership or the buyer right. ## Files and boundaries | Subtask | Area | Responsibility | | --- | --- | --- | | 1A | `contracts/contexts.json` | Registration: one entry, without a database or messaging. | | 1A | `domain/order.go`, `domain/events.go`, `domain/values.go` | The Order, its placement rules, its money value and Order Placed. | | 1B | `application/commands/place_order.go` | Refusals, existence check, creation and save. | | 1B | `application/ports/`, `application/readmodels/` | `OrderWriteRepository` (café's alias of the shared `WriteRepository`) and `ProjectBuyers`. The catalogue pack is read through the shared `ProjectionPort`. | | 1B | `contracts/marketplace/messaging/proto/`, `contracts/beto/messaging/catalogue.json` | PlaceOrder contract and its catalogue entry, for the API. The handler takes the generated command, so the contract comes before it. Generation adds the values, codecs and command registration. No broker authority yet. | | 1C | `adaptors/persistence/postgres/` | Order write repository (snapshot mapping) and its `AggregateTransaction`. The Order is stored in the shared `pharos.aggregates` table. | | 1C | `adaptors/persistence/migrations/` | Migration manifest. It lists no migrations, because the Order needs only the shared tables. | | 1C | `adaptors/persistence/mock/` | Stand-in catalogue and buyer ports. | | 1C | `adaptors/messaging/` | Private Order Placed proto, publication mapper and byte fixture. | | 1C | `services/beto/contracts/events/catalogue.json` | Private Order Placed entry, visibility `domain`. | | 1C | `contracts/marketplace/messaging/broker.json` | PlaceOrder's broker requests and the API as caller. | | 1C | `contracts/contexts.json`, `services/beto/services/beto/di/marketplace.go`, `di/module.go`, `di/owners.json` | The entry gains its database and messaging. Composition: one new file, one line in `module.go` and `"marketplace": ["command"]` in `owners.json`. | | 1D | `application/queries/`, `application/readmodels/`, `adaptors/persistence/postgres/`, `adaptors/messaging/`, `contracts/marketplace/messaging/` | ListProjectOrders: a paginated view over the stored Orders for the Project's buyers, its contract, and `"query"` beside `"command"` in `owners.json`. It is not a projection: the shared projection holds only facts that never change, and an Order's status will change. | | 1E | `contracts/marketplace/http_api/`, `contracts/http/api.openapi.json`, `services/api/adaptors/http/marketplace.go`, `services/api/adaptors/http/mappings/marketplace.json` | The POST and GET routes with `x-owner`, the body and page schemas, a `CommandBinding` with its body-to-command builder, the list binding with its owner codes, the generated body and page mappings, and one entry in `Bindings()`. | | 1C | `gitops/` | Broker identity, permissions and network rules. The routes need none: the API already reaches RabbitMQ and may send Marketplace requests. | Paths starting with `domain/`, `application/` or `adaptors/` are relative to `services/beto/contexts/marketplace`. Task 1 uses Beto's command side and the API's `CommandBinding`, both from #696 (decision 0046). Beto's contract generation and broker provisioning for contexts other than Project, and the API's generated owner types, come from the change for [#700](https://github.com/pharos-ai-factory/pharos-platform/issues/700) on the Party governance branch, which Task 1 targets. It still needs shared code that it doesn't own: - A buyer source that can be read before the transaction (Task 6). `RegisterProjection` reads one source, keyed from the command, before the transaction opens, so that no second database connection is taken while the Order is locked. The catalogue fits, keyed by `pack_id`. The buyer check doesn't, because it depends on the caller, which is in `CommandContext` rather than in the command. The in-memory stand-in can be handed to the handler directly. A real source needs the shared registration extended, so its shape is agreed first. ## Verification Verified on `13e50ba77`. `pnpm verify` and `pnpm test:integration` passed on the workstation, with three tests skipped by design. A case is a test or one of its subtests. - **1A:** 17 domain cases in [order_test.go](../../../contexts/marketplace/domain/order_test.go): placement, a free pack, twelve invalid placements and corrupt restored state. - **1B:** 6 handler cases in [place_order_test.go](../../../contexts/marketplace/application/commands/place_order_test.go) with **test-only catalogue and buyer ports**: placed, an unknown pack, a caller outside the project (including an empty actor), no buyer right and an existing Order. - **1C:** 8 unit cases for the snapshot codec, Order Placed with its frozen version 1 bytes, the stand-ins and the composition. 6 PostgreSQL cases in [orders_integration_test.go](../../../contexts/marketplace/adaptors/persistence/postgres/orders_integration_test.go) with the restricted role: atomic commit and replay, conflicting retries, stale and concurrent versions, rollback, a corrupt Order and database isolation. 1 RabbitMQ case in [marketplace_integration_test.go](../../../services/beto/di/marketplace_integration_test.go): placed, replayed, `version_conflict` and an unknown pack. - **1D:** 8 query cases in [list_project_orders_test.go](../../../contexts/marketplace/application/queries/list_project_orders_test.go): paging, buyers only, invalid input. 3 PostgreSQL cases in [order_reads_integration_test.go](../../../contexts/marketplace/adaptors/persistence/postgres/order_reads_integration_test.go): the project filter, revisions, paging and a corrupt Order. 1 RabbitMQ case: the buyer lists the Order they placed. - **1E:** 36 route cases in [marketplace_test.go](../../../../api/adaptors/http/marketplace_test.go), each response checked against the contract. The buyer comes from the session, invalid requests never reach Marketplace, and each owner answer maps to its status. ### Infrastructure lanes On the workstation, at `13e50ba77`, with native PostgreSQL 16 and Docker: - `pnpm test:project:postgres` passed, with the Marketplace PostgreSQL cases above. - `pnpm test:project:broker` passed, with the Marketplace RabbitMQ cases above. Three cases that need other fixtures were skipped. `pnpm test:authentication:e2e` (sign-up and onboarding with Marketplace composed) is left to CI's assembled authentication job on the pull request. These are fixture lanes; the installed platform was not redeployed. Not covered: the real catalogue (Task 5) and buyer source (Task 6); a subscriber for Order Placed, which stays in the outbox until Task 3; an HTTP journey with a signed-in user; refusing callers without a Party (Task 6); Beto's own check of the API's `actor_id`, which is shared Beto behaviour; and deployment, browser, UAT and Minikube/k3s checks.