=== Donation Router for GiveWP & PayPal – Route Donations by Donor Country ===
Contributors: mralaminahamed
Donate link: https://alaminahamed.com/donate
Tags: givewp, paypal, donations, donation routing, charity
Requires at least: 6.4
Tested up to: 7.0
Requires PHP: 8.1
Stable tag: 1.2.0
License: GPL-2.0-or-later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Route GiveWP donations to multiple PayPal Business accounts by donor country. Multi-entity charities, automatic credential switching.

== Description ==

Donation Router for GiveWP & PayPal extends the GiveWP PayPal Donations gateway so that a single WordPress installation can accept donations through several PayPal Business accounts, automatically routing each transaction to the correct account based on the donor's declared country.

This plugin is an independent, third-party extension. It is not affiliated with, endorsed by, or sponsored by GiveWP (StellarWP) or PayPal. The names "GiveWP" and "PayPal" appear here solely to describe compatibility with those products.

**You need at least two PayPal Business accounts and one GiveWP donation form per country for this plugin to do anything.** With a single PayPal account there is nothing to route, and GiveWP's own PayPal Donations setup already covers you.

This plugin is designed for organisations that operate two or more legally distinct charity entities — for example a US 501(c)(3) and a Canadian CRA-registered charity — that share a single donation website but must process payments through separate PayPal accounts and issue separate tax receipts.

**Key features:**

* **Multi-account admin panel** — Add and manage any number of PayPal Business accounts under Donation Forms → PayPal Accounts. Each account stores legal entity details (name, tax ID, address, country) alongside AES-256-GCM encrypted API credentials.
* **Country routing rules** — Define which PayPal account and GiveWP form handles donations from each country. One active rule per country, fully configurable from the admin.
* **Country selector popup** — A donor-facing modal popup (shortcode `[ador_country_selector]`) asks donors to identify their country before proceeding to checkout. Selection is stored in sessionStorage to avoid repeated prompts.
* **Runtime credential routing** — The plugin intercepts GiveWP's IoC container at payment time, substituting the correct PayPal credentials transparently. No GiveWP core files are modified.
* **Per-account webhook handling** — Each PayPal account receives its own webhook endpoint (`/wp-json/ador/v1/webhook/{account_id}`) with independent HMAC signature verification.
* **Per-entity email tags** — Seven GiveWP email template tags populate receipt emails with the correct legal entity name, address, tax ID, tax-deductibility statement, and a unique per-entity receipt number.
* **PDF receipts** — Optional on-demand PDF receipt download on the GiveWP success page, generated by dompdf.
* **Connect with PayPal** — Onboard any number of PayPal Business accounts through GiveWP's own approved-partner flow. No separate PayPal app to configure, and no pasting client secrets by hand.
* **Import GiveWP's existing account** — One click adopts the PayPal account GiveWP is already connected to, reusing its stored credentials, so it becomes routable and receipt-customisable without a second OAuth.
* **Live and sandbox side by side** — Every account carries an environment. Routing only applies an account whose environment matches GiveWP's current mode, so a sandbox account can never take a live donation.
* **Setup readiness panel** — The Accounts screen reports whether donations are actually routing: missing rules, accounts in the wrong environment, rules pointing at deactivated accounts or unpublished forms, missing webhooks, and donations that bypassed routing. It collapses to a single line once nothing needs attention.
* **Per-account webhook registration** — Register each account's webhook and its event subscriptions at PayPal from the admin, using that account's own credentials.
* **Security-first design** — AES-256-GCM credential storage, nonce protection, capability checks, webhook signature verification, and `$wpdb->prepare()` throughout.

= How it works =

1. A donor lands on your donation page and the country selector asks which country they are giving from. Their answer is remembered for the rest of the visit.
2. The plugin looks up the routing rule for that country to find the donation form and the PayPal Business account that should receive the gift.
3. When the donor pays, the correct account's credentials are substituted into GiveWP's PayPal gateway for that request only. PayPal charges the right legal entity — nothing is transferred between accounts afterwards.
4. The donation records which account processed it and which country the donor selected.
5. PayPal notifies the site on that account's own webhook endpoint, and the capture, denial or refund is recorded against the donation.
6. The receipt email renders that entity's registered name, address, tax ID, tax-deductibility statement and its own receipt number.

If a country has no routing rule, nothing breaks: the donation proceeds through GiveWP's default PayPal account, and the readiness panel reports that it happened.

= More from us =

Other free plugins by the same author, all on WordPress.org.

**For any site**

* [Swift Menu Duplicator](https://wordpress.org/plugins/swift-menu-duplicator/) - Duplicate menus in one click, snapshot revisions, export and import, WP-CLI and REST.
* [Author Profile Blocks](https://wordpress.org/plugins/author-profile-blocks/) - Author and team profiles as Gutenberg blocks — grid, carousel, list and single.

**For the WordPress AI Client**

* [AI Provider for OpenCode Zen](https://wordpress.org/plugins/alamin-ai-provider-for-opencode-zen/) - One API key, 57 models including GPT-5, Claude and Gemini 3, for the WordPress AI Client.
* [AI Provider for MiniMax](https://wordpress.org/plugins/alamin-ai-provider-for-minimax/) - MiniMax M2 and M3 models for text generation, for the WordPress AI Client.

== Installation ==

= Before you start =

This plugin extends GiveWP and does nothing on its own. You will need:

* **GiveWP**, installed and active, with its **PayPal Donations** gateway enabled under GiveWP → Settings → Payment Gateways. Without that gateway there is no PayPal checkout to route.
* **Two or more PayPal Business accounts.** With a single account there is nothing to route — GiveWP's own PayPal Donations setup is all you need.
* **A GiveWP donation form for each country you want to route.** A routing rule points at a specific form, so the forms have to exist before you can create the rules.
* `AUTH_KEY` and `SECURE_AUTH_SALT` set to strong, unique values in `wp-config.php`. Credentials are encrypted with a key derived from them; a standard WordPress install already has these.

= Installing =

From **Plugins → Add New**, search for "Donation Router for GiveWP", then install and activate. Everything the plugin needs is bundled — there is no Composer or build step.

To install from source instead, clone the repository into `wp-content/plugins/`, then run `composer install --no-dev --optimize-autoloader` and `yarn install --immutable && yarn build`. Only the source checkout needs this; the released package does not.

= Setting it up =

1. Go to **Donation Forms → PayPal Accounts**. The readiness panel at the top of this screen tracks everything below and tells you what is still outstanding.
2. Add your first PayPal Business account — **Connect with PayPal** (recommended: uses GiveWP's approved partner, so there is no PayPal app to create and no client secret to paste), **Import from GiveWP** if GiveWP is already connected to one, or by entering a Client ID and Secret from your own PayPal Developer app.
3. Fill in that account's legal entity details — registered name, tax ID, and address. These are what appear on its receipts, and they are the reason to run more than one account.
4. Repeat for each entity. Each account carries an environment (live or sandbox); routing only uses an account whose environment matches GiveWP's current mode.
5. Under the **Country Routing** tab, add a rule per country: the donor's country, the GiveWP form they should see, and the account that should receive the money. One active rule per country.
6. Copy the `[ador_country_selector]` shortcode — shown on the Country Routing tab with a copy button — onto your donation landing page. This is what asks donors which country they are giving from.
7. Register each account's webhook, either with the **Register webhook** action on the Accounts list or by pasting the URL from **Webhook Setup** into your PayPal Developer app. Without it, captures and refunds are never recorded against the donation.
8. Under the **Settings** tab, review the tax-deductibility statement for each account and tick the legal-review acknowledgement once a qualified adviser has approved them.

When the readiness panel collapses to a single "Ready" line, the setup is complete. Make one small live donation and confirm it appears under the account you expected.

For detailed configuration instructions, see [operations.md](https://github.com/mralaminahamed/donation-router-for-givewp-paypal-donations/blob/trunk/resources/docs/operations/operations.md) in the GitHub repository.

== Frequently Asked Questions ==

= Do I need a PayPal Developer app for every account? =

No. **Connect with PayPal** on the Add Account screen onboards a PayPal Business account through GiveWP's own approved partner, so no separate PayPal app or client secret is needed. If GiveWP is already connected to a PayPal account, **Import from GiveWP** adopts it without a second authorisation. Manual entry of a Client ID and Secret remains available.

= Does this work with recurring donations? =

Not yet — and this matters if you sell them. GiveWP's PayPal Commerce gateway supports subscriptions via the paid Recurring Donations add-on, which creates a PayPal billing plan through its own code path. This plugin routes credentials on the one-time donation path only, so with that add-on active a recurring donation's plan is created under GiveWP's default PayPal account rather than the account for the donor's country. Renewals then bill at PayPal against that plan, so nothing later can redirect them. One-time donations route correctly. If you need per-country routing for recurring gifts, treat it as unsupported for now.

= All my donations are going to the wrong PayPal account. Why? =

Almost always an environment mismatch. Every account here is either **live** or **sandbox**, and routing only applies an account whose environment matches GiveWP's current mode. If GiveWP is in test mode and your accounts are live — or the reverse — every account is skipped and the donation falls through to GiveWP's own default PayPal account. The account still shows as **Active**, because it is; it is just unusable in that mode.

The readiness panel on the Accounts screen reports this directly. The other causes it detects are a country with no active routing rule, a rule pointing at an account that was deactivated afterwards, and a rule whose donation form was deleted or unpublished. All four produce the same symptom: the money quietly reaches the default account.

= My country has no tax-deductibility statement. What do I do? =

Write your own. The plugin ships default statement text for the **United States** (501(c)(3)) and **Canada** (CRA) only; every other country starts blank, because the correct wording is jurisdiction-specific and getting it wrong is a compliance problem rather than a formatting one.

Edit an account and fill in its **Tax-Deductibility Statement**, using the email tags listed beneath the field to pull in that entity's name, tax ID and receipt number. Have a qualified tax or legal adviser approve the wording for each entity before you take live donations, then tick the legal-review acknowledgement under **Settings**. That acknowledgement is a reminder for your own records — the plugin does not block donations while it is unticked.

= Can one PayPal account receive donations from several countries? =

Yes. The constraint is one active rule per *country*, not per account — the `country_code` column carries a unique key. Point as many countries as you like at the same account. A single European entity serving a dozen countries is a normal setup.

= What happens if I change AUTH_KEY or SECURE_AUTH_SALT? =

Every stored PayPal credential becomes permanently unreadable. The encryption key is derived from those two constants, so rotating either one means the ciphertext can no longer be decrypted — there is no recovery path, and you must re-enter or re-connect each account.

The Accounts screen detects this and tells you what happened rather than failing obscurely. If you plan to rotate salts, have your credentials to hand first, or re-run **Connect with PayPal** for each account afterwards.

= How do refunds work? =

Refund a donation the normal GiveWP way and the plugin routes the refund back through the account that originally took the payment, using the account recorded on that donation rather than whichever account is currently default.

Two situations stop a refund with an explanatory message instead of failing at PayPal: the original account has since been deactivated (reactivate it first), or that account's environment no longer matches GiveWP's current mode (switch GiveWP to that mode first). Both are deliberate — the alternative is an opaque authentication error from PayPal.

= What happens if I deactivate or delete an account that still has routing rules? =

Deactivating an account leaves the rules pointing at it untouched, and donations for those countries fall through to GiveWP's default PayPal account. The readiness panel flags this specific situation, since nothing else about the admin would show it. Repoint or deactivate the affected rules as well.

Deleting an account asks for confirmation and deregisters its webhook at PayPal first. Donations already processed keep their record of which account handled them, so past receipts remain correct.

= Is geolocation required? What does it actually do? =

No, it is optional and off unless you enable it. When on, it pre-selects a country in the selector modal as a *hint* — the donor's own choice always wins, and no country is ever chosen on their behalf. If no hint is available the modal simply opens with nothing pre-selected.

Three providers are supported: Cloudflare's `CF-IPCountry` header, the MaxMind GeoIP Detect plugin, and any plugin implementing WordPress's `geoip_country_code` filter. Hints are cached for an hour per visitor.

= How are receipt numbers generated? Can there be gaps? =

Each account gets its own sequence, restarting annually, formatted as country-year-sequence — for example `US-2026-00001` and `CA-2026-00001` run independently.

Gaps are possible and are not a fault. Numbers are allocated atomically at the moment a donation record is created, so an allocated number is never reused even under simultaneous donations; but if a donation is later deleted, its number is not recycled. If your jurisdiction requires strictly gapless receipt numbering, verify that this satisfies your auditor before relying on it.

= What does the plugin store on each donation? =

Three pieces of metadata: which account processed it (`_ador_processing_account_id`), the country the donor selected (`_ador_donor_country`), and the receipt number allocated for it (`_ador_receipt_number`). No other donor data is stored by this plugin — everything else about a donation lives in GiveWP as usual.

The account that processed a donation is recorded at payment time and is not editable afterwards, because it reflects which legal entity actually received the money.

= What happens when I uninstall? =

Deleting the plugin through the Plugins screen removes its two database tables, its options, its per-account receipt templates and sequences, and its transients. Deactivating alone changes nothing, so you can deactivate safely while troubleshooting.

Donation records, receipt numbers already written to donation metadata, and GiveWP's own data are untouched — but once the accounts table is gone, receipt email tags for past donations resolve to empty strings. Export anything you need first.

= Which payment gateways does this plugin support? =

This plugin supports the GiveWP PayPal Donations gateway (also known as PayPal Commerce / PPCP) only. It does not affect or support other GiveWP gateways such as Stripe, Authorize.net, or PayPal Standard.

= How many PayPal accounts can I add? =

There is no hard limit. Each account needs its own credentials and its own webhook, but you no longer have to create a PayPal Developer app per account: **Connect with PayPal** onboards each one through GiveWP's approved partner and stores the resulting credentials for you, and the webhook can be registered from the Accounts list. Entering a Client ID and Secret from your own Developer app is still supported if you prefer it.

= How are PayPal credentials stored? Is it safe? =

Yes. PayPal Client IDs and Client Secrets are never stored in plaintext. They are encrypted with AES-256-GCM using a key derived via HKDF-SHA256 (RFC 5869) from your WordPress `AUTH_KEY` and `SECURE_AUTH_SALT` constants (32-byte key). The random nonce and GCM authentication tag are stored alongside the ciphertext as a base64 blob. Credentials are never written to any log. If `AUTH_KEY` or `SECURE_AUTH_SALT` are changed, stored credentials become unreadable and must be re-entered.

= Can I test with PayPal sandbox accounts before going live? =

Yes, and it is worth doing. Add sandbox accounts exactly the way you add live ones — **Connect with PayPal** works against the sandbox too, and manual Client ID and Secret entry still works if you prefer. Each account records the environment it belongs to, so live and sandbox accounts for the same merchant can sit side by side without interfering.

Switch GiveWP into test mode while you do this: routing only uses accounts whose environment matches GiveWP's current mode, so sandbox accounts are inert in live mode and vice versa. Sandbox webhooks need a publicly reachable HTTPS URL, so use a tunnelling tool such as ngrok for local development. See [operations.md](https://github.com/mralaminahamed/donation-router-for-givewp-paypal-donations/blob/trunk/resources/docs/operations/operations.md) for a full sandbox testing procedure.

= What happens if a routing rule is missing for a donor's country? =

If no active routing rule exists for the selected country, the credential router falls back to GiveWP's primary PayPal account. The donation is not blocked — it proceeds normally using the default account. An error entry is written to the PHP error log with prefix `[ADOR]` for the operator's attention.

= Does this plugin modify GiveWP core files? =

No. The plugin hooks into GiveWP exclusively through WordPress filters, actions, and GiveWP's public IoC container (`give()`). No GiveWP core files are modified.

= Can I use this plugin with GiveWP's built-in email receipts? =

Yes. The plugin registers additional GiveWP email template tags that you can insert into your existing GiveWP email templates (GiveWP → Settings → Emails). Tags include `{paypal_account_entity_name}`, `{paypal_account_entity_tax_id}`, `{paypal_account_receipt_number}`, and four others. Tags return an empty string for donations not routed through this plugin, so existing templates work without changes.

= The PDF receipt is not generating. What should I check? =

First check that **PDF Receipts** is enabled under the plugin's **Settings** tab — it is off by default. dompdf ships inside the released package, so there is nothing to install; if you are running from a source checkout instead, run `composer install` to fetch it. Then look in the PHP error log for a line beginning `[ADOR] PDF receipts disabled:`, which names the underlying cause.

If the button appears but downloading returns "You do not have permission", update to the latest version — releases before this one refused the download to everyone except administrators.

== Screenshots ==

1. PayPal accounts — every configured PayPal Business account with its legal entity, mode, and Active/Inactive status, plus per-row Edit, Deactivate and Delete actions.
2. Connect an account — the Connect with PayPal onboarding flow, which saves each connected Business account as its own managed account rather than overwriting a single global one.
3. Country routing — the rules mapping each donor country to the donation form and PayPal account that should receive it. One active rule per country.
4. Webhook setup — the unique per-account webhook URL to register in PayPal, with one-click copy and the list of event types to subscribe to.
5. Choose your country — the donor-facing modal (shortcode `[ador_country_selector]`), naming the legal entity that will receive the gift and its tax treatment.
6. Plugin settings — the country selector popup, geolocation provider, PDF receipts, and the legal-review acknowledgement.
7. Receipts and legal details — per-account legal entity details and the tax-deductibility statement editor, with the email tags that resolve to this account's name, tax ID, address and receipt number.

== External services ==

This plugin sends data to PayPal's REST API to authenticate with PayPal Business accounts and to verify the HMAC-SHA256 signatures of inbound webhook events.

**PayPal REST API** (`https://api-m.paypal.com`, `https://api-m.sandbox.paypal.com`, `https://api.paypal.com`, `https://api.sandbox.paypal.com`)

* **What is sent:** PayPal Client ID and Client Secret (decrypted in memory at request time, never stored in plaintext) are exchanged for OAuth 2.0 bearer tokens during donation checkout. Webhook event payloads are forwarded to PayPal's `/v1/notifications/verify-webhook-signature` endpoint for signature verification.
* **When it is sent:** Only when a donation is being processed through a PayPal Business account configured in this plugin, or when PayPal sends a webhook event to this site.
* **Service terms:** [PayPal User Agreement](https://www.paypal.com/us/legalhub/useragreement-full) | [PayPal Privacy Statement](https://www.paypal.com/us/legalhub/privacy-full)

== Upgrade Notice ==

= 1.2.0 =

Fixes PDF receipt downloads, which previously worked only for administrators — every other donor was refused. Also renames the donor country selector's CSS classes from `givewp-country-selector-*` to `ador-country-selector-*`: if your theme or custom CSS targets the old names, update them before upgrading.

== Changelog ==

= 1.2.0 - 2026-08-17 =

**Added**

* Setup readiness panel on the PayPal Accounts screen — ten checks computed from the site's real state, reporting whether donations are actually being routed and linking to the screen that fixes each problem. It collapses to a single line once nothing is failing.
* Detection for three failures the admin could not previously show: an account whose environment does not match GiveWP's test-mode setting (skipped by routing, yet still badged Active), a routing rule pointing at an account that was deactivated afterwards, and a rule whose donation form was deleted or unpublished. In each case donations silently reach GiveWP's default PayPal account.
* A record of donations that bypassed routing, shown in the panel with the form, the reason and when it last happened. Previously this appeared only in the PHP error log.
* Contextual Help tabs on the plugin screen covering setup order, country routing, and the receipt email tags.
* The donor selector shortcode is now shown on the Country Routing tab with a copy button — it previously appeared nowhere in the admin.

**Changed**

* The legal-review acknowledgement no longer states that donations are blocked until it is ticked. Nothing in the payment or receipt path enforced it, so the notice now describes what the plugin actually does.
* New plugin icon, banners and social card, rebuilt on the WordPress admin colour palette so the listing matches the plugin's own interface.
* Listing screenshots now show the plugin's real interface in a branded frame.
* The donor country selector's CSS classes are renamed from `givewp-country-selector-*` to `ador-country-selector-*`. If your theme or custom CSS targets the old names, update them.

**Fixed**

* PDF receipt downloads only worked for administrators. The permission check read a GiveWP meta key that GiveWP 4.x no longer writes, so every donor was refused, and the download link was missing the email argument the check authorises a guest donor against.
* The Accounts screen no longer fails with a fatal error when stored credentials cannot be decrypted — for example after `AUTH_KEY` or `SECURE_AUTH_SALT` is changed. It now explains what happened and what to do.
* The bypassed-routing notice names the form that failed most recently rather than the first one recorded.
* A healthy readiness panel no longer turns red when expanded.
* PayPal environment names are now translatable.

= 1.1.0 - 2026-07-06 =

**Added**

* Per-account live/sandbox environment — manage the same PayPal merchant independently in live and sandbox; routing only applies an account whose mode matches the site's current GiveWP mode.
* Import GiveWP's already-connected PayPal account in one click (reuses stored credentials, no second OAuth).
* Re-sync an imported account's credentials from GiveWP (same-merchant, same-mode guarded).
* Re-activate a deactivated account directly from the Accounts list.
* Delete a PayPal account from the Accounts list (confirmation required; deregisters the account's PayPal webhook first).
* GiveWP version compatibility guard — refuses below GiveWP 3.0 and warns on untested GiveWP majors (`ador_block_on_untested_givewp` filter to fail closed).

**Changed**

* Accounts list gains a Mode column (live/sandbox badge).
* The Import button is always shown on the Accounts list for discoverability — disabled with an inline reason when there is nothing to import (GiveWP not connected, or the account is already imported).
* Flat, minimal admin restyle built on the WordPress core colour palette — status/mode badges, buttons, modals, notices, and the receipt templates. Primary calls to action now use WordPress's native primary-button style.
* Schema 1.1.0: adds the `mode` column and a composite `UNIQUE(merchant_id, mode)` constraint.
* Schema now migrates on plugin update, not only on activation.

**Fixed**

* List-table bulk actions now reach their handlers. The bulk **Apply** collided with `WP_List_Table`'s own action field and dispatched to no handler, dying with an empty error page; the routing field is now printed after the table so it wins.
* Refunds to routed accounts no longer fail silently — the PayPal access token is re-minted at refund time instead of reusing the stale connect-time token, and a refund whose account mode does not match the site mode is blocked with an actionable admin error.
* Inbound webhooks for a genuinely unknown account return 404, while a real processing error now logs and returns 500 instead of being masked as not-found.
* Cached PayPal access tokens are encrypted at rest and re-minted on decrypt failure.
* Credential routing no longer fatals against GiveWP's PayPal Commerce contract (overrider parameter-type compatibility).
* Per-account credentials no longer leak to `wp_options` when GiveWP refreshes an access token.
* Inbound PayPal webhooks read the WP-canonicalised transmission headers, fixing signature verification and dropped events.

**Removed**

* The unused `ador_paypal_mode` option.

= 1.0.0 - 2026-05-07 =

**Added**

* Multi-account admin panel (Donation Forms → PayPal Accounts) with Accounts, Country Routing, and Settings tabs.
* Account add/edit form with AES-256-GCM encrypted credential storage.
* Country routing rules with add/edit/bulk-deactivate actions.
* Custom database tables `{prefix}ador_accounts` and `{prefix}ador_country_routing` with schema versioning.
* `PayPal_Credential_Router` intercepting GiveWP's IoC container before every PayPal AJAX payment request to substitute per-account credentials.
* IoC container rebinding for both `MerchantDetail::class` and `MerchantDetails::class` covering all GiveWP PayPal credential resolution paths.
* Refund routing with inactive-account guard.
* Per-account REST webhook endpoint `POST /wp-json/ador/v1/webhook/{account_id}` with HMAC-SHA256 signature verification and idempotency checking.
* Country selector popup shortcode `[ador_country_selector]` with sessionStorage persistence.
* Geolocation hint (non-binding IP-based country pre-selection).
* Seven GiveWP email template tags for per-entity receipt details.
* Per-entity sequential receipt numbers allocated atomically via `FOR UPDATE` lock.
* Default tax-deductibility statement templates for US and Canada.
* PDF receipt generation via dompdf behind `ador_pdf_receipts_enabled` option flag.
* Action hooks `ador_credentials_routed` and `ador_webhook_processed`.
* PHPStan level 8, WPCS 3.x, PHPUnit 9.6 with Brain\Monkey test suite.

**Security**

* AES-256-GCM encryption for all stored PayPal credentials with HKDF-SHA256 key derivation.
* PayPal HMAC-SHA256 webhook signature verification on all inbound webhook events.
* WordPress nonce protection on all admin form submissions and AJAX endpoints.
* `current_user_can( 'manage_give_settings' )` capability checks on all admin-only actions.
* `$wpdb->prepare()` for all parameterised database queries.
