Skip to content

Commit 806c9e9

Browse files
ralyodioclaude
andauthored
Let invoice senders revoke and resend (#490)
Previously only the payer could take an invoice off the table (reject), and the sender (worker) had no way to pull back an invoice they'd sent — so a mistaken invoice was stuck until the payer rejected it, with no clear resend path. Adds: - POST /api/gigs/[id]/invoice/[invoiceId]/revoke — sender-only; cancels a draft/sent/expired invoice (status='cancelled') and notifies the payer. Idempotent for already-cancelled/rejected; refuses paid invoices. - Gig-page InvoiceButton: a "Revoke invoice" control on the worker's active invoices, and a "Resend invoice" button on revoked/rejected ones that pre-fills the invoice form from the original (line items, notes, PR links, category) so they can fix and send a fresh one. - Dashboard "Invoices Sent" tab: Revoke on active invoices and a Resend hand-off to the gig page. Resend creates a brand-new invoice via the existing create route, which already permits it because a cancelled/rejected invoice no longer counts as an open (draft/sent) one. No migration needed — 'cancelled' is already an allowed status. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 726b6ed commit 806c9e9

5 files changed

Lines changed: 467 additions & 1 deletion

File tree

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
vi.mock("@/lib/auth/get-user", () => ({
4+
getAuthContext: vi.fn(),
5+
}));
6+
7+
vi.mock("@/lib/supabase/service", () => ({
8+
createServiceClient: vi.fn(),
9+
}));
10+
11+
import { POST } from "./route";
12+
import { getAuthContext } from "@/lib/auth/get-user";
13+
import { createServiceClient } from "@/lib/supabase/service";
14+
15+
const GIG_ID = "8489a861-0999-4107-afca-2592021ac338";
16+
const INVOICE_ID = "f53e4a56-3cf7-42f9-9a33-bc1cb770c4f6";
17+
const POSTER_ID = "4f16c625-c37a-4654-82db-e391067cbb13";
18+
const WORKER_ID = "666cbaba-c6ea-4756-ad44-d6a5b4248f8f";
19+
20+
const params = { params: Promise.resolve({ id: GIG_ID, invoiceId: INVOICE_ID }) };
21+
22+
function invoiceQuery(invoice: any) {
23+
return {
24+
select: vi.fn().mockReturnThis(),
25+
eq: vi.fn().mockReturnThis(),
26+
maybeSingle: vi.fn().mockResolvedValue({ data: invoice, error: null }),
27+
};
28+
}
29+
30+
// Records the row passed to update() so the test can assert on it.
31+
function updateSpy(onUpdate: (row: any) => void) {
32+
return {
33+
update: vi.fn((row: any) => {
34+
onUpdate(row);
35+
return { eq: vi.fn().mockResolvedValue({ error: null }) };
36+
}),
37+
};
38+
}
39+
40+
function baseInvoice(status: string) {
41+
return {
42+
id: INVOICE_ID,
43+
gig_id: GIG_ID,
44+
worker_id: WORKER_ID,
45+
poster_id: POSTER_ID,
46+
amount_usd: 125,
47+
status,
48+
gig: { id: GIG_ID, title: "Build thing" },
49+
};
50+
}
51+
52+
describe("POST /api/gigs/[id]/invoice/[invoiceId]/revoke", () => {
53+
beforeEach(() => vi.clearAllMocks());
54+
55+
it("lets the sender cancel a sent invoice and notifies the payer", async () => {
56+
let updatePayload: any = null;
57+
let notification: any = null;
58+
59+
(getAuthContext as any).mockResolvedValue({
60+
user: { id: WORKER_ID },
61+
supabase: {
62+
from: vi.fn(() => ({
63+
...invoiceQuery(baseInvoice("sent")),
64+
...updateSpy((row) => {
65+
updatePayload = row;
66+
}),
67+
})),
68+
},
69+
});
70+
(createServiceClient as any).mockReturnValue({
71+
from: vi.fn(() => ({
72+
insert: vi.fn((row: any) => {
73+
notification = row;
74+
return Promise.resolve({ error: null });
75+
}),
76+
})),
77+
});
78+
79+
const res = await POST({} as any, params);
80+
81+
expect(res.status).toBe(200);
82+
const body = await res.json();
83+
expect(body.data).toEqual({ invoice_id: INVOICE_ID, status: "cancelled" });
84+
expect(updatePayload).toMatchObject({ status: "cancelled" });
85+
expect(notification).toMatchObject({
86+
user_id: POSTER_ID,
87+
title: "Invoice revoked",
88+
});
89+
});
90+
91+
it("blocks anyone but the sender from revoking", async () => {
92+
(getAuthContext as any).mockResolvedValue({
93+
user: { id: POSTER_ID },
94+
supabase: { from: vi.fn(() => invoiceQuery(baseInvoice("sent"))) },
95+
});
96+
97+
const res = await POST({} as any, params);
98+
99+
expect(res.status).toBe(403);
100+
const body = await res.json();
101+
expect(body.error).toBe("Only the sender can revoke this invoice");
102+
});
103+
104+
it("refuses to revoke a paid invoice", async () => {
105+
(getAuthContext as any).mockResolvedValue({
106+
user: { id: WORKER_ID },
107+
supabase: { from: vi.fn(() => invoiceQuery(baseInvoice("paid"))) },
108+
});
109+
110+
const res = await POST({} as any, params);
111+
112+
expect(res.status).toBe(409);
113+
});
114+
115+
it("is idempotent when the invoice is already cancelled", async () => {
116+
(getAuthContext as any).mockResolvedValue({
117+
user: { id: WORKER_ID },
118+
supabase: { from: vi.fn(() => invoiceQuery(baseInvoice("cancelled"))) },
119+
});
120+
121+
const res = await POST({} as any, params);
122+
123+
expect(res.status).toBe(200);
124+
const body = await res.json();
125+
expect(body.data).toEqual({ invoice_id: INVOICE_ID, status: "cancelled" });
126+
});
127+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getAuthContext } from "@/lib/auth/get-user";
3+
import { createServiceClient } from "@/lib/supabase/service";
4+
5+
export const dynamic = "force-dynamic";
6+
7+
// POST /api/gigs/[id]/invoice/[invoiceId]/revoke
8+
// Lets the sender (the worker who billed) take back an invoice they sent before
9+
// it's paid — e.g. they billed the wrong amount or forgot a PR link. Sets
10+
// status='cancelled' and notifies the payer. The worker can then send a fresh
11+
// invoice ("resend"), which the create route already allows because a cancelled
12+
// invoice no longer counts as an open (draft/sent) one.
13+
export async function POST(
14+
request: NextRequest,
15+
{ params }: { params: Promise<{ id: string; invoiceId: string }> }
16+
) {
17+
try {
18+
const { id: gigId, invoiceId } = await params;
19+
const auth = await getAuthContext(request);
20+
if (!auth) {
21+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
22+
}
23+
24+
const { user, supabase } = auth;
25+
const { data: invoice, error } = await (supabase as any)
26+
.from("gig_invoices")
27+
.select(
28+
`
29+
id,
30+
gig_id,
31+
worker_id,
32+
poster_id,
33+
amount_usd,
34+
status,
35+
gig:gigs(id, title)
36+
`
37+
)
38+
.eq("id", invoiceId)
39+
.eq("gig_id", gigId)
40+
.maybeSingle();
41+
42+
if (error) {
43+
return NextResponse.json({ error: error.message }, { status: 400 });
44+
}
45+
if (!invoice) {
46+
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
47+
}
48+
// Only the worker who sent the invoice can revoke it. (The payer's equivalent
49+
// is "reject".)
50+
if (invoice.worker_id !== user.id) {
51+
return NextResponse.json(
52+
{ error: "Only the sender can revoke this invoice" },
53+
{ status: 403 }
54+
);
55+
}
56+
if (invoice.status === "paid") {
57+
return NextResponse.json(
58+
{ error: "A paid invoice can't be revoked." },
59+
{ status: 409 }
60+
);
61+
}
62+
if (invoice.status === "cancelled") {
63+
return NextResponse.json({ data: { invoice_id: invoice.id, status: "cancelled" } });
64+
}
65+
// A payer-rejected invoice is already off the table; treat revoke as a no-op
66+
// so the sender can just go straight to resending.
67+
if (invoice.status === "rejected") {
68+
return NextResponse.json({ data: { invoice_id: invoice.id, status: "rejected" } });
69+
}
70+
71+
const { error: updateError } = await (supabase as any)
72+
.from("gig_invoices")
73+
.update({
74+
status: "cancelled",
75+
updated_at: new Date().toISOString(),
76+
})
77+
.eq("id", invoiceId);
78+
79+
if (updateError) {
80+
return NextResponse.json({ error: updateError.message }, { status: 400 });
81+
}
82+
83+
const gig = Array.isArray(invoice.gig) ? invoice.gig[0] : invoice.gig;
84+
const title = gig?.title || "your gig";
85+
const serviceSupabase = createServiceClient();
86+
await (serviceSupabase.from("notifications") as any).insert({
87+
user_id: invoice.poster_id,
88+
type: "payment_received",
89+
title: "Invoice revoked",
90+
body: `The worker revoked their invoice for "${title}". A corrected one may follow.`,
91+
data: {
92+
gig_id: invoice.gig_id,
93+
invoice_id: invoice.id,
94+
previous_status: invoice.status,
95+
},
96+
});
97+
98+
return NextResponse.json({ data: { invoice_id: invoice.id, status: "cancelled" } });
99+
} catch (err) {
100+
console.error("[revoke invoice] failed:", err);
101+
return NextResponse.json({ error: "Failed to revoke invoice" }, { status: 500 });
102+
}
103+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import Link from "next/link";
6+
import { Button, buttonVariants } from "@/components/ui/button";
7+
import { Loader2, RefreshCw, Send } from "lucide-react";
8+
9+
type InvoiceStatus =
10+
| "draft"
11+
| "sent"
12+
| "paid"
13+
| "cancelled"
14+
| "expired"
15+
| "rejected";
16+
17+
interface SentInvoiceActionsProps {
18+
invoiceId: string;
19+
gigId: string;
20+
status: InvoiceStatus;
21+
}
22+
23+
// Sender-side controls on the "Invoices Sent" tab: revoke an invoice you sent
24+
// before it's paid, or resend a corrected one after it was revoked/rejected.
25+
// Resend hands off to the gig page, where the invoice form pre-fills from the
26+
// original (it has the wallet + line-item context this list doesn't).
27+
export function SentInvoiceActions({ invoiceId, gigId, status }: SentInvoiceActionsProps) {
28+
const router = useRouter();
29+
const [revoking, setRevoking] = useState(false);
30+
const [error, setError] = useState<string | null>(null);
31+
32+
const canRevoke = status === "sent" || status === "draft" || status === "expired";
33+
const canResend = status === "cancelled" || status === "rejected";
34+
35+
if (!canRevoke && !canResend) return null;
36+
37+
const revoke = async () => {
38+
setRevoking(true);
39+
setError(null);
40+
try {
41+
const res = await fetch(`/api/gigs/${gigId}/invoice/${invoiceId}/revoke`, {
42+
method: "POST",
43+
headers: { "Content-Type": "application/json" },
44+
});
45+
const json = await res.json();
46+
if (!res.ok) {
47+
setError(json.error || "Failed to revoke invoice");
48+
return;
49+
}
50+
router.refresh();
51+
} catch {
52+
setError("Network error. Try again.");
53+
} finally {
54+
setRevoking(false);
55+
}
56+
};
57+
58+
return (
59+
<div className="mt-3 flex flex-wrap items-center gap-2">
60+
{canRevoke && (
61+
<Button
62+
type="button"
63+
size="sm"
64+
variant="ghost"
65+
onClick={revoke}
66+
disabled={revoking}
67+
className="gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
68+
>
69+
{revoking ? (
70+
<Loader2 className="h-4 w-4 animate-spin" />
71+
) : (
72+
<RefreshCw className="h-4 w-4" />
73+
)}
74+
Revoke invoice
75+
</Button>
76+
)}
77+
{canResend && (
78+
<Link
79+
href={`/gigs/${gigId}`}
80+
className={buttonVariants({ size: "sm", variant: "outline", className: "gap-2" })}
81+
>
82+
<Send className="h-4 w-4" />
83+
Resend invoice
84+
</Link>
85+
)}
86+
{error && <p className="text-sm text-destructive">{error}</p>}
87+
</div>
88+
);
89+
}

src/app/dashboard/invoices/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
55
import { Badge } from "@/components/ui/badge";
66
import { InvoicePaymentActions } from "./InvoicePaymentActions";
77
import { InvoiceCharges } from "./InvoiceCharges";
8+
import { SentInvoiceActions } from "./SentInvoiceActions";
89
import {
910
ArrowLeft,
1011
ExternalLink,
@@ -416,6 +417,12 @@ export default async function InvoicesDashboardPage({
416417
</div>
417418
)}
418419

420+
{tab === "sent" && inv.status === "cancelled" && (
421+
<div className="mb-3 rounded-lg border border-border bg-muted/30 p-3 text-sm text-muted-foreground">
422+
You revoked this invoice. Resend a corrected one from the gig.
423+
</div>
424+
)}
425+
419426
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
420427
<span>
421428
Created {new Date(inv.created_at).toLocaleDateString()}
@@ -453,6 +460,14 @@ export default async function InvoicesDashboardPage({
453460
/>
454461
</div>
455462
)}
463+
464+
{tab === "sent" && (
465+
<SentInvoiceActions
466+
invoiceId={inv.id}
467+
gigId={inv.gig_id}
468+
status={inv.status}
469+
/>
470+
)}
456471
</div>
457472
);
458473
})}

0 commit comments

Comments
 (0)