From dfa86b4538a85f680c357ecace784e9be5c55c49 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:19:22 +0700 Subject: [PATCH 1/4] Stop on payment requests and refunds net of partial refunds for back campaigns (#89) The pledge page hides the transfer details while a moderator has stopped the campaign's payment requests, and shows amounts owed back net of refunds (give.pledge_balance). Moderators stop or resume the payment requests of a succeeded back campaign at /admin/payment-requests. The campaign page shows the public stop notice. Notices word refund_amount, the overpaid refund_owed and the stop and resume notices. Needs DOS/DOS.Me 20261106110000_give_pledge_net_refunds_and_payment_stop. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/app/admin/page.tsx | 3 + .../admin/payment-requests/actions.test.ts | 122 ++++++++++++ .../src/app/admin/payment-requests/actions.ts | 78 ++++++++ .../src/app/admin/payment-requests/page.tsx | 179 ++++++++++++++++++ apps/web/src/app/me/pledges/[code]/page.tsx | 68 +++++-- apps/web/src/components/MoneyTotals.test.ts | 2 + apps/web/src/components/PledgeTotals.test.ts | 24 +++ apps/web/src/components/PledgeTotals.tsx | 14 +- apps/web/src/lib/notices.test.ts | 65 +++++++ apps/web/src/lib/notices.ts | 49 ++++- apps/web/src/lib/payment-requests.ts | 74 ++++++++ apps/web/src/lib/pledges.test.ts | 14 ++ apps/web/src/lib/pledges.ts | 25 +++ apps/web/src/lib/statement.test.ts | 2 + apps/web/src/lib/statement.ts | 10 +- apps/web/src/server/contributions/index.ts | 5 +- apps/web/src/server/pledges/index.ts | 33 ++++ apps/web/src/server/pledges/pledges.test.ts | 36 +++- 18 files changed, 774 insertions(+), 29 deletions(-) create mode 100644 apps/web/src/app/admin/payment-requests/actions.test.ts create mode 100644 apps/web/src/app/admin/payment-requests/actions.ts create mode 100644 apps/web/src/app/admin/payment-requests/page.tsx create mode 100644 apps/web/src/lib/payment-requests.ts diff --git a/apps/web/src/app/admin/page.tsx b/apps/web/src/app/admin/page.tsx index 52106a4d..80ec7d9e 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..62cb9508 --- /dev/null +++ b/apps/web/src/app/admin/payment-requests/page.tsx @@ -0,0 +1,179 @@ +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. Hạn thanh toán vẫn chạy: cam kết chưa trả đủ khi hết hạn sẽ hết hiệu lực, phần + đã chuyển được ghi là tiền phải hoàn. Tiền vẫn chuyển tới được ghi nhận như thường. +

+
+ + {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}

    + )} +
    +
    + + + +