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
106 changes: 106 additions & 0 deletions src/app/api/gigs/[id]/invoice/[invoiceId]/accept/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { getAuthContext } from "@/lib/auth/get-user";
import { createServiceClient } from "@/lib/supabase/service";

export const dynamic = "force-dynamic";

// POST /api/gigs/[id]/invoice/[invoiceId]/accept
// Lets the payer (poster) accept an invoice they intend to pay. This doesn't
// move money — it flags the invoice (metadata.accepted_at) so it shows up in
// the payer's "Accepted" queue to be paid quickly, and notifies the worker
// that payment is coming.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string; invoiceId: string }> }
) {
try {
const { id: gigId, invoiceId } = await params;
const auth = await getAuthContext(request);
if (!auth) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { user, supabase } = auth;
const { data: invoice, error } = await (supabase as any)
.from("gig_invoices")
.select(
`
id,
gig_id,
worker_id,
poster_id,
amount_usd,
status,
metadata,
gig:gigs(id, title)
`
)
.eq("id", invoiceId)
.eq("gig_id", gigId)
.maybeSingle();

if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (!invoice) {
return NextResponse.json({ error: "Invoice not found" }, { status: 404 });
}
if (invoice.poster_id !== user.id) {
return NextResponse.json(
{ error: "Only the payer can accept this invoice" },
{ status: 403 }
);
}
if (invoice.status === "paid") {
return NextResponse.json(
{ error: "This invoice is already paid." },
{ status: 409 }
);
}
if (invoice.status === "rejected" || invoice.status === "cancelled") {
return NextResponse.json(
{ error: "This invoice can no longer be accepted." },
{ status: 409 }
);
}

const acceptedAt = invoice.metadata?.accepted_at || new Date().toISOString();
const alreadyAccepted = Boolean(invoice.metadata?.accepted_at);

if (!alreadyAccepted) {
const nextMetadata = {
...(invoice.metadata || {}),
accepted_at: acceptedAt,
};
const { error: updateError } = await (supabase as any)
.from("gig_invoices")
.update({ metadata: nextMetadata, updated_at: new Date().toISOString() })
.eq("id", invoice.id);

if (updateError) {
return NextResponse.json({ error: updateError.message }, { status: 400 });
}

const gig = Array.isArray(invoice.gig) ? invoice.gig[0] : invoice.gig;
const title = gig?.title || "your gig";
const serviceSupabase = createServiceClient();
await (serviceSupabase.from("notifications") as any).insert({
user_id: invoice.worker_id,
type: "payment_received",

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 Wrong notification type for acceptance event

type: "payment_received" misrepresents an acceptance as a completed payment. If the notification UI, analytics, or any downstream handler branch on type, the worker would see a "payment received" signal when no money has moved yet. This could cause workers to treat an acceptance as full confirmation of payment, leading to disputes. A value like "invoice_accepted" (matching the surrounding title/body copy) is semantically correct.

Fix in Codex Fix in Claude Code

title: "Invoice accepted",
body: `The client accepted your invoice for "${title}" and will pay it soon.`,
data: {
gig_id: invoice.gig_id,
invoice_id: invoice.id,
},
});
Comment on lines +86 to +96

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 Notification insert errors are silently discarded

Supabase .insert() never throws — it returns { data, error }. The returned value is not destructured here, so a failed insert (DB error, missing table, RLS rejection) is completely invisible. At minimum the error should be logged with console.error so on-call engineers can detect notification delivery failures without running queries against the table.

Fix in Codex Fix in Claude Code

}

return NextResponse.json({
data: { invoice_id: invoice.id, accepted_at: acceptedAt },
});
} catch (err) {
console.error("[accept invoice] failed:", err);
return NextResponse.json({ error: "Failed to accept invoice" }, { status: 500 });
}
}
39 changes: 39 additions & 0 deletions src/app/dashboard/invoices/InvoicePaymentActions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,43 @@ describe("InvoicePaymentActions", () => {
});
expect(screen.getByText("0.5 SOL")).toBeInTheDocument();
});

it("accepts an invoice and shows it will be paid soon", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: async () => ({
data: { invoice_id: "inv-1", accepted_at: "2030-01-01T00:00:00Z" },
}),
});

render(<InvoicePaymentActions {...baseProps} status="sent" metadata={null} />);

fireEvent.click(await screen.findByRole("button", { name: /^accept$/i }));

await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith("/api/gigs/gig-1/invoice/inv-1/accept", {
method: "POST",
headers: { "Content-Type": "application/json" },
});
});

await waitFor(() => {
expect(screen.getByText(/will be paid soon/i)).toBeInTheDocument();
});
});

it("shows the accepted indicator when already accepted", () => {
render(
<InvoicePaymentActions
{...baseProps}
status="sent"
metadata={{ accepted_at: "2030-01-01T00:00:00Z" }}
/>
);

expect(screen.getByText(/will be paid soon/i)).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /^accept$/i })
).not.toBeInTheDocument();
});
});
68 changes: 63 additions & 5 deletions src/app/dashboard/invoices/InvoicePaymentActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { CryptoPaymentBox } from "@/components/payments/CryptoPaymentBox";
import { CheckCircle2, Loader2, RefreshCw, Send, XCircle } from "lucide-react";
import { CheckCircle2, Loader2, RefreshCw, Send, ThumbsUp, XCircle } from "lucide-react";

type InvoiceStatus =
| "draft"
Expand All @@ -21,6 +21,7 @@ interface InvoicePaymentMetadata {
checkout_url?: string | null;
expires_at?: string | null;
replacement_requested_at?: string | null;
accepted_at?: string | null;
}

interface InvoicePaymentActionsProps {
Expand Down Expand Up @@ -54,6 +55,10 @@ export function InvoicePaymentActions({
Boolean(initialMetadata?.replacement_requested_at)
);
const [rejecting, setRejecting] = useState(false);
const [accepting, setAccepting] = useState(false);
const [acceptedAt, setAcceptedAt] = useState<string | null>(
initialMetadata?.accepted_at ?? null
);
const [canRequestNew, setCanRequestNew] = useState(false);
const [error, setError] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
Expand Down Expand Up @@ -213,13 +218,37 @@ export function InvoicePaymentActions({
}
};

const acceptInvoice = async () => {
setAccepting(true);
setError(null);
setStatusMessage(null);
try {
const res = await fetch(`/api/gigs/${gigId}/invoice/${invoiceId}/accept`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
const json = await res.json();
if (!res.ok) {
setError(json.error || "Failed to accept invoice");
return;
}
setAcceptedAt(json.data?.accepted_at || new Date().toISOString());
setStatusMessage("Accepted. The worker was notified you'll pay soon.");
router.refresh();
} catch {
setError("Network error. Try again.");
} finally {
setAccepting(false);
}
};

const rejectButton = (
<Button
type="button"
size="sm"
variant="ghost"
onClick={rejectInvoice}
disabled={rejecting || submitting}
disabled={rejecting || submitting || accepting}
className="gap-2 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
{rejecting ? (
Expand All @@ -231,6 +260,31 @@ export function InvoicePaymentActions({
</Button>
);

// Marks the invoice as accepted so it lands in the "Accepted" queue to be paid
// quickly. Once accepted, we show a durable confirmation instead of the button.
const acceptControl = acceptedAt ? (
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-green-700">
<ThumbsUp className="h-4 w-4" />
Accepted — will be paid soon
</span>
) : (
<Button
type="button"
size="sm"
variant="outline"
onClick={acceptInvoice}
disabled={accepting || rejecting || submitting}
className="gap-2 border-green-500/30 text-green-700 hover:bg-green-500/10 hover:text-green-700"
>
{accepting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ThumbsUp className="h-4 w-4" />
)}
Accept
</Button>
);

if (status === "paid") {
return (
<div className="rounded-lg border border-green-500/20 bg-green-500/5 p-4 text-sm text-green-700">
Expand Down Expand Up @@ -310,7 +364,10 @@ export function InvoicePaymentActions({
</Button>
</div>

<div className="flex justify-end">{rejectButton}</div>
<div className="flex items-center justify-between gap-2">
{acceptControl}
{rejectButton}
</div>

{error && <p className="text-sm text-destructive">{error}</p>}
</div>
Expand All @@ -332,12 +389,13 @@ export function InvoicePaymentActions({
Ask them to send a fresh invoice with a connected wallet.
</p>
)}
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap items-center gap-2">
{acceptControl}
<Button
type="button"
size="sm"
onClick={createPaymentRequest}
disabled={submitting || requestingNew}
disabled={submitting || requestingNew || accepting}
className="gap-2"
>
{submitting ? (
Expand Down
Loading
Loading