feat(quotes): convert an accepted quote into a Project (PMS-674) #455
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/PMS-674-convert-quote-to-project"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What
PMS-674, phase 4 of the PMS-670 Quotes epic: the "upon approval, it becomes a project" step. Adds
POST /quotes/{id}/convert, allowed only once the client has accepted.The quote supplies the client, name, scope, and budget. The request supplies only what a quote cannot know: who runs it, when, and how it bills. Those mapped fields are deliberately absent from the request body, because letting the caller restate them would allow the project to disagree with the quote the client signed.
A correction to what PMS-671 claimed
PMS-671 added
UNIQUE (converted_project_id)and I described it as the idempotency guard for this phase. That was imprecise, and worth flagging for review rather than burying.That constraint prevents two quotes pointing at one project. The failure mode here is the opposite: one quote spawning two projects, and nothing in the schema stops a second
UPDATEfrom overwriting the link. So the constraint does not cover this case at all.The actual guard is taking the quote row with
SELECT ... FOR UPDATEbefore reading anything off it. A second concurrent request blocks until the first commits, then observesconvertedand returns the existing link. The UNIQUE constraint stays as a backstop for its own (different) failure.This is verified, not asserted: the concurrency test produces four distinct projects with the lock removed, and exactly one with it in place.
Other design notes
Converting twice is not an error. It returns the same
converted_project_idrather than 409ing, because a double-clicked Convert button is a UI event, not a caller mistake.Atomic. The project insert and the quote transition share one transaction, so a failure on either leaves the quote exactly as it was rather than marked
convertedwith no project behind it. Tested with a violated FK.billing_methoddefaults tofixed_price, not theprojectscolumn default oftime_and_materials. An accepted quote IS a fixed price the client agreed to, so inheriting the column default would quietly contradict what was signed.Returns the quote, not the project. Every other route in the module returns a
QuoteResponse, and the response carriesconverted_project_idfor a caller that wants to fetch the project.Verify
6 integration tests in
tests/quote_convert.rs, each driving the full real path (create, submit, approve, send, client accepts via the portal, then convert):budget_amount= quote total,project_type=client,status=planning,billing_method=fixed_price, project manager. Both audit rows present.draftAND fromapproved, with no project created. Internal approval is not the client's signature.acceptedwith no dangling link and no project.Note on the concurrency test shape: two racers via
tokio::join!is not enough. They serialise on the reqwest connection pool and the test passes even with the row lock removed. I found this by deliberately removingFOR UPDATEand watching the test still pass, then widened it to 16tokio::spawned racers, which fails correctly without the lock.Full suite passes and clippy is clean at CI strength (
--all-targets -- -D warnings). Same pre-existing unrelatedtests/readiness.rsfailure as the earlier phases (dev.envsupplies Infisical vars while that profile is off), confirmed identical on cleanmain.Follow-ups
PMS-675 (Quotes UI in mokosh-apps) is the last phase of the epic. Seeding project tasks from
quote_lineswas left out as the ticket allowed; worth its own ticket if wanted.