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.
+
+ Đã 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}
+ )}
+
+
+
+
+
+
+ Mở lại yêu cầu thanh toán
+
+
+
+ >
+ ) : (
+
+ )}
+
+ )
+}
+
+/** 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 (
+
+
+
+
+
+
+
+ {stopped ? "Đổi ghi chú công khai" : "Tạm dừng yêu cầu thanh toán"}
+
+
+ )
+}
diff --git a/apps/web/src/app/me/pledges/[code]/page.tsx b/apps/web/src/app/me/pledges/[code]/page.tsx
index 738f2278..df76053c 100644
--- a/apps/web/src/app/me/pledges/[code]/page.tsx
+++ b/apps/web/src/app/me/pledges/[code]/page.tsx
@@ -14,14 +14,16 @@ import {
overpaid,
deliveryMonth,
fulfillmentLabel,
+ paymentView,
pledgeStatusColor,
pledgeStatusLabel,
readShippingAddress,
} from "@/lib/pledges"
+import { paymentRequestsStopped } from "@/lib/payment-requests"
import { dbAs, dbService } from "@/lib/supabase"
import { cancelPledge } from "@/app/campaigns/[slug]/pledge/actions"
import { isSessionRejected, paymentCampaign } from "@/server/contributions"
-import { getTierTitle, ownPledgeByCode, pledgePayment, type OwnPledge } from "@/server/pledges"
+import { getTierTitle, ownPledgeByCode, pledgeBalance, pledgePayment, type OwnPledge } from "@/server/pledges"
import { buildDonationPayload, isDonationCode, renderVietQrSvg } from "@/server/vietqr"
export const metadata: Metadata = { title: "Cam kết ủng hộ", robots: { index: false, follow: false } }
@@ -66,7 +68,10 @@ function Note({ tone = "gray", children }: { tone?: "gray" | "amber" | "green" |
* campaign is live the pledge can still be changed or cancelled; once the
* campaign reaches its goal this is the payment page, with a VietQR code to
* the creator's own account for the amount still due and the reference code
- * as the transfer content. Give holds no money.
+ * as the transfer content. Give holds no money. While a moderator has stopped
+ * the campaign's payment requests (DOS/Give#89) the page shows no transfer
+ * details. Amounts owed back are net of refunds already made
+ * (give.pledge_balance).
*/
export default async function PledgePaymentPage({ params }: { params: Promise<{ code: string }> }) {
const { code } = await params
@@ -79,7 +84,17 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
pledge.reward_tier_id ? getTierTitle(dbAs(token), pledge.reward_tier_id).catch(() => null) : null,
])
if (!campaign) notFound()
- const payment = awaitsPayment(pledge.status) ? await pledgePayment(service, pledge.id, userId) : null
+ const stopped = paymentRequestsStopped(campaign)
+ const [payment, balance] = await Promise.all([
+ awaitsPayment(pledge.status) && !stopped ? pledgePayment(service, pledge.id, userId) : null,
+ // A failed read falls back to the pledge's own numbers (before refunds).
+ pledgeBalance(service, pledge.id, userId).catch((error: unknown) => {
+ console.error("pledge page: pledge balance lookup failed", error)
+ return null
+ }),
+ ])
+ const excess = balance ? (pledge.status === "paid" ? balance.refund_owed_amount : 0) : overpaid(pledge)
+ const owedBack = balance ? balance.refund_owed_amount : pledge.received_amount
const shipping = readShippingAddress(pledge.shipping_address)
const fulfillment = pledge.status === "paid" ? fulfillmentLabel(pledge.fulfillment_status) : null
const month = deliveryMonth(tier?.estimated_delivery)
@@ -115,24 +130,38 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
)
break
case "payment_due":
- case "partially_paid":
- if (!payment) {
+ case "partially_paid": {
+ const view = paymentView(stopped, payment, isDonationCode)
+ if (view === "stopped") {
+ body = (
+
+
+ Give đã tạm dừng yêu cầu thanh toán của dự án này. Đừng chuyển tiền cho cam kết này, kể cả theo thông tin
+ chuyển khoản bạn đã nhận trước đó.
+
+ {campaign.payments_stopped_note &&
{campaign.payments_stopped_note}
}
+
+ Give sẽ báo khi yêu cầu thanh toán được mở lại. Hạn thanh toán vẫn tính như cũ; quá hạn mà chưa trả đủ,
+ cam kết hết hiệu lực và phần đã chuyển được hoàn lại.
+
+
+ )
+ } else if (!payment) {
body = (
Đã quá hạn thanh toán của cam kết này. Đừng chuyển thêm tiền. Nếu bạn đã chuyển, Give vẫn ghi nhận khi đối
soát được khoản này.
)
- } else if (payment.amount_due <= 0) {
- // Paid in full but not matched as paid yet (e.g. part of it was refunded): no QR for 0 đồng.
+ } else if (view === "nothing_due") {
+ // amount_due is net of refunds, so this is only a pledge Give has not marked paid yet: no QR for 0 đồng.
body = (
- Give chưa tính được số tiền còn thiếu của cam kết này (có thể vì một phần tiền đã được hoàn lại), nên không
- hiển thị mã chuyển khoản. Hãy liên hệ người tạo dự án trước hạn {formatDateTime(payment.payment_due_at)} để
- biết số còn thiếu; quá hạn mà chưa đủ, cam kết hết hiệu lực và tiền đã chuyển được hoàn lại.
+ Give ghi nhận cam kết này không còn thiếu tiền, nên không hiển thị mã chuyển khoản. Đừng chuyển thêm; Give
+ cập nhật trạng thái khi đối soát sao kê.
)
- } else if (!payment.account || !isDonationCode(payment.reference_code)) {
+ } else if (view === "no_account" || !payment.account) {
body = (
Dự án đạt mục tiêu nhưng tài khoản nhận của người tạo dự án hiện chưa dùng được, nên Give chưa hiển thị
@@ -198,15 +227,15 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
)
}
break
+ }
case "paid":
body = (
Bạn đã thanh toán đủ {formatVnd(pledge.intended_amount)}. Cảm ơn bạn đã đồng hành cùng dự án.
- {overpaid(pledge) > 0 && (
+ {excess > 0 && (
- Give ghi nhận bạn đã chuyển {formatVnd(pledge.received_amount)}, nhiều hơn số cam kết. Nếu chưa được hoàn,
- người tạo dự án phải hoàn lại phần thừa {formatVnd(overpaid(pledge))}; trang dự án công khai khoản còn nợ
- cho tới khi hoàn xong.
+ Give ghi nhận bạn đã chuyển {formatVnd(pledge.received_amount)}, nhiều hơn số cam kết. Người tạo dự án còn
+ phải hoàn lại phần thừa {formatVnd(excess)}; trang dự án công khai khoản còn nợ cho tới khi hoàn xong.
)}
{pledge.reward_tier_id && (
@@ -223,9 +252,12 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
body = (
Cam kết này không còn hiệu lực (chưa được thanh toán đủ trước hạn, hoặc tiền đến sau khi cam kết đã hết hiệu
- lực), nên người tạo dự án phải hoàn lại số tiền bạn đã chuyển ({formatVnd(pledge.received_amount)})
- {pledge.refund_due_at ? ` trước ${formatDateTime(pledge.refund_due_at)}` : ""}. Trang dự án công khai số tiền
- hoàn còn nợ cho tới khi hoàn xong.
+ lực), nên người tạo dự án phải hoàn lại cho bạn {formatVnd(owedBack)}
+ {pledge.refund_due_at ? ` trước ${formatDateTime(pledge.refund_due_at)}` : ""}.
+ {balance && balance.refunded_amount > 0
+ ? ` Đã hoàn ${formatVnd(balance.refunded_amount)}; số trên là phần còn lại.`
+ : ""}{" "}
+ Trang dự án công khai số tiền hoàn còn nợ cho tới khi hoàn xong.
)
break
diff --git a/apps/web/src/components/MoneyTotals.test.ts b/apps/web/src/components/MoneyTotals.test.ts
index 48727c04..b06180da 100644
--- a/apps/web/src/components/MoneyTotals.test.ts
+++ b/apps/web/src/components/MoneyTotals.test.ts
@@ -28,6 +28,8 @@ const totals: Totals = {
other_in_amount: 0,
refund_owed_amount: 0,
backers_count: 0,
+ payments_stopped_at: null,
+ payments_stopped_note: null,
}
/** The text of each figure, by its data-figure key. */
diff --git a/apps/web/src/components/PledgeTotals.test.ts b/apps/web/src/components/PledgeTotals.test.ts
index 45ec5f71..c9c332da 100644
--- a/apps/web/src/components/PledgeTotals.test.ts
+++ b/apps/web/src/components/PledgeTotals.test.ts
@@ -23,6 +23,8 @@ const totals: MoneyTotals = {
other_in_amount: 50_000,
refund_owed_amount: 100_000,
backers_count: 1,
+ payments_stopped_at: null,
+ payments_stopped_note: null,
}
function figure(html: string, name: string): string | undefined {
@@ -44,4 +46,26 @@ describe("PledgeTotals", () => {
const html = renderToStaticMarkup(createElement(PledgeTotals, { totals: { ...totals, refund_owed_amount: 0 } }))
expect(html).not.toContain("Hoàn tiền còn nợ")
})
+
+ it("shows no stop notice while payment requests run", () => {
+ const html = renderToStaticMarkup(createElement(PledgeTotals, { totals }))
+ expect(html).not.toContain('data-notice="payments_stopped"')
+ })
+
+ it("puts the moderator's public notice first when payment requests are stopped (DOS/Give#89)", () => {
+ const html = renderToStaticMarkup(
+ createElement(PledgeTotals, {
+ totals: {
+ ...totals,
+ payments_stopped_at: "2026-11-10T03:00:00Z",
+ payments_stopped_note: "Give đang xác minh dự án này.",
+ },
+ })
+ )
+ expect(html.indexOf('data-notice="payments_stopped"')).toBeLessThan(html.indexOf('data-figure="pledged"'))
+ expect(html).toContain("Give đã tạm dừng yêu cầu thanh toán")
+ expect(html).toContain("đừng chuyển thêm tiền")
+ // The note is text, never markup.
+ expect(html).toContain("Give đang xác minh dự án <b>này</b>.")
+ })
})
diff --git a/apps/web/src/components/PledgeTotals.tsx b/apps/web/src/components/PledgeTotals.tsx
index 2b01b3e3..345d6e87 100644
--- a/apps/web/src/components/PledgeTotals.tsx
+++ b/apps/web/src/components/PledgeTotals.tsx
@@ -1,4 +1,4 @@
-import { formatVnd } from "@/lib/format"
+import { formatDateTime, formatVnd } from "@/lib/format"
import type { MoneyTotals } from "@/lib/statement"
/**
@@ -8,11 +8,21 @@ import type { MoneyTotals } from "@/lib/statement"
* give.public_campaigns. "Đã thanh toán" leaves out money finance explained
* as not from backers (other_in: an organizer top-up, interest), as
* raisedAmount does for give campaigns, so a top-up cannot make the project
- * look paid.
+ * look paid. When a moderator stopped the payment requests after success
+ * (DOS/Give#89), the public notice comes first, with the moderator's note.
*/
export function PledgeTotals({ totals }: { totals: MoneyTotals }) {
return (
+ {totals.payments_stopped_at && (
+
+
+ Give đã tạm dừng yêu cầu thanh toán của dự án này từ {formatDateTime(totals.payments_stopped_at)}.
+
+ {totals.payments_stopped_note &&
{totals.payments_stopped_note}
}
+
Người ủng hộ đừng chuyển thêm tiền cho tới khi Give thông báo tiếp.
+
+ )}
Cam kết
diff --git a/apps/web/src/lib/notices.test.ts b/apps/web/src/lib/notices.test.ts
index 5030237a..db207f8d 100644
--- a/apps/web/src/lib/notices.test.ts
+++ b/apps/web/src/lib/notices.test.ts
@@ -143,6 +143,71 @@ describe("describeNotice", () => {
expect(owed.href).toBe(`/me/campaigns/${notice({}).campaign_id}/disbursements`)
})
+ it("words refunds net of what was already refunded, and an overpayment (DOS/Give#89)", () => {
+ const payload = {
+ campaign_title: "Đèn",
+ reference_code: "GVAB12CD",
+ received_amount: 150_000,
+ refunded_amount: 50_000,
+ refund_amount: 100_000,
+ reason: "pledge_ended",
+ refund_due_at: "2026-11-20T00:00:00Z",
+ }
+ const refund = describeNotice(notice({ kind: "refund_due", payload }))
+ expect(refund.body).toContain(formatVnd(100_000))
+ expect(refund.body).not.toContain(formatVnd(150_000))
+ const owed = describeNotice(notice({ kind: "refund_owed", payload }))
+ expect(owed.body).toContain(formatVnd(100_000))
+ expect(owed.body).toContain("không còn hiệu lực")
+
+ const over = describeNotice(
+ notice({
+ kind: "refund_owed",
+ payload: { ...payload, reason: "overpaid", refund_amount: 60_000, refund_due_at: null },
+ })
+ )
+ expect(over.body).toContain("đã chuyển nhiều hơn số cam kết")
+ expect(over.body).toContain(`phần thừa ${formatVnd(60_000)}`)
+ expect(over.body).not.toContain("không còn hiệu lực")
+ expect(over.href).toBe(`/me/campaigns/${CAMPAIGN}/disbursements`)
+
+ const reminder = describeNotice(
+ notice({
+ kind: "payment_reminder",
+ payload: { intended_amount: 200_000, received_amount: 100_000, refunded_amount: 50_000 },
+ })
+ )
+ expect(reminder.body).toContain(formatVnd(150_000))
+ })
+
+ it("covers a stop and a resume of payment requests (DOS/Give#89)", () => {
+ const base = { campaign_title: "Đèn", campaign_slug: "den-ngu", public_note: "Give đang xác minh." }
+ const backer = describeNotice(
+ notice({ kind: "payment_requests_stopped", payload: { ...base, reference_code: "GVAB12CD" } })
+ )
+ expect(backer.title).toBe("Tạm dừng yêu cầu thanh toán")
+ expect(backer.body).toContain("Đừng chuyển tiền")
+ expect(backer.body).toContain("Ghi chú của Give: Give đang xác minh.")
+ expect(backer.href).toBe("/me/pledges/GVAB12CD")
+
+ const creator = describeNotice(notice({ kind: "payment_requests_stopped", payload: base }))
+ expect(creator.body).toContain("trang thanh toán đã ẩn")
+ expect(creator.href).toBe("/campaigns/den-ngu")
+ expect(
+ describeNotice(notice({ kind: "payment_requests_stopped", payload: { campaign_slug: "../x" } })).href
+ ).toBeNull()
+
+ const resumed = describeNotice(
+ notice({
+ kind: "payment_requests_resumed",
+ payload: { ...base, reference_code: "GVAB12CD", payment_due_at: "2026-11-12T05:00:00Z" },
+ })
+ )
+ expect(resumed.title).toBe("Mở lại yêu cầu thanh toán")
+ expect(resumed.body).toContain("12/11/2026")
+ expect(resumed.href).toBe("/me/pledges/GVAB12CD")
+ })
+
it("covers account review results", () => {
expect(describeNotice(notice({ kind: "account_verified", campaign_id: null })).href).toBe("/me/accounts")
expect(describeNotice(notice({ kind: "account_rejected", payload: { reason: "Sai tên" } })).body).toContain(
diff --git a/apps/web/src/lib/notices.ts b/apps/web/src/lib/notices.ts
index 0bbe1f3a..02cd240f 100644
--- a/apps/web/src/lib/notices.ts
+++ b/apps/web/src/lib/notices.ts
@@ -1,5 +1,6 @@
import type { Db } from "@/lib/campaigns"
import { formatDate, formatDateTime, formatVnd } from "@/lib/format"
+import { SLUG_PATTERN } from "@/lib/slug"
/**
* In-app notices (give.notices, DOS/Give#52): reminders and status changes
@@ -78,9 +79,11 @@ export function describeNotice(notice: Notice): NoticeView {
case "payment_due":
case "payment_reminder": {
// Payload of give.pledge_notice_payload (DOS/Give#30); reminders add amount_due and day.
+ // Net of refunds (DOS/Give#89): older payloads have no refunded_amount.
const intended = count(p, "intended_amount")
const received = count(p, "received_amount") ?? 0
- const due = count(p, "amount_due") ?? (intended !== null ? Math.max(0, intended - received) : null)
+ const refunded = count(p, "refunded_amount") ?? 0
+ const due = count(p, "amount_due") ?? (intended !== null ? Math.max(0, intended - (received - refunded)) : null)
const dueAt = text(p, "payment_due_at")
let body =
notice.kind === "payment_due"
@@ -122,7 +125,8 @@ export function describeNotice(notice: Notice): NoticeView {
...pledgeLink(p, "Xem cam kết"),
}
case "refund_due": {
- const received = count(p, "received_amount")
+ // refund_amount: what is still owed, net of refunds (DOS/Give#89); older payloads only have received_amount.
+ const received = count(p, "refund_amount") ?? count(p, "received_amount")
const by = text(p, "refund_due_at")
let body = `Cam kết ủng hộ ${campaignName(p, "dự án")} không còn hiệu lực (chưa được thanh toán đủ trước hạn, hoặc tiền đến sau khi cam kết đã hết hiệu lực). Người tạo dự án phải hoàn lại`
body += received ? ` ${formatVnd(received)} bạn đã chuyển` : " số tiền bạn đã chuyển"
@@ -130,14 +134,20 @@ export function describeNotice(notice: Notice): NoticeView {
return { title: "Bạn sẽ được hoàn tiền", body: capitalize(body), ...pledgeLink(p, "Xem cam kết") }
}
case "refund_owed": {
- const received = count(p, "received_amount")
+ const owed = count(p, "refund_amount") ?? count(p, "received_amount")
const by = text(p, "refund_due_at")
const code = text(p, "reference_code")
let body = `Một người ủng hộ ${campaignName(p, "dự án")}`
if (code) body += ` (mã ${code})`
- body += " có khoản đã chuyển cho một cam kết không còn hiệu lực. Bạn cần hoàn lại"
- body += received ? ` ${formatVnd(received)}` : " số tiền họ đã chuyển"
- body += by ? ` trước ${formatDate(by)}` : ""
+ if (text(p, "reason") === "overpaid") {
+ // A paid pledge that received more than it pledged (DOS/Give#89).
+ body += " đã chuyển nhiều hơn số cam kết. Bạn cần hoàn lại phần thừa"
+ body += owed ? ` ${formatVnd(owed)}` : ""
+ } else {
+ body += " có khoản đã chuyển cho một cam kết không còn hiệu lực. Bạn cần hoàn lại"
+ body += owed ? ` ${formatVnd(owed)}` : " số tiền họ đã chuyển"
+ body += by ? ` trước ${formatDate(by)}` : ""
+ }
body +=
", rồi giải trình khoản chuyển đó là hoàn tiền. Trang dự án công khai số tiền hoàn còn nợ cho tới khi hoàn xong."
return {
@@ -147,6 +157,33 @@ export function describeNotice(notice: Notice): NoticeView {
linkLabel: notice.campaign_id ? "Giải trình hoàn tiền" : null,
}
}
+ case "payment_requests_stopped": {
+ // To each backer still asked to pay (their pledge in the payload) and to the creator (DOS/Give#89).
+ const note = text(p, "public_note")
+ const backer = text(p, "reference_code") !== null
+ let body = `Give đã tạm dừng yêu cầu thanh toán của ${campaignName(p, "dự án")}.`
+ body += backer
+ ? " Đừng chuyển tiền cho cam kết của bạn, kể cả theo thông tin chuyển khoản đã nhận trước đó, cho tới khi Give thông báo tiếp."
+ : " Người ủng hộ không còn được nhắc thanh toán và trang thanh toán đã ẩn. Trang dự án công khai thông báo này."
+ if (note) body += ` Ghi chú của Give: ${note}`
+ const slug = text(p, "campaign_slug")
+ return {
+ title: "Tạm dừng yêu cầu thanh toán",
+ body,
+ ...(backer
+ ? pledgeLink(p, "Xem cam kết")
+ : slug && SLUG_PATTERN.test(slug)
+ ? { href: `/campaigns/${slug}`, linkLabel: "Xem dự án" }
+ : { href: null, linkLabel: null }),
+ }
+ }
+ case "payment_requests_resumed": {
+ const dueAt = text(p, "payment_due_at")
+ let body = `Give đã mở lại yêu cầu thanh toán của ${campaignName(p, "dự án")}. Hãy thanh toán cam kết của bạn`
+ body += dueAt ? ` trước ${formatDateTime(dueAt)}.` : "."
+ body += " Quá hạn mà chưa trả đủ, cam kết hết hiệu lực."
+ return { title: "Mở lại yêu cầu thanh toán", body, ...pledgeLink(p, "Thanh toán ngay") }
+ }
case "account_verified":
return {
title: "Tài khoản nhận đã được xác minh",
diff --git a/apps/web/src/lib/payment-requests.ts b/apps/web/src/lib/payment-requests.ts
new file mode 100644
index 00000000..e3c1ec75
--- /dev/null
+++ b/apps/web/src/lib/payment-requests.ts
@@ -0,0 +1,74 @@
+import type { Db } from "./campaigns"
+
+/**
+ * The stop on payment requests of a succeeded back campaign (DOS/Give#89; DDL
+ * in DOS-Me `supabase/migrations/20261106110000_give_pledge_net_refunds_and_payment_stop.sql`).
+ *
+ * When fraud is suspected after a project reached its goal, a moderator or
+ * admin stops its payment requests (give.set_payment_requests_stopped):
+ * reminders stop, give.pledge_payment returns nothing so the payment page
+ * shows no transfer details, the backers still asked to pay and the creator
+ * get a notice, and the campaign page shows the public note. The internal
+ * reason stays in the audit log. Resuming brings the payment pages back. The
+ * payment window keeps running: pledges unpaid by the deadline lapse, and
+ * partial payers are owed a refund, as without a stop.
+ */
+
+/** Longest internal reason and public note the database keeps. */
+export const STOP_REASON_MAX = 1000
+export const STOP_PUBLIC_NOTE_MAX = 500
+
+export interface PaymentStop {
+ payments_stopped_at: string | null
+ payments_stopped_note: string | null
+}
+
+/** Whether a moderator has stopped the campaign's payment requests. */
+export function paymentRequestsStopped(campaign: Partial | null | undefined): boolean {
+ return !!campaign?.payments_stopped_at
+}
+
+/** A succeeded back campaign as the moderator's list shows it (give.public_campaigns). */
+export interface SucceededBackCampaign extends PaymentStop {
+ id: string
+ slug: string
+ title: string
+ outcome_at: string | null
+ pledged_amount: number
+ paid_amount: number
+ refund_owed_amount: number
+ backers_count: number
+}
+
+const SUCCEEDED_COLUMNS =
+ "id, slug, title, outcome_at, pledged_amount, paid_amount, refund_owed_amount, backers_count, " +
+ "payments_stopped_at, payments_stopped_note"
+
+/** bigint sums may arrive as strings, depending on the PostgREST setup. */
+function num(value: unknown): number {
+ const n = typeof value === "number" ? value : Number(value ?? 0)
+ return Number.isFinite(n) ? n : 0
+}
+
+/**
+ * Succeeded back campaigns, those with stopped payment requests first, then
+ * the most recent outcome first. Public data only.
+ */
+export async function listSucceededBackCampaigns(db: Db, limit = 200): Promise {
+ const { data, error } = await db
+ .from("public_campaigns")
+ .select(SUCCEEDED_COLUMNS)
+ .eq("type", "back")
+ .eq("status", "succeeded")
+ .order("payments_stopped_at", { ascending: false, nullsFirst: false })
+ .order("outcome_at", { ascending: false })
+ .limit(limit)
+ if (error) throw error
+ return ((data ?? []) as unknown as Record[]).map((row) => ({
+ ...(row as unknown as SucceededBackCampaign),
+ pledged_amount: num(row.pledged_amount),
+ paid_amount: num(row.paid_amount),
+ refund_owed_amount: num(row.refund_owed_amount),
+ backers_count: num(row.backers_count),
+ }))
+}
diff --git a/apps/web/src/lib/pledges.test.ts b/apps/web/src/lib/pledges.test.ts
index 0c07f068..e15a0611 100644
--- a/apps/web/src/lib/pledges.test.ts
+++ b/apps/web/src/lib/pledges.test.ts
@@ -9,6 +9,7 @@ import {
isSoldOut,
NO_REWARD,
overpaid,
+ paymentView,
paymentWindowClosed,
pledgeErrorMessage,
pledgeSchema,
@@ -131,6 +132,19 @@ describe("payment helpers", () => {
expect(overpaid({ status: "refund_due", intended_amount: 200_000, received_amount: 250_000 })).toBe(0)
})
+ it("hides the payment page while payment requests are stopped (DOS/Give#89)", () => {
+ const isCode = (code: string) => code.startsWith("GV")
+ const account = { bank_bin: "970436", account_number: "1", account_name: "A" }
+ const payment = { amount_due: 100_000, reference_code: "GVAB12CD", account }
+ expect(paymentView(true, payment, isCode)).toBe("stopped")
+ expect(paymentView(true, null, isCode)).toBe("stopped")
+ expect(paymentView(false, payment, isCode)).toBe("qr")
+ expect(paymentView(false, null, isCode)).toBe("closed")
+ expect(paymentView(false, { ...payment, amount_due: 0 }, isCode)).toBe("nothing_due")
+ expect(paymentView(false, { ...payment, account: null }, isCode)).toBe("no_account")
+ expect(paymentView(false, { ...payment, reference_code: "XX" }, isCode)).toBe("no_account")
+ })
+
it("closes the payment window at payment_due_at, before the lapse job runs", () => {
const now = new Date("2026-11-10T00:00:00Z")
expect(paymentWindowClosed({ status: "payment_due", payment_due_at: "2026-11-09T23:00:00Z" }, now)).toBe(true)
diff --git a/apps/web/src/lib/pledges.ts b/apps/web/src/lib/pledges.ts
index a077cb49..a7ae6b6a 100644
--- a/apps/web/src/lib/pledges.ts
+++ b/apps/web/src/lib/pledges.ts
@@ -68,6 +68,31 @@ export function awaitsPayment(status: string): boolean {
return status === "payment_due" || status === "partially_paid"
}
+/**
+ * What the payment page of a pledge waiting for payment shows:
+ * stopped a moderator stopped the campaign's payment requests
+ * (DOS/Give#89): no transfer details, "do not transfer";
+ * closed give.pledge_payment gave no row: the deadline has passed;
+ * nothing_due nothing left to pay (net of refunds), so no QR for 0 đồng;
+ * no_account the campaign has no usable account right now;
+ * qr the VietQR code and the transfer details.
+ * The stop wins over everything, so a page never shows an account while
+ * payment requests are stopped, even if give.pledge_payment answered.
+ */
+export type PaymentView = "stopped" | "closed" | "nothing_due" | "no_account" | "qr"
+
+export function paymentView(
+ stopped: boolean,
+ payment: { amount_due: number; reference_code: string; account: unknown } | null,
+ isCode: (code: string) => boolean
+): PaymentView {
+ if (stopped) return "stopped"
+ if (!payment) return "closed"
+ if (!(payment.amount_due > 0)) return "nothing_due"
+ if (!payment.account || !isCode(payment.reference_code)) return "no_account"
+ return "qr"
+}
+
// Delivery labels are shared with the creator's fulfillment page (DOS/Give#83).
export { FULFILLMENT_LABELS, fulfillmentLabel, type FulfillmentStatus } from "./fulfillment"
diff --git a/apps/web/src/lib/statement.test.ts b/apps/web/src/lib/statement.test.ts
index c9ec84f0..2a15557d 100644
--- a/apps/web/src/lib/statement.test.ts
+++ b/apps/web/src/lib/statement.test.ts
@@ -83,6 +83,8 @@ describe("money totals", () => {
other_in_amount: 250_000,
refund_owed_amount: 40_000,
backers_count: 2,
+ payments_stopped_at: null,
+ payments_stopped_note: null,
})
})
diff --git a/apps/web/src/lib/statement.ts b/apps/web/src/lib/statement.ts
index a5cdfccd..d60cbfea 100644
--- a/apps/web/src/lib/statement.ts
+++ b/apps/web/src/lib/statement.ts
@@ -52,12 +52,18 @@ export interface MoneyTotals {
refund_owed_amount: number
/** Back campaigns: pledges that hold their slot (pledged, payment_due, partially_paid, paid). */
backers_count: number
+ /**
+ * Back campaigns: when a moderator stopped the payment requests after
+ * success, and the public note (DOS/Give#89); null otherwise.
+ */
+ payments_stopped_at: string | null
+ payments_stopped_note: string | null
}
const TOTALS_COLUMNS =
"id, type, asset, goal_amount, pledged_amount, paid_amount, disbursable_amount, disbursed_amount, " +
"refunded_amount, unexplained_out_amount, unexplained_out_count, supporters_count, other_in_amount, " +
- "refund_owed_amount, backers_count"
+ "refund_owed_amount, backers_count, payments_stopped_at, payments_stopped_note"
type TotalsRow = Omit & { id: string }
@@ -84,6 +90,8 @@ function toTotals(row: TotalsRow): MoneyTotals {
other_in_amount: num(row.other_in_amount),
refund_owed_amount: num(row.refund_owed_amount),
backers_count: num(row.backers_count),
+ payments_stopped_at: row.payments_stopped_at ?? null,
+ payments_stopped_note: row.payments_stopped_note ?? null,
}
}
diff --git a/apps/web/src/server/contributions/index.ts b/apps/web/src/server/contributions/index.ts
index 28dac422..d408e8b3 100644
--- a/apps/web/src/server/contributions/index.ts
+++ b/apps/web/src/server/contributions/index.ts
@@ -280,12 +280,15 @@ export interface PaymentCampaign {
status: string
starts_at: string | null
ends_at: string | null
+ /** Back campaigns: set while a moderator stopped the payment requests (DOS/Give#89). */
+ payments_stopped_at: string | null
+ payments_stopped_note: string | null
}
export async function paymentCampaign(service: Db, campaignId: string): Promise {
const { data, error } = await service
.from("campaigns")
- .select("id, slug, title, type, status, starts_at, ends_at")
+ .select("id, slug, title, type, status, starts_at, ends_at, payments_stopped_at, payments_stopped_note")
.eq("id", campaignId)
.maybeSingle()
if (error) throw error
diff --git a/apps/web/src/server/pledges/index.ts b/apps/web/src/server/pledges/index.ts
index a0e5454d..618fad89 100644
--- a/apps/web/src/server/pledges/index.ts
+++ b/apps/web/src/server/pledges/index.ts
@@ -221,3 +221,36 @@ export async function pledgePayment(
: null,
}
}
+
+export interface PledgeBalance {
+ received_amount: number
+ refunded_amount: number
+ /** intended - (received - refunded) while the pledge waits for payment, 0 otherwise. */
+ amount_due: number
+ /** What the creator still owes back: a refund_due pledge's net, a paid pledge's excess (DOS/Give#89). */
+ refund_owed_amount: number
+}
+
+/**
+ * The money of the backer's own pledge net of refunds, from
+ * give.pledge_balance (any status). Null for a pledge that is not theirs.
+ */
+export async function pledgeBalance(
+ service: Db,
+ contributionId: string,
+ userId: string
+): Promise {
+ const { data, error } = await service
+ .rpc("pledge_balance", { p_contribution_id: contributionId, p_user_id: userId })
+ .select("received_amount, refunded_amount, amount_due, refund_owed_amount")
+ .maybeSingle()
+ if (error) throw error
+ const row = data as Record | null
+ if (!row) return null
+ return {
+ received_amount: num(row.received_amount),
+ refunded_amount: num(row.refunded_amount),
+ amount_due: num(row.amount_due),
+ refund_owed_amount: num(row.refund_owed_amount),
+ }
+}
diff --git a/apps/web/src/server/pledges/pledges.test.ts b/apps/web/src/server/pledges/pledges.test.ts
index 31848642..9e1f6e2a 100644
--- a/apps/web/src/server/pledges/pledges.test.ts
+++ b/apps/web/src/server/pledges/pledges.test.ts
@@ -2,7 +2,14 @@ import { describe, expect, it } from "vitest"
import { fakeDb, op, type Call } from "@/test/fake-db"
-import { listPublicTiers, organizesCampaign, ownPledgeByCode, PLEDGE_COLUMNS, pledgePayment } from "./index"
+import {
+ listPublicTiers,
+ organizesCampaign,
+ ownPledgeByCode,
+ PLEDGE_COLUMNS,
+ pledgeBalance,
+ pledgePayment,
+} from "./index"
const USER = "11111111-1111-4111-8111-111111111111"
const PLEDGE = "33333333-3333-4333-8333-333333333333"
@@ -53,6 +60,33 @@ describe("pledgePayment", () => {
})
})
+describe("pledgeBalance", () => {
+ it("asks give.pledge_balance for the backer's own pledge and converts the amounts", async () => {
+ const calls: Call[] = []
+ const db = fakeDb(
+ () => ({
+ data: { received_amount: "120000", refunded_amount: 50_000, amount_due: 0, refund_owed_amount: "70000" },
+ }),
+ calls
+ )
+ expect(await pledgeBalance(db as never, PLEDGE, USER)).toEqual({
+ received_amount: 120_000,
+ refunded_amount: 50_000,
+ amount_due: 0,
+ refund_owed_amount: 70_000,
+ })
+ expect(calls[0].target).toBe("rpc:pledge_balance")
+ expect(op(calls[0], "args")).toEqual([{ p_contribution_id: PLEDGE, p_user_id: USER }])
+ })
+
+ it("returns null for someone else's pledge, and throws database errors", async () => {
+ expect(await pledgeBalance(fakeDb(() => ({ data: null })) as never, PLEDGE, USER)).toBeNull()
+ await expect(
+ pledgeBalance(fakeDb(() => ({ error: { code: "42501", message: "denied" } })) as never, PLEDGE, USER)
+ ).rejects.toMatchObject({ code: "42501" })
+ })
+})
+
describe("organizesCampaign", () => {
it("is true only for the campaign's own organizer", async () => {
const calls: Call[] = []
From be9434ea96174a79ea7f2dcb6c4712052e6f84c9 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 3 Nov 2026 22:37:54 +0700
Subject: [PATCH 2/4] Contributions list net of refunds and stop-aware; pay
page reasons and wording from review (#89)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- /me/contributions reads give.pledge_balances: amounts due and owed back are
net of refunds, and a stopped campaign shows no Thanh toán button.
- The pledge page asks for payment details through loadPledgeMoney, which
never calls pledge_payment while stopped; no details before the deadline
is 'unavailable', not 'closed'; refunds already made are named next to the
received amount; a failed balance read leaves the amounts out.
- The stop notice and the stopped page give the deadline; the creator gets
a resume notice; the admin page says a note change sends no new notices.
Co-Authored-By: Claude Opus 5.5
---
.../src/app/admin/payment-requests/page.tsx | 3 +-
apps/web/src/app/me/contributions/page.tsx | 41 ++++-----
apps/web/src/app/me/pledges/[code]/page.tsx | 55 ++++++------
apps/web/src/lib/notices.test.ts | 12 +++
apps/web/src/lib/notices.ts | 13 +++
apps/web/src/lib/pledges.test.ts | 45 ++++++++--
apps/web/src/lib/pledges.ts | 50 ++++++++++-
apps/web/src/server/pledges/index.ts | 73 ++++++++++++----
apps/web/src/server/pledges/pledges.test.ts | 85 +++++++++++++++----
9 files changed, 281 insertions(+), 96 deletions(-)
diff --git a/apps/web/src/app/admin/payment-requests/page.tsx b/apps/web/src/app/admin/payment-requests/page.tsx
index 62cb9508..a2159be3 100644
--- a/apps/web/src/app/admin/payment-requests/page.tsx
+++ b/apps/web/src/app/admin/payment-requests/page.tsx
@@ -43,7 +43,8 @@ export default async function AdminPaymentRequestsPage() {
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.
+ đã 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. Đổ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ũ.
diff --git a/apps/web/src/app/me/contributions/page.tsx b/apps/web/src/app/me/contributions/page.tsx
index 822d62d7..a9dd7c59 100644
--- a/apps/web/src/app/me/contributions/page.tsx
+++ b/apps/web/src/app/me/contributions/page.tsx
@@ -3,15 +3,17 @@ import Link from "next/link"
import { requireSession } from "@/lib/auth/session"
import { donationStatusLabel } from "@/lib/contributions"
-import { formatDate, formatDateTime, formatVnd } from "@/lib/format"
+import { formatDate, formatVnd } from "@/lib/format"
import {
fulfillmentLabel,
- paymentWindowClosed,
+ offersPayment,
+ pledgeListSummary,
pledgeStatusColor,
pledgeStatusLabel,
withoutReplacedPledges,
} from "@/lib/pledges"
-import { dbAs } from "@/lib/supabase"
+import { dbAs, dbService } from "@/lib/supabase"
+import { pledgeBalances, type PledgeBalance } from "@/server/pledges"
export const metadata: Metadata = { title: "Ủng hộ của tôi", robots: { index: false } }
@@ -45,27 +47,13 @@ function statusColor(c: MyContribution): string {
return c.kind === "pledge" ? pledgeStatusColor(c.status) : (STATUS_COLORS[c.status] ?? "bg-gray-100 text-gray-700")
}
-function pledgeSummary(c: MyContribution): string {
- switch (c.status) {
- case "payment_due":
- case "partially_paid":
- if (paymentWindowClosed(c)) return `Đã quá hạn thanh toán · đã ghi nhận ${formatVnd(c.received_amount)}`
- return `Cần thanh toán ${formatVnd(Math.max(0, c.intended_amount - c.received_amount))}${
- c.payment_due_at ? ` trước ${formatDateTime(c.payment_due_at)}` : ""
- }`
- case "paid":
- return `Đã thanh toán ${formatVnd(c.received_amount)}`
- case "refund_due":
- return `Chờ người tạo dự án hoàn ${formatVnd(c.received_amount)}`
- default:
- return `Cam kết ${formatVnd(c.intended_amount)}`
- }
-}
-
/**
* The signed-in user's donations and pledges, read with their own token under
* RLS. Pledges that change_pledge replaced are hidden; the current one shows
* its payment and, once paid, its delivery status (section 4.2 step 7).
+ * Pledge amounts are net of refunds and the stop on payment requests is
+ * shown (give.pledge_balances, DOS/Give#89), read as the service role for the
+ * user the RLS read above has just proved.
*/
export default async function MyContributionsPage() {
const session = await requireSession("/me/contributions")
@@ -80,6 +68,13 @@ export default async function MyContributionsPage() {
.limit(200)
if (error) throw error
const contributions = withoutReplacedPledges((data ?? []) as unknown as MyContribution[])
+ const balances: Map = contributions.some((c) => c.kind === "pledge")
+ ? await pledgeBalances(dbService(), session.user.id).catch((e: unknown) => {
+ // The list still renders; pledge lines leave out what refunds could change.
+ console.error("contributions: pledge balances lookup failed", e)
+ return new Map()
+ })
+ : new Map()
return (
- Give sẽ báo khi yêu cầu thanh toán được mở lại. Hạn thanh toán vẫn tính như cũ; quá hạn mà chưa trả đủ,
- cam kết hết hiệu lực và phần đã chuyển được hoàn lại.
+ Give sẽ báo khi yêu cầu thanh toán được mở lại. Hạn thanh toán vẫn là{" "}
+ {formatDateTime(pledge.payment_due_at)}; quá hạn mà chưa trả đủ, cam kết hết hiệu lực và phần đã chuyển
+ được hoàn lại.
)
+ } else if (view === "unavailable") {
+ body = (
+
+ Give chưa hiển thị được thông tin chuyển khoản của cam kết này. Đừng chuyển tiền theo thông tin từ nơi khác;
+ hãy tải lại trang sau ít phút. Hạn thanh toán là {formatDateTime(pledge.payment_due_at)}.
+
+ )
} else if (!payment) {
body = (
@@ -184,7 +187,10 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
Dự án đã đạt mục tiêu. Hãy chuyển {formatVnd(payment.amount_due)} trước{" "}
{formatDateTime(payment.payment_due_at)}.
- {payment.received_amount > 0 && ` Give đã ghi nhận ${formatVnd(payment.received_amount)}.`}
+ {payment.received_amount > 0 &&
+ ` Give đã ghi nhận ${formatVnd(payment.received_amount)}${
+ refunded > 0 ? ` và đã hoàn lại ${formatVnd(refunded)}` : ""
+ }.`}
Nếu bạn đã chuyển khoản, đừng chuyển lại: Give cập nhật trạng thái khi đối soát sao kê, có thể sau vài
@@ -232,10 +238,12 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
body = (
Bạn đã thanh toán đủ {formatVnd(pledge.intended_amount)}. Cảm ơn bạn đã đồng hành cùng dự án.
- Give ghi nhận bạn đã chuyển {formatVnd(pledge.received_amount)}, nhiều hơn số cam kết. Người tạo dự án còn
- phải hoàn lại phần thừa {formatVnd(excess)}; trang dự án công khai khoản còn nợ cho tới khi hoàn xong.
+ Give ghi nhận bạn đã chuyển {formatVnd(pledge.received_amount)}, nhiều hơn số cam kết
+ {refunded > 0 ? ` (đã hoàn lại ${formatVnd(refunded)})` : ""}. Người tạo dự án
+ {excess === null ? " phải hoàn lại phần thừa" : ` còn phải hoàn lại phần thừa ${formatVnd(excess)}`};
+ trang dự án công khai khoản còn nợ cho tới khi hoàn xong.
)}
{pledge.reward_tier_id && (
@@ -252,12 +260,11 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
body = (
Cam kết này không còn hiệu lực (chưa được thanh toán đủ trước hạn, hoặc tiền đến sau khi cam kết đã hết hiệu
- lực), nên người tạo dự án phải hoàn lại cho bạn {formatVnd(owedBack)}
+ lực), nên người tạo dự án phải hoàn lại cho bạn{" "}
+ {balance ? formatVnd(balance.refund_owed_amount) : "số tiền bạn đã chuyển"}
{pledge.refund_due_at ? ` trước ${formatDateTime(pledge.refund_due_at)}` : ""}.
- {balance && balance.refunded_amount > 0
- ? ` Đã hoàn ${formatVnd(balance.refunded_amount)}; số trên là phần còn lại.`
- : ""}{" "}
- Trang dự án công khai số tiền hoàn còn nợ cho tới khi hoàn xong.
+ {refunded > 0 ? ` Đã hoàn ${formatVnd(refunded)}; số trên là phần còn lại.` : ""} Trang dự án công khai số
+ tiền hoàn còn nợ cho tới khi hoàn xong.
)
break
diff --git a/apps/web/src/lib/notices.test.ts b/apps/web/src/lib/notices.test.ts
index db207f8d..564e0138 100644
--- a/apps/web/src/lib/notices.test.ts
+++ b/apps/web/src/lib/notices.test.ts
@@ -189,6 +189,14 @@ describe("describeNotice", () => {
expect(backer.body).toContain("Đừng chuyển tiền")
expect(backer.body).toContain("Ghi chú của Give: Give đang xác minh.")
expect(backer.href).toBe("/me/pledges/GVAB12CD")
+ const withDue = describeNotice(
+ notice({
+ kind: "payment_requests_stopped",
+ payload: { ...base, reference_code: "GVAB12CD", payment_due_at: "2026-11-12T05:00:00Z" },
+ })
+ )
+ expect(withDue.body).toContain("Hạn thanh toán vẫn là")
+ expect(withDue.body).toContain("12/11/2026")
const creator = describeNotice(notice({ kind: "payment_requests_stopped", payload: base }))
expect(creator.body).toContain("trang thanh toán đã ẩn")
@@ -206,6 +214,10 @@ describe("describeNotice", () => {
expect(resumed.title).toBe("Mở lại yêu cầu thanh toán")
expect(resumed.body).toContain("12/11/2026")
expect(resumed.href).toBe("/me/pledges/GVAB12CD")
+ const creatorResumed = describeNotice(notice({ kind: "payment_requests_resumed", payload: base }))
+ expect(creatorResumed.body).toContain("thấy lại thông tin chuyển khoản")
+ expect(creatorResumed.href).toBe("/campaigns/den-ngu")
+ expect(creatorResumed.linkLabel).toBe("Xem dự án")
})
it("covers account review results", () => {
diff --git a/apps/web/src/lib/notices.ts b/apps/web/src/lib/notices.ts
index 02cd240f..9a26d729 100644
--- a/apps/web/src/lib/notices.ts
+++ b/apps/web/src/lib/notices.ts
@@ -166,6 +166,8 @@ export function describeNotice(notice: Notice): NoticeView {
? " Đừng chuyển tiền cho cam kết của bạn, kể cả theo thông tin chuyển khoản đã nhận trước đó, cho tới khi Give thông báo tiếp."
: " Người ủng hộ không còn được nhắc thanh toán và trang thanh toán đã ẩn. Trang dự án công khai thông báo này."
if (note) body += ` Ghi chú của Give: ${note}`
+ const dueAt = text(p, "payment_due_at")
+ if (backer && dueAt) body += ` Hạn thanh toán vẫn là ${formatDateTime(dueAt)}.`
const slug = text(p, "campaign_slug")
return {
title: "Tạm dừng yêu cầu thanh toán",
@@ -179,6 +181,17 @@ export function describeNotice(notice: Notice): NoticeView {
}
case "payment_requests_resumed": {
const dueAt = text(p, "payment_due_at")
+ const slug = text(p, "campaign_slug")
+ if (text(p, "reference_code") === null) {
+ // The creator's copy: no pledge in the payload.
+ return {
+ title: "Mở lại yêu cầu thanh toán",
+ body: `Give đã mở lại yêu cầu thanh toán của ${campaignName(p, "dự án")}: người ủng hộ đang chờ thanh toán thấy lại thông tin chuyển khoản và được nhắc như trước.`,
+ ...(slug && SLUG_PATTERN.test(slug)
+ ? { href: `/campaigns/${slug}`, linkLabel: "Xem dự án" }
+ : { href: null, linkLabel: null }),
+ }
+ }
let body = `Give đã mở lại yêu cầu thanh toán của ${campaignName(p, "dự án")}. Hãy thanh toán cam kết của bạn`
body += dueAt ? ` trước ${formatDateTime(dueAt)}.` : "."
body += " Quá hạn mà chưa trả đủ, cam kết hết hiệu lực."
diff --git a/apps/web/src/lib/pledges.test.ts b/apps/web/src/lib/pledges.test.ts
index e15a0611..14b08c1e 100644
--- a/apps/web/src/lib/pledges.test.ts
+++ b/apps/web/src/lib/pledges.test.ts
@@ -9,7 +9,9 @@ import {
isSoldOut,
NO_REWARD,
overpaid,
+ offersPayment,
paymentView,
+ pledgeListSummary,
paymentWindowClosed,
pledgeErrorMessage,
pledgeSchema,
@@ -136,13 +138,42 @@ describe("payment helpers", () => {
const isCode = (code: string) => code.startsWith("GV")
const account = { bank_bin: "970436", account_number: "1", account_name: "A" }
const payment = { amount_due: 100_000, reference_code: "GVAB12CD", account }
- expect(paymentView(true, payment, isCode)).toBe("stopped")
- expect(paymentView(true, null, isCode)).toBe("stopped")
- expect(paymentView(false, payment, isCode)).toBe("qr")
- expect(paymentView(false, null, isCode)).toBe("closed")
- expect(paymentView(false, { ...payment, amount_due: 0 }, isCode)).toBe("nothing_due")
- expect(paymentView(false, { ...payment, account: null }, isCode)).toBe("no_account")
- expect(paymentView(false, { ...payment, reference_code: "XX" }, isCode)).toBe("no_account")
+ expect(paymentView(true, false, payment, isCode)).toBe("stopped")
+ expect(paymentView(true, true, null, isCode)).toBe("stopped")
+ expect(paymentView(false, false, payment, isCode)).toBe("qr")
+ expect(paymentView(false, true, null, isCode)).toBe("closed")
+ // No details before the deadline (a stop committed between two reads): not "closed".
+ expect(paymentView(false, false, null, isCode)).toBe("unavailable")
+ expect(paymentView(false, false, { ...payment, amount_due: 0 }, isCode)).toBe("nothing_due")
+ expect(paymentView(false, false, { ...payment, account: null }, isCode)).toBe("no_account")
+ expect(paymentView(false, false, { ...payment, reference_code: "XX" }, isCode)).toBe("no_account")
+ })
+
+ it("sums up a pledge in the list net of refunds, and never offers payment while stopped (DOS/Give#89)", () => {
+ const now = new Date("2026-11-10T00:00:00Z")
+ const waiting = {
+ status: "partially_paid",
+ intended_amount: 200_000,
+ received_amount: 100_000,
+ payment_due_at: "2026-11-12T00:00:00Z",
+ }
+ const balance = { amount_due: 150_000, refund_owed_amount: 0, payments_stopped: false }
+ expect(pledgeListSummary(waiting, balance, now)).toContain(`Cần thanh toán ${formatVnd(150_000)}`)
+ expect(offersPayment(waiting, balance, now)).toBe(true)
+ // Without the balance the amount is left out rather than shown before refunds.
+ expect(pledgeListSummary(waiting, null, now)).not.toContain(formatVnd(100_000))
+ expect(pledgeListSummary(waiting, null, now)).toContain("Chờ thanh toán")
+
+ const stopped = { ...balance, payments_stopped: true }
+ expect(pledgeListSummary(waiting, stopped, now)).toContain("tạm dừng yêu cầu thanh toán")
+ expect(offersPayment(waiting, stopped, now)).toBe(false)
+
+ const refundDue = { ...waiting, status: "refund_due" }
+ expect(pledgeListSummary(refundDue, { ...balance, amount_due: 0, refund_owed_amount: 40_000 }, now)).toBe(
+ `Chờ người tạo dự án hoàn ${formatVnd(40_000)}`
+ )
+ expect(pledgeListSummary(refundDue, null, now)).toBe("Chờ người tạo dự án hoàn tiền")
+ expect(offersPayment(refundDue, balance, now)).toBe(false)
})
it("closes the payment window at payment_due_at, before the lapse job runs", () => {
diff --git a/apps/web/src/lib/pledges.ts b/apps/web/src/lib/pledges.ts
index a7ae6b6a..c80698c8 100644
--- a/apps/web/src/lib/pledges.ts
+++ b/apps/web/src/lib/pledges.ts
@@ -2,7 +2,7 @@ import { z } from "zod"
import type { DbErrorLike } from "./errors"
import { giveErrorCode } from "./errors"
-import { formatVnd, parseVnd } from "./format"
+import { formatDateTime, formatVnd, parseVnd } from "./format"
/**
* Pledges to back (reward, all-or-nothing) campaigns: docs/Give-Technical-Design.md
@@ -72,27 +72,69 @@ export function awaitsPayment(status: string): boolean {
* What the payment page of a pledge waiting for payment shows:
* stopped a moderator stopped the campaign's payment requests
* (DOS/Give#89): no transfer details, "do not transfer";
- * closed give.pledge_payment gave no row: the deadline has passed;
+ * closed the deadline has passed (give.pledge_payment gives no row);
+ * unavailable no payment details although the deadline has not passed
+ * (a stop committed meanwhile, say): reload later;
* nothing_due nothing left to pay (net of refunds), so no QR for 0 đồng;
* no_account the campaign has no usable account right now;
* qr the VietQR code and the transfer details.
* The stop wins over everything, so a page never shows an account while
* payment requests are stopped, even if give.pledge_payment answered.
*/
-export type PaymentView = "stopped" | "closed" | "nothing_due" | "no_account" | "qr"
+export type PaymentView = "stopped" | "closed" | "unavailable" | "nothing_due" | "no_account" | "qr"
export function paymentView(
stopped: boolean,
+ windowClosed: boolean,
payment: { amount_due: number; reference_code: string; account: unknown } | null,
isCode: (code: string) => boolean
): PaymentView {
if (stopped) return "stopped"
- if (!payment) return "closed"
+ if (!payment) return windowClosed ? "closed" : "unavailable"
if (!(payment.amount_due > 0)) return "nothing_due"
if (!payment.account || !isCode(payment.reference_code)) return "no_account"
return "qr"
}
+/**
+ * One line about a pledge's money for the backer's list of contributions,
+ * net of refunds when the balance (give.pledge_balances) is known; without it
+ * the amounts that refunds could change are left out.
+ */
+export function pledgeListSummary(
+ pledge: { status: string; intended_amount: number; received_amount: number; payment_due_at: string | null },
+ balance: { amount_due: number; refund_owed_amount: number; payments_stopped: boolean } | null,
+ now = new Date()
+): string {
+ switch (pledge.status) {
+ case "payment_due":
+ case "partially_paid":
+ if (balance?.payments_stopped) return "Give đang tạm dừng yêu cầu thanh toán · đừng chuyển tiền"
+ if (paymentWindowClosed(pledge, now))
+ return `Đã quá hạn thanh toán · đã ghi nhận ${formatVnd(pledge.received_amount)}`
+ return `${balance ? `Cần thanh toán ${formatVnd(balance.amount_due)}` : "Chờ thanh toán"}${
+ pledge.payment_due_at ? ` trước ${formatDateTime(pledge.payment_due_at)}` : ""
+ }`
+ case "paid":
+ return `Đã thanh toán ${formatVnd(pledge.received_amount)}`
+ case "refund_due":
+ return balance
+ ? `Chờ người tạo dự án hoàn ${formatVnd(balance.refund_owed_amount)}`
+ : "Chờ người tạo dự án hoàn tiền"
+ default:
+ return `Cam kết ${formatVnd(pledge.intended_amount)}`
+ }
+}
+
+/** Whether the list offers "Thanh toán" for a pledge (still waiting, before the deadline, not stopped). */
+export function offersPayment(
+ pledge: { status: string; payment_due_at: string | null },
+ balance: { payments_stopped: boolean } | null,
+ now = new Date()
+): boolean {
+ return awaitsPayment(pledge.status) && !paymentWindowClosed(pledge, now) && !balance?.payments_stopped
+}
+
// Delivery labels are shared with the creator's fulfillment page (DOS/Give#83).
export { FULFILLMENT_LABELS, fulfillmentLabel, type FulfillmentStatus } from "./fulfillment"
diff --git a/apps/web/src/server/pledges/index.ts b/apps/web/src/server/pledges/index.ts
index 618fad89..07858f6f 100644
--- a/apps/web/src/server/pledges/index.ts
+++ b/apps/web/src/server/pledges/index.ts
@@ -1,7 +1,7 @@
import "server-only"
import type { Db } from "@/lib/campaigns"
-import { PUBLIC_TIER_COLUMNS, type PublicRewardTier } from "@/lib/pledges"
+import { awaitsPayment, PUBLIC_TIER_COLUMNS, type PublicRewardTier } from "@/lib/pledges"
/**
* Server side of the pledge flow (section 4.2). Backers are always signed in:
@@ -223,34 +223,71 @@ export async function pledgePayment(
}
export interface PledgeBalance {
+ status: string
received_amount: number
refunded_amount: number
/** intended - (received - refunded) while the pledge waits for payment, 0 otherwise. */
amount_due: number
/** What the creator still owes back: a refund_due pledge's net, a paid pledge's excess (DOS/Give#89). */
refund_owed_amount: number
+ /** A moderator stopped the campaign's payment requests. */
+ payments_stopped: boolean
}
/**
- * The money of the backer's own pledge net of refunds, from
- * give.pledge_balance (any status). Null for a pledge that is not theirs.
+ * The money of the backer's own pledges net of refunds, from
+ * give.pledge_balances (any status), by pledge id: one pledge, or all of the
+ * user's pledges when no id is given. Only the user's own pledges.
*/
-export async function pledgeBalance(
+export async function pledgeBalances(
service: Db,
- contributionId: string,
- userId: string
-): Promise {
+ userId: string,
+ contributionId?: string
+): Promise
- Give sẽ báo khi yêu cầu thanh toán được mở lại. Hạn thanh toán vẫn là{" "}
- {formatDateTime(pledge.payment_due_at)}; quá hạn mà chưa trả đủ, cam kết hết hiệu lực và phần đã chuyển
- được hoàn lại.
+ Give sẽ báo khi yêu cầu thanh toán được mở lại. Thời gian tạm dừng không tính vào hạn thanh toán: cam kết
+ không hết hiệu lực trong lúc tạm dừng, và khi mở lại, hạn được lùi thêm đúng số ngày đã tạm dừng.
)
diff --git a/apps/web/src/lib/notices.test.ts b/apps/web/src/lib/notices.test.ts
index 564e0138..0326584e 100644
--- a/apps/web/src/lib/notices.test.ts
+++ b/apps/web/src/lib/notices.test.ts
@@ -195,8 +195,8 @@ describe("describeNotice", () => {
payload: { ...base, reference_code: "GVAB12CD", payment_due_at: "2026-11-12T05:00:00Z" },
})
)
- expect(withDue.body).toContain("Hạn thanh toán vẫn là")
- expect(withDue.body).toContain("12/11/2026")
+ expect(withDue.body).toContain("không tính vào hạn thanh toán")
+ expect(withDue.body).toContain("lùi thêm đúng số ngày đã tạm dừng")
const creator = describeNotice(notice({ kind: "payment_requests_stopped", payload: base }))
expect(creator.body).toContain("trang thanh toán đã ẩn")
@@ -213,6 +213,7 @@ describe("describeNotice", () => {
)
expect(resumed.title).toBe("Mở lại yêu cầu thanh toán")
expect(resumed.body).toContain("12/11/2026")
+ expect(resumed.body).toContain("lùi thêm đúng thời gian tạm dừng")
expect(resumed.href).toBe("/me/pledges/GVAB12CD")
const creatorResumed = describeNotice(notice({ kind: "payment_requests_resumed", payload: base }))
expect(creatorResumed.body).toContain("thấy lại thông tin chuyển khoản")
diff --git a/apps/web/src/lib/notices.ts b/apps/web/src/lib/notices.ts
index 9a26d729..16350c82 100644
--- a/apps/web/src/lib/notices.ts
+++ b/apps/web/src/lib/notices.ts
@@ -166,8 +166,10 @@ export function describeNotice(notice: Notice): NoticeView {
? " Đừng chuyển tiền cho cam kết của bạn, kể cả theo thông tin chuyển khoản đã nhận trước đó, cho tới khi Give thông báo tiếp."
: " Người ủng hộ không còn được nhắc thanh toán và trang thanh toán đã ẩn. Trang dự án công khai thông báo này."
if (note) body += ` Ghi chú của Give: ${note}`
- const dueAt = text(p, "payment_due_at")
- if (backer && dueAt) body += ` Hạn thanh toán vẫn là ${formatDateTime(dueAt)}.`
+ if (backer) {
+ body +=
+ " Thời gian tạm dừng không tính vào hạn thanh toán: cam kết không hết hiệu lực trong lúc tạm dừng, và khi mở lại, hạn được lùi thêm đúng số ngày đã tạm dừng."
+ }
const slug = text(p, "campaign_slug")
return {
title: "Tạm dừng yêu cầu thanh toán",
@@ -193,7 +195,7 @@ export function describeNotice(notice: Notice): NoticeView {
}
}
let body = `Give đã mở lại yêu cầu thanh toán của ${campaignName(p, "dự án")}. Hãy thanh toán cam kết của bạn`
- body += dueAt ? ` trước ${formatDateTime(dueAt)}.` : "."
+ body += dueAt ? ` trước ${formatDateTime(dueAt)} (hạn đã được lùi thêm đúng thời gian tạm dừng).` : "."
body += " Quá hạn mà chưa trả đủ, cam kết hết hiệu lực."
return { title: "Mở lại yêu cầu thanh toán", body, ...pledgeLink(p, "Thanh toán ngay") }
}
diff --git a/apps/web/src/lib/payment-requests.ts b/apps/web/src/lib/payment-requests.ts
index e3c1ec75..e7f3488c 100644
--- a/apps/web/src/lib/payment-requests.ts
+++ b/apps/web/src/lib/payment-requests.ts
@@ -10,8 +10,9 @@ import type { Db } from "./campaigns"
* shows no transfer details, the backers still asked to pay and the creator
* get a notice, and the campaign page shows the public note. The internal
* reason stays in the audit log. Resuming brings the payment pages back. The
- * payment window keeps running: pledges unpaid by the deadline lapse, and
- * partial payers are owed a refund, as without a stop.
+ * stop does not count against the backers: no pledge lapses while stopped,
+ * and resuming moves the deadlines and the reminder days of the pledges still
+ * waiting by the time it was stopped (owner decision on DOS/Give#89).
*/
/** Longest internal reason and public note the database keeps. */
From 4d32cca92382c79ca04a749a9aaa000032d1842b Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Tue, 3 Nov 2026 22:57:00 +0700
Subject: [PATCH 4/4] Promise a moved deadline only to pledges the stop holds
(#89)
A pledge already overdue when the stop began lapses as usual, so its page
shows the deadline as passed instead of the stop wording (stopHoldsPledge).
Co-Authored-By: Claude Opus 5.5
---
apps/web/src/app/me/pledges/[code]/page.tsx | 7 ++++---
apps/web/src/lib/payment-requests.test.ts | 21 +++++++++++++++++++++
apps/web/src/lib/payment-requests.ts | 13 +++++++++++++
3 files changed, 38 insertions(+), 3 deletions(-)
create mode 100644 apps/web/src/lib/payment-requests.test.ts
diff --git a/apps/web/src/app/me/pledges/[code]/page.tsx b/apps/web/src/app/me/pledges/[code]/page.tsx
index d07d7394..b0b6c4ee 100644
--- a/apps/web/src/app/me/pledges/[code]/page.tsx
+++ b/apps/web/src/app/me/pledges/[code]/page.tsx
@@ -19,7 +19,7 @@ import {
pledgeStatusLabel,
readShippingAddress,
} from "@/lib/pledges"
-import { paymentRequestsStopped } from "@/lib/payment-requests"
+import { stopHoldsPledge } from "@/lib/payment-requests"
import { dbAs, dbService } from "@/lib/supabase"
import { cancelPledge } from "@/app/campaigns/[slug]/pledge/actions"
import { isSessionRejected, paymentCampaign } from "@/server/contributions"
@@ -84,7 +84,6 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
pledge.reward_tier_id ? getTierTitle(dbAs(token), pledge.reward_tier_id).catch(() => null) : null,
])
if (!campaign) notFound()
- const stopped = paymentRequestsStopped(campaign)
// Never asks for the payment details while payment requests are stopped.
const { payment, balance } = await loadPledgeMoney(service, pledge, campaign, userId)
// Without the balance, amounts that refunds could change are left out.
@@ -126,7 +125,9 @@ export default async function PledgePaymentPage({ params }: { params: Promise<{
break
case "payment_due":
case "partially_paid": {
- const view = paymentView(stopped, paymentWindowClosed(pledge), payment, isDonationCode)
+ // A pledge overdue before the stop began is not held by it: "closed", and
+ // loadPledgeMoney still never asked for the payment details.
+ const view = paymentView(stopHoldsPledge(campaign, pledge), paymentWindowClosed(pledge), payment, isDonationCode)
if (view === "stopped") {
body = (
diff --git a/apps/web/src/lib/payment-requests.test.ts b/apps/web/src/lib/payment-requests.test.ts
new file mode 100644
index 00000000..4e79f365
--- /dev/null
+++ b/apps/web/src/lib/payment-requests.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from "vitest"
+
+import { paymentRequestsStopped, stopHoldsPledge } from "./payment-requests"
+
+describe("stop on payment requests (DOS/Give#89)", () => {
+ const stopped = { payments_stopped_at: "2026-11-05T00:00:00Z", payments_stopped_note: "Give đang xác minh." }
+
+ it("knows when a campaign's payment requests are stopped", () => {
+ expect(paymentRequestsStopped(stopped)).toBe(true)
+ expect(paymentRequestsStopped({ payments_stopped_at: null, payments_stopped_note: null })).toBe(false)
+ expect(paymentRequestsStopped(null)).toBe(false)
+ })
+
+ it("holds only pledges whose window was still open when the stop began", () => {
+ expect(stopHoldsPledge(stopped, { payment_due_at: "2026-11-06T00:00:00Z" })).toBe(true)
+ // Overdue before the stop: it lapses as usual, so the page must not promise a moved deadline.
+ expect(stopHoldsPledge(stopped, { payment_due_at: "2026-11-04T23:00:00Z" })).toBe(false)
+ expect(stopHoldsPledge(stopped, { payment_due_at: null })).toBe(false)
+ expect(stopHoldsPledge({ payments_stopped_at: null }, { payment_due_at: "2026-11-06T00:00:00Z" })).toBe(false)
+ })
+})
diff --git a/apps/web/src/lib/payment-requests.ts b/apps/web/src/lib/payment-requests.ts
index e7f3488c..ef4c2a2e 100644
--- a/apps/web/src/lib/payment-requests.ts
+++ b/apps/web/src/lib/payment-requests.ts
@@ -29,6 +29,19 @@ export function paymentRequestsStopped(campaign: Partial | null | u
return !!campaign?.payments_stopped_at
}
+/**
+ * Whether the stop holds this pledge's payment window: stopped, and the
+ * deadline was still ahead when the stop began, so the database lets it wait
+ * and a resume moves it. A pledge already overdue then lapses as usual.
+ */
+export function stopHoldsPledge(
+ campaign: Partial | null | undefined,
+ pledge: { payment_due_at: string | null }
+): boolean {
+ if (!campaign?.payments_stopped_at || !pledge.payment_due_at) return false
+ return new Date(pledge.payment_due_at) > new Date(campaign.payments_stopped_at)
+}
+
/** A succeeded back campaign as the moderator's list shows it (give.public_campaigns). */
export interface SucceededBackCampaign extends PaymentStop {
id: string