Donations
Record one-off and recurring gifts from donors, post them on the ledger per fund or campaign, charge pledges through a payments provider once per period, and issue numbered tax receipts with a yearly summary.
donations records gifts from donors, who are any records in
config.subjects. Gifts go to funds or campaigns and post on the
ledger, so what a fund raised is a ledger balance.
Pledges repeat a gift every week, month, quarter or year. Card and bank
charges go through a payments provider, and tax receipts get numbers from a
sequence per organization.
better-supabase add donationsimport { createDonations } from "better-supabase/blocks/donations";
import { money } from "better-supabase/commerce";The module requires tenant, access, subjects and ledger. With
outbox it records the donation.*, pledge.* and donation_receipt.*
events, with audit every change is audited, and with
number-sequences receipt numbers follow its
donation_receipt format. Amounts are bigint minor units
(ADR 0037).
Charges go through the versioned PaymentProvider interface
(ADR 0024),
so the block imports no payments SDK. How pledges post on the ledger is in
ADR 0080.
Tables
| Table | Holds |
|---|---|
donation_funds | A fund or campaign per organization, by key, with an optional goal and dates |
pledges | A recurring gift: the donor, the amount per frequency, the dates, the status and the provider that charges it |
donations | A gift: the donor, the fund, the amount, the status, the payment and its ledger transactions |
donation_receipts | A numbered tax receipt for one gift or for a donor's year in one currency |
Members with donations.read read every row of the organization. Writes go
through the functions and need donations.manage; provider events are
applied with the service role.
Your own columns
extraColumns adds columns to the donations table, and the client's fields schema types them.
// better-supabase.config.ts
blocks: {
donations: { options: { extraColumns: { appeal: "text" } } },
},
// the client
const donations = createDonations({
transport,
tenant,
fields: v.object({ appeal: v.optional(v.string()) }),
});recordGift and give validate them before any call, and every read that returns a gift parses them under gift.fields. A gift a pledge run opens has none set.
Funds and gifts
const donations = createDonations({
transport,
tenant: organizationId,
payments: stripePayments,
});
const fund = await donations
.saveFund({
key: "roof_appeal",
name: "Roof appeal",
kind: "campaign",
goal: money(5_000_000n, "EUR"),
})
.orThrow();
await donations
.recordGift({
donor: { type: "contacts", id: contactId },
amount: money(25_000n, "EUR"),
fundId: fund.id,
method: "bank_transfer",
})
.orThrow();recordGift records a gift received outside a provider (cash, a check, a
bank transfer) and posts it at once: cash in, fund income out. give opens
a pending gift and charges it through the provider; the gift posts when the
payment succeeds, either at once or from the webhook. Each posting carries a
key per gift (donation:<id>:posted, donation:<id>:refunded), so a
repeated event changes nothing. A gift
without a fund posts to the general income account. funds() returns each
fund with what it raised in its goal's currency.
| Method | Returns |
|---|---|
saveFund(input) | the fund, created or updated by key |
archiveFund(fundId) | the archived fund |
funds({ includeArchived }) | funds with what they raised |
recordGift(input) | the posted gift |
give(input) | the gift and the provider's payment |
getGift(giftId), gifts(options) | gifts, newest first |
createPledge(input) | the pledge |
updatePledge(pledgeId, update) | the pledge, paused, resumed, ended or with a new amount |
pledges(options) | pledges |
dueInstallments(options) | the installments due without a gift, oldest first |
chargeDuePledges(options) | one outcome per installment: charged, skipped or failed |
handlePaymentEvent(event) | what the provider event did to its gift |
webhook(input) | the verified webhook's events applied |
issueGiftReceipt(giftId) | a receipt for one posted gift |
issueYearlyReceipt(donor, year, currency) | a donor's receipt for the year |
voidReceipt(receiptId, reason) | the voided receipt |
receipts(options) | receipts, newest first |
givingSummary(donor, year) | a donor's posted totals per currency and fund, and their receipts |
Pledges
await donations
.createPledge({
donor: { type: "contacts", id: contactId },
amount: money(1_000n, "EUR"),
frequency: "monthly",
provider: stripePayments.name,
providerCustomer: "cus_123",
})
.orThrow();
const outcomes = await donations.chargeDuePledges().orThrow();chargeDuePledges charges every due installment of the pledges this
provider charges. A period has at most one gift (the donations row is
unique per pledge and period), and the charge carries the idempotency key
pledge:<id>:<period start>. A run that stops halfway charges nothing twice
when it runs again: a gift opened but never charged stays due under the same
key. A declined charge marks the gift failed, and the period isn't charged
again. Another error leaves it pending for the next run. Pledges without a
provider are recorded by hand with recordGift({ pledgeId, periodStart });
the charge run skips them.
updatePledge pauses, resumes or ends a pledge, or changes its amount for
installments from then on. An ended pledge can't restart; create a new one.
Payment events
export async function POST(request: Request) {
const outcome = await serviceDonations.webhook({
body: await request.text(),
headers: request.headers,
});
return new Response(null, { status: outcome.ok ? 204 : 400 });
}webhook verifies the request with the provider and applies each payment
event to its gift: a succeeded payment posts it, a failed one marks it, and
a refund reverses the posting and voids the gift's receipt. Run the webhook
client with the service role.
Tax receipts
await donations.issueGiftReceipt(giftId).orThrow();
await donations
.issueYearlyReceipt({ type: "contacts", id: contactId }, 2026, "EUR")
.orThrow();A gift receipt covers one posted gift; issuing it again returns the same
receipt. A yearly receipt covers a donor's
posted gifts received in a year and currency; issuing it again returns the
same receipt until it is voided. Numbers come from number-sequences when it
configures donation_receipt; without it each organization counts up from 1
with the prefix R-. A voided receipt keeps its number, and a voided gift
receipt frees the gift for a new one.
Section
The giving section shows a donor's giving this year on their record page.
Merging donors
When two records merge, the dropped record's gifts, pledges and receipts move to the kept record.
Errors
| Hint | Cause |
|---|---|
DONATIONS_FORBIDDEN | the caller lacks donations.read or donations.manage |
DONATIONS_NO_PAYMENTS | give, chargeDuePledges or webhook ran without a payments provider |
DONATION_AMOUNT_INVALID | the amount isn't above zero or its currency isn't valid |
DONATION_DONOR_INVALID | the donor isn't a record of the organization in config.subjects |
DONATION_FUND_INVALID | the fund's key, name, goal or dates aren't valid, or it's archived |
DONATION_FUND_NOT_FOUND | no fund with this id in the organization |
DONATION_NOT_FOUND | no gift with this id that the caller can read |
DONATION_STATUS_INVALID | the gift isn't in a status that allows the change, or isn't posted yet |
DONATION_PLEDGE_INVALID | the pledge's amount, frequency, dates or period aren't valid |
DONATION_PLEDGE_NOT_FOUND | no pledge with this id in the organization |
DONATION_PLEDGE_ENDED | the pledge has ended |
DONATION_RECEIPT_EMPTY | the donor has no posted gifts to receipt for that year and currency |
DONATION_RECEIPT_INVALID | a yearly receipt has no year or currency |
DONATION_RECEIPT_NOT_FOUND | no receipt with this id in the organization |
Events
donation.received (a gift posted), donation.failed, donation.refunded,
pledge.created, pledge.updated, donation_receipt.issued and
donation_receipt.voided. The events page lists
their payloads.
Last updated on
Expenses
Collect expense reports with lines, categories and receipts, check spend policies on submit, approve through the approvals block, and post the approved total and its reimbursement on the ledger.
Contracts
Contracts with counterparties from draft through review and signature to active, expired or terminated, with values in minor units, renewal terms that schedule reminders, and obligations with due dates and owners.