# Business rules — farmtrade (FarmTrade / AgMarket)

Evidence date: 2026-10-15.

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

No business-rules document exists in the source repository. Rules below are taken from code, SQL seeds, templates, and deploy files. Anything marked **Inferred** is a conclusion from that evidence, not a written policy.

Rules that depend on Parcel Perfect, PayFast, AgMarket copy, or the custom add-ons apply to `prod` (`429b1564`) and `qa` (`a755fe81`). They are not in `master` (`f4f46ea9`).

Secret values are not reproduced.

## Identity and access

### BR-001 — Three panels, three entry scripts

**Implemented.**

Customers enter through `index.php`. Vendors enter through `vendor.php`. Administrators enter through `admin.php`. On `prod` and `qa` the configured admin index is `fa-admin.php`, which boots the same admin area. `admin.php` is still in the tree, so a request to that filename still boots the admin area.

Evidence: `index.php`, `vendor.php`, `admin.php`, `fa-admin.php`, `config.local.php`.

### BR-002 — PHP older than 5.6.0 cannot boot

**Implemented.**

Each entry script compares `phpversion()` with `5.6.0` and stops when the installed version is lower. The Webkul add-on file header says PHP 7.1. The host's actual PHP version is not in git.

Evidence: `index.php`, `admin.php`, `fa-admin.php`.

### BR-003 — Vendor login requires a terms checkbox in the browser

**Implemented** markup. **Inferred** that the server does not store the agreement.

The checkbox is rendered only when `ACCOUNT_TYPE` is `vendor`. It is `required`. It has no `name`, so a successful post does not include an agreement flag. The link target is AgMarket page 31 in preview mode, on `https://www.agmarket.co.za`, not on the current host.

Evidence: `design/backend/templates/views/auth/login_form.tpl`.

### BR-004 — Product-import delete is limited to the root API user

**Implemented.**

`ImportProducts::delete()` returns HTTP 401 unless `auth['is_root']` is `Y`. Create and update use a different check: a company row for the caller's `company_id`, or user type `A`.

Evidence: `app/addons/product_import/Tygh/Api/Entities/ImportProducts.php`.

### BR-005 — Vendors import into their own company; administrators may name a company

**Implemented.**

User type `V` forces `company_id` from the session. User type `A` uses `-1`, and the product payload's `company_id` is used. Category import runs only for user type `A`.

Evidence: `importProducts()` and `importThings()` in the same file.

### BR-006 — Vendor custom-field admin is not granted to vendors, except file download mode

**Implemented** in the vendor permission schema.

`wk_vendor_custom_fields` is set to `permissions` false. Mode `wk_getfile` is set to `permissions` true.

Evidence: `app/addons/wk_vendor_custom_registration/schemas/permissions/vendor_multivendor.post.php`.

**Inferred** from CS-Cart's vendor permission files: false means the vendor panel may not open that controller. This repository does not contain a separate comment that states the policy.

### BR-007 — Address lookup is an unauthenticated GET

**Implemented.**

`addressing.php` defines `NO_SESSION` and does not check a user. Any client that can reach the host can request `?town=`. Non-GET requests are rejected with HTTP 400.

Evidence: `addressing.php`.

## Catalogue and vendor data

### BR-008 — A product is the same product when code and company match

**Implemented.**

Import looks up `cscart_products` by `product_code` and `company_id`. No row creates a product. A row updates that product id.

Evidence: `ImportProducts::importProducts()`.

### BR-009 — Disable sets status D and only looks at company 1

**Implemented.**

Root delete splits `product_codes` on commas and updates `cscart_products.status` to `D` where `product_code` matches and `company_id` is `1`. Rows already `D` are reported as already disabled and are not updated. Products of any other company are not selected.

Evidence: `deactivateProducts()`.

**Unknown / Requires Confirmation:** whether company id 1 is the intended vendor for this operation. The id is a literal in the method.

### BR-010 — Vendor registration seed marks a fixed set of fields required

**Implemented** as install seed, not as a later business decision.

Required (`profile_required` Y) in `database/demo.sql`: company, first name, last name, email, phone, address, city, country, state, zip code.

Not required: company description, language, url, fax.

The seed also adds `companies.firstname`, `companies.lastname`, and `companies.wk_vendor_custom_fields`.

**Unknown / Requires Confirmation:** whether this seed is what the live form still uses.

### BR-011 — An image that cannot be decoded is not stored

**Implemented.**

`saveImagesLocal()` returns false when `imagecreatefromstring` fails. The comment in the method says the image is corrupt, malicious, or unsupported.

Evidence: `ImportProducts.php`.

## Checkout, shipping, and orders

### BR-012 — The quoted service is the cheapest returned rate

**Implemented.**

`processResponse()` builds a map of service to `total` and selects `min`. It then calls `Quote.updateService` with that service. If several services share the minimum, `array_keys` supplies the first of those keys.

Evidence: `app/Tygh/Shippings/Services/Parcelperfect.php`.

### BR-013 — The first Parcel Perfect place match is used

**Implemented.** A comment in `getRequestData()` says the user would normally choose among places. The request uses `results[0]` for origin and destination.

Town name is tried first. Postcode is tried only when the name lookup returns no results.

### BR-014 — A quote id is kept on the order in the fax column

**Implemented.**

For `is_parent_order = N`, `place_order` copies `$_SESSION['agmarket']['quote_data'][company name]['quote_id']` into `?:orders.fax` when that quote id is non-empty.

Parent orders are skipped.

**Inferred:** a fax number previously stored on the order is replaced by the quote id. The code does not read the old fax value before writing.

### BR-015 — Status A, and only a child order with a quote and no tracking number, creates a collection

**Implemented.**

After a successful admin status change to `A`:

- Parent orders are ignored.
- If a tracking number already exists, Parcel Perfect is not called.
- If `orders.fax` is empty, Parcel Perfect is not called.
- Otherwise `Collection.quoteToCollection` is called with that fax value as `quoteno`.
- Collection window sent: start `08:00:00`, end `17:00:00`.
- `notes` is `AgMarket`.
- `printWaybill` is 1. `printLabels` is 0.
- `quoteCollectionDate` is an empty string. A tomorrow date formatted `d/m/Y` is computed and not sent.
- On a response that includes results, the waybill number is stored as `shipments.tracking_number`, and each order line becomes a shipment item.

The comment labels status `A` "Ready for Collection". The status title in the database was not read.

**Unknown / Requires Confirmation:** the configured name of status `A`, and whether an empty collection date is accepted by Parcel Perfect.

### BR-016 — A stored Parcel Perfect token is reused inside the configured day window

**Implemented.**

`tokenMan()` reuses the latest `sil_parcel_perfect_data` row when the absolute difference between now and the stored timestamp is less than `sil_pp_token_expire` days, or when the stored token string length is 0. Otherwise it requests a new token. The timestamp written on insert is `time()` at insert, not a timestamp returned by Parcel Perfect.

Missing URL, username, or password returns null.

Salt failure ends the request with `die`. Token failure returns the error code.

**Unknown / Requires Confirmation:** whether the empty-token branch is intentional. As written, a row whose token length is 0 is returned rather than refreshed.

### BR-017 — Checkout city selection locks the shipping postcode

**Implemented.**

Choosing an autocomplete item copies `pcode` into `#litecheckout_s_zipcode` and sets that input readonly.

Evidence: `parcel_perfect_address.js`.

### BR-018 — PayFast will not apply a notification to an order that has left incomplete

**Implemented.**

On the notify URL, if `order_info['status']` is not `N`, the script logs and `die`s after it has already sent HTTP 200.

Evidence: `app/payments/payfast.php`.

### BR-019 — A completed PayFast payment is forced to status P

**Implemented.**

`payment_status` `COMPLETE` assigns the processor map's `completed` status onto the response and calls `fn_change_order_status` with `P`. `FAILED` and `PENDING` use only the map. A later branch calls `fn_finish_payment` unless the response status equals the string `pending`.

The map itself is payment-method configuration in the database.

**Unknown / Requires Confirmation:** the letters configured in that map, and which of `P` or the mapped letter is the status customers and vendors see after both calls run.

### BR-020 — PayFast return opens incomplete child orders

**Implemented.**

When the buyer returns and the order is a parent order, each child still in status `N` is set to `O` with notifications suppressed (`fn_change_order_status(..., 'O', '', false)`).

### BR-021 — Cancelling at PayFast sets the order back to incomplete

**Implemented.**

Cancel mode finishes the payment with status `N` and reason `text_transaction_cancelled`.

### BR-022 — Sandbox PayFast does not use the store's merchant id

**Implemented.**

Mode `sandbox` replaces the merchant id and key with PayFast's published sandbox merchant and uses `sandbox.payfast.co.za`. The passphrase is included only when the mode is not sandbox and the passphrase parameter is non-empty.

The sandbox merchant values are the public test pair shipped in the plugin. They are not copied here.

### BR-023 — PayFast rejects a notification whose amount does not match the order

**Implemented.**

`pfAmountsEqual` compares `amount_gross` with the order total formatted in the processor currency. A mismatch sets the error flag and skips the status update.

Signature failure and, when debug is off, a source IP that `pfValidIP` rejects, do the same. Debug skips the IP check.

## Mail and storefront content

### BR-024 — Outgoing mail does not use the company from-address

**Implemented.**

Both the message builder and the PHPMailer transport pass `'AgMarket'` and `'noreply@agmarket.co.za'` to `setFrom`, in that order, whenever a from-parameter or an SMTP send is being prepared.

**Inferred:** PHPMailer's `setFrom` expects the address first and the display name second, so the address becomes the word AgMarket and the display name becomes the email. The repository does not document that this was intended.

### BR-025 — SMTP mail goes to Amazon SES in eu-west-1, not to the configured host

**Implemented** when `mailer_send_method` is `smtp`.

Host, port 587, TLS, and credentials in `PhpMailerTransport` replace the settings object. The previous lines that read `mailer_smtp_host` and the related keys are comments.

Credentials are committed in that file. They are not repeated in this document.

### BR-026 — The footer newsletter form and the header call-request block are not shown

**Implemented** as HTML comments in the responsive templates. The add-on code is still in the tree.

**Unknown / Requires Confirmation:** whether those add-ons are active. An active add-on can still be invisible if the template that draws it is commented out.

### BR-027 — The payment-icon block shows Mastercard and Visa

**Implemented.**

The responsive template no longer emits the 2Checkout or PayPal icon spans. `bright_theme` has a payment-icons template with Mastercard and Visa only.

This does not remove the PayPal or other payment processor code from `app/payments` and `app/addons`.

### BR-028 — The privacy policy PDF is FarmTrade; the vendor terms link is AgMarket

**Implemented** artefacts, not a single rule that connects them.

The PDF filename is `FarmTrade-Privacy-Policy-4-08-2020.pdf`. The vendor checkbox links to an AgMarket page. No template in the diff points the storefront at the PDF.

**Unknown / Requires Confirmation:** which document customers are supposed to accept.

## Deployment and environment

### BR-029 — Prod and QA deploys are branch pipelines

**Implemented.**

Branch `prod` deploys with `PROD_*` variables. On the `qa` tip, branch `qa` deploys with `QA_*` variables. `master` is not a pipeline branch. Both AppSpec files deploy into `/var/www/html/farmtrade.co.za/` as user `apache`, then start `httpd`.

**Unknown / Requires Confirmation:** whether those deployment groups still exist, and whether QA and production are different servers that happen to use the same path.

### BR-030 — The store host in config is not the path in AppSpec

**Implemented** values that disagree.

| Branch | Config host | Database name |
|---|---|---|
| `master` | `qa.farmtrade.co.za` | `farmtrade` |
| `prod` | `agmarket.co.za` | `db_agmarket` |
| `qa` | `qa.agmarket.co.za` | `db_agmarket` |

AppSpec destination on `master` and `qa`: `/var/www/html/farmtrade.co.za/`.

### BR-031 — Demo mode is off

**Implemented.**

`config.local.php` sets `demo_mode` to false on `master`, `prod`, and `qa`.

### BR-032 — Sessions are tied to the user agent, not to the IP

**Implemented** stock constants.

`SESS_VALIDATE_UA` is true. `SESS_VALIDATE_IP` is commented out. Session alive time is 2 hours. The online window is 5 minutes.

Evidence: `config.php`. These constants are on `master` and were not in the product-line diff as changed.

## Stock thresholds that the code enforces

These are CS-Cart constants in `config.php`, not rules written for FarmTrade. They are **Implemented** in this tree:

| Constant | Value | Effect named in the comment beside it |
|---|---|---|
| `USER_PASSWORD_LENGTH` | 8 | Authentication setting |
| `USER_ONE_TIME_PASSWORD_LENGTH` | 6 | One-time password |
| `USER_ONE_TIME_PASSWORD_ATTEMPTS` | 5 | Attempts |
| `CATEGORY_THRESHOLD` | 100 | Category select versus picker |
| `MAX_RECENTLY_VIEWED` | 10 | Recently viewed products in the session |
| `MAX_INITIAL_ORDER_ID` | 10000000 | Cap for the initial order id setting |
| `COOKIE_ALIVE_TIME` | 7 days | Permanent cookies |
| `SEO_DELIMITER` | `-` | SEF URL delimiter |

Order status letters defined in `config.php`: incomplete `N`, parent `T`, backordered `B`, canceled `I`. Status `A` and status `P` and status `O` are used by the custom PayFast and Parcel Perfect code and are not given names in `config.php`.
