feat(driver): serve proof of payment for assigned POs

This commit is contained in:
Dave 2026-11-11 13:57:19 +02:00
parent fc497fad96
commit a503bd55ff
4 changed files with 176 additions and 0 deletions

View file

@ -0,0 +1,39 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { fetchDriverPaymentProof, listDriverPaymentProofs } from "@/lib/server/driver-payment-proof-service";
import { GET } from "./route";
vi.mock("@/lib/server/browser-session", () => ({ getBrowserSessionUser: vi.fn() }));
vi.mock("@/lib/server/driver-payment-proof-service", () => ({
fetchDriverPaymentProof: vi.fn(),
listDriverPaymentProofs: vi.fn(),
}));
const url = "http://localhost/api/v1/driver/payment-proof/po1";
const ctx = { params: Promise.resolve({ poId: "po1" }) };
beforeEach(() => vi.resetAllMocks());
describe("driver payment proof route", () => {
it("requires the driver session before looking up a proof file", async () => {
vi.mocked(getBrowserSessionUser).mockRejectedValue(Object.assign(new Error(), { status: 401 }));
expect((await GET(new NextRequest(`${url}?payment=request1`), ctx)).status).toBe(401);
expect(fetchDriverPaymentProof).not.toHaveBeenCalled();
});
it("returns only payment identifiers and checks access again when streaming the file", async () => {
vi.mocked(getBrowserSessionUser).mockResolvedValue({ id: "driver1", name: "Driver", user_type: null });
vi.mocked(listDriverPaymentProofs).mockResolvedValue([{ requestId: "request1", recordId: "line1", filename: "proof.pdf" }]);
vi.mocked(fetchDriverPaymentProof).mockResolvedValue({ body: new Response("test PDF").body!, contentType: "application/pdf" });
const list = await GET(new NextRequest(url), ctx);
expect(await list.json()).toEqual({ payments: ["request1"] });
const file = await GET(new NextRequest(`${url}?payment=request1`), ctx);
expect(fetchDriverPaymentProof).toHaveBeenCalledWith("po1", "driver1", "request1");
expect(file.headers.get("cache-control")).toBe("private, no-store");
expect(file.headers.get("content-type")).toBe("application/pdf");
expect(await file.text()).toBe("test PDF");
});
});

View file

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { getBrowserSessionUser } from "@/lib/server/browser-session";
import { fetchDriverPaymentProof, listDriverPaymentProofs } from "@/lib/server/driver-payment-proof-service";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Authorization",
"Cache-Control": "private, no-store",
};
export function OPTIONS() {
return new NextResponse(null, { status: 204, headers });
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ poId: string }> }) {
try {
const user = await getBrowserSessionUser(req);
const { poId } = await params;
const requestId = req.nextUrl.searchParams.get("payment");
if (requestId === null) {
const proofs = await listDriverPaymentProofs(poId, user.id);
return NextResponse.json({ payments: proofs.map((proof) => proof.requestId) }, { headers });
}
const file = await fetchDriverPaymentProof(poId, user.id, requestId);
if (!file) return NextResponse.json({ error: "Proof of payment is unavailable." }, { status: 404, headers });
return new NextResponse(file.body, {
headers: { ...headers, "Content-Type": file.contentType, "X-Content-Type-Options": "nosniff" },
});
} catch (error) {
const unauthorized = (error as { status?: number } | null)?.status === 401;
return NextResponse.json(
{ error: unauthorized ? "Sign in to view proof of payment." : "Could not load proof of payment." },
{ status: unauthorized ? 401 : 500, headers },
);
}
}

View file

@ -0,0 +1,45 @@
import { describe, expect, it, vi } from "vitest";
import { listDriverPaymentProofs } from "./driver-payment-proof-service";
const { getFullList, getOne, filter } = vi.hoisted(() => ({
getFullList: vi.fn(),
getOne: vi.fn(),
filter: vi.fn(),
}));
vi.mock("./pocketbase-admin", () => ({
getAdminPocketBase: async () => ({
filter,
collection: (name: string) => name === "order_items" ? { getFullList } : { getOne },
}),
}));
describe("driver payment proofs", () => {
it("uses only paid, attached evidence on assigned PO lines and lists a shared payment once", async () => {
const line = { order: "order1", supplier: "supplier1", payment_status: "paid", payment_request: "request1", order_item_attachements: ["proof.pdf"] };
getFullList.mockResolvedValue([{ ...line, id: "line1" }, { ...line, id: "line2" }]);
getOne.mockResolvedValue({
id: "request1", status: "paid", order: "order1", supplier: "supplier1",
items: ["line1", "line2", "other"],
documents: [
{ role: "proof", collection: "order_items", recordId: "other", filename: "private.pdf" },
{ role: "support", collection: "order_items", recordId: "line1", filename: "proof.pdf" },
{ role: "proof", collection: "order_items", recordId: "line1", filename: "missing.pdf" },
{ role: "proof", collection: "order_items", recordId: "line2", filename: "proof.pdf" },
],
});
expect(await listDriverPaymentProofs("po1", "driver1")).toEqual([
{ requestId: "request1", recordId: "line2", filename: "proof.pdf" },
]);
expect(filter).toHaveBeenCalledWith(expect.stringContaining('drv_id = {:driverId}'), { poId: "po1", driverId: "driver1" });
expect(getOne).toHaveBeenCalledTimes(1);
});
it("returns nothing if the driver has no matching assigned lines", async () => {
getOne.mockClear();
getFullList.mockResolvedValue([]);
expect(await listDriverPaymentProofs("po1", "other-driver")).toEqual([]);
expect(getOne).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,54 @@
import type { SupplierPaymentRequest } from "@/lib/order-payments";
import { attachmentList } from "./order-item-files";
import { getAdminPocketBase } from "./pocketbase-admin";
type Pb = Awaited<ReturnType<typeof getAdminPocketBase>>;
type Proof = { requestId: string; recordId: string; filename: string };
async function listAssignedProofs(pb: Pb, poId: string, driverId: string): Promise<Proof[]> {
const lines = await pb.collection("order_items").getFullList({
filter: pb.filter('purchase_order = {:poId} && drv_id = {:driverId} && item_status = "assigned"', { poId, driverId }),
fields: "id,order,supplier,payment_status,payment_request,order_item_attachements",
});
const paidLines = new Map(lines.filter((line) => line.payment_status === "paid" && line.payment_request).map((line) => [line.id, line]));
const requestIds = new Set([...paidLines.values()].map((line) => String(line.payment_request)));
const proofs = await Promise.all([...requestIds].map(async (id) => {
const request = await pb.collection("supplier_payment_requests").getOne<SupplierPaymentRequest>(id, {
fields: "id,status,order,supplier,items,documents",
}).catch((error: unknown) => {
if ((error as { status?: number } | null)?.status === 404) return null;
throw error;
});
if (request?.status !== "paid") return null;
const document = (request.documents ?? []).find((doc) => {
if (doc.role !== "proof" || doc.collection !== "order_items" || !request.items.includes(doc.recordId)) return false;
const line = paidLines.get(doc.recordId);
return line?.payment_request === id && line.order === request.order && line.supplier === request.supplier
&& attachmentList(line.order_item_attachements).includes(doc.filename);
});
return document ? { requestId: request.id, recordId: document.recordId, filename: document.filename } : null;
}));
return proofs.filter((proof): proof is Proof => proof !== null);
}
export async function listDriverPaymentProofs(poId: string, driverId: string) {
return listAssignedProofs(await getAdminPocketBase(), poId, driverId);
}
export async function fetchDriverPaymentProof(poId: string, driverId: string, requestId: string) {
const pb = await getAdminPocketBase();
const proofs = await listAssignedProofs(pb, poId, driverId);
const proof = proofs.find((entry) => entry.requestId === requestId);
if (!proof) return null;
const token = await pb.files.getToken();
const url = pb.files.getURL({ collectionName: "order_items", id: proof.recordId }, proof.filename, { token });
const response = await fetch(url, { cache: "no-store" });
if (response.status === 404) return null;
if (!response.ok || !response.body) throw new Error("Could not open proof of payment.");
const type = (response.headers.get("content-type") ?? "").split(";")[0].toLowerCase();
const preview = /^(application\/pdf|image\/(jpeg|png|gif|webp|avif|bmp|heic|heif))$/.test(type);
return { body: response.body, contentType: preview ? type : "application/octet-stream" };
}