# PPL Affiliates for WooCommerce - Developer Guide

## Architecture

Every feature is a service class registered in the container by `PPL_AFW_Bootstrap`.
Services expose a `register()` method that hooks WordPress; all business logic
lives in service and repository classes, never in templates or inline callbacks.

Container access: `ppl_afw()->container->get( 'commissions' )`.

Registered services: `install`, `affiliates`, `tracking`, `commissions`,
`payouts`, `mlm`, `fraud`, `creatives`, `tiers`, `stats`, `notifications`,
`emails`, `rest`, `admin`, `admin.actions`, `frontend`, `privacy`, plus
repositories (`affiliates.repository`, `visits.repository`,
`campaigns.repository`, `referrals.repository`, `payouts.repository`,
`creatives.repository`, `tiers.repository`) and `settings`.

The whole affiliate experience runs from one shortcode, `[ppl_afw_portal]`:
it shows the signup form to visitors and non affiliates and the full
dashboard once the affiliate is active. `[ppl_afw_registration]` renders just
the signup form if a standalone page is wanted.

## Database

Custom tables prefixed `{$wpdb->prefix}ppl_afw_`, created via dbDelta with a
versioned `ppl_afw_db_version` option: affiliates, visits, referrals, payouts,
creatives, campaigns, tiers, mlm_relations (closure table), fraud_log,
notifications, lifetime_links, activity_log, stats_daily. Table names are only
resolved through `PPL_AFW\Core\Schema::table()` which whitelists keys.

## Hook reference

Actions:

| Hook | Args | Fired when |
|---|---|---|
| `ppl_afw_loaded` | Container | All services registered |
| `ppl_afw_visit_recorded` | visit_id, affiliate | A referral click is stored |
| `ppl_afw_affiliate_registered` | affiliate_id, fields | Application submitted |
| `ppl_afw_affiliate_approved` | affiliate_id | Affiliate activated |
| `ppl_afw_affiliate_rejected` | affiliate_id, note | Application rejected |
| `ppl_afw_referral_created` | referral_id, affiliate, order | Commission created |
| `ppl_afw_referral_approved` | referral_id, referral | Commission payable |
| `ppl_afw_payout_completed` | payout_id, affiliate | Payout finished |
| `ppl_afw_tier_promoted` | affiliate, tier | Auto promotion applied |
| `ppl_afw_settings_updated` | merged, values | Settings saved |
| `ppl_afw_before_registration_form` | - | Top of the signup form template |
| `ppl_afw_registration_form_bottom` | - | Above the signup submit button |
| `ppl_afw_portal_before` | affiliate | Top of the dashboard |
| `ppl_afw_portal_after` | affiliate | Bottom of the dashboard |
| `ppl_afw_settings_tab_{tab}` | settings | Inside a settings tab form |

Filters:

| Hook | Purpose |
|---|---|
| `ppl_afw_services` | Add or remove booted service ids |
| `ppl_afw_setting` | Override any setting value |
| `ppl_afw_default_settings` | Override the default setting values |
| `ppl_afw_reserved_slugs` | Extend the vanity slug blacklist |
| `ppl_afw_resolved_rate` | Override the rate cascade result |
| `ppl_afw_commission_base` | Adjust the base amount (multi-currency conversion goes here) |
| `ppl_afw_commission_amount` | Adjust the final commission |
| `ppl_afw_order_eligible` | Veto commission on an order |
| `ppl_afw_fraud_score` | Add custom fraud signals and scores |
| `ppl_afw_payout_gateways` | Register custom payout gateways |
| `ppl_afw_enabled_payout_gateways` | Filter methods offered to affiliates |
| `ppl_afw_badges` | Add custom achievement badges |
| `ppl_afw_notification` | Filter a portal notification subject/body per type |
| `ppl_afw_email_html` | Filter the broadcast email HTML |

## Emails

The transactional emails are standard `WC_Email` classes registered through
`woocommerce_email_classes` (see `includes/emails/`). They appear under
WooCommerce > Settings > Emails and use the store's email template:
`ppl_afw_affiliate_approved`, `ppl_afw_affiliate_rejected`,
`ppl_afw_new_commission`, `ppl_afw_payout`, and `ppl_afw_new_application`
(admin). The in dashboard notification feed is separate and lives in the
`notifications` service.

## Rate cascade

Most specific wins: per affiliate per product (user meta
`ppl_afw_product_rates`) > purchase sequence rule > per affiliate rate > tier
rate > product meta `_ppl_afw_rate` > category term meta `ppl_afw_rate` >
global setting. The matched rule is stored in the referral's `rate_snapshot`
for auditing.

Purchase sequence rules are arrays of
`{ from, to (0 = onward), type: flat|percentage, value }` resolved per
affiliate (user meta `ppl_afw_sequence_rules`), then tier, then the global
`sequence_rules` setting.

## Payout gateway interface

Implement `PPL_AFW\Payouts\Payout_Gateway_Interface` (`id()`, `label()`,
`process( $payout, $affiliate )` returning a gateway reference string or
`WP_Error`) and register it:

```php
add_filter( 'ppl_afw_payout_gateways', function ( $gateways ) {
    $gateways['acme'] = new Acme_Gateway();
    return $gateways;
} );
```

## REST API (ppl-afw/v1)

Cookie + `X-WP-Nonce` auth. Affiliate endpoints require an active affiliate
account; admin endpoints require the mapped capabilities.

| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| `/me` | GET | affiliate | Profile, links, earnings |
| `/me/referrals` | GET | affiliate | Paginated (`page`, `per_page`) |
| `/me/visits` | GET | affiliate | Paginated |
| `/me/payouts` | GET | affiliate | Paginated |
| `/me/slug` | POST | affiliate | `{ "slug": "my-brand" }` |
| `/me/stats` | GET | affiliate | Daily series + totals (`days` 7-365) |
| `/me/payout-method` | GET/POST | affiliate | Masked details / save encrypted details |
| `/me/notifications` | GET | affiliate | Portal feed + unread count |
| `/me/notifications/{id}/read` | POST | affiliate | Mark read |
| `/me/preferences` | GET/POST | affiliate | Email notification prefs |
| `/me/creatives` | GET | affiliate | Active creatives with embed codes |
| `/me/campaigns` | GET/POST | affiliate | List / create campaigns |
| `/me/downline` | GET | affiliate | MLM recruits (empty when disabled) |
| `/me/badges` | GET | affiliate | Achievement badges |
| `/leaderboard` | GET | affiliate | Anonymized top earners (opt-in) |
| `/affiliates` | GET | ppl_afw_manage_affiliates | `status` filter |
| `/affiliates/{id}/status` | POST | ppl_afw_manage_affiliates | `{ "status": "active", "note": "" }` |
| `/reports/summary` | GET | ppl_afw_view_reports | Program KPIs |

Example:

```
curl -H 'X-WP-Nonce: ...' --cookie '...' https://store.example/wp-json/ppl-afw/v1/me
```

## Template overrides

Copy any file from `templates/` into `yourtheme/ppl-afw/` keeping the relative
path, e.g. `yourtheme/ppl-afw/portal/portal.php`.

## Security notes

* All SQL runs through `$wpdb->prepare`; table names via the Schema whitelist.
* IPs and fingerprints are stored as salted HMAC hashes only, plus a truncated display form.
* Payout details are encrypted with `sodium_crypto_secretbox`; the key derives from WordPress auth salts, so rotating salts invalidates stored payout details (affiliates must re-enter them).
* The tracking cookie is HMAC signed and rejected on tamper.
* Registration and the impression beacon are rate limited via hashed IP transients.

## Default decisions (unspecified in the spec)

* Attribution defaults to last click, 30 day window.
* Grace period defaults to 14 days; commissions auto approve by daily cron once the order is completed or processing and the lock expired.
* Invoices are generated as print ready HTML (browser print to PDF) rather than bundling a PDF library.
* MLM overrides default to a percentage of the direct commission, 2 levels at 10%/5%.
* Fraud thresholds: hold at score 60, suspend at 90.

## SaaS migration note

The layers that carry over to a multi-tenant SaaS unchanged: service classes
(business logic), repositories (data access), and the REST contract
(`ppl-afw/v1`). What changes:

* Tenancy: add a `tenant_id` column to every table (composite indexes with existing keys); repositories inject the tenant scope centrally in the base `Repository` class.
* Key management: replace the salt derived libsodium key with per-tenant keys from a KMS; `Crypto` is the single seam to swap.
* Queue: replace WP-Cron notification and maintenance workers with a real queue (SQS/Redis); each cron callback is already a single idempotent method.
* Auth: replace cookie + nonce with API keys/OAuth at the REST permission callbacks; route handlers are transport agnostic.

## Free and premium editions

The two plugins are built from this one codebase, but they are **not** a
gated pair. WordPress.org Guideline 5 forbids shipping code that is present
but disabled, so the free plugin literally does not contain the premium
features: there is no `Features` class, no capability checks, and no
`includes/mlm`, `includes/fraud`, `includes/tiers`, `includes/creatives`, or
`includes/pro` directory.

Premium behaviour attaches through the core's own extension points, so the
free plugin stays complete and self contained while the premium build layers
on top.

### Extension points the premium build uses

| Hook | Premium behaviour |
|---|---|
| `ppl_afw_resolved_rate` | Per product, per category, per affiliate per product, sequence, and tier rates |
| `ppl_afw_order_attribution` | Coupon and lifetime attribution |
| `ppl_afw_current_attribution` | Cookieless fingerprint fallback |
| `ppl_afw_overwrite_attribution` | First click attribution |
| `ppl_afw_visit_campaign_id` | Campaign / sub ID capture |
| `ppl_afw_referral_created` | Lifetime customer binding, MLM overrides, fraud scoring |
| `ppl_afw_payout_gateways` | PayPal, Stripe, bank transfer |
| `ppl_afw_payout_method_labels` | Their labels in settings |
| `ppl_afw_default_settings` | Premium setting defaults |
| `ppl_afw_settings_tabs` / `ppl_afw_settings_tab_{$tab}` / `ppl_afw_settings_tab_keys` | Premium settings UI and persistence |
| `ppl_afw_admin_pages` | Extra admin screens |
| `ppl_afw_services` | Extra services |

Adding a premium feature means adding a hook here if one does not exist,
then implementing against it in the overlay. It must never mean adding a
conditional that turns something off.

### Building

Tooling lives in `wp-content/ppl-afw-build/`, outside both plugin folders,
because anything inside a plugin folder is distributed code.

```bash
cd wp-content/ppl-afw-build
php build-pro.php    # regenerate the premium plugin from this source
php package.php      # build both, then write clean zips to dist/
```

`build-pro.php` copies this plugin, rewrites the text domain to
`affiliate-engine-pro-for-woocommerce`, drops the free-only files, and lays
`pro-overlay/` on top. The `ppl_afw_` prefix on tables, options, and hooks is
deliberately **not** rewritten: that shared namespace is what lets a store
move between editions with no migration.

Never edit the premium plugin directly; it is wiped and regenerated.

### Switching editions

Both editions declare the same classes, so only one may run:

* Free stands down when the premium plugin is active.
* Premium stands down only if `ppl_afw()` is already declared, which happens
  on exactly one request: the one activating it. It then deactivates free on
  `activated_plugin` and boots normally from the next page load.

Premium must not test "is free active", or the two would defer to each other
and neither would run.

Each edition's `uninstall.php` refuses to drop tables while the other
edition's directory exists, so deleting one after switching never destroys
the data the other is using.

### Secrets

`Crypto` uses the plugin's own secrets, never WordPress auth salts. Salts are
rotated to invalidate login sessions, and that must not break stored hashes
or make encrypted payout details unreadable. Both secrets can be pinned in
`wp-config.php`:

```php
define( 'PPL_AFW_HASH_KEY', '...' );        // email, IP, fingerprint hashes
define( 'PPL_AFW_ENCRYPTION_KEY', '...' );  // payout detail encryption
```

Otherwise they are generated on activation and stored as autoloaded options.

### Translating before `init` is a bug

Never call `__()` while defining defaults or during `plugins_loaded`.
Services register on `plugins_loaded`, and the first `Settings::get()` builds
the defaults array, so a `__()` there fires before `init` and WordPress 6.7
emits a `_load_textdomain_just_in_time` notice.

`Settings::default_registration_fields()` therefore stores plain English
labels and `Settings::field_label()` translates them at render time. That
also stops a translated default being frozen into the options row in
whichever locale happened to be active at first save.

### Coding standards

Repository and service files that query the plugin's own tables carry a
file level `phpcs:disable` for the `WordPress.DB.*` and
`PluginCheck.Security.DirectDB.*` sniffs, with a justification. WordPress has
no data API for custom tables, so the sniffs cannot be satisfied, only
explained. Table names always resolve through `Schema::table()` and user
values always bind through `$wpdb->prepare()` or the base repository's
`bind()` helper.
