# 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.

Source: https://bettersupabase.com/docs/blocks/donations

`donations` records gifts from donors, who are any records in
`config.subjects`. Gifts go to funds or campaigns and post on the
[ledger](/docs/blocks/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.

```bash
better-supabase add donations
```

```ts
import { 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](/docs/blocks/number-sequences) receipt numbers follow its
`donation_receipt` format. Amounts are bigint minor units
([ADR 0037](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0037-money-on-the-wire.md)).
Charges go through the versioned `PaymentProvider` interface
([ADR 0024](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0024-money-and-commerce-providers.md)),
so the block imports no payments SDK. How pledges post on the ledger is in
[ADR 0080](https://github.com/ScaleDockHQ/better-supabase/blob/main/docs/decisions/0080-recurring-charges-on-the-ledger.md).

## Tables [#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 [#your-own-columns]

`extraColumns` adds columns to the `donations` table, and the client's `fields` schema types them.

```ts
// 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 [#funds-and-gifts]

```ts
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 [#pledges]

```ts
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 [#payment-events]

```ts
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 [#tax-receipts]

```ts
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 [#section]

The [giving section](/docs/blocks/records) shows a donor's giving this year
on their record page.

## Merging donors [#merging-donors]

When two records merge, the dropped record's gifts, pledges and receipts
move to the kept record.

## Errors [#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 [#events]

`donation.received` (a gift posted), `donation.failed`, `donation.refunded`,
`pledge.created`, `pledge.updated`, `donation_receipt.issued` and
`donation_receipt.voided`. The [events page](/docs/extending/events) lists
their payloads.