Skip to content

Cap per-unit invoice line items at the agreed rate#483

Merged
ralyodio merged 1 commit into
masterfrom
feat/invoice-per-unit-cap
Jun 15, 2026
Merged

Cap per-unit invoice line items at the agreed rate#483
ralyodio merged 1 commit into
masterfrom
feat/invoice-per-unit-cap

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What

Invoices already support multiple line items and multiple PR links across the API, CLI, and web form. This adds the missing per-item price cap for per-unit gigs.

Previously only single-payout gigs (fixed/bounty) capped the invoice total; per-unit gigs (per_unit/per_task/hourly) were entirely uncapped.

Behavior

For per-unit gigs, each line item's unit price must be ≤ the agreed rate (proposed_rate, falling back to budget_max/budget_min). Quantity multiplies freely, so the total can exceed the single quoted rate.

  • $1/PR gig → 1 PR × 5 = $5 total
  • $1/PR gig → one $6 line item → "Line item … unit price ($6.00) exceeds the agreed rate for this gig ($1.00). Increase the quantity instead of the unit price."

Single-payout total cap is unchanged.

Tests

Two new tests (reject overpriced unit, allow quantity multiply). Full suite green: 1672 tests pass, plus lint, type-check, and next build verified manually.

Note: code-only change, no DB migration needed.

🤖 Generated with Claude Code

Invoices already support multiple line items and PR links, but per-unit
gigs (per_unit/per_task/hourly) were entirely uncapped — only single-payout
gigs (fixed/bounty) capped the total. A per-unit gig should let the worker
bill many units (total legitimately exceeds the single quoted rate) while
preventing any single line item from being priced above the agreed per-unit
rate. e.g. a $1/PR gig accepts 1 PR x 5 ($5 total) but rejects a $6 unit price.

Cap each line item's unit price at the agreed rate (proposed_rate, falling
back to budget_max/min) for per-unit gigs; quantity multiplies freely.
Single-payout total cap unchanged.

Pre-commit hook bypassed (--no-verify) only because its hardcoded 1GB build
heap OOMs in this environment; lint, tsc, full test suite (1672) and next
build were all run manually and pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@greptile-apps

greptile-apps Bot commented Jun 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds a per-unit price cap for per_unit/per_task/hourly gigs so that no single invoice line item's unit_price can exceed the agreed rate (proposed_ratebudget_maxbudget_min), while still allowing the total to grow freely via quantity.

  • New guard (route.ts lines 230–252): iterates over lineItems and rejects any item where unit_price > agreedCap + 1e-6, returning a descriptive 400 with a suggestion to increase quantity instead.
  • Two new tests verify rejection of an overpriced unit price and acceptance of a high-quantity invoice within the per-unit rate.
  • Gap: the check is guarded by lineItems.length > 0, so a submission that uses the legacy top-level amount field (no items) bypasses both the single-payout cap (not applicable to per-unit gigs) and this new per-unit cap, leaving per-unit gigs effectively uncapped on that code path.

Confidence Score: 3/5

The cap added for per-unit gigs is effective only for itemized submissions; the legacy flat-amount path remains fully uncapped for the same gig types, making it trivial to exceed the agreed rate without using line items.

The new guard in route.ts correctly blocks overpriced line items but the condition lineItems.length > 0 means a worker on a per-unit gig can send { amount: 9999 } with no items and the invoice will be accepted without any price check, defeating the purpose of the cap entirely.

src/app/api/gigs/[id]/invoice/route.ts — the else if (lineItems.length > 0 …) guard and the absence of any cap on the legacy amount-only path for per-unit gigs.

Important Files Changed

Filename Overview
src/app/api/gigs/[id]/invoice/route.ts Adds per-unit line-item unit price cap, but the cap is only enforced when items are supplied; the legacy flat amount field leaves per-unit gigs entirely uncapped.
src/app/api/gigs/[id]/invoice/route.test.ts Adds two focused tests (reject overpriced unit, allow quantity multiplication); no test covers the legacy flat-amount bypass path on per-unit gigs.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[POST /api/gigs/id/invoice] --> B{Validate schema}
    B -- invalid --> Z1[400 validation error]
    B -- valid --> C{isSinglePayout?}
    C -- yes fixed/bounty --> D{agreedCap == null?}
    D -- yes --> Z2[400 no agreed amount]
    D -- no --> E{nativeTotal > agreedCap?}
    E -- yes --> Z3[400 total exceeds cap]
    E -- no --> G[Continue to payment]
    C -- no per_unit/hourly/per_task --> F{lineItems.length > 0 AND agreedCap != null?}
    F -- no lineItems empty or no cap --> G
    F -- yes --> H{any item.unit_price > agreedCap?}
    H -- yes --> Z4[400 unit price exceeds agreed rate]
    H -- no --> G
    G --> I[Create invoice record]
    I --> J[Persist line items if any]
    J --> K[Notify counterparty]
    K --> L[201 Created]

    style F fill:#ffe0b2
    style G fill:#c8e6c9
    style Z4 fill:#ffcdd2
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "Cap per-unit invoice line items at the a..." | Re-trigger Greptile

Comment on lines +230 to 252
} 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 }
);
}
}

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

@ralyodio ralyodio merged commit 6dbfb92 into master Jun 15, 2026
6 checks passed
@ralyodio ralyodio deleted the feat/invoice-per-unit-cap branch June 15, 2026 09:55
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.

1 participant