Skip to content

Validate affiliate payout conversion_id before querying conversions#423

Merged
ralyodio merged 1 commit into
profullstack:masterfrom
jsdavid278-cyber:codex/ugig-affiliate-pay-conversion-id
Jun 6, 2026
Merged

Validate affiliate payout conversion_id before querying conversions#423
ralyodio merged 1 commit into
profullstack:masterfrom
jsdavid278-cyber:codex/ugig-affiliate-pay-conversion-id

Conversation

@jsdavid278-cyber
Copy link
Copy Markdown
Contributor

Summary

  • reject non-string or blank conversion_id values in the affiliate conversion payout endpoint
  • trim the validated id before using it in Supabase filters
  • add a regression test that proves malformed ids are rejected before querying affiliate_conversions

Fixes #422.

Validation

  • npm.cmd run test:run -- "src/app/api/affiliates/offers/[id]/conversions/pay/route.test.ts"
  • npm.cmd run type-check

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Jun 6, 2026

Greptile Summary

This PR tightens the conversion_id input guard in the affiliate payout route, replacing a loose falsy check with an explicit typeof + .trim() test, and stores the trimmed value in conversionId for all downstream Supabase calls.

  • route.ts: Validation now rejects objects, arrays, numbers, null, empty strings, and whitespace-only strings before the affiliate_conversions table is ever queried; the trimmed id is consistently used in both the select and the update calls.
  • route.test.ts: One regression test is added, covering the non-string (object) input path and asserting that no downstream mocks fire; the whitespace-only string branch introduced by .trim() is not yet tested.

Confidence Score: 4/5

Safe to merge; the validation change is correct and targeted with no risk of regressions to the happy path.

The implementation change is small, well-scoped, and correct — the stricter guard reliably blocks non-string and blank inputs before any DB call. The only gap is a missing test for the whitespace-only string branch that the .trim() call was added to handle; a regression removing that trim would go undetected by the current test suite.

route.test.ts would benefit from a whitespace-only test case to fully exercise the new trim-based guard.

Important Files Changed

Filename Overview
src/app/api/affiliates/offers/[id]/conversions/pay/route.ts Strengthens conversion_id validation from a loose falsy check to a strict typeof+trim guard; trims before use in Supabase filters. Logic is correct; minor observation on missing blank-string test in the companion file.
src/app/api/affiliates/offers/[id]/conversions/pay/route.test.ts Adds one regression test for non-string conversion_id; correctly asserts early rejection and that downstream mocks are never reached. Does not cover the whitespace-only string case despite the PR description mentioning blank values.

Sequence Diagram

sequenceDiagram
    participant Client
    participant POST Route
    participant Supabase

    Client->>POST Route: POST /pay { conversion_id: <value> }
    POST Route->>Supabase: .from("affiliate_offers").select().eq().single()
    Supabase-->>POST Route: offer data

    alt not owner
        POST Route-->>Client: 403 Not authorized
    end

    alt "typeof !== "string" OR trimmed === """
        POST Route-->>Client: 400 conversion_id must be a non-empty string
        Note right of POST Route: affiliate_conversions never queried
    end

    POST Route->>Supabase: .from("affiliate_conversions").select().eq(conversionId).single()
    Supabase-->>POST Route: conversion data

    alt not found / already paid / clawed back / not settled / zero commission
        POST Route-->>Client: 400/404 error
    end

    POST Route->>Supabase: internalTransfer (seller to affiliate)
    POST Route->>Supabase: .update(status=paid).eq(conversionId)
    POST Route->>Supabase: .insert wallet_transactions
    POST Route-->>Client: 200 { ok: true, commission_sats }
Loading

Reviews (1): Last reviewed commit: "Validate affiliate payout conversion id" | Re-trigger Greptile

Comment on lines +57 to +84
it("rejects non-string conversion_id before querying conversions (#422)", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});

mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") {
return chainable({
id: "offer-1",
seller_id: "seller-1",
});
}
return chainable(null);
});

const res = await POST(
makePostRequest("offer-1", { conversion_id: { id: "conv-1" } }),
makeParams("offer-1")
);

expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({
error: "conversion_id must be a non-empty string",
});
expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions");
expect(mockGetUserLnWallet).not.toHaveBeenCalled();
expect(mockInternalTransfer).not.toHaveBeenCalled();
});
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 The PR description states it rejects both non-string and blank conversion_id values, but the test only exercises the non-string (object) case. The whitespace-only path (" ") is the one the trim() guard was specifically added for and deserves its own assertion — otherwise a regression that removes the .trim() call would not be caught by this suite.

Suggested change
it("rejects non-string conversion_id before querying conversions (#422)", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});
mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") {
return chainable({
id: "offer-1",
seller_id: "seller-1",
});
}
return chainable(null);
});
const res = await POST(
makePostRequest("offer-1", { conversion_id: { id: "conv-1" } }),
makeParams("offer-1")
);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({
error: "conversion_id must be a non-empty string",
});
expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions");
expect(mockGetUserLnWallet).not.toHaveBeenCalled();
expect(mockInternalTransfer).not.toHaveBeenCalled();
});
it("rejects non-string conversion_id before querying conversions (#422)", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});
mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") {
return chainable({
id: "offer-1",
seller_id: "seller-1",
});
}
return chainable(null);
});
const res = await POST(
makePostRequest("offer-1", { conversion_id: { id: "conv-1" } }),
makeParams("offer-1")
);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({
error: "conversion_id must be a non-empty string",
});
expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions");
expect(mockGetUserLnWallet).not.toHaveBeenCalled();
expect(mockInternalTransfer).not.toHaveBeenCalled();
});
it("rejects blank/whitespace-only conversion_id before querying conversions", async () => {
mockGetAuthContext.mockResolvedValue({
user: { id: "seller-1", authMethod: "session" },
});
mockFrom.mockImplementation((table: string) => {
if (table === "affiliate_offers") {
return chainable({
id: "offer-1",
seller_id: "seller-1",
});
}
return chainable(null);
});
const res = await POST(
makePostRequest("offer-1", { conversion_id: " " }),
makeParams("offer-1")
);
expect(res.status).toBe(400);
await expect(res.json()).resolves.toEqual({
error: "conversion_id must be a non-empty string",
});
expect(mockFrom).not.toHaveBeenCalledWith("affiliate_conversions");
expect(mockGetUserLnWallet).not.toHaveBeenCalled();
expect(mockInternalTransfer).not.toHaveBeenCalled();
});

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@ralyodio ralyodio merged commit 9c7b508 into profullstack:master Jun 6, 2026
6 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.

Affiliate payout accepts non-string conversion_id before querying conversions

2 participants