Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ export default async function AdminPage() {
<Link href="/admin/evidence" className="btn-secondary">
Ảnh chứng từ bị báo cáo
</Link>
<Link href="/admin/payment-requests" className="btn-secondary">
Yêu cầu thanh toán
</Link>
{reviewsAccounts && (
<>
<Link href="/admin/accounts" className="btn-secondary">
Expand Down
122 changes: 122 additions & 0 deletions apps/web/src/app/admin/payment-requests/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) {
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")
})
})
78 changes: 78 additions & 0 deletions apps/web/src/app/admin/payment-requests/actions.ts
Original file line number Diff line number Diff line change
@@ -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<Action, string> = {
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<string, string> = {
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<ActionResult> {
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])
}
180 changes: 180 additions & 0 deletions apps/web/src/app/admin/payment-requests/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto max-w-5xl space-y-8 px-4 py-10">
<div>
<Link href="/admin" className="text-sm text-gray-600 hover:text-gray-900">
← Kiểm duyệt
</Link>
<h1 className="mt-2 text-3xl font-bold">Yêu cầu thanh toán của dự án ủng hộ</h1>
<p className="mt-2 text-sm text-gray-600">
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ũ.
</p>
</div>

{campaigns.length === 0 ? (
<p className="card p-6 text-gray-600">Chưa có dự án ủng hộ nào đạt mục tiêu.</p>
) : (
<ul className="space-y-4">
{campaigns.map((campaign) => (
<CampaignCard key={campaign.id} campaign={campaign} />
))}
</ul>
)}
</div>
)
}

function CampaignCard({ campaign }: { campaign: SucceededBackCampaign }) {
const stopped = !!campaign.payments_stopped_at
return (
<li className="space-y-4 card p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h2 className="text-lg font-semibold">{campaign.title}</h2>
<p className="text-sm text-gray-600">Đạt mục tiêu {formatDate(campaign.outcome_at)}</p>
</div>
<Link href={`/campaigns/${campaign.slug}`} className="btn-secondary" target="_blank">
Xem dự án
</Link>
</div>
<dl className="grid gap-2 text-sm sm:grid-cols-4">
<div>
<dt className="text-gray-500">Cam kết</dt>
<dd>{formatVnd(campaign.pledged_amount)}</dd>
</div>
<div>
<dt className="text-gray-500">Đã thanh toán</dt>
<dd>{formatVnd(campaign.paid_amount)}</dd>
</div>
<div>
<dt className="text-gray-500">Hoàn tiền còn nợ</dt>
<dd>{formatVnd(campaign.refund_owed_amount)}</dd>
</div>
<div>
<dt className="text-gray-500">Người đồng hành</dt>
<dd>{campaign.backers_count}</dd>
</div>
</dl>

{stopped ? (
<>
<div className="space-y-1 rounded-md bg-red-50 px-3 py-2 text-sm text-red-900">
<p className="font-semibold">
Đã tạm dừng yêu cầu thanh toán từ {formatDateTime(campaign.payments_stopped_at)}.
</p>
{campaign.payments_stopped_note && (
<p className="whitespace-pre-line">Ghi chú công khai: {campaign.payments_stopped_note}</p>
)}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<ActionForm
action={setPaymentRequestsStopped.bind(null, campaign.id)}
confirm="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."
className="space-y-2"
>
<input type="hidden" name="action" value="resume" />
<label htmlFor={`resume-reason-${campaign.id}`} className="label">
Lý do mở lại (nội bộ)
</label>
<textarea
id={`resume-reason-${campaign.id}`}
name="reason"
className="field"
rows={2}
required
maxLength={STOP_REASON_MAX}
/>
<SubmitButton>Mở lại yêu cầu thanh toán</SubmitButton>
</ActionForm>
<StopForm campaign={campaign} />
</div>
</>
) : (
<StopForm campaign={campaign} />
)}
</li>
)
}

/** Stops the payment requests, or changes the public note of a stop (no new notices then). */
function StopForm({ campaign }: { campaign: SucceededBackCampaign }) {
const stopped = !!campaign.payments_stopped_at
return (
<ActionForm
action={setPaymentRequestsStopped.bind(null, campaign.id)}
confirm={
stopped
? "Đổi ghi chú công khai của lần tạm dừng này?"
: "Tạm dừng yêu cầu thanh toán của dự án này? Người ủng hộ đang chờ thanh toán và người tạo dự án sẽ được báo, trang dự án hiện ghi chú công khai."
}
className="space-y-2"
>
<input type="hidden" name="action" value="stop" />
<label htmlFor={`stop-reason-${campaign.id}`} className="label">
{stopped ? "Lý do đổi ghi chú (nội bộ)" : "Lý do tạm dừng (nội bộ, ghi vào nhật ký kiểm toán)"}
</label>
<textarea
id={`stop-reason-${campaign.id}`}
name="reason"
className="field"
rows={2}
required
maxLength={STOP_REASON_MAX}
placeholder={stopped ? undefined : "Ví dụ: báo cáo gian lận, đã đối chiếu bằng chứng."}
/>
<label htmlFor={`stop-note-${campaign.id}`} className="label">
Ghi chú công khai (người ủng hộ và trang dự án thấy)
</label>
<textarea
id={`stop-note-${campaign.id}`}
name="public_note"
className="field"
rows={2}
required
maxLength={STOP_PUBLIC_NOTE_MAX}
defaultValue={campaign.payments_stopped_note ?? ""}
placeholder="Ví dụ: Give đang xác minh dự án này. Đừng chuyển tiền cho tới khi có thông báo mới."
/>
<SubmitButton className={stopped ? "btn-secondary" : "btn-secondary text-red-700"}>
{stopped ? "Đổi ghi chú công khai" : "Tạm dừng yêu cầu thanh toán"}
</SubmitButton>
</ActionForm>
)
}
Loading
Loading