@@ -518,25 +517,34 @@ export default async function EditCampaignPage({ params }: { params: Promise<{ i
{submitBlocked && (
- Chưa gửi duyệt được: chiến dịch từ thiện cần gắn một tài khoản nhận tiền đã xác minh, đứng tên bạn.{" "}
- {accounts?.bound
- ? accounts.boundHasTransactions
- ? "Tài khoản đang gắn không còn dùng được và đã nhận giao dịch nên không nhả được: gửi yêu cầu đổi tài khoản trong mục "
- : "Tài khoản đang gắn không còn dùng được: nhả nó trong mục "
- : "Chiến dịch này chưa gắn tài khoản: chọn tài khoản trong mục "}
-
- Tài khoản nhận tiền
- {" "}
- {accounts?.bound
- ? accounts.boundHasTransactions
- ? "ở trên để bộ phận tài chính duyệt"
- : "ở trên rồi gắn tài khoản khác"
- : "ở trên"}
- , hoặc khai báo và xác minh tài khoản tại trang{" "}
-
- Tài khoản nhận tiền
-
- .
+ Chưa gửi duyệt được: chiến dịch cần gắn một tài khoản nhận tiền đã xác minh, đứng tên bạn.{" "}
+ {accounts?.bound && accounts.boundHasTransactions && !isGive ? (
+ <>
+ Tài khoản đang gắn không còn dùng được và đã nhận giao dịch nên không nhả được: hãy liên hệ đội ngũ
+ Give để được hỗ trợ.
+ >
+ ) : (
+ <>
+ {accounts?.bound
+ ? accounts.boundHasTransactions
+ ? "Tài khoản đang gắn không còn dùng được và đã nhận giao dịch nên không nhả được: gửi yêu cầu đổi tài khoản trong mục "
+ : "Tài khoản đang gắn không còn dùng được: nhả nó trong mục "
+ : "Chiến dịch này chưa gắn tài khoản: chọn tài khoản trong mục "}
+
+ Tài khoản nhận tiền
+ {" "}
+ {accounts?.bound
+ ? accounts.boundHasTransactions
+ ? "ở trên để bộ phận tài chính duyệt"
+ : "ở trên rồi gắn tài khoản khác"
+ : "ở trên"}
+ , hoặc khai báo và xác minh tài khoản tại trang{" "}
+
+ Tài khoản nhận tiền
+
+ .
+ >
+ )}
)}
Tài khoản nhận tiền
- Người ủng hộ chuyển khoản thẳng vào tài khoản này qua mã VietQR. Chiến dịch từ thiện cần gắn một tài khoản đã
- xác minh, đứng tên bạn, trước khi gửi duyệt. Khai báo và xác minh tài khoản tại trang{" "}
+ {type === "give"
+ ? "Người ủng hộ chuyển khoản thẳng vào tài khoản này qua mã VietQR."
+ : "Khi dự án gây quỹ thành công, người ủng hộ thanh toán cam kết thẳng vào tài khoản này qua mã VietQR."}{" "}
+ Chiến dịch cần gắn một tài khoản đã xác minh, đứng tên bạn, trước khi gửi duyệt. Khai báo và xác minh tài
+ khoản tại trang{" "}
Tài khoản nhận tiền
@@ -621,6 +635,9 @@ function ReceivingAccountSection({
{boundUnusable && (
Tài khoản đang gắn tạm không dùng được: {boundUnusable} Chiến dịch không nhận được tiền qua tài khoản này.
+ {type === "back" && (status === "live" || status === "suspended")
+ ? " Dự án có quà tặng chưa đổi được tài khoản khi đang gây quỹ hoặc tạm dừng: hãy liên hệ đội ngũ Give."
+ : ""}
)}
{releasable ? (
@@ -637,7 +654,9 @@ function ReceivingAccountSection({
) : (
{boundHasTransactions && status === "changes_requested"
- ? "Tài khoản đã nhận giao dịch nên là lịch sử tiền của chiến dịch và không nhả sớm được; nếu nó không dùng được nữa, hãy đề nghị đổi tài khoản bên dưới."
+ ? type === "give"
+ ? "Tài khoản đã nhận giao dịch nên là lịch sử tiền của chiến dịch và không nhả sớm được; nếu nó không dùng được nữa, hãy đề nghị đổi tài khoản bên dưới."
+ : "Tài khoản đã nhận giao dịch nên là lịch sử tiền của chiến dịch và không nhả sớm được; nếu nó không dùng được nữa, hãy liên hệ đội ngũ Give."
: "Khi chiến dịch đang chờ duyệt, đã duyệt hoặc đang chạy, tài khoản không nhả được; tài khoản được nhả khi chiến dịch đã xong hẳn phần tiền."}
)}
@@ -795,7 +814,7 @@ function StatusNotice({
}: {
status: string
reviewNote: string | null
- /** Why a give campaign has no usable receiving account (an approved one does not open), or null. */
+ /** Why the campaign has no usable receiving account (an approved one does not open), or null. */
accountBlock: "no_account" | "unusable" | null
ended: boolean
}) {
diff --git a/apps/web/src/app/me/campaigns/actions.test.ts b/apps/web/src/app/me/campaigns/actions.test.ts
index 79042f26..14bdaa38 100644
--- a/apps/web/src/app/me/campaigns/actions.test.ts
+++ b/apps/web/src/app/me/campaigns/actions.test.ts
@@ -227,10 +227,29 @@ describe("submitForReview", () => {
expect(transitioned()).toBe(false)
})
- it("leaves back campaigns alone: no account reads", async () => {
+ it("refuses a back campaign with no bound account too (DOS/Give#85)", async () => {
state.userHandler = reads({ bindings: [], campaign: owned({ type: "back" }) })
+ expect(await submitForReview(CAMPAIGN, null, new FormData())).toEqual({ ok: false, message: SUBMIT_NO_ACCOUNT })
+ expect(transitioned()).toBe(false)
+ })
+
+ it("submits a back campaign with a usable bound account", async () => {
+ state.userHandler = reads({ campaign: owned({ type: "back" }) })
expect((await submitForReview(CAMPAIGN, null, new FormData()))?.ok).toBe(true)
- expect(state.userCalls.map((c) => c.target)).toEqual(["rpc:managed_campaigns", "rpc:transition_campaign"])
+ expect(transitioned()).toBe(true)
+ })
+
+ it("tells a back campaign whose unusable account holds money to contact Give, not to swap", async () => {
+ const base = reads({
+ acct: account({ verification_status: "disabled" }),
+ campaign: owned({ type: "back", status: "changes_requested" }),
+ })
+ state.userHandler = (call) => (call.target === "bank_transactions" ? { data: [{ id: "t" }] } : base(call))
+ expect(await submitForReview(CAMPAIGN, null, new FormData())).toEqual({
+ ok: false,
+ message: submitUnusableAccount("Tài khoản chưa được xác minh.", true, false),
+ })
+ expect(transitioned()).toBe(false)
})
it("explains that a give campaign needs a bound verified account (DOS/Give#38)", async () => {
diff --git a/apps/web/src/app/me/campaigns/actions.ts b/apps/web/src/app/me/campaigns/actions.ts
index b662cc39..1158978b 100644
--- a/apps/web/src/app/me/campaigns/actions.ts
+++ b/apps/web/src/app/me/campaigns/actions.ts
@@ -249,17 +249,15 @@ export async function submitForReview(
if (!session) return fail(SIGNED_OUT)
const { campaign, error: loadError } = await ownedCampaign(session, campaignId)
if (!campaign) return fail(loadError)
- // A charity campaign needs a usable bound account before review (DOS/Give#32);
- // give.transition_campaign re-checks it under its locks (DOS/Give#38).
- if (campaign.type === "give") {
- let problem
- try {
- problem = await checkSubmitAccount(dbAs(session.accessToken), campaign.id, session.user.id)
- } catch (error) {
- return fail(dbErrorMessage(error as { message?: string; code?: string }))
- }
- if (problem) return fail(problem)
+ // Every campaign needs a usable bound account before review (give: DOS/Give#32,
+ // back: DOS/Give#85); give.transition_campaign re-checks it under its locks.
+ let problem
+ try {
+ problem = await checkSubmitAccount(dbAs(session.accessToken), campaign.id, session.user.id, campaign.type)
+ } catch (error) {
+ return fail(dbErrorMessage(error as { message?: string; code?: string }))
}
+ if (problem) return fail(problem)
const failed = await transition(session, campaign, "in_review")
if (failed) return failed
refresh(campaign)
diff --git a/apps/web/src/lib/campaign-account.test.ts b/apps/web/src/lib/campaign-account.test.ts
index bebd97f3..b6afcd71 100644
--- a/apps/web/src/lib/campaign-account.test.ts
+++ b/apps/web/src/lib/campaign-account.test.ts
@@ -69,6 +69,11 @@ describe("submitAccountProblem", () => {
it("asks for a swap, not a release, when the unusable account holds money history (DOS/Give#77)", () => {
const text = submitAccountProblem(account("a", { verification_status: "disabled" }), organizer, true)
expect(text).toBe(submitUnusableAccount("Tài khoản chưa được xác minh.", true))
+ // A back campaign has no swap: its organizer contacts Give (DOS/Give#85).
+ const back = submitAccountProblem(account("a", { verification_status: "disabled" }), organizer, true, false)
+ expect(back).toBe(submitUnusableAccount("Tài khoản chưa được xác minh.", true, false))
+ expect(back).toContain("liên hệ đội ngũ Give")
+ expect(back).not.toContain("đổi tài khoản")
expect(text).toContain("yêu cầu đổi tài khoản")
expect(text).not.toContain("Hãy nhả")
})
@@ -141,7 +146,7 @@ describe("loadEditorAccounts", () => {
if (call.target === "campaigns") return { data: [{ id: OTHER, slug: "khac", title: "Quỹ khác", status: "live" }] }
return undefined
}, calls)
- const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "draft")
+ const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "draft", "give")
expect(view.bound?.id).toBe("here")
expect(view.boundUnusable).toBe("Tài khoản chưa được xác minh.")
expect(view.submitBlocked).toBe(submitUnusableAccount("Tài khoản chưa được xác minh."))
@@ -164,7 +169,7 @@ describe("loadEditorAccounts", () => {
? { data: organizer }
: undefined
)
- const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "draft")
+ const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "draft", "give")
expect(view.submitBlocked).toBeNull()
expect(view.choices).toEqual([])
expect(view.boundHasTransactions).toBe(false)
@@ -180,7 +185,7 @@ describe("loadEditorAccounts", () => {
if (call.target === "account_swap_requests") return { data: [{ id: "s", status: "pending" }] }
return undefined
}, calls)
- const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "changes_requested")
+ const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "changes_requested", "give")
expect(view.boundHasTransactions).toBe(true)
expect(view.swaps.map((s) => s.id)).toEqual(["s"])
expect(view.submitBlocked).toBe(submitUnusableAccount("Tài khoản chưa được xác minh.", true))
@@ -205,7 +210,7 @@ describe("loadEditorAccounts", () => {
if (call.target === "organizers") return { data: organizer }
return undefined
}, calls)
- await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "draft")
+ await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "draft", "give")
expect(calls.map((c) => c.target)).not.toContain("bank_transactions")
expect(calls.map((c) => c.target)).not.toContain("account_swap_requests")
})
@@ -218,11 +223,41 @@ describe("loadEditorAccounts", () => {
return { error: { code: "42P01", message: "relation does not exist" } }
return undefined
})
- const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "changes_requested")
+ const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "changes_requested", "give")
expect(view.bound?.id).toBe("here")
expect(view.boundHasTransactions).toBe(true) // unknown history: offer the swap, not a release
expect(view.swaps).toEqual([])
})
+
+ it("shows a back campaign's account and blocks its submission without one (DOS/Give#85)", async () => {
+ const db = fakeDb((call) =>
+ call.target === "receiving_accounts"
+ ? { data: [account("spare")] }
+ : call.target === "organizers"
+ ? { data: organizer }
+ : undefined
+ )
+ const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "draft", "back")
+ expect(view.bound).toBeNull()
+ expect(view.submitBlocked).toBe(SUBMIT_NO_ACCOUNT)
+ expect(view.choices.map((c) => [c.account.id, c.blockedReason])).toEqual([["spare", null]])
+ })
+
+ it("never reads swaps for a back campaign, and words its money-history block without a swap", async () => {
+ const calls: Call[] = []
+ const db = fakeDb((call) => {
+ if (call.target === "receiving_accounts")
+ return { data: [account("here", { verification_status: "disabled", bindings: bound(CAMPAIGN) })] }
+ if (call.target === "organizers") return { data: organizer }
+ if (call.target === "bank_transactions") return { data: [{ id: "t" }] }
+ return undefined
+ }, calls)
+ const view = await loadEditorAccounts(db as unknown as Db, "owner", CAMPAIGN, "changes_requested", "back")
+ expect(view.boundHasTransactions).toBe(true)
+ expect(view.swaps).toEqual([])
+ expect(calls.map((c) => c.target)).not.toContain("account_swap_requests")
+ expect(view.submitBlocked).toBe(submitUnusableAccount("Tài khoản chưa được xác minh.", true, false))
+ })
})
describe("canPickAccount", () => {
diff --git a/apps/web/src/lib/campaign-account.ts b/apps/web/src/lib/campaign-account.ts
index b8df6785..1f5445e7 100644
--- a/apps/web/src/lib/campaign-account.ts
+++ b/apps/web/src/lib/campaign-account.ts
@@ -1,5 +1,5 @@
import { bindingHasTransactions, listCampaignSwaps, SWAP_STATUSES, type AccountSwapRequest } from "./account-swaps"
-import type { CampaignStatus, Db, Organizer } from "./campaigns"
+import type { CampaignStatus, CampaignType, Db, Organizer } from "./campaigns"
import { getOrganizer } from "./campaigns"
import {
accountUnusableReason,
@@ -15,43 +15,51 @@ import {
/**
* The receiving account as the campaign editor sees it (DOS/Give#32): which of
- * the organizer's accounts holds a give campaign, which ones it could take,
- * and whether the campaign may be sent for review.
+ * the organizer's accounts holds a campaign, which ones it could take, and
+ * whether the campaign may be sent for review.
*
- * give.transition_campaign refuses draft -> in_review for a give campaign
- * without a usable account (GIVE_CAMPAIGN_NO_RECEIVING_ACCOUNT, DOS/Give#38);
- * the app checks first so the organizer gets a message that says what to do.
+ * give.transition_campaign refuses draft -> in_review for a campaign without
+ * a usable account (GIVE_CAMPAIGN_NO_RECEIVING_ACCOUNT): give campaigns since
+ * DOS/Give#38, back campaigns since DOS/Give#85, because their backers pay
+ * into that account once the campaign succeeds. The app checks first so the
+ * organizer gets a message that says what to do.
*/
-/** Refusal when a give campaign is sent for review with no bound account. */
+/** Refusal when a campaign is sent for review with no bound account. */
export const SUBMIT_NO_ACCOUNT =
- "Chưa gửi duyệt được: chiến dịch từ thiện cần gắn một tài khoản nhận tiền đã xác minh, đứng tên bạn. Chọn tài khoản trong mục Tài khoản nhận tiền của chiến dịch này; nếu bạn chưa có tài khoản đã xác minh, hãy khai báo và xác minh tại trang Tài khoản nhận tiền (/me/accounts)."
+ "Chưa gửi duyệt được: chiến dịch cần gắn một tài khoản nhận tiền đã xác minh, đứng tên bạn. Chọn tài khoản trong mục Tài khoản nhận tiền của chiến dịch này; nếu bạn chưa có tài khoản đã xác minh, hãy khai báo và xác minh tại trang Tài khoản nhận tiền (/me/accounts)."
/**
* Refusal when the bound account can no longer receive money. An account that
* already recorded a bank transaction holds the campaign's money history and
- * is never released early (GIVE_BINDING_NOT_RELEASABLE), so the organizer
- * asks finance to swap it instead (DOS/Give#55, #77).
+ * is never released early (GIVE_BINDING_NOT_RELEASABLE), so the organizer of
+ * a give campaign asks finance to swap it instead (DOS/Give#55, #77); back
+ * campaigns have no swap (`canSwap` false), so their organizer contacts Give.
*/
-export function submitUnusableAccount(reason: string, boundHasTransactions = false): string {
+export function submitUnusableAccount(reason: string, boundHasTransactions = false, canSwap = true): string {
+ if (boundHasTransactions && !canSwap) {
+ return `Chưa gửi duyệt được: tài khoản nhận tiền đang gắn không dùng được. ${reason} Tài khoản này đã nhận giao dịch nên không nhả được; hãy liên hệ đội ngũ Give để được hỗ trợ.`
+ }
return boundHasTransactions
? `Chưa gửi duyệt được: tài khoản nhận tiền đang gắn không dùng được. ${reason} Tài khoản này đã nhận giao dịch nên không nhả được; hãy gửi yêu cầu đổi tài khoản trong mục Tài khoản nhận tiền của chiến dịch để bộ phận tài chính duyệt.`
: `Chưa gửi duyệt được: tài khoản nhận tiền đang gắn không dùng được. ${reason} Hãy nhả tài khoản đó rồi gắn một tài khoản đã xác minh khác (quản lý tại trang Tài khoản nhận tiền, /me/accounts).`
}
/**
- * Why a give campaign cannot be sent for review because of its account, or
- * null when its bound account is usable. `bound` is the account the campaign's
- * active binding points at, or null when it has none.
+ * Why a campaign cannot be sent for review because of its account, or null
+ * when its bound account is usable. `bound` is the account the campaign's
+ * active binding points at, or null when it has none. `canSwap` is whether
+ * the campaign's type has the finance-approved swap (give only).
*/
export function submitAccountProblem(
bound: Pick
| null,
organizer: Pick | null,
- boundHasTransactions = false
+ boundHasTransactions = false,
+ canSwap = true
): string | null {
if (!bound) return SUBMIT_NO_ACCOUNT
const unusable = accountUnusableReason(bound, organizer)
- return unusable ? submitUnusableAccount(unusable, boundHasTransactions) : null
+ return unusable ? submitUnusableAccount(unusable, boundHasTransactions, canSwap) : null
}
/**
@@ -59,7 +67,12 @@ export function submitAccountProblem(
* the organizer with the caller's token (RLS shows the owner their own rows).
* Throws on a read error, like the query helpers it calls.
*/
-export async function checkSubmitAccount(db: Db, campaignId: string, organizerId: string): Promise {
+export async function checkSubmitAccount(
+ db: Db,
+ campaignId: string,
+ organizerId: string,
+ type: CampaignType
+): Promise {
const [bindings, organizer] = await Promise.all([
activeBindingsOfCampaign(db, campaignId),
getOrganizer(db, organizerId),
@@ -72,7 +85,7 @@ export async function checkSubmitAccount(db: Db, campaignId: string, organizerId
bound !== null && accountUnusableReason(bound, organizer) !== null
? await bindingHasTransactions(db, bindings[0].id)
: false
- return submitAccountProblem(bound, organizer, hasTransactions)
+ return submitAccountProblem(bound, organizer, hasTransactions, type === "give")
}
export interface AccountChoice {
@@ -139,7 +152,7 @@ export interface EditorAccounts {
}
/**
- * Why the editor knows a give campaign has no usable receiving account (so an
+ * Why the editor knows a campaign has no usable receiving account (so an
* approved one does not open): none bound, or the bound one unusable. Null
* when it has one, or when the accounts could not be read.
*/
@@ -151,12 +164,13 @@ export function openingBlock(
return accounts.boundUnusable !== null ? "unusable" : null
}
-/** Everything the editor shows about a give campaign's receiving account. */
+/** Everything the editor shows about a campaign's receiving account (both types, DOS/Give#85). */
export async function loadEditorAccounts(
db: Db,
organizerId: string,
campaignId: string,
- status: CampaignStatus
+ status: CampaignStatus,
+ type: CampaignType
): Promise {
const [accounts, organizer] = await Promise.all([listMyAccounts(db, organizerId), getOrganizer(db, organizerId)])
const bound = boundAccountOf(accounts, campaignId)
@@ -165,7 +179,7 @@ export async function loadEditorAccounts(
.filter((id): id is string => Boolean(id) && id !== campaignId)
const binding = bound ? activeBinding(bound) : null
// Money history matters only to a campaign sent back for changes (release
- // or swap), and swaps only where a swap can happen (DOS/Give#55). A failed
+ // or swap), and swaps only where a swap can happen (give, DOS/Give#55). A failed
// read of either shows the section without them rather than hiding it; the
// database refuses anything they would have prevented. An unknown money
// history counts as some, so the editor offers the swap (which the database
@@ -175,7 +189,9 @@ export async function loadEditorAccounts(
binding && status === "changes_requested"
? bindingHasTransactions(db, binding.id).catch(() => true)
: Promise.resolve(false),
- SWAP_STATUSES.includes(status) ? listCampaignSwaps(db, campaignId).catch(() => []) : Promise.resolve([]),
+ type === "give" && SWAP_STATUSES.includes(status)
+ ? listCampaignSwaps(db, campaignId).catch(() => [])
+ : Promise.resolve([]),
])
return {
bound,
@@ -183,6 +199,6 @@ export async function loadEditorAccounts(
boundHasTransactions,
swaps,
choices: accountChoices(accounts, campaignId, organizer, refs),
- submitBlocked: submitAccountProblem(bound, organizer, boundHasTransactions),
+ submitBlocked: submitAccountProblem(bound, organizer, boundHasTransactions, type === "give"),
}
}
diff --git a/apps/web/src/lib/campaigns.ts b/apps/web/src/lib/campaigns.ts
index ad7ddcfa..bfb1e357 100644
--- a/apps/web/src/lib/campaigns.ts
+++ b/apps/web/src/lib/campaigns.ts
@@ -570,7 +570,7 @@ export interface WaitingForAccount extends ReviewItem {
}
/**
- * Approved give campaigns without a usable receiving account, which the
+ * Approved campaigns (give and, since DOS/Give#85, back) without a usable receiving account, which the
* activation cron skips, earliest start first (moderators and admins; others
* get none). The database picks them; the rows come from managed_campaigns.
*/
diff --git a/apps/web/src/lib/errors.ts b/apps/web/src/lib/errors.ts
index a86a9eae..6153fe17 100644
--- a/apps/web/src/lib/errors.ts
+++ b/apps/web/src/lib/errors.ts
@@ -47,10 +47,10 @@ const GIVE_MESSAGES: Record = {
GIVE_ACCOUNT_NOT_VERIFIED:
"Tài khoản nhận chưa được xác minh, hoặc hồ sơ người tổ chức chưa được xác minh hay đã đổi tên pháp lý.",
GIVE_ACCOUNT_ALREADY_BOUND:
- "Tài khoản này đang gắn với một chiến dịch khác. Hãy nhả tài khoản khỏi chiến dịch đó trước: bản nháp, hay chiến dịch từ thiện được yêu cầu chỉnh sửa mà tài khoản chưa nhận giao dịch nào, nhả được ngay; các chiến dịch khác đã gửi duyệt chỉ nhả được khi đã xong hẳn phần tiền.",
+ "Tài khoản này đang gắn với một chiến dịch khác. Hãy nhả tài khoản khỏi chiến dịch đó trước: bản nháp, hay chiến dịch được yêu cầu chỉnh sửa mà tài khoản chưa nhận giao dịch nào, nhả được ngay; các chiến dịch khác đã gửi duyệt chỉ nhả được khi đã xong hẳn phần tiền.",
GIVE_BINDING_NOT_FOUND: "Tài khoản này hiện không gắn với chiến dịch nào.",
GIVE_BINDING_NOT_RELEASABLE:
- "Chưa nhả được tài khoản: chiến dịch phải là bản nháp, chiến dịch từ thiện được yêu cầu chỉnh sửa, hoặc đã hủy, mà tài khoản chưa nhận giao dịch nào; hoặc đã kết thúc và xong hẳn phần tiền (chiến dịch từ thiện cần kết thúc ít nhất 30 ngày). Giao dịch đã vào tài khoản là lịch sử tiền của chiến dịch nên tài khoản không nhả sớm được. Nếu tài khoản không dùng được nữa khi chiến dịch đang gây quỹ, tạm dừng hoặc cần chỉnh sửa, hãy gửi yêu cầu đổi tài khoản ở mục Tài khoản nhận tiền của chiến dịch.",
+ "Chưa nhả được tài khoản: chiến dịch phải là bản nháp, được yêu cầu chỉnh sửa, hoặc đã hủy, mà tài khoản chưa nhận giao dịch nào; hoặc đã kết thúc và xong hẳn phần tiền (chiến dịch từ thiện cần kết thúc ít nhất 30 ngày). Giao dịch đã vào tài khoản là lịch sử tiền của chiến dịch nên tài khoản không nhả sớm được. Nếu tài khoản không dùng được nữa khi chiến dịch từ thiện đang gây quỹ, tạm dừng hoặc cần chỉnh sửa, hãy gửi yêu cầu đổi tài khoản ở mục Tài khoản nhận tiền của chiến dịch; với dự án có quà tặng, hãy liên hệ đội ngũ Give.",
GIVE_BINDING_HAS_OPEN_TRANSACTIONS:
"Chưa nhả được tài khoản: còn giao dịch của chiến dịch chưa được đối chiếu hoặc chưa được giải trình.",
GIVE_CAMPAIGN_ALREADY_BOUND: "Chiến dịch này đã có tài khoản nhận khác.",
@@ -162,12 +162,13 @@ export function dbErrorMessage(error: DbErrorLike | null | undefined, overrides:
/**
* GIVE_CAMPAIGN_NO_RECEIVING_ACCOUNT worded for the organizer submitting a
- * campaign. The database raises it when a give campaign has no active binding
- * to a verified account in the verified organizer's own name.
+ * campaign. The database raises it when a campaign (give or back,
+ * DOS/Give#85) has no active binding to a verified account in the verified
+ * organizer's own name.
*/
export const SUBMIT_MESSAGES: Record = {
GIVE_CAMPAIGN_NO_RECEIVING_ACCOUNT:
- "Chưa gửi duyệt được: chiến dịch từ thiện cần một tài khoản nhận tiền đã xác minh, đứng tên bạn, gắn vào chiến dịch. Vào mục Tài khoản nhận tiền để khai báo, xác minh và gắn tài khoản; nếu tài khoản đang gắn đã bị khóa hoặc không còn dùng được, hãy nhả nó rồi gắn tài khoản khác (tài khoản đã nhận giao dịch thì không nhả được: gửi yêu cầu đổi tài khoản trong cùng mục). Nếu bạn vừa đổi tên pháp lý, hồ sơ người tổ chức cần được xác minh lại, và tài khoản nhận tiền phải đứng tên pháp lý mới.",
+ "Chưa gửi duyệt được: chiến dịch cần một tài khoản nhận tiền đã xác minh, đứng tên bạn, gắn vào chiến dịch. Vào mục Tài khoản nhận tiền để khai báo, xác minh và gắn tài khoản; nếu tài khoản đang gắn đã bị khóa hoặc không còn dùng được, hãy nhả nó rồi gắn tài khoản khác (tài khoản đã nhận giao dịch thì không nhả được: với chiến dịch từ thiện, gửi yêu cầu đổi tài khoản trong cùng mục; với dự án có quà tặng, liên hệ đội ngũ Give). Nếu bạn vừa đổi tên pháp lý, hồ sơ người tổ chức cần được xác minh lại, và tài khoản nhận tiền phải đứng tên pháp lý mới.",
}
/** The same code worded for a moderator approving or opening a campaign. */
diff --git a/apps/web/src/lib/receiving-accounts.test.ts b/apps/web/src/lib/receiving-accounts.test.ts
index b520fbf9..96424d7c 100644
--- a/apps/web/src/lib/receiving-accounts.test.ts
+++ b/apps/web/src/lib/receiving-accounts.test.ts
@@ -124,7 +124,7 @@ describe("binding helpers", () => {
it("lets the owner ask for a release of a draft's or an ended campaign's account", () => {
expect(canAskRelease("draft")).toBe(true)
expect(canAskRelease("changes_requested", "give")).toBe(true)
- expect(canAskRelease("changes_requested", "back")).toBe(false)
+ expect(canAskRelease("changes_requested", "back")).toBe(true) // DOS/Give#85
expect(canAskRelease("changes_requested")).toBe(false)
expect(canAskRelease("cancelled")).toBe(true)
expect(canAskRelease("closed")).toBe(true)
diff --git a/apps/web/src/lib/receiving-accounts.ts b/apps/web/src/lib/receiving-accounts.ts
index ed6c2b94..90e9d6df 100644
--- a/apps/web/src/lib/receiving-accounts.ts
+++ b/apps/web/src/lib/receiving-accounts.ts
@@ -102,15 +102,17 @@ export function isBindableStatus(status: CampaignStatus): boolean {
* to free the account: a draft at once (to change its account), an ended
* campaign once the database's terms hold (no transaction for a draft
* cancelled without submission; otherwise 30 days and every transaction
- * matched or explained). A give campaign sent back for changes also releases
- * at once when no transaction reached the account, so the organizer can
- * replace an account that stopped being usable (DOS/Give#38). The database
- * decides and explains a refusal.
+ * matched or explained). A campaign sent back for changes also releases at
+ * once when no transaction reached the account, so the organizer can replace
+ * an account that stopped being usable (give: DOS/Give#38, back:
+ * DOS/Give#85); that case needs the campaign's `type`, so a caller that does
+ * not know the campaign is refused. The database decides and explains a
+ * refusal.
*/
export const RELEASE_STATUSES: readonly CampaignStatus[] = ["draft", "cancelled", "closed", "failed", "succeeded"]
export function canAskRelease(status: CampaignStatus | undefined, type?: CampaignType): boolean {
- if (status === "changes_requested") return type === "give"
+ if (status === "changes_requested") return type !== undefined
return status !== undefined && RELEASE_STATUSES.includes(status)
}