If you’ve ever helped a small nonprofit pick a WordPress donation plugin, you already know the shape of the problem. There are several reputable options — GiveWP, Charitable, WooCommerce’s own donation extensions — and all of them follow the same business model: a free version that handles basic one-time donations, and a paid tier that unlocks the features the
nonprofit actually needs. Recurring donations: paid. Custom fields: paid. Campaign progress bars: paid. Donor management: paid. Tax receipts: paid. The paid tiers usually start around $200 a year and climb from there.
For a well-funded nonprofit, $200 a year is a rounding error. For a
small no-kill shelter that runs on community donations / memberships and operates on a tight budget, it’s a real expense — and worse, it’s an expense that scales the wrong way: the more your nonprofit grows, the more features you need, the more you pay. You end up paying a software vendor for permission to accept your own donations.
This post is about a plugin I built to refuse that bargain. It’s called
vcpahumane-wc-donations (still named starter-shelter internally — more on that in a moment), and it’s a WordPress plugin that turns WooCommerce into a real donations platform without locking the useful features behind a paywall. It powers the donations system at Venango County Humane Society, a small no-kill shelter in northwestern Pennsylvania that I’ve been doing development work for since early 2025.
The plugin went live on the production site last week. As of this writing it’s deployed, integrated, and ready. Writing this now feels like the right moment: while the design decisions are fresh and before the inevitable post-launch reality has forced me to revise them.
The repo is at github.com/jwincek/vcpahumane-wc-donations. It’s GPL, the issues tracker is open, and developer collaboration is the primary reason this post exists.
A note about the name
The plugin’s GitHub repo and the post title both call itvcpahumane-wc-donations, but if you clone the repo and look inside, the main file is starter-shelter.php, the namespace is Starter_Shelter\, and the text domain is starter-shelter. That’s residual from earlier iterations — the plugin started as a “shelter donations starter template” intended to be reusable across different shelter sites, then got specific to VCHS, then I started thinking about it as a generic starter again. A rename is planned but hasn’t happened yet.
I’m mentioning this up front because it’s exactly the kind of thing
that will confuse a developer cloning the repo for the first time, and I’d rather acknowledge the inconsistency than pretend it isn’t there. Everything in this post that says “vcpahumane-wc-donations” you can read as “the plugin formerly known as starter-shelter, currently being renamed in place.” If you contribute a PR before the rename is done, you’ll see both names floating around, and that’s expected.
What this plugin does, briefly
It accepts donations, memberships, and in-memoriam tributes through WooCommerce’s checkout. It stores those records as custom post types that the shelter can query, report on, and display however they want. It ships with about ten Gutenberg blocks for donation forms, memberships, memorial walls, campaign progress, and donor stats. It has an admin UI for managing everything. It’s all GPL.
It does not (yet) support recurring donations, donor self-service
accounts, tax receipt PDFs, or employer matching. Those are real gaps and I’ll talk about them at the end. The plugin is intentionally a starting point, not a finished product.
What it does have, that I think is architecturally interesting:
- A pattern for syncing custom post types with WooCommerce variable products that lets you treat donations as first-class data instead of as a side effect of e-commerce orders
- A JSON-driven input mapping system that converts WooCommerce order data into structured donation records without writing any custom processing code per donation type
- A memorial wall block that solves a real maintenance problem the shelter was facing
- WordPress 6.9 Abilities API integration that gives the plugin the same single-source-of-truth architecture I wrote about for the Petstablished sync plugin
Decision 1: CPTs over WooCommerce as the source of truth
The first and biggest decision. Most WordPress donation plugins do one of two things:
- Build their own checkout, payment gateway integration, and order storage from scratch (this is what GiveWP does)
- Treat donations as WooCommerce orders and store everything in the WooCommerce order tables (this is what most WC-based donation plugins do)
The first approach means rebuilding everything WooCommerce already does. The second approach means your donation data lives in tables designed for selling t-shirts, not for tracking philanthropic giving — and querying “how much did this donor give in 2025, and to which programs” requires joining wp_wc_orders to wp_wc_order_itemmeta to figure out what the line items meant.
This plugin takes a third approach. WooCommerce handles the transaction, custom post types are the source of truth for the donation data. When a customer completes an order containing donation items, the plugin processes the order, extracts the donation-relevant data, and creates new posts in custom post types:
sd_donation— one record per donation transactionsd_membership— one record per active membershipsd_memorial— one record per memorial tributesd_donor— one record per unique donor (deduplicated by email)
The WooCommerce order remains as the transaction log, but it’s not where queries go. When the admin asks “show me all donations from November 2025 grouped by donor,” the query runs against sd_donation posts (which are indexed and meta-cached normally), not against the WooCommerce order tables.
This has several practical benefits:
Reporting is fast and natural. A WP_Query againstpost_type => 'sd_donation' with date and donor filters is the kind of query WordPress is designed for. Joining order tables for the same question requires custom SQL.
Donations have their own permalinks, taxonomies, and meta. A
donation can be tagged to a campaign, attached to a memorial, marked anonymous, given a public-facing display name distinct from the billing name, and so on — all using WordPress’s native meta and taxonomy systems.
The plugin can evolve independently of WooCommerce. If WooCommerce changes its order table structure (and it has, recently), the donation data is unaffected. The integration layer is small and the data layer is decoupled.
Migrations and exports are easy. Exporting “all donations” is a
post export, not a WooCommerce order export with a filter applied.
The cost is that the plugin has to maintain the sync from orders to
posts, which is the next decision.
Decision 2: a config-driven input mapping DSL
When a WooCommerce order completes, the plugin needs to know which items are donations, which are memberships, which are memorial tributes — and for each one, how to extract the relevant fields from the order’s line items, custom checkout fields, product variations, and order meta. Hardcoding that logic per product type would mean a PHP function per donation type, and adding a new donation type would require code changes.
Instead, the entire mapping lives in config/products.json:
```json
{
"shelter-donations": {
"ability": "shelter-donations/create",
"input_mapping": {
"amount": { "source": "item_total" },
"allocation": {
"source": "attribute",
"key": "preferred-allocation",
"transform": "normalize_allocation",
"default": "general-fund"
},
"dedication": { "source": "order_meta", "key": "_sd_dedication" },
"is_anonymous": {
"source": "order_meta",
"key": "_sd_is_anonymous",
"transform": "boolean",
"default": false
},
"campaign_id": { "source": "order_meta", "key": "_sd_campaign_id" }
}
},
"shelter-memorials": {
"ability": "shelter-memorials/create",
"input_mapping": {
"honoree_name": { "source": "order_meta", "key": "_sd_honoree_name" },
"memorial_type": {
"source": "attribute",
"key": "in-memoriam-type"
},
"tribute_message": {
"source": "order_meta",
"key": "_sd_tribute_message"
},
"notify_family": {
"source": "composite",
"fields": {
"enabled": { "source": "order_meta", "key": "_sd_notify_family_enabled" },
"name": { "source": "order_meta", "key": "_sd_notify_family_name" },
"email": { "source": "order_meta", "key": "_sd_notify_family_email" }
}
}
}
}
}
The Product_Mapper class reads this config and turns it into the input array passed to the corresponding ability. It supports several “source” types — item_total, attribute, order_meta, order_field, item_meta, product_meta, static, and composite (for nested fields like the family notification block) — plus optional transform functions and default values.
The win here isn’t that the input mapping system is unusual — it’s that adding a new donation type is a JSON edit, not a code change. Want to add a “wishlist item purchase” donation type that maps to a specific allocation? Add an entry to products.json, point it at an existing or new ability, and the order processor handles the rest. The plugin doesn’t need to learn about the new product type at the PHP level.
I’ll be honest: this is the kind of pattern that’s easy to over-engineer. For a plugin with three product types I could have just written three PHP functions and called them from a switch statement. I went with the config-driven approach because (a) I expect to add more product types over time, and (b) I’d already built the same pattern for the Petstablished sync plugin and was happy with how it scaled. The cost of the abstraction is low; the benefit compounds with each new type.
Decision 3: the memorial wall
I want to spend some time on this section because it’s the feature that has the clearest non-technical story, and it’s the second-most important thing the plugin does after actually accepting donations.
The Venango County Humane Society receives around 300 memorial donations a year. Most of those are “in memory of” tributes — someone loses a beloved pet or family member and makes a donation to the shelter in their memory. The shelter’s previous practice was to maintain a flat list / spreadsheet of memorials on their website, manually, by hand. As you can imagine, this was a real pain to keep up: every memorial meant editing a static page, every typo meant re-editing it, and it scaled badly with volume.
The shelter would have accepted the same flat list / spreadsheet. What they got instead is the memorial wall block: a paginated, searchable, year-filterable grid of all public memorial tributes, with each card showing the honoree’s name, the tribute message, the donor’s name (unless they chose to be anonymous), and the date. The block uses the WordPress 6.9 Interactivity API for client-side pagination — search and filter without page reloads, with bookmarkable URL state via query parameters.
<!-- wp:vcpa/memorial-wall {
"columns": 3,
"perPage": 12,
"showSearch": true,
"showYearFilter": true,
"paginationStyle": "numbered"
} /-->
The data flows like this: a memorial donation is placed via the memorial form block in the donation flow; it goes through WooCommerce checkout; the order processor extracts the honoree name, message, and donor info; a new sd_memorial post is created; and the memorial wall block displays it on the next page render. End to end, the only “manual” step is the donor filling out the form. The shelter staff doesn’t touch anything.
Three things about this section worth knowing:
The memorial wall grew out of a separate plugin. I originally built in-memoriam-donations-manager as a standalone plugin and later folded its functionality into the donations plugin. The separate plugin still exists in the same parent directory but it’s deprecated. The lesson: when two plugins are doing related work for the same nonprofit, fold them together early. The architectural overhead of maintaining two plugins for one shelter is real and not worth it.
Anonymous donors are first-class. The form has an “anonymous” checkbox; when it’s checked, the donor name is replaced with “Anonymous” on the wall but the donation itself is still recorded under the donor’s account in the database. The display layer respects the privacy choice; the data layer doesn’t lose information.
The donor name is denormalized into the memorial post. When the memorial is created, the donor’s display name is copied into a _sd_donor_display_name meta field on the memorial post. This is unusual — the “right” way would be to look up the donor by ID at display time. The reason for the denormalization is search: the memorial wall has a search box, and searching across memorials by donor name without N+1’ing into the donor table requires the donor name to be on the memorial post itself. This is a deliberate performance decision that I’m noting here because it’s the kind of thing that looks wrong on first read.
Decision 4: Abilities API as the action layer
Same pattern as the Petstablished sync plugin (which I wrote about in a previous post in this series), so I’ll keep this section short. Every action the plugin can take is registered as a WordPress 6.9 Ability with a name, an input schema, a permission callback, and an execute function:
shelter-donations/create— create a new donation recordshelter-donations/list— list donations with filtersshelter-memorials/create— create a new memorialshelter-memorials/list— list memorials with filtersshelter-memberships/create— create a new membershipshelter-donors/get— get a donor by emailshelter-donors/upsert— create or update a donorshelter-reports/summary— aggregate reporting- … and several more
When a WooCommerce order completes, the order processor doesn’t write to the database directly — it executes abilities. When the memorial wall block fetches data, it executes the shelter-memorials/list ability. When the admin reports page generates a summary, it executes the shelter-reports/summary ability.
The benefit, as in the Petstablished plugin, is that there’s one implementation of “create a donation” in the entire codebase, and the order processor, the REST API, the import/export tools, and the internal data integrity checks all share it. If I change how donations are stored, the change happens in one place.
The Abilities API also gives me a clean permission model. Most abilities are declared as internal (only callable from the plugin itself) or admin_only. The few that need front-end access — for the memorial wall block, the donor stats block, the donation form’s campaign list — go through a thin REST wrapper, the same pattern I documented in the Petstablished sync post.
Decision 5: standard WooCommerce variable products with custom prices
The plugin doesn’t define a custom WooCommerce product type. Donations, memberships, and memorials are all standard variable products with specific attributes:
shelter-donations(product) → variations for “General Fund,” “Medical Care,” “Food & Supplies,” etc., via thepreferred-allocationattributeshelter-memberships(product) → variations for tier names (“Single $10,” “Family $25,” “Contributing $50”) via themembership-levelattributeshelter-donations-in-memoriam(product) → “Person” or “Pet” variations via thein-memoriam-typeattribute
All of these products are created automatically on plugin activation by an Activator class. They’re marked virtual (no shipping), no inventory tracking, and tax-exempt. The product images are inline SVGs generated at activation time.
The clever part is that these are variable products with dynamic prices. WooCommerce normally requires variations to have fixed prices, but the donation forms let the donor enter any amount. The plugin uses the woocommerce_before_calculate_totals hook to override the line item price before the cart total is calculated:
add_action(
'woocommerce_before_calculate_totals',
function ( $cart ) {
foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
if ( isset( $cart_item['sd_custom_amount'] ) ) {
$cart_item['data']->set_price( $cart_item['sd_custom_amount'] );
}
}
}
);
This works, and it’s the standard way to do “name your own price” products in WooCommerce, but it’s the kind of thing that would be nice to formalize. A custom product type might be cleaner long-term; for now, the standard variable product approach has the benefit of working with every WooCommerce extension out of the box.
What’s still open
Five features I haven’t built yet, in rough order of how often they get requested by nonprofits:
1. Recurring donations. This is the single biggest gap. Monthly donors are roughly five to ten times more valuable to a nonprofit over their lifetime than one-time donors, and every commercial donation plugin paywalls recurring giving as their flagship premium feature. WooCommerce Subscriptions is a natural fit and the plugin’s order processor already has a hook for woocommerce_subscription_renewal_payment_complete — the missing work is the membership-renewal-handling logic, the donor-facing UI for managing recurring donations, and the failure handling for declined renewals. This is the feature I most want help building, and the one most likely to make this plugin an actual replacement for paid alternatives.
2. Donor accounts and a self-serve donor portal. Currently, donors have no way to log in, see their giving history, update their contact info, or change a recurring donation. WooCommerce’s “My Account” page is registered but it’s mostly empty for donor-specific use cases. I’m planning to pick this up next.
3. Tax receipts. Year-end donor summaries (PDF, optionally emailed) and individual donation receipts are useful for both donors and shelter staff. The shelter currently handles this manually, which is the kind of work that scales badly with donor count. There’s a stub donation-receipt email template in the plugin but no PDF generation behind it.
4. Employer matching support. Many corporate donors check “is this match-eligible?” before giving. A simple “your employer matches donations” question on the donation form, plus a list of known match-eligible employers that the shelter can maintain, would unlock a meaningful slice of corporate giving without requiring full matching automation.
5. Business sponsor logo display. The plugin already collects business sponsor logos at checkout (with admin moderation) but doesn’t have the front-end blocks to actually display them anywhere. Picking this up alongside donor accounts.
If any of those sound interesting and you’d like to take a swing, the repo welcomes issues and pull requests. The codebase has good separation of concerns, the abilities pattern means most new features can be added without touching unrelated code, and I’m happy to pair on design decisions before anyone starts implementing.
A few smaller things worth knowing
The plugin requires WordPress 6.9 and WooCommerce. The 6.9 requirement comes from the Abilities API; the WooCommerce requirement is obvious. This is a meaningful constraint — some smaller shelters don’t have 6.9 yet — but it’s the right constraint, because the plugin’s architecture wouldn’t work without abilities.
There are no automated tests yet. Same admission as the companion plugin and the sync plugin. The donation flow in particular would benefit from end-to-end tests, and the order processor is the right candidate for a unit-testable harness. If you want to contribute a test setup, this is the most valuable kind of contribution you can make.
Performance is decent but not benchmarked. The plugin uses an Entity Hydrator pattern with N+1 prevention (same approach as the Petstablished sync plugin), but I haven’t actually measured page load times under realistic donation volumes. If you’re using this on a site with thousands of memorials, please open an issue with your numbers — even “no problems noticed” is useful data.
There’s a legacy data migration tool for shelters coming from other donation plugins. It’s currently scoped to the specific predecessor I wrote (shelter-donations-wc-simple) but the framework is general — if you’re moving from GiveWP, Charitable, or another WC-based plugin, it should be possible to write an importer module.
Why I built this
I want to close on something I’ve been thinking about a lot, both for this plugin and for the others in this series of posts.
Nonprofit software has a particular flavor of business model that’s worth naming explicitly. Most commercial nonprofit tools — donation platforms, donor management CRMs, email marketing systems, event management — operate on the principle that the most important features should cost money, and that nonprofits will pay for them because they have to. This is a perfectly rational business model. It’s also, collectively, a tax on the entire nonprofit sector. Money that could be spent on programs is instead spent on software vendors.
Open source has historically been bad at addressing this. The free WordPress donation plugins exist, but they’re free because they’re deliberately limited, with the expectation that serious nonprofits will upgrade. The premium tiers are not “for the very largest nonprofits with complex needs” — they’re for anyone who actually wants to run a donation program. The pricing is not designed to reflect cost; it’s designed to capture value from organizations that can’t afford to negotiate.
A genuinely open-source alternative is uncomfortable for everyone involved. It threatens the commercial vendors’ business model, it asks nonprofits to use software that doesn’t have a 24/7 support hotline, and it puts the burden of maintenance on developers who have to find some other way to fund their time. It is not obviously sustainable. I am building this on a small monthly stipend from a shelter that has fewer than sixty animals at a time, and the math of “how does this scale to other shelters” is genuinely unclear to me.
But I think it’s worth doing anyway, for three reasons:
- The technical foundations have gotten dramatically better. WordPress 6.9’s Abilities API, WooCommerce’s maturity as a payments platform, the Block Editor’s evolution into a real content management surface, the Interactivity API — these are the building blocks that make a serious open-source donation plugin tractable for one developer to build. Five years ago this would have been a much bigger project.
- The collective value is much larger than the per-shelter value. A plugin that saves one shelter $200 a year in software fees is not interesting. A plugin that saves a thousand shelters $200 a year each is $200,000 a year of recovered nonprofit budget. The marginal cost of a shelter adopting an existing plugin is nearly zero; the marginal value is real.
- Someone has to start. Open-source nonprofit infrastructure doesn’t exist by default. It exists because individual developers decide to build it and then maintain it. I am one such developer building one such piece of infrastructure for one such nonprofit, and I’m publishing it openly in the hope that others will find it useful.
If you work for or with a nonprofit and you’d consider trying this plugin, I’d love to hear from you. If you’re a developer who’s frustrated by the same paywalled-features dynamic and you want to help, even better.
The repo is at github.com/jwincek/vcpahumane-wc-donations. Issues, PRs, and emails are welcome.