# Business rules — ohlief-app-api

Evidence date: 2026-10-08. Source: `master` `08d6e03`.

Rules below are what this repository does. None of them is a signed-off business policy. Each rule is marked **Implemented** or **Inferred**. The client has its own rules in `silicon_ark/ohlief-app-client/business-rules.md`. Where the two differ, both sides are stated.

## Identity and access

### BR-001 — EDI rows share two partition keys

**Implemented.** Order import writes `userId` `OrderRow`. Invoice updates address `OrderRow`. Product create, get, and update use `userId` `ProductRow`. The caller's Cognito id is not the key for those operations. The identity-id expressions are commented out beside the literals.

Evidence: `ftptos3.js`, `get.js`, `update.js`, `invoiceupdate.js`, `invoicetoftp.js`, `createProduct.js`, `getproduct.js`, `updateProduct.js`.

### BR-002 — A note belongs to the caller

**Implemented.** `POST /ohlief` sets `userId` to `cognitoIdentityId`. It also sets `noteId` and `transactionId` to two different UUID v1 values, plus `content`, `attachment`, and `createdAt`. It does not set `type`.

Evidence: `create.js`.

### BR-003 — Delete addresses the caller, not an EDI row

**Implemented.** `DELETE /ohlief/{id}` deletes (`cognitoIdentityId`, path `id`). It cannot delete an item stored under `OrderRow` or `ProductRow`.

Evidence: `delete.js`.

### BR-004 — Most routes require IAM; two do not

**Implemented.** `authorizer: aws_iam` is set on every HTTP event except `ftptos3` and `invoicetoftp`, where that line is commented out.

There is no role check inside any handler.

Evidence: `serverless.yml`.

### BR-005 — Lists are not scoped to a user

**Implemented.** Order, invoice, and product lists filter on `type` only. Any caller who can invoke the route receives the page of matching items.

Evidence: `list.js`, `listinvoices.js`, `listProducts.js`.

## Orders and invoices

### BR-006 — An order line is stored only when a product matches

**Implemented.** Import splits the file on `|`. A line is skipped unless index 9 is defined. Products are scanned where `packageBarcode` equals index 15 (`barCode`). The put runs inside that product loop. No match means no order item. Several matches mean several order items for one file line.

The FTP file is still archived and the email still sends. Those steps do not check that a put happened.

Evidence: `ftptos3.js` `WritetoDB`, `WritetoFTP`, `SendEmail`.

### BR-007 — File columns versus stored columns

**Implemented.** Indexes below are what the import reads. "Stored" is the DynamoDB attribute.

| Index | Stored attribute | Value written |
|---|---|---|
| 0 | `datetime` | File value, or a single space |
| 1 | `orderNumber` | File value, or a space |
| 2 | `buyerReference` | File value, or a space |
| 3 | `venderGLN` | File value, or a space. The attribute name is spelled `vender` |
| 4 | `buyerGLN` | File value, or a space |
| 5 | `accountNumber` | File value, or a space |
| 6 | `vendorName` | File value, or a space |
| 7 | `VendorVatRegNum` | File value, or a space |
| 8 | `buyerRegionGLN` | File value, or a space |
| 9 | `buyerRegionDescription` | File value, or a space. Also the "line is long enough" check |
| 10 | `storeGLN` | File value, or a space |
| 11 | `storeSiteCode` | File value, or a space |
| 12 | `storeSiteDescription` | File value, or a space |
| 13 | `deliveryDate` | File value, or a space |
| 14 | `sellerProductCode` | Product `productCode`, not the file. Space if missing |
| 15 | `barCode` | File value. Also the barcode used to find the product |
| 16 | `sellerProductDescription` | Product `productDescription`, not the file |
| 17 | `buyerProductCode` | File value, or a space |
| 18 | `weightUOM` | Product `unit`, not the file |
| 19 | `weight` | Product `sizeOfProduct`, not the file |
| 20 | `UOM` | The literal `CS`. The file value is not stored |
| 21 | `orderQty` and `invoiceQty` | Both start as the file value, or a space |
| 22 | `singleUnitPrice` | Product `unitPrice`, not the file |
| 23 | `discountPercentage` | File value, or `0` |
| 24 | `discountValue` | File value, or `0` |
| 25 | `orderLinePriceExcl` | File value, or a space |
| 26 | `orderLineVat` | File value, or a space |
| 27 | `orderLinePriceIncl` | File value, or a space |
| 28 | `orderTypeCode` | File value, or a space |
| 29 | `documentStatusCode` | File value, or a space |
| 30 | `documentActionCode` | File value, or a space |
| 31 | `costPer` | File value, or a space |
| 32 | `allocationOrder#` | File value, or a space. The attribute name includes `#` |
| 33 | `originator` | File value, or a space |
| — | `quantityPerPack` | Product `quantityPerPack` |
| — | `rsp` | Product `rsp` |
| — | `content` | `RowContent[0]`, the first line of the file, not the current line |
| — | `attachment` | The FTP file name |
| — | `type` | `order` |
| — | `process` | `"0"` |
| — | `userId` | `OrderRow` |
| — | `noteId`, `transactionId` | New UUID v1 values |
| — | `createdAt` | `Date.now()` |

Evidence: `ftptos3.js`.

### BR-008 — Invoice quantity can be overwritten, and zero becomes null

**Implemented.** `PUT /ohlief/{id}` sets `invoiceQty` to `data.invoiceQty || null`. A numeric `0` is falsy, so it is stored as `null`. The same operator is applied to `content` and `attachment`.

The handler does not compare invoice quantity to order quantity. The client input has `min` 0 and `max` order quantity; that limit is not in this API.

Evidence: `update.js`. Client limit: `ohlief-app-client` BR-010.

### BR-009 — "Invoice" means the row's type changes

**Implemented.** `GET /invoiceupdate/{orderNumber}` finds items with that `orderNumber` and sets `type` to `invoice` on key (`OrderRow`, `transactionId`). It does not copy the row, does not change `process`, and does not write FTP.

It does not require the current `type` to be `order`. Rows that are already `invoice` match the scan and are updated again.

The response is sent before the updates finish.

Evidence: `invoiceupdate.js`. This is the behaviour the client rule BR-007 left unknown.

### BR-010 — An exported invoice line uses stored fields and a 0.21 factor

**Implemented.** Export selects `type` = `invoice` and `process` = `"0"`. For each line with an `orderNumber`:

- Ex-VAT line = `invoiceQty` × `singleUnitPrice` × `quantityPerPack`, each passed through `parseFloat(...).toFixed(3)` before the multiply.
- VAT line = that amount × `0.21`, then `toFixed(3)`.
- Case unit price = `singleUnitPrice` × `quantityPerPack`, same `toFixed(3)` pattern.
- Inclusive line = ex-VAT + VAT.

The first loop adds those amounts into three module-level totals (`totalexclVat`, `invoicedVat`, `totalVatIncl`) across every selected line. The second loop writes every line into one string. The file name is `I` plus the first `orderNumber` seen, plus `.csv`.

**Inferred:** `0.21` is 21% VAT. The source never says "VAT" in a comment. The attribute `VendorVatRegNum` is copied onto the line and is not used in the arithmetic.

The client displays `Math.round(qty * singleUnitPrice * quantityPerPack)` with no `0.21` factor. Display totals and file totals are different calculations.

Evidence: `invoicetoftp.js`. Client: `ohlief-app-client` BR-008.

### BR-011 — One export file holds every unprocessed invoice

**Implemented.** The comparison that would keep only the current `orderNumber` is commented out. Totals in the file are the running grand totals, repeated on each line. Scan order is not sorted, so which order number becomes the file name is whatever DynamoDB returned first.

Evidence: `invoicetoftp.js` (the commented `if(result.orderNumber == orderNum)` block).

### BR-012 — A short buffer does not upload or email

**Implemented.** FTP put and the invoice email run only when `content.length > 10`. The `process` update to `"1"` is inside the line loop, before that check, and is not awaited.

Commit `d1a5923` added the length check. Before that commit the upload and email ran unconditionally.

Evidence: `invoicetoftp.js`.

### BR-013 — Process flag

**Implemented.** Import sets `process` to the string `"0"`. Export sets it to the string `"1"` on key (`OrderRow`, `transactionId`).

`invoiceupdate` does not set `process`. A row can be `type` `invoice` and still `process` `"0"`, which is what the export scan selects.

Evidence: `ftptos3.js`, `invoicetoftp.js`, `invoiceupdate.js`.

### BR-014 — Invoice file column order

**Implemented.** Each output line is pipe-separated and ends with `\r\n`:

`datetime`, `I` + first order number, `venderGLN`, `buyerGLN`, `VendorVatRegNum`, `buyerProductCode`, `sellerProductDescription`, `barCode`, `UOM`, `weightUOM`, `weight`, `invoiceQty`, case unit price (3 decimals), ex-VAT line (3 decimals), VAT line, inclusive line (3 decimals), `discountPercentage`, `discountValue`, order number without the `I` prefix, grand ex-VAT total, grand VAT total (3 decimals), grand inclusive total, `quantityPerPack`.

Evidence: `invoicetoftp.js`.

### BR-015 — List membership

**Implemented.**

| Route | Rule |
|---|---|
| `GET /ohlief` | `type` = `order` |
| `GET /listinvoices` | `type` = `invoice` |
| `GET /listProducts` | `type` = `product` |
| `GET /listOrders` | partition `OrderRow`, any `type` |

A row marked invoiced leaves the order scan and enters the invoice scan, because only `type` changes. It remains in the `OrderRow` query.

Evidence: `list.js`, `listinvoices.js`, `listProducts.js`, `listOrders.js`.

The client also hides rows with a falsy `orderNumber`. This API does not.

## Products

### BR-016 — Product fields are stored as sent

**Implemented.** Create copies the nine body fields in FR-002 with no format check, no uniqueness check on barcode, and no numeric check on price. Update writes the same nine fields with `|| null`, so a falsy number becomes `null`.

`type` stays whatever it already was on update. Create sets `type` to `product`.

Evidence: `createProduct.js`, `updateProduct.js`.

### BR-017 — Catalogue values override the order file

**Implemented.** On import, seller code, seller description, unit price, pack quantity, size, unit, and RSP come from the matched product. See BR-007. The file's own values at those positions are not stored.

Evidence: `ftptos3.js`.

## Billing

### BR-018 — Storage charge tiers

**Implemented.** For `POST /billing`, rate is `4` if `storage <= 10`, else `2` if `storage <= 100`, else `1`. The Stripe amount is `rate * storage * 100`, currency `usd`, description `Scratch charge`.

No Oh-lief document in this repository explains the tiers. **Inferred** starter behaviour that is still routed.

Evidence: `libs/billing-lib.js`, `billing.js`.

## Notifications

### BR-019 — Who is mailed

**Implemented.** Both mail functions send to `jaco@overdrive.co.za`, `accounts@ohlief.com`, and `christine@ohlief.com`. Source address `accounts@ohlief.com`. Configuration set `info`.

Order subject: `OH-LIEF New Order submitted`. Invoice subject: `OH-LIEF New Invoice submitted`.

The HTML body includes the file text and `<a href='http://orders.ohlief.com'>Order System</a>`. The text part does not include the link.

Evidence: `ftptos3.js`, `invoicetoftp.js`.

## Data limits visible in code

### BR-020 — One page from DynamoDB

**Implemented.** No handler passes `ExclusiveStartKey` or loops on `LastEvaluatedKey`. A scan or query returns a single page.

Evidence: the `scan` and `query` params in `list.js`, `listinvoices.js`, `listProducts.js`, `listOrders.js`, `invoiceupdate.js`, `invoicetoftp.js`, `ftptos3.js`.

### BR-021 — Missing JSON is not a handled 500

**Implemented.** `JSON.parse(event.body)` is outside `try` in `create.js`, `createProduct.js`, `update.js`, `updateProduct.js`, and `billing.js`. A body that is not JSON does not hit `failure()`.

Evidence: those handlers.

### BR-022 — Two table names in one template

**Implemented** as configuration, not as a rule someone wrote down. Handlers use `process.env.tableName`. The template sets that variable to `order`. The DynamoDB resource is named from `custom.tableName`, which is `${stage}-ohlief`.

**Unknown / Requires Confirmation:** which name the running system uses.

Evidence: `serverless.yml`, `resources/dynamodb-table.yml`.
