# PRD — farmtrade (FarmTrade / AgMarket)

Evidence date: 2026-10-15.

Source: `git@bitbucket.org:overdrive/farmtrade.git`.

Default branch: `master` at `f4f46ea9` (2020-07-03). That revision is stock CS-Cart Multi-Vendor plus CodeDeploy. The requirements below that mention Parcel Perfect, PayFast, AgMarket, vendor registration, or product import are **Implemented** on `prod` (`429b1564`, 2020-09-15) and `qa` (`a755fe81`, 2020-09-16), and are absent from `master`.

There is no prior PRD in the source repository or in Silicon Ark. Requirements are recovered from code, config, SQL, and commit history. Provenance is labelled. Secret values are not copied.

## Product purpose

**Implemented.** The running application is CS-Cart Multi-Vendor 4.11.4.SP3 (`config.php`: `PRODUCT_NAME` Multi-Vendor, `PRODUCT_EDITION` MULTIVENDOR). Multi-Vendor is a marketplace: a storefront for customers, a vendor panel for sellers, and an admin panel for the marketplace operator. That split is the stock product, visible in `index.php` (`AREA` C), `vendor.php`, and `admin.php` / `fa-admin.php` (`AREA` A, `ACCOUNT_TYPE` admin).

**Implemented** names on the product line: FarmTrade (deploy path, privacy-policy PDF, early host) and AgMarket (later hosts, mail strings, shipping contact name, vendor terms URL). No file states a product goal beyond those names and the CS-Cart licence.

**Unknown / Requires Confirmation:** which name is the product name, and whether a customer of FarmTrade and a customer of AgMarket are the same audience.

## Users and roles

| Actor | Evidence | Status |
|---|---|---|
| Customer | `index.php` sets `AREA` to `C`. Stock storefront controllers under `app/controllers/frontend/` | **Implemented** platform. Catalogue and account data are in MySQL, not in git |
| Vendor | `vendor.php`. `config.local.php` sets `vendor_index` to `vendor.php` on every branch read | **Implemented** |
| Marketplace administrator | `admin.php` on every branch. On `prod`/`qa`, `config.local.php` sets `admin_index` to `fa-admin.php`. `fa-admin.php` is the same bootstrap as `admin.php` with a different filename | **Implemented** |
| Root API user | `ImportProducts::authentication()` allows delete only when `auth['is_root'] == 'Y'` | **Implemented** on `qa`/`prod` |
| Vendor or admin API user | `ImportProducts::authenticateVendor()` allows the import path when a `cscart_companies` row exists for `auth['company_id']`, or when `user_type` is `A` | **Implemented** on `qa`/`prod` |
| Deploy operator | Bitbucket Pipelines and CodeDeploy scripts. No UI | **Inferred** from those files |

Stock CS-Cart usergroups (`USERGROUP_GUEST`, `USERGROUP_REGISTERED`, and ids from 3 upward) are defined in `config.php`. Which usergroups this marketplace created is **Unknown / Requires Confirmation**.

## Systems the product talks to

1. The visitor's browser, loading the CS-Cart storefront.
2. MySQL through `mysqli`. Prefix `cscart_`. Session backend `database`. Cache backend `file`. Lock backend `database`.
3. Parcel Perfect, over HTTP, URL taken from add-on setting `sil_pp_account_url`. Tracking URL returned by the service is `https://epx.pperfect.com/`.
4. PayFast. Live host `www.payfast.co.za`. Sandbox host `sandbox.payfast.co.za`.
5. Amazon SES SMTP at `email-smtp.eu-west-1.amazonaws.com` port 587 with TLS, hardcoded when the mailer method is `smtp`. The admin SMTP settings in that branch are commented out.
6. AWS S3 and AWS CodeDeploy, when a pipeline runs.
7. `httpd` on the CodeDeploy host.
8. Google Fonts, from the stock Greeny stylesheet (`fonts.gstatic.com`). The font colour in that file was changed; the font URL was not.
9. A CloudFront backend name in config (`cdn_backend` = `cloudfront`). No distribution is named in the lines read.

`config.local.php` also contains `db_host`, `db_user`, `db_password`, and `crypt_key`. Those values are omitted.

## Major workflows

### WF-1 — Customer uses the stock marketplace

`index.php` boots CS-Cart for the customer area and calls `fn_dispatch()`. From there the stock controllers serve the catalogue, cart, checkout, profiles, and orders.

This repository does not contain the catalogue. What a customer can buy is **Unknown / Requires Confirmation** and lives in the database.

Provenance: **Implemented** platform. The workflow steps inside stock checkout, other than the hooks below, were not re-specified for this project.

### WF-2 — Checkout city lookup and Parcel Perfect quote

On `qa`/`prod`:

1. `parcel_perfect_address.js` binds jQuery UI autocomplete to `#litecheckout_city`. The request URL is `location.origin + '/addressing.php?town='` plus the typed town. `lite_checkout.js` calls `addressLookup(location.origin)` again when the city field is focused and already has a value.
2. `addressing.php` accepts GET only. Any other method returns HTTP 400 and `["Bad Request"]`. A missing `town` returns HTTP 400 and `["Bad Request, Must Specify Town"]`.
3. It loads CS-Cart with `API` and `NO_SESSION`, asks `Tygh\ParcelPerfectAuth::tokenMan()` for a token, and calls Parcel Perfect `Quote.getPlacesByName` with that town.
4. If the call returns `errorcode` 0 and at least one result, the JSON results are returned with HTTP 200. Otherwise the response is one object with empty `town`, `place`, and `pcode`, still HTTP 200.
5. If no token is returned, the response is HTTP 500 and `["Unable to retreive token"]` (spelling as in the file).
6. On select, the script writes the town into the city field and `pcode` into `#litecheckout_s_zipcode`, then sets that zip field readonly.
7. When CS-Cart rates the Parcel Perfect service, `Parcelperfect::getRequestData()` resolves collection and delivery places by town name, then by postcode if the name lookup returns no results. It sends `Quote.requestQuote`. If both place lookups return a result, the first result of each is used. A comment in the file says the user would normally choose. The code does not ask the user.
8. `processResponse()` takes the minimum `total` across returned services, calls `Quote.updateService` with that service and the quote number, and stores price and quote id in `$_SESSION['agmarket']['quote_data']` under the vendor company name.
9. Hook `place_order` (`fn_sil_parcel_perfect_place_order`) runs for orders whose `is_parent_order` is `N`. If the session holds a quote id for that order's company name, it writes the quote id into `?:orders.fax`.

Provenance: **Implemented** on `qa`/`prod`.

Token retrieval (`ParcelPerfectAuth`):

- Settings required: API URL, username, and password. If any is empty, `tokenMan()` returns null.
- Otherwise the newest row in `?:sil_parcel_perfect_data` is read. The row is reused when the absolute difference between now and `sil_pp_token_expiry` is less than `sil_pp_token_expire` days, or when the stored token string length is 0. Otherwise `retrieveToken()` runs.
- `retrieveToken()` calls `Auth.getSalt` with the username as `email`, then `Auth.getSecureToken` with `md5(password + salt)`. On success it inserts `time()` and the token id. On salt failure the method `die`s. On token failure it returns `errorcode`.
- cURL is called with peer and host verification off. Timeout defaults to 10 seconds when the setting is empty. The request URL and the raw response are written with `error_log`.

The expiry column stores the time the token was saved, not an absolute expiry from Parcel Perfect. The reuse condition is **Implemented** as written. Whether "length is 0 means reuse" is intentional is **Unknown / Requires Confirmation**.

Package weight sent to Parcel Perfect uses CS-Cart's `full_pounds` figure. The store's weight unit is **Unknown / Requires Confirmation**. Treating pounds as the courier's mass unit is **Inferred** risk, not a written rule.

### WF-3 — Pay with PayFast

`app/payments/payfast.php` is the PayFast processor (header: PayFast plugin 1.0.0, author Ron Darby). `sql/payfast_mysql_install.sql` registers processor script `payfast.php` against the outside-card template.

When the processor mode is `sandbox`, the host is `sandbox.payfast.co.za` and the merchant id and key are replaced with PayFast's published sandbox merchant. Otherwise the host is `www.payfast.co.za` and the merchant id, key, and optional passphrase come from the payment method's processor parameters. The passphrase is applied only when the mode is not sandbox and the parameter is non-empty.

Notification mode `payment_notification.notify`:

- The order's payment script must be `payfast.php`.
- The handler answers HTTP 200, then stops if the order status is not `N` (incomplete).
- It checks the PayFast signature, and, unless debug is on, the source IP. It asks PayFast to validate the posted string, and it compares `amount_gross` with the order total.
- `COMPLETE`: sets the response status from the processor status map entry `completed`, and calls `fn_change_order_status` to `P`.
- `FAILED`: sets the response status from map entry `denied`.
- `PENDING`: sets the response status from map entry `pending`.
- Any other `payment_status` does not change the response status.
- If the response status equals the string `pending`, the order status is changed to that value. Otherwise `fn_finish_payment` and `fn_order_placement_routines` run.

Return mode, for Multi-Vendor: if the order status is the parent-order status, each child order still in incomplete status `N` is set to `O` (open). Then the customer is routed through order placement.

Cancel mode: finishes the payment with status `N` and the language string `text_transaction_cancelled`.

Provenance: **Implemented** on `qa`/`prod`. The status letters in the processor map are settings, stored in the database, and were not read. The hardcoded `P` on `COMPLETE` is in the PHP file. Commit `bba9bdc2` ("Update payfast order status after successfull payment") is the change that sets `P`.

### WF-4 — Vendor signs in and accepts terms

On the vendor login form only (`ACCOUNT_TYPE == 'vendor'`), `design/backend/templates/views/auth/login_form.tpl` adds a checkbox labelled "I Agree to Terms and Conditions". The checkbox is `required` and `cm-required`. It has no `name`. The link is:

`https://www.agmarket.co.za/index.php?dispatch=pages.view&page_id=31&action=preview`

The link is absolute, including on the QA host. Page 31 is a CS-Cart page id. The page body is **Unknown / Requires Confirmation** because pages are in the database. The PDF `FarmTrade-Privacy-Policy-4-08-2020.pdf` is a different file and is not the target of this link.

Provenance: **Implemented** on `qa`/`prod`.

### WF-5 — Administrator sets an order to status A

In `app/controllers/backend/orders.php`, after a successful status change, if the new status is `A`:

- The order is loaded with its shipping service module. A code comment says "Order Status A: Ready for Collection".
- Parent orders (`is_parent_order` not `N`) are skipped.
- If the order has no shipment tracking number and `orders.fax` is non-empty, that fax value is sent as `quoteno` to Parcel Perfect `Collection.quoteToCollection`.
- The request also sends `starttime` `08:00:00`, `endtime` `17:00:00`, `notes` `AgMarket`, `printWaybill` 1, `printLabels` 0, and `quoteCollectionDate` as an empty string. A tomorrow date in `d/m/Y` is calculated into `$date` and is not assigned to the request.
- If the response has results and the order has products, a row is inserted into `?:shipments` with `waybillno` as the tracking number and the shipping module as the carrier, and one `?:shipment_items` row is inserted per order line.

Provenance: **Implemented** on `qa`/`prod`. The human-readable name of status `A` is a comment, not a status record from the database. **Unknown / Requires Confirmation** whether the installed status `A` is named "Ready for Collection".

Debug `error_log` lines marked `AUDIT_TEMP_EXIT` remain in this path and in `ParcelPerfectAuth::makeCall`.

### WF-6 — Import or disable products by API

Add-on `product_import` registers API entity `app/addons/product_import/Tygh/Api/Entities/ImportProducts.php`.

- `index`, `create`, and `update` call `controllTheThings`. That method returns HTTP 401 unless `authenticateVendor()` passes.
- A vendor (`user_type` V) is limited to `auth['company_id']`. An administrator (`user_type` A) sets company id `-1`, and each product may carry its own `company_id`.
- Types `POST`, `PUT`, `UPDATE`, and `IMPORT` call `importThings`. Types `GET`, `RETRIEVE`, `DELETE`, and `REMOVE` return an empty OK payload from this router.
- Categories are imported only when the user type is `A`. The category string is split on `|`. The code appends an undefined variable `$bizz` to the path before the lookup. That append is **Implemented**. Its effect is **Inferred** as a PHP notice and a path that does not match the caller's string.
- Products are matched on `product_code` plus `company_id` in `cscart_products`. No row means create. A row means update. Images are fetched from URLs on the payload and dropped if `imagecreatefromstring` fails.
- `delete` does not use the router. It requires `is_root == Y`. It reads `$_GET['product_codes']`, splits on commas, and sets `status` to `D` on matching products whose `company_id` is hardcoded to `1`. Products already `D` are counted as already disabled. The method prints the result array and still returns an empty OK payload.
- `privileges()` returns `create_things`, `edit_things`, `delete_things`, and `view_things`. Those names were not found in the add-on's own schema. Whether CS-Cart grants them to anyone is **Unknown / Requires Confirmation**.
- `fn_product_import_import_products()` is registered on hook `import_products` and only reads `$_SESSION['auth']`. It does not import.
- `controllers/index.post.php` sets an empty string and does nothing else.
- Add-on setting `tags_for_products` defaults to `Y`. The PHP that was read does not reference it.

Provenance: **Implemented** on `qa`/`prod`.

The product queries concatenate `product_code` and `company_id` into SQL text. That is **Implemented**. It is recorded here as behaviour, not as an instruction to exploit it.

### WF-7 — Deploy

A push to branch `prod` runs the "Deploy on PROD" pipeline. On `qa` at `a755fe81`, a push to branch `qa` runs "Deploy on QA". `master`'s pipeline file has the `prod` step only.

Each step exports AWS access key, secret, region, CodeDeploy application name, deployment group, and S3 bucket from Bitbucket variables (`PROD_*` or `QA_*`), installs `zip` and `boto3==1.3.0`, zips the working tree to `/tmp/artifact.zip`, and runs `codedeploy_deploy.py`.

`appspec.yml` copies `/` to `/var/www/html/farmtrade.co.za/`, owner and group `apache`. Before install: `scripts/install_dependencies` (`yum install -y httpd`) as root, timeout 300. Application stop: `scripts/remove_archives.sh`. Application start: `scripts/start_server` (`service httpd start`).

Provenance: **Implemented**. Whether the pipeline variables exist in Bitbucket, and whether a deploy has succeeded, is **Unknown / Requires Confirmation**.

## Functional requirements

### FR-001 — Multi-Vendor marketplace shell

The system serves a CS-Cart Multi-Vendor 4.11.4.SP3 storefront, admin, vendor panel, and REST API.

- Trigger: HTTP request to `index.php`, `admin.php`, `fa-admin.php`, `vendor.php`, or `api.php`.
- Entry check: PHP older than 5.6.0 is rejected with a printed message and `die`.
- Output: `fn_dispatch()` or, for `api.php`, `Tygh\Api::handleRequest()`.

Provenance: **Implemented** on `master` and on the product line. `fa-admin.php` exists only on the product line.

### FR-002 — Configured host and database follow the branch

| Branch | `http_host` and `https_host` | `db_name` | `admin_index` |
|---|---|---|---|
| `master` | `qa.farmtrade.co.za` | `farmtrade` | `admin.php` |
| `prod` | `agmarket.co.za` | `db_agmarket` | `fa-admin.php` |
| `qa` | `qa.agmarket.co.za` | `db_agmarket` | `fa-admin.php` |

`customer_index` is `index.php` and `vendor_index` is `vendor.php` on all three. `demo_mode` is false. HTTP and HTTPS paths are empty (the stock sample paths `/store/cart` and `/secure_dir/cart` are commented).

Provenance: **Implemented** in each branch's `config.local.php`.

### FR-003 — Parcel Perfect add-on

Add-on id `sil_parcel_perfect`, version 1.0.0, name "Parcel Perfect", supplier "Silicon Overdrive cc", description "Integrate ParcelPerfect, EPX shipping into CS-Cart". Compatibility `MULTIVENDOR`. Status in the manifest: `active`.

On install it creates `?:sil_parcel_perfect_data` (`sil_pp_id`, `sil_pp_token_expiry`, `sil_pp_api_token`). On uninstall it drops that table.

Settings: ParcelPerfect API URL, username, password, API timeout, token expiry in days.

Provenance: **Implemented** on `qa`/`prod`. Whether the add-on was installed into a given database is **Unknown / Requires Confirmation**. The manifest status is not the same thing as a row in the installed add-ons table.

### FR-004 — Cheapest Parcel Perfect service is selected

When several rates come back, the shipping service uses the minimum `total` and calls `updateService` with that service code (the first key if several share the minimum).

Provenance: **Implemented**.

### FR-005 — Quote id is stored in the order fax column

For a non-parent order, the quote id from the session is written to `?:orders.fax` during `place_order`.

Provenance: **Implemented**. The column is the stock order fax field. Using it as a quote id is what the code and its comment do. Whether that overwrites a fax number the customer entered is **Inferred**.

### FR-006 — Status A asks Parcel Perfect for a collection

See WF-5. The collection window in the request is 08:00:00 to 17:00:00. `quoteCollectionDate` is sent empty.

Provenance: **Implemented**.

### FR-007 — PayFast processor

See WF-3. Customers are sent to PayFast. The site learns the result from the notification URL `payment_notification.notify?payment=payfast&order_id=...`.

Provenance: **Implemented** on `qa`/`prod`. Not present on `master`.

### FR-008 — Vendor terms checkbox

See WF-4. The checkbox does not post a value. The browser blocks submit while it is unchecked because of `required`.

Provenance: **Implemented** markup. No server-side check of agreement was found in the diff.

### FR-009 — Vendor custom registration fields

Add-on `wk_vendor_custom_registration` version 2.0, supplier Webkul Software Pvt. Ltd., `auto_install` MULTIVENDOR. It adds `companies.firstname`, `companies.lastname`, and `companies.wk_vendor_custom_fields`, and creates section, field, and value tables.

The install seed in `database/demo.sql` inserts 14 fields: company, company description, language, first name, last name, email, phone, url, fax, address, city, country, state, zip code. Required in that seed (`profile_required` = `Y`): company, first name, last name, email, phone, address, city, country, state, zip code. Not required: description, language, url, fax.

An administration menu item points at `wk_vendor_custom_fields.manage`. The vendor permission schema sets the controller permission to false, except mode `wk_getfile`, which is true.

Provenance: **Implemented** add-on and seed on `qa`/`prod`. The seed is Webkul's demo data. Whether production still has these rows is **Unknown / Requires Confirmation**.

### FR-010 — Product import and disable

See WF-6.

Provenance: **Implemented** on `qa`/`prod`.

### FR-011 — Outgoing mail uses fixed AgMarket strings and SES

When a message has a `from` parameter, `AMessageBuilder` no longer uses the resolved company from-address. It calls `setFrom('AgMarket', 'noreply@agmarket.co.za')`.

When the mailer send method is `smtp`, `PhpMailerTransport` ignores the configured SMTP host, username, password, and encryption. It uses Amazon SES in `eu-west-1`, port 587, TLS, and hardcoded credentials. `initByMessage` calls `setFrom('AgMarket', 'noreply@agmarket.co.za')` as well.

Provenance: **Implemented** on `qa`/`prod`. Credentials are not recorded in this PRD. The argument order versus PHPMailer's address-then-name parameter is **Inferred** as swapped.

### FR-012 — Storefront blocks that were turned off in templates

- The newsletter subscription markup in `design/themes/responsive/templates/addons/newsletters/blocks/static_templates/subscribe.tpl` is inside an HTML comment.
- The call-request block in `design/themes/responsive/templates/addons/call_requests/blocks/call_request.tpl` is inside an HTML comment.
- Payment icon templates for the responsive theme and `bright_theme` show Mastercard and Visa. The 2Checkout and PayPal icon spans were removed from the responsive template.

Provenance: **Implemented** on `qa`/`prod`. Commenting the template does not uninstall the add-on. Whether the add-ons are still active is **Unknown / Requires Confirmation**.

### FR-013 — SEO add-on toggle returns immediately

`fn_settings_actions_addons_seo` returns `true` before the stock storefront SEO setup. The stock body is still in the file and does not run.

Provenance: **Implemented** on `qa`/`prod`.

### FR-014 — Unknown storefront relation does not throw

In `RelationsManager::getRelationSchema`, the check that threw `DeveloperException` for an unknown relation name is commented out. The method still indexes `$this->schema[$relation_name]`.

Provenance: **Implemented** on `qa`/`prod`. Commit message: "Relations manager error fix".

### FR-015 — Privacy policy file

`FarmTrade-Privacy-Policy-4-08-2020.pdf` is committed on the product line (commit `69d42f44`, 2020-08-04). Nothing in the templates read links this PDF. The vendor terms link points at a CS-Cart page, not at this file.

Provenance: **Implemented** file. The policy text was not extracted into this PRD.

### FR-016 — Deployed document root and process

See WF-7. The document root in `appspec.yml` is `/var/www/html/farmtrade.co.za/` on both `master` and `qa`.

`scripts/stop_server` is not referenced by `appspec.yml`. Its contents assign `isExistApp = `pgrep httpd`` with spaces around `=`, which is not a valid Bash assignment, then tests `$isExistApp`.

Provenance: **Implemented**.

## Validation and error behaviour

| Case | Behaviour | Provenance |
|---|---|---|
| PHP below 5.6.0 | Entry script prints the version and stops | **Implemented** |
| `addressing.php` not GET | HTTP 400, `["Bad Request"]` | **Implemented** |
| `addressing.php` without `town` | HTTP 400, message that town must be specified | **Implemented** |
| Parcel Perfect token missing | HTTP 500 from `addressing.php` | **Implemented** |
| Place name lookup empty | HTTP 200 and one blank place object | **Implemented** |
| Place lookup empty during rating | Falls back to postcode lookup | **Implemented** |
| Both place lookups empty | No quote details are built; `requestQuote` is still called with whatever request array exists | **Implemented** |
| Salt request error | `retrieveToken()` calls `die` | **Implemented** |
| PayFast notify on an order that is not status `N` | Handler stops after HTTP 200 | **Implemented** |
| PayFast signature, IP, validate, or amount check fails | Error flags set; order status is not updated in the success branch | **Implemented** |
| PayFast status other than COMPLETE, FAILED, PENDING | No status assignment in the switch | **Implemented** |
| Import without vendor/admin auth | HTTP 401 | **Implemented** |
| Import of an unknown image | Image omitted; comment says corrupt, malicious, or unsupported | **Implemented** |
| Delete import when not root | HTTP 401 | **Implemented** |
| Store closed | `store_closed.html` exists. `robots.txt` disallows `/store_closed.html` and `/app/` | **Implemented** file. When the store mode is closed is a database setting |

## Constraints

- Table prefix is `cscart_` (`DEFAULT_TABLE_PREFIX` and `config.local.php`).
- Password length constant `USER_PASSWORD_LENGTH` is 8. One-time password length is 6, with 5 attempts. These are stock `config.php` constants.
- Session lifetime constants: session alive 2 hours, cookie alive 7 days, online window 5 minutes. `SESS_VALIDATE_UA` is true. `SESS_VALIDATE_IP` is commented out.
- Category picker thresholds, page thresholds, and `MAX_RECENTLY_VIEWED` of 10 are stock constants in `config.php`.
- `robots.txt` disallows `/app/` and `/store_closed.html`. It does not disallow `addressing.php`, `api.php`, or `fa-admin.php`.

## Assumptions not made

- That `master` is what customers use. The code says it is the default branch, and the product customisations are on `qa` and `prod`.
- That status `A` is displayed as "Ready for Collection".
- That the Webkul demo fields are still the live vendor form.
- That the SES credentials or database password in git are current. They are present. Their values are omitted on purpose.
