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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions apps/web/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,9 @@ export default async function AdminPage() {
Chiến dịch đã duyệt chờ tài khoản nhận{waiting ? ` (${waiting.length})` : ""}
</h2>
<p className="mb-4 text-sm text-gray-600">
Chiến dịch từ thiện đã duyệt nhưng tài khoản nhận tiền không còn dùng được, nên hệ thống không tự mở. Chiến
dịch tự mở nếu tài khoản dùng được trở lại; nếu không, hãy yêu cầu chỉnh sửa để người tổ chức nhả tài khoản
cũ, gắn tài khoản khác rồi gửi duyệt lại.
Chiến dịch (từ thiện hoặc dự án có quà tặng) đã duyệt nhưng tài khoản nhận tiền không còn dùng được, nên hệ
thống không tự mở. Chiến dịch tự mở nếu tài khoản dùng được trở lại; nếu không, hãy yêu cầu chỉnh sửa để người
tổ chức nhả tài khoản cũ, gắn tài khoản khác rồi gửi duyệt lại.
</p>
{waiting === null ? (
<p className="card p-6 text-gray-600">Chưa tải được danh sách này. Hãy tải lại trang sau.</p>
Expand All @@ -201,7 +201,8 @@ export default async function AdminPage() {
<li key={campaign.id} className="space-y-4 card p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<h3 className="text-lg font-semibold">{campaign.title}</h3>
<TypeBadge type={campaign.type} />
<h3 className="mt-1 text-lg font-semibold">{campaign.title}</h3>
<p className="text-sm text-red-700">{accountProblemLabel(campaign.account_problem)}</p>
</div>
<Link href={`/campaigns/${campaign.slug}`} className="btn-secondary" target="_blank">
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/app/me/accounts/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,13 +306,14 @@ describe("releaseAccount", () => {
expect(result?.message).toContain("Giao dịch đã vào tài khoản là lịch sử tiền")
})

it("does not ask for a back project sent back for changes", async () => {
it("releases the account of a back project sent back for changes too (DOS/Give#85)", async () => {
state.userHandler = bindReads({
acc: account({ bindings: [bound] }),
camp: campaign({ status: "changes_requested", type: "back" }),
})
expect((await releaseAccount(ACCOUNT, null, new FormData()))?.ok).toBe(false)
expect(state.serviceCalls).toHaveLength(0)
state.serviceHandler = () => ({ data: "b" })
expect((await releaseAccount(ACCOUNT, null, new FormData()))?.ok).toBe(true)
expect(state.serviceCalls.some((c) => c.target === "rpc:release_account_binding")).toBe(true)
})

it("does not ask while the campaign is in review or live", async () => {
Expand Down
8 changes: 4 additions & 4 deletions apps/web/src/app/me/accounts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -268,9 +268,9 @@ function AccountItem({
<Link href={`/me/campaigns/${holder.campaign_id}`} className="font-medium text-brand-600">
{boundTitle(holder.campaign_id)}
</Link>{" "}
từ ngày {formatDate(holder.bound_at)}. Khi chiến dịch còn là bản nháp, hoặc là 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, bạn có thể nhả tài khoản để đổi tài khoản khác; ngoài ra,
sau khi gửi duyệt, tài khoản chỉ được nhả khi chiến dịch đó đã xong hẳn phần tiền.
từ ngày {formatDate(holder.bound_at)}. Khi chiến dịch còn là bản nháp, hoặc được yêu cầu chỉnh sửa mà tài
khoản chưa nhận giao dịch nào, bạn có thể nhả tài khoản để đổi tài khoản khác; ngoài ra, sau khi gửi duyệt,
tài khoản chỉ được nhả khi chiến dịch đó đã xong hẳn phần tiền.
{(account.verification_status === "disabled" || unusable) && (
<span className="font-semibold text-red-700">
{" "}
Expand Down Expand Up @@ -301,7 +301,7 @@ function AccountItem({
) : (
<ActionForm
action={bindAccount.bind(null, account.id)}
confirm="Gắn tài khoản này với chiến dịch đã chọn? Sau khi gửi duyệt, tài khoản chỉ được nhả khi chiến dịch đã xong hẳn phần tiền, hoặc khi 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."
confirm="Gắn tài khoản này với chiến dịch đã chọn? Sau khi gửi duyệt, tài khoản chỉ được nhả khi chiến dịch đã xong hẳn phần tiền, hoặc khi 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."
className="space-y-2 border-t border-gray-100 pt-4"
>
<label htmlFor={`campaign-${account.id}`} className="label">
Expand Down
81 changes: 50 additions & 31 deletions apps/web/src/app/me/campaigns/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,10 @@ export default async function EditCampaignPage({ params }: { params: Promise<{ i
listMilestones(db, campaign.id),
listAllUpdates(db, campaign.id),
isPublicStatus(campaign.status) ? getMoneyTotals(db, campaign.id) : Promise.resolve(null),
// A failed account read shows a notice in its section instead of failing the
// whole editor; submitForReview still checks the account on the server.
isGive
? loadEditorAccounts(db, session.user.id, campaign.id, campaign.status).catch(() => null)
: Promise.resolve(null),
// Both types need a usable account before review (DOS/Give#85). A failed
// account read shows a notice in its section instead of failing the whole
// editor; submitForReview still checks the account on the server.
loadEditorAccounts(db, session.user.id, campaign.id, campaign.status, campaign.type).catch(() => null),
// Reward tiers of a back campaign (DOS/Give#83); a failed read degrades its section only.
isGive ? Promise.resolve(null) : listCampaignTiers(db, campaign.id).catch(() => null),
])
Expand Down Expand Up @@ -485,7 +484,7 @@ export default async function EditCampaignPage({ params }: { params: Promise<{ i
</section>
)}

{isGive && !accounts && (
{!accounts && (
<section id="receiving-account" className="card p-6">
<h2 className="text-lg font-semibold">Tài khoản nhận tiền</h2>
<p className="text-sm text-red-700">
Expand Down Expand Up @@ -518,25 +517,34 @@ export default async function EditCampaignPage({ params }: { params: Promise<{ i
</p>
{submitBlocked && (
<p role="alert" className="mb-3 rounded-md bg-amber-50 px-4 py-3 text-sm text-amber-900">
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 "}
<a href="#receiving-account" className="underline">
Tài khoản nhận tiền
</a>{" "}
{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{" "}
<Link href="/me/accounts" className="font-medium underline">
Tài khoản nhận tiền
</Link>
.
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 "}
<a href="#receiving-account" className="underline">
Tài khoản nhận tiền
</a>{" "}
{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{" "}
<Link href="/me/accounts" className="font-medium underline">
Tài khoản nhận tiền
</Link>
.
</>
)}
</p>
)}
<SubmitButton disabled={Boolean(submitBlocked)}>Gửi duyệt</SubmitButton>
Expand Down Expand Up @@ -575,9 +583,12 @@ export default async function EditCampaignPage({ params }: { params: Promise<{ i
}

const BIND_CONFIRM =
"Gắn tài khoản này với chiến dịch? Sau khi gửi duyệt, tài khoản chỉ được nhả khi chiến dịch đã xong hẳn phần tiền, hoặc khi 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."
"Gắn tài khoản này với chiến dịch? Sau khi gửi duyệt, tài khoản chỉ được nhả khi chiến dịch đã xong hẳn phần tiền, hoặc khi 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."

/** The give campaign's receiving account: the bound one with a release, or a picker (DOS/Give#32). */
/**
* The campaign's receiving account: the bound one with a release, or a picker
* (DOS/Give#32; back campaigns too since DOS/Give#85).
*/
function ReceivingAccountSection({
campaignId,
status,
Expand All @@ -597,8 +608,11 @@ function ReceivingAccountSection({
<div>
<h2 className="text-lg font-semibold">Tài khoản nhận tiền</h2>
<p className="text-sm text-gray-600">
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{" "}
<Link href="/me/accounts" className="font-medium text-brand-600">
Tài khoản nhận tiền
</Link>
Expand All @@ -621,6 +635,9 @@ function ReceivingAccountSection({
{boundUnusable && (
<p className="text-sm text-amber-800">
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."
: ""}
</p>
)}
{releasable ? (
Expand All @@ -637,7 +654,9 @@ function ReceivingAccountSection({
) : (
<p className="text-sm text-gray-600">
{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."}
</p>
)}
Expand Down Expand Up @@ -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
}) {
Expand Down
23 changes: 21 additions & 2 deletions apps/web/src/app/me/campaigns/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
18 changes: 8 additions & 10 deletions apps/web/src/app/me/campaigns/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading