diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 1da16bf7..56fd0b53 100644 --- a/apps/web/src/app/admin/page.tsx +++ b/apps/web/src/app/admin/page.tsx @@ -52,6 +52,9 @@ export default async function AdminPage() { Ảnh chứng từ bị báo cáo + + Yêu cầu thanh toán + {reviewsAccounts && ( <> diff --git a/apps/web/src/app/admin/payment-requests/actions.test.ts b/apps/web/src/app/admin/payment-requests/actions.test.ts new file mode 100644 index 00000000..8a72b9d7 --- /dev/null +++ b/apps/web/src/app/admin/payment-requests/actions.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { fakeDb, op, type Call, type Handler } from "@/test/fake-db" + +const MODERATOR = "66666666-6666-4666-8666-666666666666" +const CAMPAIGN = "77777777-7777-4777-8777-777777777777" + +const state = vi.hoisted(() => ({ + userHandler: (() => undefined) as Handler, + serviceHandler: (() => undefined) as Handler, + userCalls: [] as Call[], + serviceCalls: [] as Call[], +})) + +vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })) +vi.mock("@/lib/auth/session", () => ({ + getSession: vi.fn(async () => ({ user: { id: MODERATOR, email: null, name: null }, accessToken: "token" })), +})) +vi.mock("@/lib/supabase", () => ({ + dbAs: () => fakeDb((call) => state.userHandler(call), state.userCalls), + dbService: () => fakeDb((call) => state.serviceHandler(call), state.serviceCalls), +})) + +const { setPaymentRequestsStopped } = await import("./actions") + +function form(values: Record) { + const data = new FormData() + for (const [key, value] of Object.entries(values)) data.set(key, value) + return data +} + +beforeEach(() => { + state.userCalls.length = 0 + state.serviceCalls.length = 0 + state.userHandler = (call) => (call.target === "rpc:is_admin" ? { data: true } : undefined) + state.serviceHandler = () => ({ data: "stopped" }) +}) + +describe("setPaymentRequestsStopped", () => { + it("stops with the reason and the public note, after a role check with the moderator's token", async () => { + const result = await setPaymentRequestsStopped( + CAMPAIGN, + null, + form({ action: "stop", reason: " Báo cáo gian lận #12 ", public_note: " Give đang xác minh. " }) + ) + expect(result?.ok).toBe(true) + expect(op(state.userCalls[0], "args")).toEqual([{ p_roles: ["moderator", "admin"] }]) + expect(state.serviceCalls[0].target).toBe("rpc:set_payment_requests_stopped") + expect(op(state.serviceCalls[0], "args")).toEqual([ + { + p_actor_id: MODERATOR, + p_campaign_id: CAMPAIGN, + p_stopped: true, + p_reason: "Báo cáo gian lận #12", + p_public_note: "Give đang xác minh.", + }, + ]) + }) + + it("resumes with a reason and never sends a public note", async () => { + state.serviceHandler = () => ({ data: "resumed" }) + const result = await setPaymentRequestsStopped( + CAMPAIGN, + null, + form({ action: "resume", reason: "Đã xác minh.", public_note: "x" }) + ) + expect(result?.ok).toBe(true) + expect(result?.message).toContain("Đã mở lại") + expect(op(state.serviceCalls[0], "args")).toEqual([ + expect.objectContaining({ p_stopped: false, p_public_note: null }), + ]) + }) + + it("needs a reason, a public note to stop, a valid action and campaign", async () => { + expect( + (await setPaymentRequestsStopped(CAMPAIGN, null, form({ action: "stop", reason: " ", public_note: "x" })))?.ok + ).toBe(false) + expect((await setPaymentRequestsStopped(CAMPAIGN, null, form({ action: "stop", reason: "x" })))?.message).toContain( + "Ghi chú công khai là bắt buộc" + ) + expect((await setPaymentRequestsStopped(CAMPAIGN, null, form({ action: "pause", reason: "x" })))?.ok).toBe(false) + expect( + (await setPaymentRequestsStopped("nope", null, form({ action: "stop", reason: "x", public_note: "y" })))?.ok + ).toBe(false) + expect( + ( + await setPaymentRequestsStopped( + CAMPAIGN, + null, + form({ action: "stop", reason: "x", public_note: "y".repeat(501) }) + ) + )?.message + ).toContain("tối đa 500") + expect(state.serviceCalls).toEqual([]) + }) + + it("refuses non-moderators before calling the database", async () => { + state.userHandler = (call) => (call.target === "rpc:is_admin" ? { data: false } : undefined) + const result = await setPaymentRequestsStopped( + CAMPAIGN, + null, + form({ action: "stop", reason: "x", public_note: "y" }) + ) + expect(result?.message).toContain("Chỉ người kiểm duyệt") + expect(state.serviceCalls).toEqual([]) + }) + + it("words the database refusals for moderators", async () => { + state.serviceHandler = () => ({ error: { code: "42501", message: "GIVE_SELF_REVIEW" } }) + expect( + (await setPaymentRequestsStopped(CAMPAIGN, null, form({ action: "stop", reason: "x", public_note: "y" }))) + ?.message + ).toContain("chính bạn tạo") + state.serviceHandler = () => ({ + error: { code: "23514", message: "GIVE_PAYMENT_REQUESTS_NOT_STOPPABLE: back live" }, + }) + expect( + (await setPaymentRequestsStopped(CAMPAIGN, null, form({ action: "stop", reason: "x", public_note: "y" }))) + ?.message + ).toContain("đã đạt mục tiêu") + }) +}) diff --git a/apps/web/src/app/admin/payment-requests/actions.ts b/apps/web/src/app/admin/payment-requests/actions.ts new file mode 100644 index 00000000..fba8c374 --- /dev/null +++ b/apps/web/src/app/admin/payment-requests/actions.ts @@ -0,0 +1,78 @@ +"use server" + +import { revalidatePath } from "next/cache" + +import { getSession } from "@/lib/auth/session" +import { dbErrorMessage, done, fail, type ActionResult } from "@/lib/errors" +import { isUuid } from "@/lib/ids" +import { STOP_PUBLIC_NOTE_MAX, STOP_REASON_MAX } from "@/lib/payment-requests" +import { dbAs, dbService } from "@/lib/supabase" + +/** + * A moderator stops or resumes the payment requests of a succeeded back + * campaign (DOS/Give#89). give.set_payment_requests_stopped runs as the service + * role with the moderator's id as an argument; it checks the moderator/admin + * role, refuses the moderator's own campaign and writes the audit row and the + * notices. Because it trusts the id, the role check below runs with the + * moderator's own token first: PostgREST verifies that token, so a forged + * session cookie fails here. + */ + +const SIGNED_OUT = "Phiên đăng nhập đã hết. Hãy đăng nhập lại." +const MODERATOR_ROLES = ["moderator", "admin"] + +const ACTIONS = ["stop", "resume"] as const +type Action = (typeof ACTIONS)[number] + +const DONE_MESSAGES: Record = { + stop: "Đã tạm dừng yêu cầu thanh toán: không còn nhắc thanh toán, trang thanh toán đã ẩn, người ủng hộ đang chờ thanh toán và người tạo dự án đã được báo, trang dự án hiện ghi chú công khai.", + resume: "Đã mở lại yêu cầu thanh toán: trang thanh toán hiện lại và người ủng hộ đang chờ thanh toán đã được báo.", +} + +const STOP_MESSAGES: Record = { + GIVE_NOT_ADMIN: "Chỉ người kiểm duyệt hoặc quản trị viên được tạm dừng yêu cầu thanh toán.", + GIVE_SELF_REVIEW: "Không thể thao tác trên dự án do chính bạn tạo; một người kiểm duyệt khác cần xử lý.", + GIVE_PAYMENT_REQUESTS_NOT_STOPPABLE: + "Chỉ dừng được yêu cầu thanh toán của dự án ủng hộ đã đạt mục tiêu. Dự án đang gây quỹ thì tạm dừng dự án.", + GIVE_REASON_REQUIRED: "Ghi lý do nội bộ (chỉ lưu vào nhật ký kiểm toán).", + GIVE_REASON_TOO_LONG: `Lý do tối đa ${STOP_REASON_MAX} ký tự.`, + GIVE_PUBLIC_NOTE_REQUIRED: "Ghi chú công khai là bắt buộc khi tạm dừng; người ủng hộ và trang dự án sẽ thấy.", + GIVE_PUBLIC_NOTE_TOO_LONG: `Ghi chú công khai tối đa ${STOP_PUBLIC_NOTE_MAX} ký tự.`, + GIVE_CAMPAIGN_NOT_FOUND: "Không tìm thấy dự án.", +} + +export async function setPaymentRequestsStopped( + campaignId: string, + _state: ActionResult, + form: FormData +): Promise { + const session = await getSession() + if (!session) return fail(SIGNED_OUT) + if (!isUuid(campaignId)) return fail(STOP_MESSAGES.GIVE_CAMPAIGN_NOT_FOUND) + const action = String(form.get("action") ?? "") as Action + if (!ACTIONS.includes(action)) return fail("Thao tác không hợp lệ.") + const reason = String(form.get("reason") ?? "").trim() + const publicNote = action === "stop" ? String(form.get("public_note") ?? "").trim() : "" + if (!reason) return fail(STOP_MESSAGES.GIVE_REASON_REQUIRED) + if (reason.length > STOP_REASON_MAX) return fail(STOP_MESSAGES.GIVE_REASON_TOO_LONG) + if (action === "stop" && !publicNote) return fail(STOP_MESSAGES.GIVE_PUBLIC_NOTE_REQUIRED) + if (publicNote.length > STOP_PUBLIC_NOTE_MAX) return fail(STOP_MESSAGES.GIVE_PUBLIC_NOTE_TOO_LONG) + + const role = await dbAs(session.accessToken).rpc("is_admin", { p_roles: MODERATOR_ROLES }) + if (role.error) return fail(dbErrorMessage(role.error)) + if (role.data !== true) return fail(STOP_MESSAGES.GIVE_NOT_ADMIN) + + const { error } = await dbService().rpc("set_payment_requests_stopped", { + p_actor_id: session.user.id, + p_campaign_id: campaignId, + p_stopped: action === "stop", + p_reason: reason, + p_public_note: publicNote || null, + }) + if (error) return fail(dbErrorMessage(error, STOP_MESSAGES)) + + revalidatePath("/admin/payment-requests") + revalidatePath("/campaigns/[slug]", "page") + revalidatePath("/me/pledges/[code]", "page") + return done(DONE_MESSAGES[action]) +} diff --git a/apps/web/src/app/admin/payment-requests/page.tsx b/apps/web/src/app/admin/payment-requests/page.tsx new file mode 100644 index 00000000..6c82006e --- /dev/null +++ b/apps/web/src/app/admin/payment-requests/page.tsx @@ -0,0 +1,180 @@ +import type { Metadata } from "next" +import Link from "next/link" +import { notFound } from "next/navigation" + +import { ActionForm, SubmitButton } from "@/components/forms" +import { requireSession } from "@/lib/auth/session" +import { isStaff } from "@/lib/campaigns" +import { formatDate, formatDateTime, formatVnd } from "@/lib/format" +import { + listSucceededBackCampaigns, + STOP_PUBLIC_NOTE_MAX, + STOP_REASON_MAX, + type SucceededBackCampaign, +} from "@/lib/payment-requests" +import { dbAs } from "@/lib/supabase" + +import { setPaymentRequestsStopped } from "./actions" + +export const metadata: Metadata = { title: "Yêu cầu thanh toán", robots: { index: false } } + +/** + * Moderators stop the payment requests of a back campaign that reached its + * goal when fraud is suspected, and resume them once it is cleared + * (DOS/Give#89). The list is public data (give.public_campaigns); the database + * function checks the role again and refuses the moderator's own campaign. + */ +export default async function AdminPaymentRequestsPage() { + const session = await requireSession("/admin/payment-requests") + const db = dbAs(session.accessToken) + if (!(await isStaff(db))) notFound() + + const campaigns = await listSucceededBackCampaigns(db) + + return ( +
+
+ + ← Kiểm duyệt + +

Yêu cầu thanh toán của dự án ủng hộ

+

+ Dự án ủng hộ đạt mục tiêu thì người ủng hộ được yêu cầu chuyển tiền thẳng vào tài khoản của người tạo dự án. + Khi nghi gian lận, hãy tạm dừng yêu cầu thanh toán: Give ngừng nhắc thanh toán, ẩn thông tin chuyển khoản, báo + cho người ủng hộ đang chờ thanh toán và người tạo dự án, và trang dự án hiện ghi chú công khai. Lý do nội bộ + chỉ lưu vào nhật ký kiểm toán. Trong lúc tạm dừng không cam kết nào hết hạn; khi mở lại, hạn thanh toán và + ngày nhắc của các cam kết đang chờ được lùi thêm đúng thời gian đã tạm dừng. Tiền vẫn chuyển tới được ghi nhận + như thường. Đổi ghi chú của một lần tạm dừng chỉ đổi trang dự án; thông báo đã gửi giữ ghi chú cũ. +

+
+ + {campaigns.length === 0 ? ( +

Chưa có dự án ủng hộ nào đạt mục tiêu.

+ ) : ( +
    + {campaigns.map((campaign) => ( + + ))} +
+ )} +
+ ) +} + +function CampaignCard({ campaign }: { campaign: SucceededBackCampaign }) { + const stopped = !!campaign.payments_stopped_at + return ( +
  • +
    +
    +

    {campaign.title}

    +

    Đạt mục tiêu {formatDate(campaign.outcome_at)}

    +
    + + Xem dự án + +
    +
    +
    +
    Cam kết
    +
    {formatVnd(campaign.pledged_amount)}
    +
    +
    +
    Đã thanh toán
    +
    {formatVnd(campaign.paid_amount)}
    +
    +
    +
    Hoàn tiền còn nợ
    +
    {formatVnd(campaign.refund_owed_amount)}
    +
    +
    +
    Người đồng hành
    +
    {campaign.backers_count}
    +
    +
    + + {stopped ? ( + <> +
    +

    + Đã tạm dừng yêu cầu thanh toán từ {formatDateTime(campaign.payments_stopped_at)}. +

    + {campaign.payments_stopped_note && ( +

    Ghi chú công khai: {campaign.payments_stopped_note}

    + )} +
    +
    + + + +