Home›Restock›Manual
Restock · Manual

Restock Manual

Restock — User Manual

Demand forecasting and a reorder board, computed from your own sales history. Version 4.1.3 [src: app/controllers/api.php:95].

About this manual

Every statement here was written by reading Restock's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.

What Restock is

It forecasts demand from your own sales history and tells you what to reorder [src: app/controllers/mcp.php:60]. Each product carries a lead time, a safety buffer and a current stock level; from those it computes average daily demand, a reorder point, days of stock left and a suggested order quantity.

There is no model involved. The engine is pure, deterministic arithmetic with no AI and no network call [src: app/src/Forecast.php:3], and every function in it is unit-tested against hand-computed fixtures [src: app/src/Forecast.php:16].

The arithmetic, in full

The source states the whole method in five lines [src: app/src/Forecast.php:5], and it is short enough to reproduce here so you can check it against your own spreadsheet:

avg daily demand = units sold in the window / window days   (window ends today, inclusive)
safety stock     = manual override qty, OR safety-days × avg daily demand
reorder point    = (avg daily demand × lead-time days) + safety stock
days of stock    = current stock / avg daily demand         (null when demand is zero)
on order         = units on SENT purchase orders not yet received
status           = (current stock + on order) against the reorder point
suggested order  = ceil(target-cover days × avg daily demand − current stock − on order), floored at 0

Stock already on order counts (since 3.1.4) [src: app/src/Forecast.php:155]. A product on a sent purchase order is judged by what is on the shelf plus what is coming, so it is not flagged — or suggested — a second time while the delivery is on its way; days of stock still counts only the shelf. A draft order is not on order until it is sent, and a received order is already in stock [src: app/controllers/app.php:391].

The supplier's order rules round the suggestion (since 3.2.0) [src: app/src/Forecast.php:140]. When a product has a supplier minimum or is sold in cases, the figure above is what you need; what Restock suggests is that need raised to the minimum and then rounded up to whole cases, and the product page shows both (for example "225 units — needed 213; minimum 200, cases of 25"). When nothing is needed, nothing is suggested, whatever the minimum.

A few details the formulas alone do not show:

Days with no sales count as zero-demand days, not as missing data [src: app/src/Forecast.php:34]. The window is the N days ending today, inclusive [src: app/src/Forecast.php:35].

A manual safety-stock override wins outright over the calculated figure [src: app/src/Forecast.php:61]. If you know a product needs a floor of 40 units regardless of what the maths says, that is the number used.

Days of stock is null rather than zero when there is no measurable demand [src: app/src/Forecast.php:73]. A product nobody is buying does not have "0 days left"; it has no meaningful answer, and the product says so rather than inventing an alarming number.

A suggested order is never negative [src: app/src/Forecast.php:80]. Overstocked products suggest nothing rather than a negative quantity.

Status: below, near, healthy

Three states [src: app/src/Forecast.php:87]:

StatusMeaning
belowAt or under the reorder point — order now
nearWithin 25% above the reorder point — order soon [src: app/src/Forecast.php:23]
healthyAbove that

A product with no demand and no safety floor reports healthy [src: app/src/Forecast.php:89], because a reorder point of zero cannot be breached. That is worth knowing: healthy here can mean "genuinely well stocked" or "nothing is happening with this product at all", and the days-of-stock column is what distinguishes them.

Importing sales and products

The importer treats every upload as adversarial input, and the specifics are worth knowing because they determine what happens to a messy file [src: app/src/Importer.php:5]:

Columns are mapped by header name, never by position, with common aliases accepted [src: app/src/Importer.php:6]. Reordering your columns does not break the import.

Every string is clipped to its column width and every integer clamped, so a 500-character SKU or a 15-digit quantity lands truncated rather than producing a database error [src: app/src/Importer.php:7].

Malformed rows are skipped with a per-line reason, and you are shown how many imported, how many were skipped, and why [src: app/src/Importer.php:9].

There are hard caps on both the upload size and the row count, and the file is stream-parsed so a huge one never loads into memory [src: app/src/Importer.php:11].

Importing the same dates twice counts them once (since 3.1.4). A sales file's rows are totaled per SKU per date, and each total replaces what is stored for that SKU on that date, so a weekly export that overlaps last week's no longer inflates demand [src: app/src/Importer.php:157], [src: app/src/Importer.php:276]. The dry run says how many stored SKU-days a file would replace. Rows sent over the REST API or MCP are added, as before, unless the request says "replace": true.

A quantity of 0 clears that day (since 3.2.0). Because a file replaces the days it covers, 0 is a real answer — none sold that day — and removes what was stored; only a negative quantity is refused [src: app/src/Importer.php:157]. Over the REST API, 0 is accepted with "replace": true and refused without it, where adding nothing would mean nothing [src: app/controllers/app.php:13].

Stock can follow sales (since 3.2.0, off by default). With Settings → Stock follows sales switched on, every door that writes sales — the CSV import, the REST API and MCP — takes the units it added off current stock, and a replaced day that now holds fewer units puts the difference back, so re-sending a day never takes its units twice. Stock never goes below zero, the dry run shows which products would move, and each move is written to the audit trail like a hand correction [src: app/src/Importer.php:222], [src: app/src/Importer.php:245], [src: app/controllers/app.php:88]. With it off, stock changes only when you import the product file, correct a count, receive a purchase order, or a till sends its stock levels. Leave it off if a till such as Vendra sends stock levels itself.

A till that feeds Restock

A till or shop system can keep Restock current over the REST API: each day's units per SKU go to POST /api/sales-history with "replace": true, and the shelf figure per SKU to POST /api/stock-levels, which sets current stock rather than adding to it [src: app/controllers/api.php:206], [src: app/controllers/app.php:102]. Unknown SKUs and impossible figures are refused per row, each product whose figure changed gets an audit line naming the key's user, and a product pushed under its reorder point raises the same alert as any other change. The key needs a user who may import sales and set stock — a member or an administrator; a viewer's key is refused, on both doors [src: app/controllers/api.php:175].

Vendra 3.2.0 does this for you: under Connected apps, give it this installation's address and a member's API key. Every sale and refund then replaces that day's units, and the shelf count follows each sale, delivery and correction. When Vendra is connected, receive deliveries in Vendra: its count is the one Restock shows after the next sale.

Planning ahead: models, events, kits (3.2.0)

Three forecast models, chosen per product [src: app/src/Forecast.php:98]. The model sets the average daily demand; everything else in the arithmetic above stays as it is. The product page prints the formula of the one in use.

Moving average            = units sold in the window / window days                     (the default)
Recent weighted double    = (2 × units in the recent half + units in the older half)
                            / (2 × recent-half days + older-half days)
Same period last year     = units sold in the next N days one year ago / N              (N = target cover days)

The weighted model reacts faster to a product that is picking up or slowing down [src: app/src/Forecast.php:104]. The last-year model suits seasonal stock: in September it plans October with last October's sales. It works only when the product was already selling a year ago; otherwise the product uses the moving average and its page says why [src: app/controllers/app.php:349].

Planned events (Planned events, in the menu) tell the forecast that demand will change for a while: a promotion, a season, a fair, or a closure (−100%). Each day an event covers adds its percentage of the average, for one product or for every product [src: app/src/Forecast.php:117]. Event days inside the lead time raise the reorder point; event days inside the lead time plus the cover you order for raise the suggestion. Events that have ended change nothing [src: app/controllers/app.php:378]. The product page lists the events ahead and the units they add.

Kits and bundles. On a product's edit page, list the parts a kit is built from and how many of each go into one kit [src: app/controllers/planning.php:24]. Restock never suggests reordering a kit. Each kit sold counts as demand on its parts, times the units per kit, so the parts are reordered in time; a part's page shows how many of its units went out inside kits [src: app/controllers/app.php:301]. With "Importing sales lowers stock" switched on, a kit sold takes its parts off stock, and the kit's own figure is left alone [src: app/src/Importer.php:248]. One level only: a kit is not a part of another kit.

Overstock lists what sits on the shelf beyond a multiple of your target cover (two covers by default; choose 1 to 6), with the cash it holds at unit cost: excess = stock − multiple × cover days × average daily demand [src: app/controllers/planning.php:92]. A product that has not sold in the window is all excess and is marked "not selling". Kits hold no stock and are left out. The list downloads as a CSV.

Batches and expiry. When you receive a purchase order, each line takes an optional lot code and expiry date; the batch is recorded with the quantity received [src: app/controllers/v3.php:595]. A batch can also be recorded, or removed, on the product page [src: app/controllers/planning.php:132]. Restock keeps one stock figure per product, so it does not know which batch each sale came from; it assumes first expiry, first out: what is on the shelf is the latest-expiring batches, adding up to current stock [src: app/src/Lots.php:37]. Expiring stock lists the batches that expire within the days you choose, and how many units the current sell rate will not clear before each expires, with their value at unit cost [src: app/src/Lots.php:64]. Units not recorded in any batch are not counted there.

Supplier lead times, measured

Every purchase order marked sent and later received is a measured lead time. The Purchase orders page lists each supplier's deliveries, the actual lead time — the median of the calendar days from sent to received in your timezone, rounded up — its range, and the lead time that supplier's products are planned with [src: app/src/LeadTimes.php:47], [src: app/src/LeadTimes.php:24]. The median means one delivery lost in the post does not double every reorder point. Where the plan differs, Use N days sets that supplier's active products to the measured figure; the figure applied is re-measured at that moment and the change is audited [src: app/controllers/v3.php:446]. The product page shows the measured figure beside the planned one. Nothing changes a forecast until you apply it.

The dry run is a real run that writes nothing

In dry-run mode every row is parsed, validated and classified exactly as it would be on a real import — including the unknown-SKU lookup and the insert-versus-update decision — but nothing is written [src: app/src/Importer.php:14].

That is the useful kind of preview: it tells you what would happen rather than what might.

Cost and margin on purchase orders (3.3.0)

Give a product a selling price (optional, on the product form or as a selling price column in the products CSV) and Restock shows what an order makes before you raise it. Raise orders from what needs reordering lists each product's unit cost and its margin at your selling price, flagged when the cost is at or above the price [src: app/src/helpers.php:170]. Each line of a raised order keeps the selling price it was raised with, so changing a price later does not rewrite an old order [src: app/controllers/v3.php:508]. The order page shows the margin on each line and what the order sells for at your prices, with the margin in money and as a percentage, and says how many lines have no selling price [src: app/views/purchase_order.php:54]. The selling price and the margin are yours only: they are not on the PDF you send the supplier or in any CSV export. A column named just price in the products CSV is still read as the unit cost, as before; use selling price, retail price, sale price or RRP for what you sell at [src: app/src/Importer.php:43], and a file without such a column leaves the prices you have alone [src: app/src/Importer.php:119].

Purchase-order exports

The reorder board decides what to buy; a purchase order records that you did. The export presets are the last hop — the file your supplier or bookkeeper will actually accept [src: app/src/ExportPresets.php:5].

Each preset is a fixed, documented column list, and the source is explicit that a supplier's import template breaks the moment a column moves, so the headers are covered by tests and must not be reordered casually [src: app/src/ExportPresets.php:7].

Formula injection is neutralized

Every cell passes through the CSV writer, which is RFC 4180 and quotes any cell beginning =, +, -, @, tab or carriage return unless it is a plain number — so a product named =cmd|' /c calc'!A0 cannot execute in your supplier's spreadsheet [src: app/src/ExportPresets.php:11].

Plain numbers are left alone, so quantity and price columns still import as numbers.

Users and roles

Three roles, and the source names the split [src: app/controllers/app.php:192]: a buyer needs to correct stock, ingest sales and raise purchase orders, while pricing, settings, roles, deleting products and wiping history stay with the owner.

Permissionviewermemberadmin
.view permissionsyesyesyes
audit.viewyesyesyes
stock.set—yesyes
sales.ingest—yesyes
po.create / po.advance—yesyes
product.edit—yesyes
everything else——yes

⚠️ The audit trail is readable by a viewer

Restock's viewer role is granted *.view — a wildcard rather than a list [src: app/controllers/app.php:194]. Because the audit permission is named audit.view, the wildcard matches it, so a viewer can read the audit trail. This has been raised against the product.

Backups follow the model

Restock's database backup downloads are permission-gated, requiring the settings permission [src: app/controllers/api.php:285]. A viewer cannot download the database.

The API and agent access

Five tools are exposed to an agent, of which two write [src: app/controllers/mcp.php:67]: listing products, product detail and the reorder report on the read side; adjusting a stock level and ingesting sales on the write side.

The instructions state the constraint that matters: you cannot change a forecast directly — it is derived from sales [src: app/controllers/mcp.php:65].

That is the right shape. An agent can correct the inputs, and the outputs follow; it cannot reach in and set a reorder point to a number it prefers.

What Restock does not do

It does not use AI to forecast [src: app/src/Forecast.php:3]. The arithmetic is published above and you can check it by hand.

It does not let anyone edit a forecast [src: app/controllers/mcp.php:65]. Correct the sales history or the stock level; the forecast follows.

It does not order anything. It produces a purchase order and a file your supplier accepts [src: app/src/ExportPresets.php:5]; sending it is yours.

It does not invent a days-of-stock figure for a product with no demand [src: app/src/Forecast.php:73].

It does not detect seasonality or promotions by itself. It plans for them when you tell it: the same-period-last-year model for a seasonal product, or a planned event for a promotion [src: app/src/Forecast.php:98]. Until then the average is exactly that — an average over the window you chose.

It does not track which batch each sale came from. Expiry figures assume first expiry, first out [src: app/src/Lots.php:37].

Data requests: erasing a former team member (4.1.1)

The people Restock keeps records about are your own team; a supplier is a free-text name on a product or a purchase order. Data requests (whoever manages the team) exports everything held about an account, or erases a deactivated one: their name and email are replaced with [removed], a pending invitation to their address is blanked, and so is their address on the saved views and forecast events they set up; their password, two-factor secrets and API keys go. Restock refuses to erase an account that can still sign in, and the last admin.

Kept: their address as "Raised by" on the purchase orders they raised, and the audit trail's entries recording what they did, because purchase orders are business records and the audit trail cannot be edited.

Looking after your install (from 4.0)

Updates from inside Restock

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Restock never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Restock is unavailable for about a minute while this runs. The update replaces Restock's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Restock shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Restock is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: a wholesale supplier's products and four months of sales to try things on. You can remove it in one click. [src: app/install/index.php:137]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample products, sales and purchase orders? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Backup & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:80]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Restock reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Restock keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/restock/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Restock sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Restock on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Restock's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

On Ownware Cloud, opening Restock from the console starts a fresh session [src: app/index.php:57].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:28].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Restock's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Restock in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:12]. If you had Restock framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Restock's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Restock · Quickstart · API · Test run