Assetora Manual
Assetora — User Manual
A fixed-asset register: costs, depreciation, book value and disposal. Version 4.1.3 [src: app/controllers/api.php:16].
About this manual
Every statement here was written by reading Assetora'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.
Read this first
Assetora is an accounting record-keeping register. It records asset costs and computes depreciation figures; it moves no money, integrates with no bank, general ledger or payment system, and is not tax or accounting advice [src: app/src/Depreciation.php:22].
Methods and rates must be confirmed with your accountant [src: app/src/Depreciation.php:24]. Nothing in this manual is advice either — it describes what the arithmetic does.
The money
Integer cents throughout, with depreciation done in exact integer arithmetic [src: app/src/Depreciation.php:11].
Book value never drops below salvage [src: app/src/Depreciation.php:12].
The two methods, and the difference that matters when you choose
Two are offered [src: app/src/Depreciation.php:34], and they behave differently at the end of life in a way worth seeing before you pick one.
Straight-line accounts for every penny
The depreciable base — cost less salvage — is spread evenly over the useful life. Each period takes an equal integer share and the final period absorbs the rounding remainder, so accumulated depreciation equals exactly cost less salvage at the end of life [src: app/src/Depreciation.php:13].
Running the product's own arithmetic on an awkward figure — cost 1,000,000, salvage 13,337, life 7 periods, a base of 986,663 that does not divide evenly:
periods 1–6: 140,951 each
period 7: 140,957 ← absorbs the 6-cent remainder
sum: 986,663 = cost − salvage, exactly
Reducing-balance is asymptotic and does not finish at salvage
A per-period rate in basis points is applied to the opening book value each period, with round-half-up integer arithmetic, clamped so book value never crosses salvage [src: app/src/Depreciation.php:16].
The source is explicit that this stops at end of life with book value greater than or equal to salvage — it does not force book value down to exactly salvage the way straight-line does [src: app/src/Depreciation.php:18].
The same asset at 20% per period shows what that means in practice:
period 1: charge 200,000 → book 800,000
period 4: charge 102,400 → book 409,600
period 7: charge 52,429 → book 209,715
salvage was 13,337
Same cost, same salvage, same seven periods: straight-line leaves the asset fully depreciated; reducing-balance leaves 209,715 of book value still on the register.
That is not a defect — it is what reducing balance means — but it is the single most useful thing to understand before choosing a method, and nothing in the product's own screens will tell you.
Disposal
Gain or loss on disposal is proceeds less book value at the disposal date, computed exactly [src: app/src/Depreciation.php:20].
Two statuses: active and disposed [src: app/src/Depreciation.php:37].
Reinstating a disposed asset clears its disposal date and proceeds, so it takes the same permission as disposing (asset.dispose) and is written to the audit trail [src: app/controllers/app.php:456].
Where it is and who has it
From 3.2 each asset carries a serial number and a home location, where it lives when nobody has it. On the asset's page, Check it out hands it to a person, a department or another location, optionally until a due-back date [src: app/controllers/v32.php:15]. Checking it out again is a handoff: the previous holder's record closes on the same day with a note naming who it went to, and a handoff cannot be dated before the previous one [src: app/src/Custody.php:61]. Return closes the record and the asset is back at its home location [src: app/src/Custody.php:86]. Each change runs with the asset row locked, so one asset is never with two holders at once [src: app/src/Custody.php:109]; a disposed asset cannot be checked out.
Every record is kept on the asset's page. Assets past their due-back date appear under Needs your action [src: app/controllers/app.php:206] (listed by [src: app/src/Custody.php:100]), and the stock-take's Not yet found list says where each missing asset was expected and with whom [src: app/views/scan.php:63]. Custody moves no money: it changes no depreciation, book value or journal.
The stock-take on a phone (from 3.2)
With a stock-take open, Scan with this phone's camera on the Audit scan page opens the phone's rear camera in the page and reads each QR label (or barcode) with the browser's own code reader: no app to install, and nothing leaves the phone but the code itself. It keeps scanning, so you walk the floor pointing at label after label; each one is recorded at once and the page shows what happened ("Sighted…", "already sighted", "nothing in the register matches") and the running count. The same label read twice in a few seconds is recorded once [src: app/controllers/v3.php:1004].
Where the browser has no code reader of its own (Safari on the iPhone, for example) or the camera cannot be used in the page (it needs HTTPS), the page says so. Then the phone's own camera app does the job: a label opens its asset's page, which, while a stock-take is open, shows Record as sighted, one tap [src: app/controllers/v32.php:49]. Every way in (the typed tag, the pasted link, the camera, the tap) goes through one sighting writer, so a disposed asset found in the building, an unknown code and a second sighting are handled the same way each time [src: app/controllers/v3.php:1020].
Warranty end dates (from 3.2)
An asset can carry the date its maker's warranty ends (Warranty until on the asset form, optional) [src: app/controllers/app.php:281]. The asset's page shows it, the dashboard warns 30 days before it ends so a fault is reported while it still counts, the Warranties report lists them soonest first, and the API returns warranty_until.
Maintenance (from 3.2)
Each asset's page has Maintenance: schedule what is done and how often ("Service, every 6 months", "PAT test, every year"), with the next due date (blank means one interval from today) and, optionally, an address for its alerts [src: app/controllers/v32.php:80]. Log it as done records the date, the cost and a note, and moves the next due date on from the day it was done; a month-based interval keeps the day of the month and stops at the month's end (31 January plus one month is 28 or 29 February) [src: app/src/Maintenance.php:32]. A service cannot be logged in the future, before the last one, or twice on the same day [src: app/src/Maintenance.php:89]. A plan can be paused. Overdue and due-today services are listed under Needs your action and on the Maintenance page, which has the menu's count [src: app/controllers/v32.php:128].
Alerts are off until you switch them on in Settings → Maintenance alerts, with your own SMTP server [src: app/controllers/v32.php:142]. They email the plan's own address, or the team address, a set number of days before each due date ("14, 3, 0" unless you change it). Each step goes once per due date; an install that was off sends only the step it is in now, never the missed ones [src: app/src/Maintenance.php:155]. There is a daily cap, the Maintenance page shows exactly what goes out today without sending anything, every attempt is logged, and a refused send can be tried again [src: app/src/Maintenance.php:238]. Alerts go out a few at a time as the dashboard is opened, or from your scheduler at /cron/maintenance?t=<your scheduled-backup token> [src: app/controllers/v32.php:167]. The public demo records its alerts and sends none.
Reports and Excel (from 3.2)
Reports holds eleven views of the register [src: app/src/Reports.php:16]: the asset register on a date, book value by category, assets by location, additions and disposals between two dates, fully depreciated assets still in use, warranties, who has what, the last stock-take's not-found list, maintenance due and maintenance done. Each can be filtered by category, and each downloads as CSV or as an Excel workbook [src: app/controllers/v32.php:248]. The screen, the CSV and the workbook render one structure built from the same engines as the rest of Assetora, so they cannot disagree [src: app/src/Reports.php:34]. In the workbook money is a number with two decimals (so a sum works), the header is frozen, and text is text: a cell that starts with "=" is never a formula [src: app/src/Xlsx.php:20]. The workbook is written by Assetora itself; no spreadsheet software or extension is needed on the server.
Your own report (from 3.3) is the twelfth: tick the columns you want from the asset record and the figures the other reports compute (cost, depreciation to date and book value on the date chosen, warranty end, who has it and when it is due back, and more) [src: app/src/Reports.php:35], filter by category, by in service or disposed, by checked out or at home and by location, sort by any column, and download it as CSV or Excel like the rest. Anyone who can add assets can save it by name, and a saved report is listed for everyone who can read reports; what it asks for is checked again every time it runs [src: app/src/Reports.php:61] [src: app/controllers/v32.php:209].
Closing a period and the journal
A close records what the register said through a date: the assets held on that date, their cost, accumulated depreciation and book value, the depreciation booked since the last close, and the disposals dated inside the period [src: app/controllers/v3.php:151]. An asset disposed during the period still counts for its depreciation up to the disposal date; it is no longer counted as held [src: app/controllers/v3.php:237].
Each close downloads as a journal CSV in a generic, QuickBooks Online or Xero manual-journal shape [src: app/src/JournalExport.php:37]. The journal debits depreciation expense per category and credits accumulated depreciation, then posts one entry per disposal in the period: Dr proceeds, Dr accumulated depreciation, Dr loss / Cr the asset's cost, Cr gain [src: app/src/JournalExport.php:86]. The five account names are yours to set in Settings; blank ones fall back to names a bookkeeper recognizes, never an invented code [src: app/src/JournalExport.php:55].
The journal is checked against what the close froze. If an asset's cost, a disposal or a reinstatement changed the period after the close, the download refuses and says which [src: app/src/JournalExport.php:86].
A close recorded before 3.1.4 left out the depreciation of assets that were already disposed. Its journal still downloads exactly as it was closed, and the report shows beside it how much it left out, to post by hand [src: app/controllers/v3.php:341].
⭐ The QR label sheet, and why it is written the hard way
The printable label sheet — one QR code per asset, opening that asset's page — carries the clearest engineering justification in the product [src: app/src/Qr.php:5]:
A label sheet that does not scan is worse than no label sheet — somebody sticks four hundred of them onto four hundred machines before anyone finds out.
So the encoder is written from the ISO standard rather than approximated, and the suite checks it against an independent reference implementation, matrix for matrix, not by eye [src: app/src/Qr.php:8].
Why there is no QR service or library
Two reasons, both stated [src: app/src/Qr.php:11]:
A QR image endpoint would send your asset URLs to a third party on every label print, and the product is sold on the promise that it talks to nobody.
And there is no package dependency either — the application is drop-in-and-run PHP [src: app/src/Qr.php:13].
The deliberate limits
Byte mode only, error-correction level M — about 15% recovery, which the source calls "the right trade for a label that will be scuffed but is not on a foundry floor" — and versions 1 to 10, carrying a URL of up to 213 bytes [src: app/src/Qr.php:15].
Beyond that the encoder refuses loudly rather than truncating, because "a label that quietly lost the end of its URL is the worst outcome" [src: app/src/Qr.php:18].
If your installation lives at a very long URL, that limit is worth checking before you print four hundred labels.
Users and roles
Three roles, and unusually for this family every one is enumerated — even admin [src: app/controllers/v3.php:19]:
| Permission | viewer | member | admin |
|---|---|---|---|
asset.read | yes | yes | yes |
asset.write | — | yes | yes |
audit.scan | — | yes | yes |
asset.dispose | — | — | yes |
category.write | — | — | yes |
settings.write | — | — | yes |
Two things follow from that. Disposing of an asset is an administrator's act, separate from editing one — disposal is where a gain or loss enters the books. And because admin holds a list rather than a wildcard, a new permission is not silently granted to anybody; it has to be added to a role deliberately.
Note that audit.scan is a stock-take scan, not an audit trail. Assetora has no audit-trail permission at all.
Backups are an administrator's download
Both backup downloads — the JSON export and the raw SQLite file — take settings.write, which only an administrator holds [src: app/controllers/api.php:278]. Before 3.1.4 any signed-in user could take them. The JSON export leaves out password hashes, two-factor secrets, API-key hashes and signing secrets [src: app/controllers/v3.php:68]; the SQLite file is the database itself.
Importing assets from a CSV takes asset.write, so a viewer cannot [src: app/controllers/api.php:333].
The API and agent access
Five tools are exposed to an agent [src: app/controllers/v3.php:432]: listing assets, asset detail and a depreciation report on the read side; creating an asset and disposing of one on the write side.
Note that an agent can dispose of an asset — the act that puts a gain or loss into the books. If that is not what you want from an automation, issue it a key whose role lacks asset.dispose [src: app/controllers/v3.php:24].
The REST write follows the key holder's role (from 3.2): POST /api/assets needs a role that may add assets, so a viewer's key reads and never writes [src: app/controllers/api.php:54]. The AI tools already hid write tools from a role that lacks them.
What Assetora does not do
It does not move money or post to a ledger [src: app/src/Depreciation.php:22], and it is not tax or accounting advice [src: app/src/Depreciation.php:23].
It does not choose a depreciation method for you, and the two do not end at the same place [src: app/src/Depreciation.php:18].
It does not let book value fall below salvage [src: app/src/Depreciation.php:12].
It does not send your asset URLs anywhere to make a QR code [src: app/src/Qr.php:11].
It does not truncate a URL that will not fit on a label [src: app/src/Qr.php:18] — it refuses.
Looking after your install (from 4.0)
Updates from inside Assetora
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; Assetora 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. Assetora is unavailable for about a minute while this runs. The update replaces Assetora'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 Assetora 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 Assetora 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 fixed-asset register with categories and depreciation to try things on. You can remove it in one click. [src: app/install/index.php:129]. 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 assets and categories? 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:88]. 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 backups stay in data/backups/ (14 unless you change Backups to keep), 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 Assetora 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 Assetora 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/assetora/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)
Assetora 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 Assetora 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. Accepting an invite starts a fresh session. Single sign-on starts a fresh session. 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 Assetora'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.
Accepting an invite now starts a fresh session (a new session id), as every other sign-in already did [src: app/controllers/v3.php:1372].
Single sign-on now starts a fresh session too [src: app/controllers/v3.php:1464].
On Ownware Cloud, opening Assetora from the console starts a fresh session [src: app/index.php:54].
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:21].
New in 4.1.3: security update
New in 4.1.3 (security update): no other site can put Assetora'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 Assetora 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:4]. If you had Assetora 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 Assetora'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].
The audit CSV at /audit/export.csv now uses the same rule, so an entry that starts with a formula character opens in a spreadsheet as text [src: app/controllers/v3.php:414].
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.