# Business rules — wow_turf (WowTurf)

Evidence date: 2026-09-28. Revision: `master` `00137305`.

Rules below are taken from `docs/`, `README.md`, or the code. Each rule is marked **Documented** or **Inferred**. An inferred rule is what the code does. It is not a confirmed business decision.

No separate business-rules document existed in the source repository or in Silicon Ark.

## Who may use the product

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-001 | Public registration is only as a personal user (subscriber) or an advertiser (customer) | `docs/users.php` | Documented |
| BR-002 | Admin uses wp-admin. Customers and subscribers see different dashboards | `docs/users.php` | Documented |
| BR-003 | Only a super admin may run a bursary competition | `docs/pointstokens.php` | Documented |
| BR-004 | A logged-out visitor cannot complete an ad interaction | `single-campaign.php` | Inferred from the template branch |
| BR-005 | An advertiser (customer role) cannot earn points. The template tells them to create a subscriber account | `single-campaign.php` | Inferred |
| BR-006 | The author of a campaign cannot interact with that campaign | `single-campaign.php` | Inferred |

## Profile

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-007 | A subscriber cannot interact with campaigns until the profile is complete | `docs/users.php` | Documented |
| BR-008 | An advertiser cannot create campaigns until the profile is complete | `docs/users.php` | Documented |
| BR-009 | Complete means `getProfilePercentage` equals 100. Subscribers have 12 counted fields. Advertisers have 13, including headline | `single-campaign.php`, `dashboard-campaign-a.php`, `nwu.php` | Inferred (the docs say "full profile" and do not list the denominator) |
| BR-010 | Counted fields are first name, last name, email, avatar, language, phone, country, region, city, gender, category preferences, and date of birth | `getProfilePercentage` | Inferred |
| BR-011 | A phone number, after a leading plus is removed, must be 11 digits and contain only numbers. The error text says those 11 digits include the country code | `handleProfileForm` | Inferred |
| BR-012 | Once location is saved it is locked. The profile page unlocks it 365 days after `location_locked_date` | `nwu.php`, `dashboard-profile.php`. Commit `166cb207` says "Added location lock to profile for 1 year." | Inferred. The one-year period is implemented, not written in `docs/` |
| BR-013 | Date of birth submitted as `d/m/Y` is stored as `Ymd` | `handleProfileForm` | Inferred |
| BR-014 | Gender is required on the profile form action. Empty gender returns "please add your gender" | `handleProfileForm` | Inferred |
| BR-015 | Language is required on the same form action | `handleProfileForm` | Inferred |

## Points, tokens, and the draw

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-016 | View only awards 1 point, click through 2, take action 3, premium 4 | `docs/pointstokens.php`, `docs/campaigns.php` | Documented |
| BR-017 | Take action is complete when the user watches a video, completes a poll, or both, as the advertiser configured | `docs/campaigns.php` | Documented |
| BR-018 | Premium may require any of the other interactions before points are awarded | `docs/campaigns.php` | Documented |
| BR-019 | Points are awarded only after the required interaction is complete | `docs/campaigns.php` | Documented |
| BR-020 | The documented token table is 4 points = 1 entry, then 8 = 2, 16 = 3, 32 = 4, 64 = 5, 128 = 6, 256 = 7, 512 = 8, 1024 = 9, 2028 = 10, 4096 = 11, 8192 = 12 | `docs/pointstokens.php` | Documented. The 2028 figure is printed as-is |
| BR-021 | The live converter does not hardcode that table. It counts how many comma-separated values in option `token_costs` are less than or equal to the user's points | `getTokensAmount` | Inferred. Whether options match BR-020 is **Unknown / Requires Confirmation** |
| BR-022 | Points cannot be reduced below zero | `adjustUserPoints` | Inferred |
| BR-023 | A published contributed article adds 100 points to the creating user | `contribute_subs` | Inferred. Not in `docs/`. The method does not show a one-time guard |
| BR-024 | Running a competition clears `nfh_entries`, writes one row per token for users whose points exceed the first token cost, shuffles, and marks one row the winner | `docs/pointstokens.php`, `runCompetition` | Documented and implemented |
| BR-025 | If the number of distinct users in the entries table does not match the number of users selected, the draw is stored as an error and no winner is chosen | `runCompetition` | Inferred |
| BR-026 | After the draw, the user's points become the remainder after the last token threshold, and `extra_tokens` is set to 0 | `convertPointstoEntries` | Inferred |
| BR-027 | Payout of the bursary is manual. An admin marks it paid afterwards | `docs/pointstokens.php` | Documented |
| BR-028 | Invite registration gives the inviter and the new user one extra token for the next draw | `docs/users.php`, `docs/pointstokens.php` | Documented |

## Invites and coupons

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-029 | A personal invite code is derived from the sender's first name plus their user id | `sendInvite` | Inferred |
| BR-030 | An invite that is not redeemed sends a reminder after more than 7 days | `check_invite_reminders` | Inferred. The method index comment says "over 1 week" |
| BR-031 | An advertiser coupon is a balance of free interactions. The documented example is a $100 coupon giving $100 of free interactions across campaigns. When the balance is used up, campaigns are charged | `docs/users.php` | Documented. The dollar amounts are the example in the doc, not a hardcoded constant found in this pass |
| BR-032 | While advertiser coupon balance is greater than the per-interaction bid, the bid is subtracted from the coupon and logged in `nfh_advertiser_coupons` instead of being left entirely to card billing | `log_campaign_spend` | Inferred |

## Campaigns

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-033 | An advertiser must add a card before creating campaigns | `docs/payments.php` | Documented |
| BR-034 | A campaign becomes active only when an admin publishes it | `docs/campaigns.php` | Documented |
| BR-035 | Advertiser confirmation moves the campaign to pending review and notifies admin | `docs/campaigns.php` | Documented |
| BR-036 | If a campaign is not ready, admin is responsible for telling the advertiser what to change. The product does not describe an in-app rejection form | `docs/campaigns.php` | Documented |
| BR-037 | Admin can return a campaign to pending review, or trash it, which cancels it | `docs/campaigns.php` | Documented |
| BR-038 | A paused campaign is hidden and can be unpaused by the advertiser | `docs/campaigns.php` | Documented |
| BR-039 | A disabled campaign stays hidden until admin publishes it again | `docs/campaigns.php` | Documented |
| BR-040 | A cancelled campaign is trashed. Advertiser or admin can cancel | `docs/campaigns.php` | Documented |
| BR-041 | The advertiser cannot cancel until the outstanding balance is paid | Commit `bd0758f5` (2019-05-06) "cannot cancel without paying balance first." Payment docs say cancel bills the remaining amount | Documented for the bill. The hard stop is **Inferred** from the commit subject and was not re-read as a separate guard in this pass |
| BR-042 | When several campaigns survive the filters, the highest bid is shown. Equal bids are shuffled and one is kept | `filter_by_bid` | Inferred. Docs say "they will go down to a bid" |
| BR-043 | A campaign is eligible on budget when remaining total budget is greater than or equal to the bid. The older daily-budget check is commented out | `filter_by_hasbudget` | Inferred |
| BR-044 | Age filter includes campaigns whose from/to age contains the user's age, plus campaigns whose from and to age are both 0 | `filter_by_age` | Inferred. Zero is treated as "no age limit" |
| BR-045 | Gender `all` matches only campaigns marked all. Gender female matches female and all. Gender male matches male and all | `filter_by_gender` | Inferred |
| BR-046 | Campaigns past the end date are ended by `flushCompletedCampaigns` | Method comment in `nwc-actions.php` | Inferred |
| BR-047 | Bids are expressed per N interactions. N is option `clicks_per_bid_amount`. Docs say the usual value is 1000 and that it can be changed under Billing/Coupons | `docs/campaigns.php`, `adjust_campaign_spend_threshold` | Documented and implemented |
| BR-048 | If `clicks_per_bid_amount` is missing, the code uses 0 | `adjust_campaign_spend_threshold` | Inferred. Division by zero is a possible outcome. Not a stated business rule |

## Billing

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-049 | Only advertisers pay | `docs/payments.php` | Documented |
| BR-050 | When tracked spend reaches the billing threshold, Stripe charges that threshold amount and the tracker keeps the remainder | `docs/payments.php`, `adjust_campaign_spend_threshold` | Documented and implemented |
| BR-051 | The threshold is an admin option, not a constant in code | `docs/payments.php` | Documented |
| BR-052 | Cancelling a campaign bills the full remaining amount owed | `docs/payments.php` | Documented |
| BR-053 | If the campaign cannot fund another bid, the full remaining amount owed is billed | `docs/payments.php` | Documented |
| BR-054 | Deleting an advertiser account bills outstanding amounts on all of that advertiser's campaigns | `docs/payments.php` | Documented |
| BR-055 | Stripe sends a receipt from the card details captured when the card was added | `docs/payments.php` | Documented |
| BR-056 | The site also notifies the advertiser. The advertiser may choose periodic notices (weekly or monthly) instead of a notice on every charge | `docs/payments.php` | Documented |
| BR-057 | A periodic payment run should be more than one week after the previous run | `can_run_payments` comment | Inferred |
| BR-058 | Card numbers are not stored. The database keeps the Stripe card id | `docs/database.php` | Documented |

## Notifications and mail

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-059 | Users can choose whether they receive notifications or mails | `docs/notifications.php` | Documented |
| BR-060 | Admin notifications can target all subscribers, advertisers, all users, or specific users | `docs/notifications.php` | Documented |
| BR-061 | Some notifications are only in the dashboard and are not emailed | `docs/notifications.php` | Documented |
| BR-062 | Transactional mail is Mandrill through Post SMTP. Marketing mail is two Mailchimp lists: newsletter and special offers | `docs/notifications.php` | Documented. Plugins are not in the repository |

## Access and operations

| ID | Rule | Source | Label |
|---|---|---|---|
| BR-063 | Staging is on AWS and is reached through OpenVPN. Database access is MySQL Workbench. Files are FTP | `README.md`, `docs/hosting.php`, `docs/database.php` | Documented |
| BR-064 | The campaign debugger should be purged and turned off in production | `docs/plugins.php` comment on `debuggerLog` | Documented as an operator instruction |
| BR-065 | ZotaPay is not in use | `README.md` | Documented. The plugin is still in the tree, so the "not used" claim is a documented intent, not proof the code is unloaded |

## Conflicts that need a human decision

- BR-020 prints 2028 as the tenth token threshold. The rest of the list doubles. The code does not contain this list.
- BR-021 will follow whatever is stored in `token_costs`, which may differ from BR-020.
- Docs say referrals live in `nfh_referrals`. The invite insert writes `nfh_refferals`.
- `filter_by_age` builds a `d/m/Y` string and then treats the first number as a month. If that is unintentional, BR-044 does not match the user's real age. **Requires confirmation** before anyone "fixes" it from this note alone.
- `master` (2019-06-12) and `staging` (2020-02-26) disagree about later UI and ad-display behaviour. These rules were read from `master` and the `docs/` that were committed there.
