Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/app/api/gigs/[id]/invoice/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,126 @@ describe("POST /api/gigs/[id]/invoice", () => {
expect(body.error).toMatch(/no agreed amount/i);
});

it("rejects a per-unit line item priced above the agreed rate", async () => {
const gig = {
id: GIG_ID,
title: "PR Gig",
poster_id: POSTER_ID,
payment_coin: "SOL",
budget_type: "per_unit",
budget_min: 1,
budget_max: 1,
};
const application = {
id: APP_ID,
applicant_id: WORKER_ID,
status: "accepted",
proposed_rate: 1, // $1 per PR
};

const sb = mockSupabase({
gigs: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: gig, error: null }),
},
applications: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: application, error: null }),
},
});
(getAuthContext as any).mockResolvedValue({ user: { id: WORKER_ID }, supabase: sb });

// $6 for a single PR exceeds the $1/PR rate — should be rejected even though
// a 6 × $1 invoice for the same $6 total would be fine.
const res = await POST(
req({
application_id: APP_ID,
items: [{ description: "Pull request", quantity: 1, unit_price: 6 }],
payment_currency: "sol",
merchant_wallet_address: "So11111111111111111111111111111111111111112",
}),
params
);

expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/exceeds the agreed rate/i);
expect(createPayment).not.toHaveBeenCalled();
});

it("allows a per-unit gig to multiply quantity past the single-unit rate", async () => {
const gig = {
id: GIG_ID,
title: "PR Gig",
poster_id: POSTER_ID,
payment_coin: "SOL",
budget_type: "per_unit",
budget_min: 1,
budget_max: 1,
};
const application = {
id: APP_ID,
applicant_id: WORKER_ID,
status: "accepted",
proposed_rate: 1, // $1 per PR
};

let inserted: any = null;
const sb = mockSupabase({
gigs: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: gig, error: null }),
},
applications: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({ data: application, error: null }),
},
gig_invoices: mockInvoiceTable({
insertResult: { id: "local-inv-perunit", metadata: {} },
onInsert: (row) => {
inserted = row;
},
}),
profiles: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
single: vi
.fn()
.mockResolvedValue({ data: { username: "w", full_name: "Test Worker" }, error: null }),
},
notifications: { insert: vi.fn().mockResolvedValue({ error: null }) },
});
(getAuthContext as any).mockResolvedValue({ user: { id: WORKER_ID }, supabase: sb });
(createServiceClient as any).mockReturnValue({
auth: {
admin: {
getUserById: vi
.fn()
.mockResolvedValue({ data: { user: { email: "poster@example.com" } } }),
},
},
from: vi.fn(() => ({ insert: vi.fn().mockResolvedValue({ error: null }) })),
});

// 1 PR × 5 at the $1/PR rate = $5 total, which is fine on a per-unit gig.
const res = await POST(
req({
application_id: APP_ID,
items: [{ description: "Pull requests", quantity: 5, unit_price: 1 }],
payment_currency: "sol",
merchant_wallet_address: "So11111111111111111111111111111111111111112",
}),
params
);

expect(res.status).toBe(201);
expect(inserted.amount_usd).toBe(5);
});

it("denominates a sats gig's invoice in USD instead of treating sats as dollars", async () => {
const gig = {
id: GIG_ID,
Expand Down
22 changes: 22 additions & 0 deletions src/app/api/gigs/[id]/invoice/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,28 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
{ status: 400 }
);
}
} else if (lineItems.length > 0 && agreedCap != null) {
// Per-unit / per-task / hourly gigs: the *total* legitimately exceeds the
// single quoted rate because the worker bills multiple units, so we don't
// cap the total here. But no single line item may be priced above the
// agreed per-unit rate — e.g. a $1/PR gig can be invoiced as 1 PR × 5
// ($5 total), not as one $6 line item. Bill more by raising the quantity,
// not the unit price.
const overpriced = lineItems.find((it) => it.unit_price > agreedCap + 1e-6);
if (overpriced) {
return NextResponse.json(
{
error: `Line item "${
overpriced.description || "item"
}" unit price (${fmtNative(
overpriced.unit_price
)}) exceeds the agreed rate for this gig (${fmtNative(
agreedCap
)}). Increase the quantity instead of the unit price.`,
},
{ status: 400 }
);
}
}
Comment on lines +230 to 252

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Legacy flat-amount path bypasses the new per-unit cap

The new unit-price check fires only when lineItems.length > 0 (line 230). A worker invoicing a per-unit gig through the legacy top-level amount field (schema allows either items or amount) skips both the single-payout total cap (because isSinglePayout is false) and the new per-unit check (because lineItems is empty). Sending { amount: 9999, application_id: "…" } on a $1/PR gig will create the invoice without any cap applied.

Fix in Codex Fix in Claude Code


// Convert the native total to USD — the canonical amount CoinPay charges.
Expand Down
Loading