Block paid affiliate conversion edits#207
Conversation
Greptile SummaryThis PR adds an early-return guard to the
Confidence Score: 4/5The change closes one real hole but leaves an adjacent one open: a seller can still revert a paid conversion's status back to pending or mutate its commission amount via the same PUT endpoint. The guard is directionally correct but only half-complete. Blocking writes TO "paid" is necessary, but the handler never fetches the conversion's current status, so a seller can downgrade an existing paid conversion to "pending" or "clawed_back" — bypassing the payout system's bookkeeping and potentially enabling a second payout. The DELETE handler in the same file already shows the correct fix (read the row, gate on status === "paid"), so the pattern is established; it just wasn't applied to the update path. src/app/api/affiliates/offers/[id]/conversions/route.ts — the PUT handler's update path needs a pre-flight fetch to guard against editing already-paid conversions. Important Files Changed
Sequence DiagramsequenceDiagram
participant Caller
participant PUT Handler
participant DB as affiliate_conversions
Caller->>PUT Handler: PUT {conversion_id, status: "paid"}
PUT Handler-->>Caller: 400 "Use the payout endpoint" ✅
Caller->>PUT Handler: PUT {conversion_id, status: "pending"}<br/>(conversion is already "paid")
PUT Handler->>DB: UPDATE status="pending" WHERE id=conv_id ❌
DB-->>PUT Handler: ok
PUT Handler-->>Caller: 200 {ok: true} (paid status silently reverted)
|
| describe("PUT /api/affiliates/offers/[id]/conversions", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| it("rejects marking a conversion paid without the payout endpoint", async () => { | ||
| mockGetAuthContext.mockResolvedValue({ | ||
| user: { id: "user-seller", authMethod: "session" }, | ||
| }); | ||
|
|
||
| mockFrom.mockImplementation((table: string) => { | ||
| if (table === "affiliate_offers") { | ||
| return chainable({ | ||
| id: "offer-1", | ||
| seller_id: "user-seller", | ||
| commission_rate: 0.1, | ||
| commission_type: "percentage", | ||
| commission_flat_sats: 0, | ||
| }); | ||
| } | ||
| return chainable([]); | ||
| }); | ||
|
|
||
| const res = await PUT( | ||
| makePutRequest("offer-1", { | ||
| conversion_id: "conv-1", | ||
| status: "paid", | ||
| }), | ||
| makeParams("offer-1") | ||
| ); | ||
|
|
||
| expect(res.status).toBe(400); | ||
| const body = await res.json(); | ||
| expect(body.error).toBe("Use the payout endpoint to mark conversions paid"); | ||
| expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions"); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
PUT suite covers only the rejection case
The new describe("PUT …") block has a single test. The PUT handler handles unauthenticated requests (401), non-owner requests (403), missing conversion_id (400), valid note/status/amount updates, and a "nothing to update" branch — none of which have coverage. A regression in, say, the ownership check or the sale_amount_sats recalculation path would pass CI undetected.
Fixes #205.
Summary
status: "paid"in the generic affiliate conversion edit endpointuGig task
Submitted for the active uGig repo testing task: https://ugig.net/gigs/4741218f-a723-46bb-82cb-6516120331ae
No payout details included; those can be provided after acceptance.
Verification
./node_modules/.bin/vitest run 'src/app/api/affiliates/offers/[id]/conversions/route.test.ts'./node_modules/.bin/eslint 'src/app/api/affiliates/offers/[id]/conversions/route.ts' 'src/app/api/affiliates/offers/[id]/conversions/route.test.ts'./node_modules/.bin/prettier --check 'src/app/api/affiliates/offers/[id]/conversions/route.ts' 'src/app/api/affiliates/offers/[id]/conversions/route.test.ts'./node_modules/.bin/tsc --noEmit --pretty falsegit diff --check -- 'src/app/api/affiliates/offers/[id]/conversions/route.ts' 'src/app/api/affiliates/offers/[id]/conversions/route.test.ts'