# PRD — wow_turf (WowTurf)

Evidence date: 2026-09-28. Source: `git@bitbucket.org:overdrive/wow_turf.git`, `master` at `00137305` (2019-06-12).

There is no prior PRD in Silicon Ark. The repository already contains a technical spec under `docs/` and a product summary in `README.md`. Requirements below come from those documents and from the code that implements them. Provenance is marked on each item.

`origin/staging` is 215 commits ahead of `master` and was last changed on 2020-02-26. This PRD does not treat that branch as the product unless a requirement says so. Whether customers see `master`, `staging`, or another tree is **Unknown / Requires Confirmation**.

## Product purpose

WowTurf gives users bursary entries funded by advertising. A user interacts with campaigns and earns points. Points become entry tokens for a draw. An advertiser pays to run those campaigns.

Provenance: **Documented** (`README.md`).

## Users and roles

| Role | What they do | Evidence | Provenance |
|---|---|---|---|
| Subscriber (personal user) | Registers, completes a profile, reads content, interacts with ads, earns points and tokens, invites friends, manages notifications | `docs/users.php`, role `subscriber` in `registerUser` | Documented and implemented |
| Customer (advertiser) | Registers, completes a profile including a headline, stores a card, creates campaigns, sees billing | `docs/users.php`, role `customer` | Documented and implemented |
| Admin | Reviews and publishes campaigns, sends in-site notifications, edits system mails, uses wp-admin | `docs/campaigns.php`, `docs/notifications.php` | Documented |
| Super admin | Runs a competition and marks a payout complete | `docs/pointstokens.php`, page `dashboard-competitions.php` | Documented. The README names a local super-admin login. The password in that file is not repeated here |
| Anonymous visitor | Can open public pages. Cannot complete an ad interaction | `single-campaign.php` blocks logged-out users | Implemented |

People can register only as customer or subscriber. Provenance: **Documented** (`docs/users.php`). `registerUser` sets `subscriber`, then switches to `customer` when `signupusertype` is `customer`.

Advertisers cannot earn points on ads. If the viewer is the campaign author, interactions are disabled. If the viewer has the customer role, the template tells them to create a subscriber account. Provenance: **Implemented** (`single-campaign.php`).

## Main workflows

### W1 — Register and sign in

Trigger: a person opens the signup or login page.

Documented behaviour:

- Login is a Formidable form from the Registration / Login addon. The shortcode is on the Login page.
- Advertiser registration form in wp-admin: Formidable → Register Customer User.
- Personal registration form: Formidable → Register Personal User.
- Ninja Users hooks add validation.

Implemented REST, separate from the Formidable description:

- `POST /wp-json/nff/v1/registeruser/` creates a WordPress user. Username and email are required. The method generates an 8-character password and does not accept one from the client. Duplicate email returns "User exists."
- `POST /wp-json/nff/v1/loginuser/` loads the user by email.
- `POST /wp-json/nff/v1/fbloginreg/` is registered for Facebook login or registration. The request body and Facebook app configuration are **Unknown / Requires Confirmation** beyond the route existing.

Formidable itself is not in the repository. Provenance of the forms: **Documented**. Provenance of the REST registration: **Implemented**.

### W2 — Complete a profile

Trigger: a signed-in user opens the profile dashboard or the profile modal.

The profile is complete when `getProfilePercentage` returns 100. Subscribers are scored out of 12 fields. Customers are scored out of 13 because they also need `user_headline`.

Fields counted: first name, last name, email, custom avatar, language, phone, country, region, city, gender, category preferences, date of birth, and headline for customers.

Until the percentage is 100:

- `single-campaign.php` refuses the interaction and links to `dashboard-personal-profile/?profilemodal=show`.
- `dashboard-campaign-a.php` refuses campaign creation and links to the profile modal.

Phone rule on profile save (`handleProfileForm`): after a leading `+` is removed, the value must be 11 digits and numeric. The error text says the 11 digits include the country code.

Location rule: saving a location sets `location_locked` and `location_locked_date`. The profile template disables the location controls while locked. `dashboard-profile.php` clears the lock when `location_locked_date` plus 365 days is in the past.

Date of birth is parsed as `d/m/Y` and stored as `Ymd`.

Provenance: **Implemented**. The docs state the same gates in prose without listing the 12 or 13 fields.

### W3 — Invite a friend

Trigger: a subscriber sends an invite from the earn dashboard. Documented URL: `https://staging.wowturf.org/dashboard-personal-earn/`.

Implemented behaviour (`sendInvite`):

- Coupon code is the sender's first name, lowercased, with spaces replaced by hyphens, then an underscore and the user id.
- Mail uses system-mail code `invite_mail` and a join link `/signup/?coupon={code}`.
- A row is inserted into `nfh_refferals` (spelled with two f's in code).
- A copy is mailed to the sender.
- On `init`, `check_invite_reminders` loads unredeemed rows with `remindersent = 0`. If more than 604800 seconds (7 days) have passed, it sends a reminder.

Documented reward: when the invitee registers with the code, both people receive one extra token for the next draw. Provenance: **Documented** (`docs/users.php`, `docs/pointstokens.php`). The registration hook `do_custom_action_after_registration` is the code path that applies a personal coupon. The extra-token field used at draw time is `extra_tokens` (`convertPointstoEntries`).

Advertiser coupons are different. A coupon amount stored on the advertiser is spent down as campaign bids are logged, before card charges. When it is used, mail code `coupon_amount_used` is sent. Provenance: **Documented** and **Implemented** (`log_campaign_spend`).

### W4 — Create, review, and run a campaign

Trigger: an advertiser whose profile is 100% complete opens the campaign wizard.

Documented steps, matching theme templates:

1. `dashboard-campaign-a.php` — name and schedule (start immediately and recurring, or start and end dates).
2. `dashboard-campaign-b.php` — geography, demographics, categories, budget, bid, rules, frequency.
3. `dashboard-campaign-c.php` — campaign type, dimensions, engagement options, creatives.
4. `dashboard-campaign-d-stripe.php` — summary, billing details, payment method.

The advertiser must have card details before creating campaigns. Provenance: **Documented** (`docs/payments.php`).

When the advertiser confirms, status becomes `pending_review` and an admin is notified. An admin publishes the campaign in wp-admin. Publishing writes it to `nfh_campaigns` as `active`. Only an admin publish makes it live. Provenance: **Documented** (`docs/campaigns.php`).

Statuses documented:

| Status | Meaning |
|---|---|
| `active` | Live. Only after admin publish |
| `pending_review` | Advertiser confirmed. Waiting for admin |
| `paused` | Advertiser paused. Advertiser can unpause |
| `disabled` | Not visible. Needs admin before publish again |
| `cancelled` | Trashed. Advertiser or admin can cancel |

Serving: each ad slot calls `get_campaign_id()`. Candidates pass filters for size, region, preferences, age, gender, remaining budget, start and end date, time of day, how often the user has already been served, status, and language. If several remain, the highest bid wins. Equal bids are shuffled and one is kept. Listing pages can show multiple campaigns. If nothing matches, a random published campaign in the default-campaigns term can be used. Provenance: **Implemented** (`nwc.php`). Daily budget checks exist only as commented code. Current budget check is total budget left greater than or equal to the bid.

Ad slots documented: XL leaderboard, standard leaderboard, XL medium rectangle, XL rectangle, WowWin listing, home listing.

### W5 — Interact with an ad and earn points

Trigger: a subscriber with a complete profile opens a campaign. The click opens a modal iframe of `single-campaign.php`, which includes a template under `templates/adtypes/`.

Documented interaction types and points:

| Type | What completes it | Points |
|---|---|---|
| View only | Open the popup | 1 |
| Click through | Click an external link | 2 |
| Take action | Watch a video, complete a poll, or both, as configured | 3 |
| Premium | Any of the above interactions the advertiser required | 4 |

The subscriber then presses a control to activate points. On `interaction_complete` with a valid nonce `user_interaction`, the single template continues into spend and points logging. Flow notes in `function_flows.txt`: `adjustUserPoints`, then `log_campaign_spend`, then `adjust_campaign_spend_threshold`, then `log_campaign_interaction`.

Points cannot go below zero (`adjustUserPoints` with `subtract`).

A contributed article that is published adds 100 points to `user_id_created` and can set the post author to that user when `credit_user` is set (`contribute_subs`). The 100 is not in the four-type table. Provenance: **Implemented**. The docs do not describe this award.

### W6 — Convert points to draw entries

Documented ladder (cumulative totals in the table):

| Points | Entries |
|---|---|
| 4 | 1 |
| 8 | 2 |
| 16 | 3 |
| 32 | 4 |
| 64 | 5 |
| 128 | 6 |
| 256 | 7 |
| 512 | 8 |
| 1024 | 9 |
| 2028 | 10 |
| 4096 | 11 |
| 8192 | 12 |

The value 2028 is what `docs/pointstokens.php` prints. The surrounding rows double. Whether 2028 was meant to be 2048 is **Unknown / Requires Confirmation**.

Implemented calculation (`getTokensAmount`): the ladder is not hardcoded. It reads ACF option `token_costs`, splits on commas, and counts how many costs are less than or equal to the user's points. The next cost is the following item in that list. After a competition, remaining points are total points minus the cost of the last token earned. Extra tokens from invites are added in `convertPointstoEntries` and then cleared.

A commented block above that logic subtracts each cost in sequence. Provenance of the live path: **Implemented**. Provenance of the printed ladder: **Documented**. Whether the option value still matches the table is **Unknown / Requires Confirmation**.

The user can see points and tokens in the dashboard and the header. Provenance: **Documented**.

### W7 — Run a bursary draw

Trigger: super admin opens the competitions page (documented URL `https://staging.wowturf.org/competitions/`) and chooses run a competition.

`runCompetition($payout_amount)`:

1. Truncates `nfh_entries`.
2. Selects `wp_usermeta` rows where `meta_key` is `user_points` and `meta_value` is greater than the first token cost.
3. Inserts one `nfh_entries` row per token, including `extra_tokens`.
4. If the count of distinct user ids in entries does not equal the selected user count, it mails a fixed developer address, prints an error, and stores `nfh_competitions` with `winner` 0 and `comp_error` set.
5. Otherwise it shuffles entries and marks one row `winner` = 1, then stores the result with the payout amount.

After a manual payout, the admin marks it paid. `nfh_payouts` records users paid for a bursary. Provenance: **Documented** and **Implemented**. How the payout amount is chosen on the page, and who pays it outside the site, is **Unknown / Requires Confirmation**.

### W8 — Bill an advertiser

Trigger: spend on a campaign crosses the billing threshold, or a settlement event fires.

Per interaction, the bid is divided by ACF option `clicks_per_bid_amount` (the docs say this is under wp-admin Options → Billing/Coupons, and that bids are per 1000 interactions unless that option changes). The result is added to campaign spent and to `campaign_spend_threshold_tracker`.

When the tracker reaches ACF option `billing_threshold`, Stripe is charged for the threshold amount, the tracker is reduced by that amount, and the charge is logged in `nfh_payments_stripe_charges`.

Full remaining balance is billed when:

- a campaign is cancelled
- the campaign does not have enough budget left for another bid
- the advertiser deletes the account (all campaigns)

The advertiser can email themselves a billing history from the dashboard. Stripe sends its own receipt. The site also notifies the advertiser. The advertiser can choose periodic notices (weekly or monthly) instead of a mail on every charge. Endpoint: `/wp-json/nff/v1/periodicinvoicing/`. `can_run_payments` expects the last run to be more than a week ago. `can_invoice_campaign` uses `$invoicing_period`.

Provenance: **Documented** and **Implemented**.

Card rows in `nfh_payments_stripe_cards` store Stripe card ids, not card numbers. Provenance: **Documented**.

### W9 — Notifications and mail

Dashboards show notifications stored as a custom post type. Some duplicate transactional mails. Some exist only in the dashboard.

An admin can create a notification for all subscribers, advertisers, all users, or specific users (`/wp-admin/notifications/add new`).

Users choose whether to receive notifications or mails from the dashboard.

Transactional mail is documented as Mandrill through Post SMTP. Marketing mail is Mailchimp, with a newsletter list and a special-offers list, opted in at registration or in the dashboard.

System mail copy for custom plugins is edited at `/wp-admin/system mails/`. Each plugin `send_email()` sends the mail and can add a dashboard notification. Login and registration copy is in Formidable.

Provenance: **Documented**. Mandrill, Post SMTP, and Mailchimp plugins are not in the git tree.

### W10 — Read content and search

Public templates include home, article (`single.php`), category listing described as WowFun, WowWin listing, search results, about, white-label (example given: How Points Work), and 404.

`GET /wp-json/nff/v1/categoryposts/` and `GET /wp-json/nff/v1/getcampaigns/` serve listings. `function_flows.txt` describes the WowWin path: the page calls `WOWTURF.ajax.get_more_campaigns`, which hits `getcampaigns`, which calls `get_campaign_id`.

Bookmarks: `processBookmarks` and `/wp-json/nff/v1/bookmark/`. Viewed posts: `nfh_postviews` and `/wp-json/nff/v1/viewedposts/`.

Advertisers can preview placements on home, article, WowFun, and WowWin (`preview-*.php`).

Provenance: **Documented** (template list) and **Implemented**.

### W11 — Delete an account

Commit history records delete-account work in March 2019, including email and cancelling campaigns. `nfh_deleted_users` stores basic data of deleted users and which campaigns were theirs. Docs say deleting an advertiser account bills all outstanding campaign amounts. Provenance: **Documented** for the billing effect. The full field list of the delete form was not traced in this pass.

## Integrations

| System | Direction | Purpose | Auth as found | Failure |
|---|---|---|---|---|
| Stripe | Outbound | Create customer, store card id, charge threshold and outstanding balances | Stripe API from Ninja Payments STRIPE. Key storage is **Unknown** (not copied) | Errors go to `nfh_payments_stripe_errors`. User-facing copy is **Unknown** beyond `save_error_data` |
| Mandrill / Post SMTP | Outbound | Transactional mail | Documented plugin setup. Not in repo | **Unknown** |
| Mailchimp | Outbound | Newsletter and offers | Method index name `subcribeControllerMailchimp` | **Unknown** |
| Facebook | Inbound to `fbloginreg` | Login or registration | **Unknown** | **Unknown** |
| Cloudflare | Site edge | Plugin `cloudflare` is in the tree | **Unknown** | **Unknown** |
| OpenVPN staging | Operator access | DB via MySQL Workbench, files via FTP | Documented as a supplied text file, not in git | **Unknown** |

## Important constraints

- Subscribers need a 100% profile before ad interactions. Advertisers need a 100% profile before creating a campaign, and card details before campaigns (card rule is documented; the profile rule is implemented on campaign step A).
- Advertisers and campaign authors do not earn points on ads.
- A campaign is not publicly active until an admin publishes it.
- Location stays locked for 365 days after it is saved.
- Phone numbers on the profile form must be 11 digits including country code.
- Bids are divided by `clicks_per_bid_amount` before they count as spend.
- Competition entry rows are wiped at the start of each draw.
- Custom tables use the `nfh_` prefix except `geo_records`.

## Requirements index

| ID | Requirement | Provenance | Implementation |
|---|---|---|---|
| FR-001 | The product offers bursary entries funded by ad interactions | Documented | Implemented as points, tokens, and `runCompetition` |
| FR-002 | Two public roles: subscriber and advertiser (customer), plus admin | Documented | Implemented |
| FR-003 | Profile must be complete before ad interaction or campaign creation | Documented and implemented | `getProfilePercentage` == 100 |
| FR-004 | Four campaign interaction types award 1, 2, 3, or 4 points | Documented | Templates under `templates/adtypes/` |
| FR-005 | Token counts follow an options ladder, documented as the table in W6 | Documented table; implemented via `token_costs` | Requires confirmation that options match the table |
| FR-006 | Only super admin runs a draw; payout is marked manually | Documented | `runCompetition`, `markPayoutComplete` |
| FR-007 | Advertiser charges go through Stripe at a configurable threshold | Documented and implemented | `adjust_campaign_spend_threshold` |
| FR-008 | Cancel, insufficient next bid, or account deletion settles the outstanding balance | Documented | Payment plugin methods `pay_outstanding_balance_*` |
| FR-009 | Invite coupons grant both parties an extra draw token; advertiser coupons are spend credit | Documented and implemented | `extra_tokens`, `advertiser_coupon_amount` |
| FR-010 | Campaigns are targeted and the highest bid is served | Documented and implemented | `get_campaign_id` |
| FR-011 | Admin must publish a confirmed campaign before it is active | Documented | Status machine in Ninja Campaigns |
| FR-012 | In-app notifications and editable system mails exist | Documented | Notification CPT and `system_mails` options |

## Discrepancies

- Docs name table `nfh_referrals`. Inserts use `nfh_refferals`.
- Docs list class methods as `nfps`. The payment object used in campaigns is `$nwps`.
- `getProfilePercentage($current_user->ID)` passes an integer. The method looks for `$data['uid']` and otherwise uses the current user. On a normal web request those are the same person. Callers that pass another user's id are not read by the `uid` branch unless the argument is an array.
- `docs/pointstokens.php` prints 2028 where the series is otherwise powers of two.
- `contribute_subs` adds 100 points whenever a published post with `user_id_created` is saved. No "already credited" flag was seen in that method.
- `filter_by_age` formats the birth date as `d/m/Y` and then passes the first segment to `mktime` as the month. Provenance: **Inferred** defect. Age targeting may be wrong for many dates.
- Profile phone validation calls `preg_replace("[^0-9]", "", $noplus)` without regex delimiters.
- Several REST routes have no `permission_callback`. See technical documentation. This PRD does not treat those routes as a product requirement to stay open.

## Unknown / Requires Confirmation

- Production URL, and whether `staging` or `master` is deployed.
- Live values of `token_costs`, `billing_threshold`, and `clicks_per_bid_amount`.
- Currency. Coupon examples in the docs use dollars. `geo_records` is documented as including currency. The charge currency sent to Stripe was not confirmed.
- Legal rules of the bursary draw (eligibility, age of winners, tax, payout method).
- Whether Facebook login is enabled in production.
- Contents of Formidable forms, because that plugin is not in git.
