From c23c4f38f542806221c115312057cb311659ee1e Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 14 Jun 2026 07:11:46 +0000 Subject: [PATCH] feat(invoices): accept invoices + status filter to pay quickly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an Accept action on received invoices so the payer can confirm intent to pay: sets metadata.accepted_at and notifies the worker the invoice was accepted and will be paid soon. Accepting doesn't move money — it queues the invoice to be paid. Replace the extra "Accepted" tab with a status filter (All / Pending / Accepted / Paid) on both the Received and Sent tabs, so accepted-but- unpaid invoices can be triaged and paid quickly. No DB migration — reuses the gig_invoices.metadata JSONB column. Co-Authored-By: Claude Opus 4.8 --- .../[id]/invoice/[invoiceId]/accept/route.ts | 106 +++++++++++++++++ .../invoices/InvoicePaymentActions.test.tsx | 39 +++++++ .../invoices/InvoicePaymentActions.tsx | 68 ++++++++++- src/app/dashboard/invoices/page.tsx | 109 +++++++++++++++--- 4 files changed, 303 insertions(+), 19 deletions(-) create mode 100644 src/app/api/gigs/[id]/invoice/[invoiceId]/accept/route.ts 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 = ( ); + // 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 + + ) : ( + + ); + 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}
{/* 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" && (