# PRD — jumpstarter_crowdfunding

Evidence date: 2026-10-11. Source: `git@bitbucket.org:overdrive/jumpstarter_crowdfunding.git` at `39ef15a` (`master`, 2023-08-04).

There is no prior PRD in the repository or in Silicon Ark. Requirements below were recovered from code, the README, and git history. Provenance is marked on each item.

Secret values are not copied here. Pages, projects, menus, and plugin activation live in the database and are marked **Unknown / Requires Confirmation** where the code cannot decide them.

## Product purpose

Jumpstarter is a crowdfunding platform. Campaigns raise funds. A member of the public can back a project and can optionally receive a voucher or reward.

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

The implementation is a WordPress 4.9.14 site using IgnitionDeck Crowdfunding and the Fundify theme, with Overdrive additions for SnapScan, PayGate (ZAR), reward images, and a backer export.

Provenance: **Implemented**.

The organisation's legal name, the production domain's current status, and whether `jumpstarter.co.za` is still served from this tree are **Unknown / Requires Confirmation**. Code and plugin headers use `https://jumpstarter.co.za/`.

## Users and roles

| Role | What they can do | Evidence | Provenance |
|---|---|---|---|
| Public visitor | Reads published campaigns. Can open a PayGate form or a SnapScan QR for a published project | Fundify single template; `so-snapscan` | Implemented in code. Which theme is active is Unknown |
| Backer who registers at pledge time | The purchase form can create a WordPress user with role `subscriber`, log them in, and store shipping fields in the PHP session | `project-purchase-form.php` | Implemented |
| Logged-in backer | Same purchase form skips registration and posts to PayGate | `project-purchase-form.php` | Implemented |
| Project creator | WordPress author of an `ignition_product`. Can export that project's backers. IgnitionDeck front-end submission is vendored | Export check compares `post_author` to the current user. `id_fes_form` filters add image and QR fields | Implemented. Whether front-end submission is enabled is Unknown |
| Administrator | WordPress `administrator`. Can export any project's backers. Can open Settings → SnapScan and see QR codes for published projects | `export_project_backers`; `manage_options` on the SnapScan menu | Implemented. The export checks `$current_user->roles[0]` only |
| SnapScan anonymous backer | Payments whose SnapScan `userReference` is not a WordPress user are stored against `anonymous@jumpstarter.co.za` | `sync_snapscan_mdid_orders` | Implemented, if that user exists |
| WordPress editor, author, contributor, subscriber | Standard capabilities | WordPress core | Implemented by core. Which roles are used beyond the purchase-form subscriber is Unknown |

No custom role is registered by the Overdrive plugins. Sign-in on the purchase form is `wp_login_form()` plus a custom register form. `wp-login.php` remains the core login. Provenance: **Implemented**.

## Main workflows

### W1 — Read a campaign

Trigger: a visitor opens a published `ignition_product`.

Behaviour in `wp-content/themes/fundify/single-ignition_product.php`:

1. Load the IgnitionDeck project from post meta `ign_project_id`.
2. Show the project title, video or thumbnail, pledged total, goal, percentage, backer count, and days left when the end type is `closed`.
3. Backer count is `COUNT(DISTINCT email)` from the table name `wp_ign_pay_info` (literal prefix `wp_`, not `$wpdb->prefix`). If that query returns a backer count it replaces `$hDeck->pledges`.
4. If the project is not closed, show "Contribute Now". The link query is `purchaseform=fundify&prodid={project_id}`.
5. If the project is closed, show "Project Closed" with no link.
6. List reward levels. Each level shows `meta_image`, a pledge price, `meta_count` followed by the literal string "ZAR", and `meta_short_desc`. The level link uses `purchaseform=1`, not `purchaseform=fundify`.
7. Show description, updates, comments, backers (`mdid_backers_list` when `use_idc()` is true), FAQs, and updates a second time.
8. Above the level list, include `project/custom-pledge.php`: a "Pure donation without a reward" amount field. See W3.

The active theme is an option in the database. It is **Unknown**. The child theme replaces header, footer, and functions only. The single template above is in the parent. A child does not override it (the child has only a backup copy `single-ignition_product.php.bck`).

On a project page, `so-snapscan.js` reads `data-projectid` from `.contribute-now` and POSTs it to `/wp-json/so-snapscan/v1/sync/`. See W5.

Provenance: **Implemented**.

### W2 — Pledge with PayGate

Trigger: the visitor is logged in and the request query `purchaseform` equals `fundify`.

`single-ignition_product.php` then includes `project/project-purchase-form.php` instead of the campaign body.

Behaviour of that partial:

1. If the visitor is logged out, show `wp_login_form()` and a register form (email, first name, last name, password, and shipping address, country, state, city, zip). Submit name is `wp-register`.
2. On that submit, if the email does not exist, create a `subscriber`. Then attempt `wp_setcookie` and `wp_set_current_user`. Shipping is stored in `$_SESSION`, not in user meta, in this file. `session_start()` runs at the top of the partial.
3. If the visitor is logged in, read `mdid_checkout`, `level`, and `price` from the query string. Currency is the literal `ZAR`. Amount sent to PayGate is `price * 100` (rands to cents).
4. Reference string is `{mdid_checkout}/{level}/{time+random}/{current user id}`.
5. Return URL is the literal `https://jumpstarter.co.za/payment-status/`.
6. The form POSTs to `https://www.paygate.co.za/paywebv2/process.trans` with PayGate PayWeb v2 fields and an MD5 checksum. The PayGate id and the checksum secret are hardcoded in this file. Values are not recorded in this PRD.
7. The visitor sees "Amount to Pay: R {amount} (ZAR)" and a "Pay now" button.

"Contribute Now" links to `purchaseform=fundify` but does not set `mdid_checkout`, `level`, or `price`. The form reads those three query keys, not `prodid`. A Contribute Now click therefore opens the PayGate form with empty product, level, and price unless another layer adds them.

Reward-level anchors use `purchaseform=1`. The single template only swaps in the PayGate form when `purchaseform` is `fundify`. A level click, as this template is written, stays on the campaign view.

`functions.php` `fundify_swap_purchaseform` filters `idcf_purchase_url` to `purchaseform=fundify&prodid={id}` for `ignition_product` posts. That filter does not add level or price. The sidebar level markup does not use that filter; it builds its own URL.

Provenance: **Implemented**. Whether production relies on a different template than the one named above is **Unknown / Requires Confirmation**.

There is no captcha on this register form. Better WordPress reCAPTCHA is vendored. Whether it is active is **Unknown**.

### W3 — Pure donation (no reward)

Trigger: the visitor types an amount into `#pure_idc_lightbox` on the campaign sidebar and submits "Next Step".

`custom-pledge.php` posts to `{site}/projects/{post_name}/?purchaseform=fundify&mdid_checkout={project_id}&level=0`.

`wp-content/plugins/ignitiondeck/js/idf-min.js` (commit `a1a63cc`, 2020-08-17) on change of that field:

1. Strips characters other than digits and `.`.
2. Appends `&price={amount}` to the form `action`.
3. Shows the Next Step button by removing `mfp-hide`.

Each change appends another `&price=` segment. It does not replace a previous one. Provenance: **Implemented**.

The form action assumes the project permalink is `/projects/{slug}/`. The live permalink structure is a WordPress option. Provenance: **Inferred** from the hardcoded path. **Unknown / Requires Confirmation** whether production uses that structure.

The comment in `custom-pledge.php` says level 0 is not optional. Provenance: **Documented** in that file comment.

### W4 — PayGate return

Trigger: PayGate redirects the browser to the return URL. The theme file that reads the PayGate response is `wp-content/themes/fundify/update.php`, template name "update-ts Page".

Which WordPress page uses that template, and whether `https://jumpstarter.co.za/payment-status/` is that page, is **Unknown / Requires Confirmation**.

Behaviour when the template runs:

1. Read PayGate request fields (`PAYGATE_ID`, `REFERENCE`, `TRANSACTION_STATUS`, `RESULT_CODE`, `AUTH_CODE`, `AMOUNT`, `RESULT_DESC`, `TRANSACTION_ID`, `RISK_INDICATOR`, `CHECKSUM`).
2. Split `REFERENCE` on `/`. Segment 0 is the product id. Segment 1 is the level id.
3. Recompute the MD5 checksum with the hardcoded PayGate id and secret.
4. Accept the payment only when all of the following are true: checksum matches, `AUTH_CODE` is non-empty, `TRANSACTION_STATUS` is `1`, and `RESULT_CODE` is `990017`.
5. On accept, insert into `{prefix}ign_pay_info` (name, email, session shipping fields, product id, PayGate transaction id, the full reference as `preapproval_key`, level, `prod_price` = amount/100, status `P`). Then insert `{prefix}memberdeck_orders` with `level_id` hardcoded to `1` and status `P`, then `{prefix}mdid_orders`.
6. If both inserts return ids, the page prints "Thank you for supporting. Your payment is accepted." If the first insert returns no id, it prints "Thank you for supporting. Do not refresh again."
7. If the checksum check fails and `REFERENCE` is non-empty: when the product id does not match a post, print "YOUR PAYMENT IS UNSUCCESSFUL." Otherwise redirect to that project's permalink.
8. Shipping on the payment row comes from `$_SESSION`. This template does not call `session_start()`. The purchase partial does. Whether the session is available on the return request is **Unknown / Requires Confirmation**.

The CSV export does not read `ign_pay_info.address`. It replaces the shipping column from user meta `md_shipping_info`. Nothing in the Overdrive code writes `md_shipping_info`. Provenance: **Implemented** read. The write, if any, would be IgnitionDeck or missing. **Unknown / Requires Confirmation**.

`update.php` does not check that the PayGate transaction id is new before insert. A refresh that still satisfies the checksum check can insert another row. The failure copy tells the visitor not to refresh, which matches that behaviour. Provenance: **Inferred** from the absence of a duplicate check.

### W5 — SnapScan QR and payment sync

Trigger: a published project is viewed, or an administrator opens the QR tools.

Plugin: `wp-content/plugins/so-snapscan/so-snapscan.php`. API host constant `SNAPSCAN_API_URL` is `https://pos.snapscan.io/`. Snap code, API key, and token are PHP constants in that file. Values are omitted here.

QR image URL shape: `{API}/qr/{snapcode}.png?id={project_id}&project={title}&snap_code_size=125`.

Where QR codes appear:

- Front-end submission form: filter `id_fes_form` adds a file field whose value is the QR URL (`id_fes_qr_code`).
- Creator dashboard: `idcommerce/templates/_myProjects.php` adds a download icon with class `download_project_qr_code`.
- wp-admin → Settings → SnapScan: a table of every published `ignition_product` with title and QR image. A text box filters the table in the browser (`so-snapscan-admin.js`). The settings form's Save button and the API key fields are commented out. The page does not save credentials.

`so-snapscan.js` click on `.download_project_qr_code`: if the anchor already has an href and a project id, open it. Otherwise POST `/wp-json/so-snapscan/v1/download/` and open the URL the callback returns. The callback builds a SnapScan QR URL. It does not store a file.

Sync, `sync_snapscan_mdid_orders`, route POST `/wp-json/so-snapscan/v1/sync/`:

1. Require a user `anonymous@jumpstarter.co.za`. If that user has no id, return `500`.
2. Load `{prefix}ign_pay_info` rows for the posted `project_id` whose `preapproval_key` is not empty. If there are none, return `500` and do not call SnapScan.
3. GET `{API}/merchant/api/v1/payments` with HTTP basic auth using the API key and token constants.
4. Keep payments whose `extra.id` equals the project id, whose `status` is `completed`, and whose transaction id `{snapcode}{payment.id}` is not already in `ign_pay_info.transaction_id`.
5. Resolve the backer with `get_user_by('email', userReference)`. If that user exists and the display name has more than one word, split it into first and last name. Otherwise use "Anonymous" / "Anonymous" and the anonymous user's id.
6. Insert `ign_pay_info` (product level hardcoded to `1`, price = `totalAmount/100`, `preapproval_key` = `{project}/{1}/{timestamp}/{user id}`), `memberdeck_orders` (status `P`, `level_id` `0`), and `mdid_orders`.
7. Return `200` if at least one insert path runs. The function's starting value is `500`.

The route callbacks do not check a WordPress capability. On WordPress 4.9.14 a REST route is public unless the callback checks the user. The same is true of POST `/wp-json/img-upload/v1/upload/`.

A `five_seconds` cron schedule is registered. No `wp_schedule_event` in this plugin uses it. Sync is triggered by the project page script, not by cron. Provenance: **Implemented**.

`class-deck.php` adds `project_id` onto the `hDeck` object returned by `hDeck()`. The project page's `data-projectid` is set from post meta in `project-details.php`, not from `hDeck`. Provenance: **Implemented**.

### W6 — Export backers

Trigger: a request has query key `export_project_backers` set to a WordPress post id. The handler runs on `init`.

The creator dashboard link was changed from `export_project={post_id}` to `export_project_backers={post_id}` (`_myProjects.php`, commit `ea3c0d9`).

Rules in code:

1. Resolve `ign_project_id` from that post.
2. Allow the download only when the current user id equals the post author, or `$current_user->roles[0]` is `administrator`.
3. CSV columns: Name, Surname, Email Address, Shipping Address, Reward Level Chosen, Amount Pledged, Date Completed. Source table `{prefix}ign_pay_info` for that product id, ordered by first name, last name.
4. Shipping Address is overwritten from user meta `md_shipping_info` when the email matches a user. Empty meta values are dropped. Remaining values are joined with newlines.
5. If there are no rows, the CSV is one empty record with those headers.
6. The file is written under the uploads directory, sent with a CSV `Content-Disposition`, then unlinked. `readfile` is given the uploads **URL**, not the filesystem path.

Provenance: **Implemented**.

### W7 — Reward image on a level

Trigger: a project form includes the level image field added by `idjs`.

`idjs` (`Jumpstarter IgnitionDeck Customization`, author header says IgnitionDeck, description says it is for Jumpstarter):

- On `id_fes_form`, insert a file input and a hidden "removed" flag after each level description. The current image URL is post meta `ign_project_level_image_{n}`.
- On `id_postmeta_box_fields`, insert one "Level Image" file field (`ign_project_level_image_1`) before `ign_product_short_description`. The admin box only adds level 1.

`img-upload.js` on change of `project_level_image_*` POSTs the file to `/wp-json/img-upload/v1/upload/` with `post_id` taken from the query key `edit_project` and the level number taken from the input name.

`upload_image` accepts png, jpg, jpeg, and gif, stores a WordPress attachment on that post, and sets post meta `ign_project_level_image_{level}` to the attachment URL. The `permission_callback` that would require a logged-in user is commented out. The callback does not check that the user can edit the post.

The project single template prints `$level->meta_image` for each reward. Whether IgnitionDeck copies `ign_project_level_image_{n}` into `meta_image` is vendor behaviour. Provenance: **Inferred** that the meta key is what the upload plugin writes; the single template reads `meta_image`.

`idjs-js.js` listens for `idcfLevelCloned` and renames cloned image inputs. The selectors it rewrites (`project_level_{n}_image`) do not match the input names `idjs` actually outputs (`project_level_image_{n}`). Provenance: **Implemented**.

## Functional requirements

### FR-001 — Campaign page

The system shows a published IgnitionDeck project with media, amount pledged, goal, percent, distinct-email backer count, days left when the project end type is closed, reward levels, description, updates, comments, backers, and FAQs.

Who: public visitor. Trigger: open the project permalink.

Inputs: post id, post meta `ign_project_id`, IgnitionDeck deck, rows in `wp_ign_pay_info`.

Output: Fundify project layout. Closed projects show "Project Closed" and no contribute link.

Validation: none beyond IgnitionDeck's `project_closed()`.

Error: if `ign_project_id` is not greater than 0, the stats block is skipped. Later template code still uses `$hDeck` and `$levels`. That path is **Inferred** to be unsafe when the meta is missing.

Evidence: `single-ignition_product.php`, `project/project-details.php`. Implementation: **Implemented**. Confidence: High.

### FR-002 — PayGate checkout in ZAR

A logged-in visitor reaching `purchaseform=fundify` can submit a PayGate PayWeb v2 payment in ZAR. Amount is the `price` query value in rands, sent as cents. Return URL is `https://jumpstarter.co.za/payment-status/`.

Who: logged-in visitor. Trigger: that query string.

Inputs: `mdid_checkout`, `level`, `price`, current user id.

Output: browser POST to PayGate.

Validation: `price` is filtered as a float. Product id and level are filtered as strings. No minimum amount is checked in this template.

Error: missing query values still render the form. PayGate's response to an empty amount is **Unknown**.

Evidence: `project/project-purchase-form.php`. Implementation: **Implemented**. Confidence: High for the form. Medium for whether Contribute Now supplies the query values (it does not, in this template).

### FR-003 — Register while pledging

A logged-out visitor on the purchase partial can log in or register. A new email creates a subscriber. Shipping fields are placed in the PHP session.

Who: anonymous visitor. Trigger: POST `wp-register`.

Inputs: email, first name, last name, password, address, country, state, city, zip.

Output: a `subscriber` user when the email is new, and an attempted login.

Validation: email is required in the HTML. The PHP does not show a validation error if `wp_insert_user` fails. Duplicate email reuses the existing user id and still attempts login with the posted password.

Error: `wp_setcookie` is called with `$user_pass`, which is not set in this function (the posted field is `$password`). Provenance: **Implemented**.

Evidence: `project-purchase-form.php`. Confidence: High.

### FR-004 — Record an approved PayGate payment

When the return template accepts a PayGate result, it writes `ign_pay_info`, `memberdeck_orders`, and `mdid_orders`, then thanks the visitor.

Who: the returning browser. Trigger: PayGate redirect onto a page that uses `update.php`.

Inputs: PayGate fields listed in W4.

Output: three table rows and a thank-you heading, or an unsuccessful heading, or a redirect to the project.

Validation: checksum, non-empty auth code, transaction status `1`, result code `990017`.

Error conditions: checksum failure with an unknown product id shows the unsuccessful heading. Checksum failure with a known product redirects to the project with no message. Duplicate submits are not rejected.

Evidence: `update.php`. Implementation: **Implemented** in the template. The page assignment is **Unknown**. Confidence: High for the template, Low for production wiring.

### FR-005 — Pure donation amount

The campaign sidebar offers a reward-free amount. Changing the field appends `&price=` to the form action and reveals Next Step. Level sent with that form is `0`.

Who: visitor. Trigger: change `#pure_idc_lightbox`, then submit.

Evidence: `custom-pledge.php`, `idf-min.js`. Implementation: **Implemented**. Confidence: High.

### FR-006 — SnapScan QR for a project

The system can show a SnapScan QR whose `id` query parameter is the IgnitionDeck project id, for published projects, on the admin SnapScan screen, on the creator project list, and as a front-end submission field.

Who: administrator (settings page), project creator (dashboard icon), submitter (FES field).

Evidence: `so-snapscan.php`, `_myProjects.php`. Implementation: **Implemented**, if the plugin is active. Confidence: High for the code, Unknown for activation.

### FR-007 — Import completed SnapScan payments

Viewing a project page asks the sync route to copy completed SnapScan payments for that project into the three order tables, skipping transaction ids already stored.

Who: any caller who can POST the route, including the project page script.

Preconditions in code: the anonymous Jumpstarter user exists, and the project already has at least one `ign_pay_info` row with a non-empty `preapproval_key`.

Output: HTTP-style integers `200` or `500` in the JSON body (the callback returns a bare integer).

Evidence: `so-snapscan.php`, `so-snapscan.js`. Implementation: **Implemented**. Confidence: High.

### FR-008 — Backer CSV

The project author or an administrator can download a CSV of backers for a project by requesting `export_project_backers={post id}`.

Evidence: `so-snapscan.php`, `_myProjects.php`. Implementation: **Implemented**. Confidence: High.

### FR-009 — Reward level image

A front-end project form can upload a png, jpg, jpeg, or gif for a reward level. The file becomes an attachment and `ign_project_level_image_{level}` is set to its URL.

Who: any caller of the upload route. The intended user is a creator editing `?edit_project=`.

Evidence: `idjs.php`, `img-upload.php`, `img-upload.js`. Implementation: **Implemented**. Confidence: High for the write. Medium for display, because the single template prints `meta_image`.

### FR-010 — ZAR presentation

The child theme appends a ZAR option to `#pp-currency` and `#stripe_currency` in wp-admin. The purchase form currency is ZAR. The child footer prints the sum of `prod_price` from the literal table `wp_ign_pay_info` as "ZAR {amount} Funded". Reward levels label `meta_count` with the word ZAR.

Who: visitor (footer and levels), administrator (currency dropdowns).

Evidence: `fundify-child/functions.php`, `fundify-child/footer.php`, `single-ignition_product.php`. Implementation: **Implemented** in those files. The footer and admin script run only when the child theme is active. Confidence: High for the files, Unknown for the active theme.

The stock Fundify meaning of `meta_count` is a backer count. This template still prints that field and changes the label to ZAR. Provenance: **Inferred** label change. Requires confirmation that the field holds an amount.

### FR-011 — Home page blocks

The child theme registers shortcodes `home_block_left`, `home_block_right`, and `callout`. The page template "Home Custom" (`fundify-child/page-templates/Home-Custom.php`) prints the page content inside `.home-box-container` and hides `.idsocial-post-icons.ignitiondeck`. The left block's progress bar is hardcoded to 80% in the shortcode output.

Who: an editor who places the shortcode. Whether any page does that is **Unknown**.

Evidence: `functions.php`, `Home-Custom.php`. Implementation: **Implemented**. Confidence: High for the shortcodes, Unknown for use.

## Integrations

| System | Purpose | Direction | Auth in repo | Success the code expects | Failure the code shows |
|---|---|---|---|---|---|
| PayGate PayWeb v2 | Card payment in ZAR | Browser POST out; PayGate redirects back | Checksum secret hardcoded in the theme | Status `1`, result `990017`, checksum match, auth code present | Unsuccessful heading, or redirect to the project |
| SnapScan POS | QR and payment list | Server GET payments; browser opens QR URL | API key and token constants | `status == completed` and a new transaction id | Sync returns `500`; no user-visible error (`console.log` only) |
| Google Analytics | Page tag in the child header | Browser to Google | Measurement id `UA-55077943-1` | Tag is printed | No fallback |
| Google Tag Manager, Site Kit, Facebook Pixel, Yoast, Wordfence, ManageWP Worker, CleanTalk, reCAPTCHA, Contact Form 7, Icegram | Vendored plugins | Various | Not in git | **Unknown** which are active | **Unknown** |
| IgnitionDeck Commerce libraries (PayPal Adaptive, Stripe, Authorize.Net, Coinbase, Mailchimp, Mandrill, SendGrid) | Vendored inside `idcommerce/lib` | Outbound if that gateway is enabled | Credentials would be in the database or `wp-config.php`, which is absent | **Unknown** | **Unknown** |

## Constraints

- WordPress 4.9.14. No upgrade path is documented in git.
- Currency handled by the custom checkout is ZAR. Amounts are stored as rands (`cents / 100`) in `prod_price`.
- PayGate return URL is a production host literal, not `home_url()`.
- Several queries use the literal table prefix `wp_` (`project-details.php`, child footer). Plugin queries use `$wpdb->prefix`. If the live prefix is not `wp_`, the theme stats and the plugin rows diverge. Prefix is **Unknown**.
- `wp-config.php`, uploads, and the active plugin list are not in git.
- Credentials for SnapScan and PayGate are in PHP source. See `technical-documentation.md`. This PRD does not repeat them.

## Requires confirmation

1. Active theme (`fundify` or `fundify-child`) and the active plugin list.
2. That `https://jumpstarter.co.za/payment-status/` uses the "update-ts Page" template.
3. Permalink structure, especially the `/projects/{slug}/` path hardcoded for pure donations.
4. That a user `anonymous@jumpstarter.co.za` exists.
5. Table prefix and database name (`jumpstarter_wp` appears only in a comment in `wp-admin/test.php`).
6. PHP version on the host (footer calls `money_format`, removed in PHP 8).
7. Whether PayPal, Stripe, WooCommerce, or WP Crowdfunding are enabled in addition to PayGate and SnapScan.
8. Whether `md_shipping_info` is populated by IgnitionDeck. The export reads it. The PayGate return writes the session address onto `ign_pay_info` instead.
9. Whether reward `meta_image` is the same value as `ign_project_level_image_{n}`.
10. Whether the hidden outbound links in `fundify-child/header.php` are an intrusion. They are unrelated to crowdfunding. See technical documentation.
11. Whether the SnapScan and PayGate secrets still match the live merchant accounts, and whether they should be rotated. Values are in source and must not be copied into further documents.
12. `sonar-project.properties`, `sonarqube_scan.bat`, and `wp-snapshots` were on the 2026-09-28 scan list and are not in `39ef15a`.
