# Business rules — jumpstarter_crowdfunding

Evidence date: 2026-10-11. Source revision: `39ef15a` on `master`.

Rules below are taken from code. Each rule is marked **Documented** (README or an in-code comment), **Implemented** (the code enforces it), or **Inferred** (a reasonable reading that the code does not state). None of these were confirmed by a product owner for this document.

Secret values are omitted.

## Product

| ID | Rule | Label |
|---|---|---|
| BR-001 | A campaign exists to raise funds. A member of the public may back it and may choose a voucher or reward. | **Documented** (`README.md`) |
| BR-002 | A reward-free gift is offered as "Pure donation without a reward" and is sent as reward level `0`. The file comment says this level is not optional. | **Documented** in `custom-pledge.php`; **Implemented** as the form |
| BR-003 | Money shown and charged by the custom checkout is South African rand. PayGate currency is the literal `ZAR`. Stored `prod_price` is rands (PayGate and SnapScan amounts are divided by 100). | **Implemented** |

## Who may act

| ID | Rule | Label |
|---|---|---|
| BR-010 | A logged-out visitor on the purchase partial may log in with the WordPress login form or register. Registration creates role `subscriber`. | **Implemented** |
| BR-011 | The PayGate form is rendered only after `is_user_logged_in()` is true. | **Implemented** |
| BR-012 | Backer CSV download is allowed when the current user is the project post's author, or when the first entry in `roles` is `administrator`. No other role is accepted. A user with `administrator` in a later role entry is refused. | **Implemented**. The "first role only" limit is **Inferred** as an effect of `roles[0]`, not as an intended policy |
| BR-013 | The SnapScan settings page requires `manage_options`. | **Implemented** |
| BR-014 | The SnapScan sync route, the QR URL route, and the reward image upload route do not check a capability or a nonce. | **Implemented** |
| BR-015 | SnapScan payments whose email is not a WordPress user are recorded against `anonymous@jumpstarter.co.za`, with the name Anonymous Anonymous, if that user exists. | **Implemented** |
| BR-016 | If that anonymous user does not exist, sync returns `500` and writes nothing. | **Implemented** |

## Campaign display

| ID | Rule | Label |
|---|---|---|
| BR-020 | A closed project does not offer "Contribute Now". The control reads "Project Closed". | **Implemented** |
| BR-021 | Backer count on the project is the number of distinct emails in `wp_ign_pay_info` for that product id, when the query returns a value. Otherwise the IgnitionDeck deck pledge count is used. | **Implemented** |
| BR-022 | Days remaining are shown only when the deck end type is `closed` and `days_left` is greater than or equal to 0. | **Implemented** |
| BR-023 | A reward level that `getLevelLimitReached` marks invalid is rendered without a link and with class `inactive`. If IgnitionDeck is not licensed, every level is treated as invalid. | **Implemented** (calls vendor functions) |
| BR-024 | The level sidebar prints `meta_count` and then the word ZAR. | **Implemented**. Whether that field is an amount or a count is **Inferred** and needs confirmation |

## PayGate

| ID | Rule | Label |
|---|---|---|
| BR-030 | The amount posted to PayGate is the query `price` in rands, multiplied by 100. | **Implemented** |
| BR-031 | The PayGate reference is `{product id}/{level}/{time and random integer}/{WordPress user id}`. | **Implemented** |
| BR-032 | The shopper is sent back to `https://jumpstarter.co.za/payment-status/`. | **Implemented** as a literal. Which page template answers that URL is **Unknown** |
| BR-033 | A return is accepted only when the checksum matches, the auth code is non-empty, transaction status is `1`, and result code is `990017`. | **Implemented** in `update.php` |
| BR-034 | An accepted payment is stored with status `P` on `ign_pay_info` and on `memberdeck_orders`. Commerce order `level_id` is always `1`, even when the reference level segment is different. | **Implemented** |
| BR-035 | Shipping stored on the PayGate `ign_pay_info` row comes from the PHP session keys `address`, `country`, `state`, `city`, and `zip`. Empty session values are stored as a single space. | **Implemented** |
| BR-036 | There is no check for an existing PayGate transaction id before insert. | **Implemented** as absence. The page tells the visitor not to refresh when the pay-info insert does not return an id |
| BR-037 | If the checksum check fails and a reference is present, a known project redirects to the project permalink. An unknown product id shows "YOUR PAYMENT IS UNSUCCESSFUL." | **Implemented** |

## SnapScan

| ID | Rule | Label |
|---|---|---|
| BR-040 | A project's QR encodes the IgnitionDeck project id (`extra.id` / query `id`) and the project title. | **Implemented** |
| BR-041 | Sync considers only SnapScan payments with status `completed` whose `extra.id` equals the requested project id. | **Implemented** |
| BR-042 | A SnapScan payment is a duplicate when `ign_pay_info.transaction_id` already equals `{snap code}{SnapScan payment id}`. Duplicates are not inserted again. | **Implemented** |
| BR-043 | Sync does not call SnapScan unless the project already has at least one `ign_pay_info` row with a non-empty `preapproval_key`. | **Implemented** |
| BR-044 | SnapScan `totalAmount` is divided by 100 before it is stored as `prod_price`. | **Implemented** |
| BR-045 | SnapScan rows use product level `1` always. The commerce order `level_id` stored for those rows is `0`. | **Implemented** |
| BR-046 | The backer's email is SnapScan `userReference`. If that email belongs to a user whose display name contains a space, the name is split on the first space into first and last name. | **Implemented** |
| BR-047 | Opening a project page in the browser fires the sync request for that project id. | **Implemented** in `so-snapscan.js` |
| BR-048 | Credentials are the PHP constants in `so-snapscan.php`, not the wp-admin settings form. That form's save control is commented out. | **Implemented** |

## Export

| ID | Rule | Label |
|---|---|---|
| BR-050 | The CSV lists first name, last name, email, shipping address, reward level, amount pledged, and completion time, ordered by first name then last name. | **Implemented** |
| BR-051 | The query key is the WordPress post id. Rows are filtered by IgnitionDeck product id from meta `ign_project_id`. | **Implemented** |
| BR-052 | Shipping in the CSV is user meta `md_shipping_info` for that email, not the `address` column written by the PayGate return. | **Implemented** |
| BR-053 | An empty backer list still downloads a CSV with one blank data row. | **Implemented** |

## Reward images

| ID | Rule | Label |
|---|---|---|
| BR-060 | Allowed upload types are png, jpg, jpeg, and gif. Other types are not attached. | **Implemented** |
| BR-061 | A successful upload sets `ign_project_level_image_{level}` to the attachment URL. | **Implemented** |
| BR-062 | The admin project meta box adds a level image field for level 1 only. The front-end form adds one file input per level description. | **Implemented** |
| BR-063 | The upload request's post id is the `edit_project` query value on the page that holds the file input. | **Implemented** in `img-upload.js` |

## Home and footer

| ID | Rule | Label |
|---|---|---|
| BR-070 | The footer "Funded" figure is the sum of `prod_price` in the literal table `wp_ign_pay_info`, formatted with `money_format` and prefixed with `ZAR`. It is shown when `fundify_is_crowdfunding()` is false. | **Implemented** in the child footer. **Inferred** that the author intended a site-wide total |
| BR-071 | `home_block_left` always renders an 80% progress bar, ignoring campaign data. | **Implemented** |

## What is not a rule

- No minimum or maximum pledge is coded in the PayGate template.
- No rule in this repository states fees, refunds, payouts to project creators, voucher redemption, or what happens when a campaign misses its goal. IgnitionDeck may implement goal handling when active. That behaviour was not re-derived for this document.
- Tax, invoices, and receipts are not implemented by the Overdrive files. IgnitionDeck Commerce contains receipt email templates. Whether they are enabled is **Unknown**.
- The five-second cron interval is not a business schedule. Nothing in the SnapScan plugin queues work on it.
