diff --git a/src/app/api/gigs/[id]/invoice/[invoiceId]/accept/route.ts b/src/app/api/gigs/[id]/invoice/[invoiceId]/accept/route.ts
new file mode 100644
index 00000000..d5057614
--- /dev/null
+++ b/src/app/api/gigs/[id]/invoice/[invoiceId]/accept/route.ts
@@ -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",
+ 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,
+ },
+ });
+ }
+
+ 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 });
+ }
+}
diff --git a/src/app/dashboard/invoices/InvoicePaymentActions.test.tsx b/src/app/dashboard/invoices/InvoicePaymentActions.test.tsx
index d360971d..ab5d44c6 100644
--- a/src/app/dashboard/invoices/InvoicePaymentActions.test.tsx
+++ b/src/app/dashboard/invoices/InvoicePaymentActions.test.tsx
@@ -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).mockResolvedValueOnce({
+ ok: true,
+ json: async () => ({
+ data: { invoice_id: "inv-1", accepted_at: "2030-01-01T00:00:00Z" },
+ }),
+ });
+
+ render( );
+
+ 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(
+
+ );
+
+ expect(screen.getByText(/will be paid soon/i)).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /^accept$/i })
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/src/app/dashboard/invoices/InvoicePaymentActions.tsx b/src/app/dashboard/invoices/InvoicePaymentActions.tsx
index e168df9e..7812dab9 100644
--- a/src/app/dashboard/invoices/InvoicePaymentActions.tsx
+++ b/src/app/dashboard/invoices/InvoicePaymentActions.tsx
@@ -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"
@@ -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 {
@@ -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(
+ initialMetadata?.accepted_at ?? null
+ );
const [canRequestNew, setCanRequestNew] = useState(false);
const [error, setError] = useState(null);
const [statusMessage, setStatusMessage] = useState(null);
@@ -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 = (
{rejecting ? (
@@ -231,6 +260,31 @@ export function InvoicePaymentActions({
);
+ // 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 ? (
+
+
+ Accepted — will be paid soon
+
+ ) : (
+
+ {accepting ? (
+
+ ) : (
+
+ )}
+ Accept
+
+ );
+
if (status === "paid") {
return (
@@ -310,7 +364,10 @@ export function InvoicePaymentActions({
- {rejectButton}
+
+ {acceptControl}
+ {rejectButton}
+
{error && {error}
}
@@ -332,12 +389,13 @@ export function InvoicePaymentActions({
Ask them to send a fresh invoice with a connected wallet.
)}
-
+
+ {acceptControl}
{submitting ? (
diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx
index 1a82f912..98231eaa 100644
--- a/src/app/dashboard/invoices/page.tsx
+++ b/src/app/dashboard/invoices/page.tsx
@@ -22,6 +22,30 @@ export const metadata = {
type TabKey = "received" | "sent";
+type StatusFilter = "all" | "pending" | "accepted" | "paid";
+
+const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
+ { key: "all", label: "All" },
+ { key: "pending", label: "Pending" },
+ { key: "accepted", label: "Accepted" },
+ { key: "paid", label: "Paid" },
+];
+
+// Pending = billed and awaiting payment, not yet accepted.
+function matchesStatusFilter(inv: InvoiceRow, filter: StatusFilter): boolean {
+ switch (filter) {
+ case "pending":
+ return isAwaitingPayment(inv.status) && !inv.metadata?.accepted_at;
+ case "accepted":
+ return isAcceptedUnpaid(inv);
+ case "paid":
+ return inv.status === "paid";
+ case "all":
+ default:
+ return true;
+ }
+}
+
interface Counterparty {
id: string;
username: string | null;
@@ -48,6 +72,7 @@ interface InvoiceRow {
checkout_url?: string | null;
expires_at?: string | null;
replacement_requested_at?: string | null;
+ accepted_at?: string | null;
pr_links?: string[] | null;
} | null;
created_at: string;
@@ -113,10 +138,15 @@ function isAwaitingPayment(status: InvoiceRow["status"]) {
return status === "sent" || status === "expired";
}
+// An invoice the payer accepted but hasn't paid yet — the "Accepted" queue.
+function isAcceptedUnpaid(inv: InvoiceRow) {
+ return Boolean(inv.metadata?.accepted_at) && isAwaitingPayment(inv.status);
+}
+
export default async function InvoicesDashboardPage({
searchParams,
}: {
- searchParams: Promise<{ tab?: string }>;
+ searchParams: Promise<{ tab?: string; status?: string }>;
}) {
const supabase = await createClient();
const {
@@ -126,8 +156,13 @@ export default async function InvoicesDashboardPage({
redirect("/login?redirect=/dashboard/invoices");
}
- const tabParam = (await searchParams).tab;
- const tab: TabKey = tabParam === "sent" ? "sent" : "received";
+ const sp = await searchParams;
+ const tab: TabKey = sp.tab === "sent" ? "sent" : "received";
+ const statusFilter: StatusFilter = STATUS_FILTERS.some(
+ (f) => f.key === sp.status
+ )
+ ? (sp.status as StatusFilter)
+ : "all";
const { data: invoiceData } = await (supabase as any)
.from("gig_invoices")
@@ -146,10 +181,15 @@ export default async function InvoicesDashboardPage({
const invoices = (invoiceData || []) as InvoiceRow[];
const sent = invoices.filter((i) => i.worker_id === user.id);
const received = invoices.filter((i) => i.poster_id === user.id);
+ const accepted = received.filter(isAcceptedUnpaid);
const totalOwed = received
.filter((i) => isAwaitingPayment(i.status))
.reduce((s, i) => s + Number(i.amount_usd || 0), 0);
+ const totalAccepted = accepted.reduce(
+ (s, i) => s + Number(i.amount_usd || 0),
+ 0
+ );
const totalEarned = sent
.filter((i) => i.status === "paid")
.reduce((s, i) => s + Number(i.amount_usd || 0), 0);
@@ -157,7 +197,8 @@ export default async function InvoicesDashboardPage({
.filter((i) => isAwaitingPayment(i.status))
.reduce((s, i) => s + Number(i.amount_usd || 0), 0);
- const list = tab === "sent" ? sent : received;
+ const base = tab === "sent" ? sent : received;
+ const list = base.filter((i) => matchesStatusFilter(i, statusFilter));
return (
@@ -178,11 +219,15 @@ export default async function InvoicesDashboardPage({
{/* Stats */}
-
+
${totalOwed.toFixed(2)}
You owe (unpaid received)
+
+
${totalAccepted.toFixed(2)}
+
Accepted — ready to pay
+
${totalPendingForMe.toFixed(2)}
Awaiting payment to you
@@ -194,7 +239,7 @@ export default async function InvoicesDashboardPage({
{/* Tabs */}
-
+
+ {/* Status filter */}
+
+ {STATUS_FILTERS.map((f) => {
+ const count = base.filter((i) => matchesStatusFilter(i, f.key)).length;
+ const active = statusFilter === f.key;
+ return (
+
+ {f.label} ({count})
+
+ );
+ })}
+
+
{/* Invoice list */}
- {tab === "received" ? "Invoices from workers" : "Invoices you've sent"}
+ {tab === "sent" ? "Invoices you've sent" : "Invoices from workers"}
+ {statusFilter !== "all" && (
+
+ {" · "}
+ {STATUS_FILTERS.find((f) => f.key === statusFilter)?.label}
+
+ )}
{list.length === 0 ? (
- {tab === "received" ? "No invoices received yet" : "No invoices sent yet"}
+ {statusFilter !== "all"
+ ? `No ${STATUS_FILTERS.find((f) => f.key === statusFilter)?.label.toLowerCase()} invoices`
+ : tab === "sent"
+ ? "No invoices sent yet"
+ : "No invoices received yet"}
- {tab === "received"
- ? "When a worker bills you (or you initiate a payment), it will appear here."
- : "Once you're accepted on a gig, you can send an invoice from the gig page."}
+ {tab === "sent"
+ ? "Once you're accepted on a gig, you can send an invoice from the gig page."
+ : "When a worker bills you (or you initiate a payment), it will appear here."}
) : (
{list.map((inv) => {
const counterparty =
- tab === "received" ? inv.worker : inv.poster;
+ tab === "sent" ? inv.poster : inv.worker;
return (
- {tab === "received" ? "From " : "To "}
+ {tab === "sent" ? "To " : "From "}
{counterpartyName(counterparty)}
@@ -277,6 +353,11 @@ export default async function InvoicesDashboardPage({
{statusBadge(inv.status)}
+ {isAcceptedUnpaid(inv) && (
+
+ Accepted · will be paid soon
+
+ )}
{inv.metadata?.replacement_requested_at &&
isAwaitingPayment(inv.status) && (
@@ -343,7 +424,7 @@ export default async function InvoicesDashboardPage({
)}
- {tab === "received" && (
+ {tab !== "sent" && (