Skip to content

Block paid affiliate conversion edits#207

Merged
ralyodio merged 1 commit into
profullstack:masterfrom
morganschp:fix-affiliate-conversion-paid-status
May 23, 2026
Merged

Block paid affiliate conversion edits#207
ralyodio merged 1 commit into
profullstack:masterfrom
morganschp:fix-affiliate-conversion-paid-status

Conversation

@morganschp
Copy link
Copy Markdown
Contributor

Fixes #205.

Summary

  • reject status: "paid" in the generic affiliate conversion edit endpoint
  • preserve normal pending/clawback status edits plus note and amount edits
  • add regression coverage proving the edit path does not touch conversions when a caller tries to mark one paid

uGig 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 false
  • git diff --check -- 'src/app/api/affiliates/offers/[id]/conversions/route.ts' 'src/app/api/affiliates/offers/[id]/conversions/route.test.ts'

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 23, 2026

Greptile Summary

This PR adds an early-return guard to the PUT handler that rejects any request attempting to set a conversion's status to "paid", routing callers to the dedicated payout endpoint instead. The status allow-list is also narrowed from ["pending", "paid", "clawed_back"] to ["pending", "clawed_back"].

  • Paid-status write blocked in PUT: status === "paid" now returns a 400 before touching the database, backed by a single regression test that asserts affiliate_conversions is never queried.
  • Incomplete protection for already-paid conversions: The guard only blocks setting status to "paid"; there is no check of the conversion's current status before the update runs. A seller can still send { status: "pending" } against a paid conversion and revert it, or change sale_amount_sats on a paid record and get the commission recalculated. The DELETE handler already demonstrates the fix pattern (fetch the row, gate on conv.status === "paid").

Confidence Score: 4/5

The 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

Filename Overview
src/app/api/affiliates/offers/[id]/conversions/route.ts Adds an early-return guard in PUT to reject status:"paid". The guard is directionally correct but incomplete — it blocks setting status TO paid, while leaving no protection against callers downgrading an already-paid conversion back to pending/clawed_back or mutating its commission amount.
src/app/api/affiliates/offers/[id]/conversions/route.test.ts Adds PUT import, helper, and a single regression test verifying the 400 rejection for status:"paid". Formatting changes only for existing POST/GET tests. No test covers the missing guard for already-paid conversions.

Sequence Diagram

sequenceDiagram
    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)
Loading

Comments Outside Diff (1)

  1. src/app/api/affiliates/offers/[id]/conversions/route.ts, line 246-254 (link)

    P1 Paid conversions can be downgraded to pending via this endpoint

    The new guard blocks setting status to "paid", but nothing prevents a caller from sending { status: "pending" } against a conversion that is already "paid". The update query at line 246 runs unconditionally against any conversion_id matching the offer, so a seller can revert a paid conversion back to "pending" or "clawed_back" — potentially enabling a second payout. The DELETE handler on line 307 already models the correct pattern: fetch the conversion first, check conv.status === "paid", and reject. The same fetch-then-guard approach should be applied here before updateData is written to the database.

Reviews (3): Last reviewed commit: "Block paid affiliate conversion edits" | Re-trigger Greptile

Comment on lines +326 to +362
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");
});
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Comment thread src/app/api/affiliates/offers/[id]/conversions/route.ts
@ralyodio ralyodio closed this May 23, 2026
@ralyodio ralyodio reopened this May 23, 2026
@ralyodio ralyodio closed this May 23, 2026
@ralyodio ralyodio reopened this May 23, 2026
@ralyodio ralyodio merged commit 62adc77 into profullstack:master May 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: affiliate conversion edits can mark commissions paid without payout

2 participants