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ị
@@ -155,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
@@ -198,15 +233,17 @@ 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 === null ? 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
+ {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 && (
@@ -223,9 +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 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{" "}
+ {balance ? formatVnd(balance.refund_owed_amount) : "số tiền bạn đã chuyển"}
+ {pledge.refund_due_at ? ` trước ${formatDateTime(pledge.refund_due_at)}` : ""}.
+ {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/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..0326584e 100644
--- a/apps/web/src/lib/notices.test.ts
+++ b/apps/web/src/lib/notices.test.ts
@@ -143,6 +143,84 @@ 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 withDue = describeNotice(
+ notice({
+ kind: "payment_requests_stopped",
+ payload: { ...base, reference_code: "GVAB12CD", payment_due_at: "2026-11-12T05:00:00Z" },
+ })
+ )
+ 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")
+ 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.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")
+ expect(creatorResumed.href).toBe("/campaigns/den-ngu")
+ expect(creatorResumed.linkLabel).toBe("Xem dự án")
+ })
+
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..16350c82 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,48 @@ 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}`
+ 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",
+ 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")
+ 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)} (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") }
+ }
case "account_verified":
return {
title: "Tài khoản nhận đã được xác minh",
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
new file mode 100644
index 00000000..ef4c2a2e
--- /dev/null
+++ b/apps/web/src/lib/payment-requests.ts
@@ -0,0 +1,88 @@
+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
+ * 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. */
+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
+}
+
+/**
+ * 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
+ 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..14b08c1e 100644
--- a/apps/web/src/lib/pledges.test.ts
+++ b/apps/web/src/lib/pledges.test.ts
@@ -9,6 +9,9 @@ import {
isSoldOut,
NO_REWARD,
overpaid,
+ offersPayment,
+ paymentView,
+ pledgeListSummary,
paymentWindowClosed,
pledgeErrorMessage,
pledgeSchema,
@@ -131,6 +134,48 @@ 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, 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", () => {
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..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
@@ -68,6 +68,73 @@ 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 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" | "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 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/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..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:
@@ -221,3 +221,73 @@ export async function pledgePayment(
: null,
}
}
+
+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 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 pledgeBalances(
+ service: Db,
+ userId: string,
+ contributionId?: string
+): Promise