# Business rules — qoutes (Silicon Overdrive Quotes)

Evidence date: 2026-10-05.

Source: `git@bitbucket.org:overdrive/qoutes.git`, `master` at `0eb2f86` (2019-11-19).

Rules below are taken from code, from `relationships.rtf`, or from `Sales Workflow.xlsx`. Each rule is marked **Documented** or **Inferred** (including rules that are only visible as implemented behaviour). Database passwords are not copied here.

Where the spreadsheet and the code disagree, both are recorded. The code is what this snapshot does. The spreadsheet is not treated as an approved rule just because it is in the repository.

## Identity and access

### BR-001 — Login identity

**Inferred** from `login.php`.

A person logs in with the email stored on `sysUser`. The security group must have `blnActive = 1`. The user must have `blnActive = 1`. The password matches when `strPasswordMD5` equals MD5 of the typed password.

Failure text does not say which of email, group, or password failed, except the inactive-user message, which is specific.

### BR-002 — Login attempt log

**Inferred** from `login.php` and `usage.log.php`.

Each attempt is inserted through `tblLoginInsert` with one of: "Login successful", "Login failed: Incorrect password", "Login failed: Inactive User", "Login failed: User not found or SG not active".

`usage.log.php` shows the stored password value for rows that are not "Login successful", and masks successful ones with asterisks. The usage log therefore displays failed-password material to anyone who can open that page.

### BR-003 — Lockout warning

**Inferred** from `CheckSecurityLock`.

The function counts the latest login rows, up to 5, stopping at a success. If there are 5 incorrect-password failures and no success in that window, it prepares `blnLogin = 0` and emails a suspension warning. The `Save()` that would persist `blnLogin` is commented out. From 2 failures upward, and below 5, the login message adds how many attempts remain (`5 - failures`).

**Discrepancy:** the email says the account has been suspended. The persistence line is commented out, so this snapshot does not show the flag being stored.

### BR-004 — Page view right

**Inferred** from `Nemo::iniSecurity`.

A logged-in user may open a script only when `sysSecurity.blnView` is 1 for their group and that script URL. Otherwise `windowLocation` prints a script that sends the browser to `message.php`, and PHP continues. The denial counter is `$_SESSION[intAttempt]`, incremented after that print. When the counter is already greater than 5, the message id is `e10` instead of `r09`. `message.php` has no `e10` text.

Delete, save, new, and special flags are stored and edited on the security-group screen. This document does not claim every button checks them. List classes receive the security row. A full audit of every button was not completed.

### BR-005 — Security group meaning

**Requires Confirmation.**

| Comment | Id | File |
|---|---|---|
| Dev admin or admin | 1 or 2 | `csp.dashboard.php` |
| Sales person | 3 | `report.php` |
| Admin | 3 | `quote.php` `MarkQuoteAsInvoiced` |

Cron jobs that have no session assume group 1.

The names that staff see come from `sysSecurityGroup` and are not in git.

### BR-006 — Sales-person report default

**Inferred** from `report.php`.

When the session group id is 3, the sales-person filter defaults to the `tblSalesPerson` row whose email equals the user email. Other groups default to "All".

## Quote numbering and revisions

### BR-007 — New quote number

**Inferred** from `Quote::Save` and `GetNextQuoteNumber`.

The first save of a new quote sets status `1. New`, sets `intQuoteNumber` to one more than the current maximum, and sets `strQuoteNumber` to `Q` + that integer padded to 7 digits + `.1`.

Example shape: `Q0000002.1`. The sample PDF in the repo is named `System_Proposal_Q00001.1_Notebook_2014-02-13.pdf`.

### BR-008 — Revision number

**Inferred** from `Quote::NewRevision`.

A new revision copies forward under the same `intQuoteNumber`. The revision integer is one more than the maximum revision for that number. The display number uses the new revision as the suffix.

The controller generates a PDF before and after the revision is created.

### BR-009 — New quote status is locked

**Inferred** from `quote.php`.

While `QuoteID` is empty, the status control is set to `1. New` and disabled. `Save` also forces `1. New` when `QuoteID` is 0.

## Pricing

### BR-010 — Line price

**Inferred** from `Quote::Save`.

Posted markup is divided by 100 and stored. Line price is cost plus cost times markup, rounded to 2 decimals. If `blnExcludeMargin` is 1, line price equals cost. Units are stored rounded to 1 decimal after commas are removed.

### BR-011 — Quote totals and VAT

**Inferred** from `Quote::Save`.

Exclusive total is the sum of line price times units. Inclusive total is exclusive total times `(1 + VAT setting)`, unless the posted currency is `$`, in which case inclusive total equals exclusive total.

The VAT rate is `sysSettings` key `VAT`. The rate is not in git.

### BR-012 — New line markup

**Inferred** from `Quote::AddItem`.

A blank line takes `percMarkup` from the selected client, units 1, and grouping order one higher than the current maximum on that quote.

### BR-013 — Bundle markup

**Inferred** from `includes/quote.bundle.cls.php`.

The class constant `percMarkupDefault` is `0.13`. A comment dated 2015-07-10 says markup was removed on the bundle at Theo's request, and a comment dated 2015-07-09 says bundle items stay editable.

**Requires Confirmation:** which of those comments matches the screen staff use. Both the constant and the "removed" comment are in the same file.

### BR-014 — Payment terms wording

**Inferred** from `EmailQuoteTo`.

If `strPaymentTerms` is `Month End`, the email says payment is due by the last working day of the month invoiced. Any other value uses the upfront sentence: the order is processed and released following payment.

### BR-015 — Labour on the profit report

**Inferred** from `includes/rpt.profit.php`.

Products with `blnLabour = 1` contribute 0 to dealer cost and contribute their extended price to labour. Profit is quoted extended price minus non-labour dealer cost minus labour. The report includes quotes in statuses `4. Invoiced`, `5. Paid`, `7. Ordered`, `8. Stock Arrived`, and `9. Delivered`.

## Status rules

### BR-016 — Status list used by the application

**Inferred** from `ChangeStatus` and report SQL. The dropdown itself is whatever enum MySQL returns.

| Status | Extra behaviour in `ChangeStatus` or `Save` |
|---|---|
| `0. Error` | Label only in `ChangeStatus`. Hourly cron sets this when `quickbooks_errmsg` is not empty |
| `1. New` | Forced on create |
| `2. Sent` | Set after a successful quote email |
| `3a. To be Invoiced` | Requires a PO number. Sends "Quote To Be Invoiced". Browser calls the web-service handler |
| `3b. Rejected` | `ChangeStatus` sends "Quote Rejected". List archive sets the status and does not send that email |
| `3c. Revised` | Label only |
| `3d. Pending Approval` | Sends "Quote Pending Approval" |
| `4. Invoiced` | Hourly cron can set this from an invoice number. `Save` sends "Quote Invoiced" when an invoice number is saved, `blnInvoiced` was not 1, and the previous status was not invoiced |
| `5. Paid` | Hourly cron sets this when the invoice row is paid and has an invoice number |
| `6. Credited` | Message claims removal from the web-service queue. The HTTP call is commented out |
| `7. Ordered` | Label only in `ChangeStatus` |
| `8. Stock Arrived` | Sends "Quote Arrived" and "Quote Arrived HelpDesk" |
| `9. Delivered` | Sends "Quote Delivered" |

`Sales Workflow.xlsx` also names Unassigned, Assigned, Follow Up Sent, and Approved Quote. Those strings were not found as `ChangeStatus` cases. Provenance of the extra names: **Documented** in the spreadsheet only.

### BR-017 — PO number

**Inferred** from `quote.php`.

Status cannot move to `3a. To be Invoiced` when the PO number is blank. The browser alerts and restores the previous status. Server-side `ChangeStatus` does not repeat that check.

### BR-018 — Last status date

**Inferred** from `Quote::Save`.

Every save sets `dtLastStatusDate` to today's date, including saves that do not change status.

`ChangeStatus` copies the previous status and previous status date before updating status. It does not itself set `dtLastStatusDate` in the `UPDATE` shown. The following save, when the controller calls `Save` before `ChangeStatus`, will have set the date already.

### BR-019 — Email sets Sent

**Inferred** from `EmailQuoteTo`.

A successful send to the client or to the user calls `ChangeStatus` with `2. Sent`. A failed send returns "SENDING FAILED" and does not show a status change.

`Sales Workflow.xlsx` says status Sent applies "only if sent directly to Client" and "Don't email if self-assigned". The code sets `2. Sent` for both client and user sends, and does not check self-assignment.

**Discrepancy:** spreadsheet versus `EmailQuoteTo`.

### BR-020 — Archive is not the rejected email

**Inferred** from `Quote::SetRejected`.

The list archive action updates status to `3b. Rejected` and returns "Records Rejected." It does not call `EmailQuote`.

### BR-021 — Invoiced flag

**Inferred** from `Quote::Save` and `cron.hourly.php`.

`blnInvoiced` is forced back to 1 on save if the stored row is already 1. The hourly cron sets `blnInvoiced = 1` wherever the invoice number is non-empty and the flag is 0, and does not send the invoiced email (that call is commented out).

The form shows the flag as Yes or No and does not leave it as an editable checkbox (`hidden` and `readonly`).

### BR-022 — Supplier order number

**Inferred** from `Quote::Save`.

If the posted invoice number is non-empty and a client is selected, the supplier order number is `{invoice number}_{first word of the client name}`. Otherwise the save writes an empty supplier order number.

The field is read-only. Placeholder text says it is generated when invoiced.

### BR-023 — QuickBooks overwrites status

**Inferred** from `cron.hourly.php`.

For each quote joined to `invoice_table`:

- A non-empty QuickBooks error message sets `0. Error` (this assignment happens after the paid/invoiced choice, so an error replaces paid or invoiced in the same loop).
- Otherwise an invoice number with `paid == 1` sets `5. Paid`.
- Otherwise an invoice number sets `4. Invoiced`.

Previous status and previous status date are copied. `dtLastStatusDate` is set to today. `ChangeStatus` is not used, so the status emails do not fire from this path.

`Sales Workflow.xlsx` notes that marking paid when payment is allocated later "may cause issues if we rush an order". That note is **Documented**. The code still sets Paid when `paid == 1`.

### BR-024 — CSP invoicing PO

**Inferred** from `CSPClient::sendForInvoicing`.

Each CSP client in status `4. To be Invoiced` is given a quote if needed. That quote is moved to `3a. To be Invoiced` with PO number `Office 365`. The CSP client status becomes `5. Sent to QB`.

Because this calls `Quote::ChangeStatus`, the "to be invoiced" email and the browser is not involved; the web-service AJAX lives in the HTML returned to a normal quote screen. This cron-less button path returns the script tag inside the message string. Whether that script runs on the CSP dashboard was not verified in a browser.

## Email rules

### BR-025 — Client quote email

**Inferred** from `EmailQuoteTo`.

Template "Client Quote". From address is the logged-in user's email. Client sends BCC `EmailClientBCC` and CC the quote's `strEmailCC`. User sends do not add CC or BCC. The PDF under `documents/` plus the stored filename is attached. Logo URL in the substitution is `https://www.quotes.overdrive.co.za/images/SignatureLogo.jpg` (note `www`).

### BR-026 — Internal status email

**Inferred** from `EmailQuote`.

| Event key | Template | Recipient setting |
|---|---|---|
| ToBeInvoiced | Quote To Be Invoiced | `EmailToBeInvoiceQuotesRecipient` |
| Rejected | Quote Rejected | `EmailRejectQuotesRecipient` |
| Pending Approval | Quote Pending Approval | `EmailPendingApprovalRecipient` |
| Arrived | Quote Arrived | `EmailArrivedQuotesRecipient` |
| Arrived HelpDesk | Quote Arrived HelpDesk | `EmailArrivedHelpDeskQuotesRecipient` |
| Delivered | Quote Delivered | `EmailDeliveredQuotesRecipient` |
| Invoiced | Quote Invoiced | `EmailInvoiceQuotesRecipient` |

Template bodies and the actual addresses are in the database.

`Sales Workflow.xlsx` names specific mailboxes (`ops@`, `control@`, `rmaeta@`, `theo@`). Follow-up code hardcodes `ops@overdrive.co.za`. The hourly digest hardcodes `gareth@overdrive.co.za` and ignores the variable loaded from `Email QBWS Recipients`. Other status mail uses settings. **Requires Confirmation** which mailbox is current for each event.

### BR-027 — Stale quote digest

**Inferred** from `cron.daily12.php`.

If any quote has `dtLastStatusDate` on or before yesterday and status in `1. New`, `5. Paid`, or `0. Error`, the script emails `Email Quote Status Report Recipients` using template "Report Quote Status". If none match, it prints "All the quotes have been checked or followed up" and does not send.

The comment at the top of the file says the same three statuses. It does not mention `3a. To be Invoiced` in the SQL, despite the comment's first line, which does mention `3a`. **Discrepancy** inside the file: the comment lists `3a. To be Invoiced`; the query does not.

### BR-028 — ETA digest

**Inferred** from `cron.daily.php`.

The ETA report is emailed every run to `Email ETA Report Recipients` with template "Report ETA". The substitution dates are the first day of the month six months ago through today. There is no "only at 16:00" check in the script.

`Sales Workflow.xlsx` says the ETA list should be produced every day at 16:00. Schedule is **Unknown** (no crontab in git).

### BR-029 — Client follow-up

**Inferred** from `emailQuoteFollowup`.

Unattended mode selects status `2. Sent` with `dtQuoteDate` older than 7 days, groups by `intQuoteNumber`, and sends one message per client with every matching PDF attached. From and BCC are `ops@overdrive.co.za`. Template "Client Quote Followup". Subject is `{client} Pending Authorisations & Archiving: Silicon`.

A successful send does not change quote status.

The manual list action passes selected quote ids and does not apply the 7-day rule.

### BR-030 — QuickBooks digest recipient

**Inferred** from `cron.hourly.php`.

Rows with `blnEmail = 0` are included. The message describes an error, or "Has been paid.", or "Has been invoiced." After a successful `mail()`, those invoice ids get `blnEmail = 1`.

The send is hardcoded to `gareth@overdrive.co.za` from the same address. Setting `Email QBWS Recipients` is not used.

## Catalogue and deletion

### BR-031 — Product archive

**Inferred** from `includes/product.cls.php`.

Archive sets `blnActive = 0`. It does not delete the row. Product lists can filter on `blnActive`. Category and supplier dropdown SQL in that class keeps rows with `blnActive = 1`.

### BR-032 — Foreign keys as notes

**Documented** in `relationships.rtf` and repeated in class comments.

The notes declare `ON DELETE CASCADE` for:

- Product to supplier and product to category.
- Quote to client.
- Quote item to quote and quote item to product.
- Quote template item to quote template and to product.
- Quote item to supplier (comment in `includes/quote.cls.php`, 2016-11-22).
- Quote bundle item to bundle and to product; bundle SKU to product (`includes/quote.bundle.cls.php`).
- Service 360 to its grouping (several commented variants in `includes/service.360.cls.php`).

These statements are not executed by the application at runtime. **Unknown** whether production applied them.

### BR-033 — Client import

**Inferred** from `ajaxfunctions.php`.

`loadEitClientDDL` and `ImportEitClient` read the eIT connection. The quote form links "Select from eIT" and, once a client is chosen, "View" opens `client.php`.

## CSP pipeline

### BR-034 — CSP statuses

**Inferred** from the filter SQL in `includes/csp.dashboard.cls.php`.

The filter offers: `1. New`, `2. Pre screened`, `3a. Callback`, `3b. Meeting`, `3c. Sent`, `4. To be Invoiced`, `5. Sent to QB`, `6. Invoiced`, `7. Not interested`.

These are not the quote statuses in BR-016.

### BR-035 — CSP tiers

**Inferred** from the same filter.

Tier filter values are `Tier 1`, `Tier 2`, and `Tier 3`, plus All.

### BR-036 — Who may send CSP clients for invoicing

**Inferred** from `csp.dashboard.php`.

Export and "Send For Invoicing" are shown when the session group id is 1 or 2.

## Spreadsheet process that the code does not enforce

**Documented** only in `Sales Workflow.xlsx`. Not found as enforcement in PHP:

- Capture details from external emails only.
- Skip email when the user self-assigns the quote.
- Email a named person when a quote is rejected or revised, as a rule separate from the settings-driven templates.
- Limit supplier purchase-order actions to named staff.
- Build the ETA list at 16:00.
- User must set `2. Sent` after finishing a revision. The revision action does not set that status; the email action does, for any successful send.

Treat the spreadsheet as a process note from the repository, not as a rule the application checks.
