Skip to content
szabolevi98Public

About

Cloud-based, modular ERP for trading companies: products, partners, orders through to invoices, purchasing, multi-warehouse stock and CRM. PHP 8.4.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Cloudexus

Cloudexus

A business management system for trading companies that hold a lot of stock and sell every day: the products and the partners, the warehouses and what is in them, customer orders through to invoices and the money that comes in for them, purchase orders through to supplier invoices and the money that goes out, the cash desk, and the work around the customers: quotes, a sales pipeline, to-dos and the reports on them. One installation is one company, with its own database and its own people.

The dashboard: sales of the last ten days, what is owed to us and by us, the top categories

Written in PHP 8.4 with Twig and MariaDB/MySQL. No framework: a router, a handful of core classes, one controller per area and models that do all the talking to the database, a stylesheet built by concatenating its own sources, and every front-end library served from the application itself — it runs without the internet, apart from the optional reCAPTCHA on sign-in.

What it does

Products and partners

  • A product catalog with SKU, barcode, a category tree (a product can sit in several), a unit of measure, dimensions, parameters, pictures (uploaded or by URL), related and substitute products, a short and a long description in a rich-text editor, and its live stock.
  • Net price and VAT, an optional sale price, and customer groups: a partner belongs to one, and the group can have its own price and sale price for any product.
  • Price rules on top: a customer-group discount, a quantity break or a dated promotion, as a percentage off the list price or a fixed net price, for one product, one category and everything under it, or all of them. The order and invoice lines are priced for their quantity and the document's date, the rule that set the price is named under it, and when several fit, the lowest price wins — rules do not add up. Purchase forms take the plain price.
  • Partners — customers, suppliers or both — with tax number, addresses and a history of calls, emails and meetings, each one tied to the person it was with.
  • Contacts at each partner: name, position, email, phone, and which of them is the primary one and which gets the invoices — invoices and quotes are emailed to that person by default, with the others one click away.
  • Credit limit and payment terms per partner. The order, quote and invoice forms warn when the unpaid invoices reach the limit or some are overdue, and an invoice is due as many days after issue as the partner's terms say.
  • CSV import of products (by SKU) and partners (by tax number, or name), in the same columns the exports write, so an export can be edited and loaded back: headers recognised in Hungarian or English, semicolons or commas, a Hungarian Excel file as it is; a preview row by row — new, updated, unchanged, refused with the reason — and nothing written until it is confirmed. An update writes only the cells filled in and never touches a product's pictures, parameters or links; stock only changes through movements.
  • Searchable, filterable, paged lists everywhere, sorted by clicking a column header (ascending, descending, then back to the list's own order), and search-as-you-type pickers for the long ones (products, categories, partners). A list's filter can be saved under a name, for oneself or for everybody; products and partners can be ticked and changed at once (active, webshop, category, customer group, a tag). A deleted product, partner, price rule, deal or to-do can be brought back from the next page for ten minutes — with its id, its pictures, parameters, categories and links, and its partner's to-dos linked back.

Price rules: who, on what, from how many, how much, and when

The product list, with live stock

Stock

  • Several warehouses, each with its storage locations.
  • Stock in, stock out, and transfers between warehouses (an out and an in, in one transaction), and a barcode collector for booking a whole delivery with a hand scanner.
  • Stock is never stored: it is the sum of the movements, so it cannot drift. Every check and booking of a stock-out holds the warehouse's row lock, so two people — on the web or on the warehouse app — cannot both take the last piece.
  • Stocktaking: count what is on the shelf, and the difference is booked as a correction. The book quantity is read at the moment of booking, not taken from a form opened an hour earlier.
  • An overview of the stock by warehouse and product, and the products under their minimum on the dashboard.

The stock overview

Sales

  • Quotes before the orders: the same line items and pricing (group prices, price rules, VAT per line), a validity date and a note for the customer, a PDF and sending by email, accepted or rejected (with the reason), shown as expired once the date passes, and made an order of in one click — the order says which quote it came from.
  • Customer orders with line items priced as they are picked and a total that follows as they are typed; an order becomes an invoice in one click.
  • Invoices with VAT per line, at each product's own rate, a VAT summary by rate, and shipping and payment costs with their VAT; amounts in forints are rounded to whole forints. Issuing an invoice can book the goods out of a warehouse — refused, before the invoice gets a number, if the stock is not there.
  • The buyer's and the seller's name, tax number and address are written onto the invoice when it is issued, with the date of supply and the payment method, so a later change to a partner does not rewrite an old invoice.
  • An invoice as a PDF, drawn on the server (A4, with every Hungarian letter), to download or to send by email to the customer with a message of one's own; the invoice page says when and to whom it last went.
  • Nothing issued is deleted or edited. A wrong invoice is reversed with a storno invoice: the goods go back into the warehouse and the order can be invoiced again. An invoiced order cannot be cancelled or deleted.
  • Gapless numbering per year for every document — invoice, order, purchase order, supplier invoice, cash voucher, stocktaking — taken from a locked counter when the document is saved, so two saves at the same moment cannot share a number and a refused one does not use one up.

An invoice: VAT per line, the summary, and the payments on it

CRM

  • The partner's page has it all in one place: this year's revenue against last year's, what they owe and how much of it is overdue, how late they pay on average, their quote win rate, their most bought products, and one timeline of orders, quotes, invoices, payments, calls and to-dos that can be filtered to documents, money or activity.
  • Tags on partners, typed in freely ("restaurant", "wholesale", "VIP"), filtered on in the list and given to or taken off many partners at once.
  • A sales pipeline of deals on a board, one column per stage (lead, qualified, proposal, negotiation, won, lost), moved by dragging. Each deal has a value, a probability (the stage's by default), an expected close date and an owner; each column shows its total and its weighted total. A deal is only marked lost with the reason. A quote made from a deal moves it to the proposal stage, and the order made from that quote wins it.
  • To-dos: a task, call, email or meeting, with a due date and time, a person, and the partner, deal or quote it belongs to. One can repeat daily, weekly or monthly — ticking it off makes the next. They show in a week calendar, on the deal's and the quote's page, and on the dashboard as one's own list, ticked off from there.
  • Dormant customers: active customers who have bought before but not for 60, 90, 180 or 365 days, the ones who brought the most first, each with a one-click call to-do — or the open to-do they already have.
  • A CRM report for a month, quarter, year or any two dates: the pipeline now and its forecast by expected close month (value and weighted), deals won and lost with the win rate and why they were lost, how many quotes became orders, the calls, emails and meetings logged and the to-dos done — and all of it per salesperson.

The sales pipeline: a column per stage, each with its total and its weighted total

Purchasing

  • Purchase orders and supplier invoices; a supplier invoice can book its goods into a warehouse as it is recorded. Cancelling one books them back out — and refuses if they have already been sold.

Money

  • Partial payments: an invoice, ours or a supplier's, takes any number of payments — bank transfer, card, cash on delivery, or a cash voucher — and is paid when they reach its total. A payment cannot be more than what is still open or dated in the future; a mistaken one can be reversed, and the invoice is open again. An invoice with payments on it cannot be reversed until they are.
  • The cash desk: incoming and outgoing cash vouchers and the running cash balance. A voucher tied to an invoice is a payment on it; deleting the voucher takes the payment back.
  • Open items, what customers owe us and what we owe suppliers, per partner, by days past due — not yet due, 1–30, 31–60, 61–90, over 90 — for today or any earlier day (counting only the invoices issued and the payments received by then), with a CSV export.

Open items: receivables per partner, by days past due

People and access

  • Roles and a permission matrix: six built-in roles — super admin, manager, finance, sales, warehouse, read-only — and any number of one's own, each allowed what is ticked for it: issuing or reversing invoices, marking them paid, moving stock, managing prices, and so on.
  • Every request is checked on the server, the warehouse app's API included; the menu, the dashboard and the buttons only show what the role can use, and a change to the matrix applies from the next click, without signing in again.
  • The super admin can do everything, from code rather than from the matrix, and the last active super admin cannot be demoted, deactivated or deleted — a broken setting cannot lock everybody out.
  • An audit log of sign-ins and failed ones, two-step sign-in turned on and off, refused access, changes to users and to the matrix (what was added and what was taken away), invoices issued, reversed and paid, payments, cash vouchers, stocktakings, price rules and the company details — with who, when and from where, filtered by user, action, subject and date.

The permission matrix

The audit log

The rest

  • Ctrl+K jumps anywhere: a product, partner, invoice, order, quote, deal or page by a few letters of it, or — with nothing typed — what one opened lately; only what one's role can see.
  • Hungarian and English, chosen per visitor. The catalog's own texts — product names and descriptions, categories, units, parameters — are stored per language too, and a missing translation falls back to the default language instead of showing nothing. Issued documents keep the name a product had when they were issued.
  • Currencies: one primary currency that every amount is kept and shown in, and exchange rates for the others, fetched from the Hungarian National Bank's daily mid rates with a button or from cron.
  • A dark theme: one button in the header switches between light and dark, and the profile can follow the system's instead. The choice is kept with the user, so it follows them to every browser.
  • A morning digest by email on weekdays, for whoever asks for it on their profile: overdue customer and supplier invoices, products under their minimum, their own to-dos that are due — only what their role can see, and no email on a quiet day.

The dashboard in the dark theme

The invoice list in Hungarian

The warehouse app

Stock in, stock out, transfers and shelf moves, stocktaking, picking customer orders and receiving purchase orders are done from a phone or a handheld barcode scanner with Cloudexus Mobile, an Android app the warehouse staff sign into with their own username. What they book is credited to them and shows up in the web interface at once: a picked order and a received purchase order say so on their page, and the invoice made from them later does not move the stock a second time. The APK is on the app's releases page.

Security, in short

  • One CSRF gate for every form post, including sign-out; the session cookie is HttpOnly and SameSite.
  • A browser stays signed in for a year from its last click. A new password signs its owner out of every other browser (and of the mobile app), and so does "Sign out everywhere else" on the profile.
  • Passwords with bcrypt; ten failed sign-ins from one address lock it out for fifteen minutes, on the web and on the API alike; an optional score-based reCAPTCHA v3 on the sign-in form.
  • Two-step sign-in, turned on by each user on their profile: a code from any authenticator app (TOTP) after the password, on the web and on the warehouse app's sign-in alike, with ten single-use recovery codes. A code works once, wrong codes count towards the lock-out, and turning it off or making new recovery codes asks for the password again. Whoever manages the users can turn it off for somebody who has lost their phone.
  • A forgotten password is a link by email, good for an hour and once; the answer never says whether the user exists, five requests per address per quarter of an hour, and the new password signs the warehouse app out everywhere. Without email the page says to ask an administrator.
  • Every page and every API call checks its permission on the server; the interface hiding a button is a convenience, never the gate.
  • A Content-Security-Policy with script-src 'self': no inline script and no inline event handler anywhere — the templates hand data to the scripts in data attributes and JSON blocks that do not run, and the behaviour lives in web/assets/js — so a <script> or onerror="…" that somehow got into a page does not run. (reCAPTCHA's two addresses are added when it is on.) The policy also forbids framing, plugins and foreign form targets; with nosniff, a strict referrer policy, and HSTS when served over HTTPS.
  • Money and stock are written in transactions with the rows they depend on locked: a payment cannot overpay an invoice, a stock-out cannot oversell.
  • The document root is the web/ folder alone; the code, the configuration and the runtime data live outside it.

Running it

Needs PHP 8.4 (with pdo_mysql, mbstring and curl), MariaDB or MySQL, Composer, and Apache with mod_rewrite whose document root is the web/ folder.

composer install
cp config/config.ini.dist config/config.ini       # then fill in the database and base_url
php database/migrate.php                           # creates the tables, and later brings them up to date
php database/create_admin.php admin a-long-password

Then open base_url and sign in. Every link is built from app.base_url, so it has to be the address the application is opened at.

database/migrate.php runs every file under database/core/ each time, so each one is written to be run again (IF NOT EXISTS, INSERT IGNORE); it also loads the starting permission matrix, once per role, and a permission added in a later version is granted once to the roles it is meant for — one taken away in the matrix is not given back.

In production (debug = 0) the compiled templates are cached under var/cache; after an update, php bin/clear_cache.php empties it (--logs, --sessions or --all for more). The built stylesheet is committed: after changing its sources under src/View/Css, php bin/build_css.php builds it again.

Email — invoices to customers, forgotten passwords — goes through a queue that a job sends every minute; [mail] transport in config.ini chooses PHP's mail(), an SMTP server, .eml files in var/mail (for development) or none. What the mail server refuses is tried again, and in the end shows up on Settings → Email with the reason. And the exchange rates, every weekday morning (the bank publishes them in the morning):

* * * * *   php /path/to/cloudexus/bin/outbox.php
* * * * *   php /path/to/cloudexus/bin/webhooks.php
30 7 * * 1-5 php /path/to/cloudexus/bin/digest.php
10 7 * * 1-5 php /path/to/cloudexus/bin/sync_currency_rates.php --quiet

For development, PHP's own server stands in for Apache:

php -S 127.0.0.1:8080 -t web bin/dev-router.php   # with base_url = "http://127.0.0.1:8080"

Something to look at

php database/seed_demo.php

Forty-eight products in Hungarian and English (some on sale, some with group prices), seventeen partners in three customer groups, three warehouses with storage locations, a few hundred stock movements, a hundred and thirty orders and the hundred or so invoices made from them — most paid, some in part, some overdue — purchase orders and supplier invoices, cash vouchers, five price rules, and three currencies. On the CRM side: contacts at every customer, a few with a credit limit and payment terms, quotes in every state (some made orders of), deals on the pipeline board with their owners, calls, emails and meetings for this week and the next, tags, and three dormant customers. The existing users are the salespeople. It empties every business table first (the users stay), so it can be run again at any time.

The API

A JSON API for integrations — a webshop, say — and for the warehouse app, with bearer tokens: integration tokens are made under API → API users; the staff sign in with their own username and password (POST /api/auth/login) and get a token that acts as them, with their role's permissions.

  • Read: the whole catalog (products, categories, units, parameters), stock, invoices, currencies, languages, and the price of a product for a partner, a quantity and a date.

  • Write: partners and orders, in full.

  • Stock: a product by its barcode (with its price, image and whether it is under its minimum), and stock in, out, transfers and shelf-to-shelf moves with any number of lines, credited to the person signed in; their own bookings of the day.

  • Warehouse work for the PDA: a stocktaking counted by scanning; customer orders picked shelf by shelf, booked out at the pick; purchase orders received, in parts if they come in parts, booked in at the receipt. The invoice of a picked order, or of a received purchase order, then moves no stock again.

  • Safe retries: an Idempotency-Key header on any change — a booking, a partner, an order — makes a request resent over bad Wi-Fi or after a timeout happen once; the resend gets the first answer back.

  • Amounts are in the primary currency (meta.currency); the catalog's texts come in the language asked for with ?language= (meta.language). Every answer and every error is JSON, in English.

  • Webhooks: a webshop or bookkeeping can be told instead of asking — new and cancelled orders, invoices issued, paid and reversed, stock that moved (from any path, gathered once a minute), products and partners changed. Signed with HMAC-SHA256, retried when the receiver does not answer, with every delivery and its answer on the webhook's page; addresses in private networks are refused.

The endpoints, with their requests, answers and curl examples, are in web/API.md, and inside the application under API → API documentation. For programs the same is an OpenAPI 3.1 document at /api/openapi.json, for Postman, Insomnia or a client generator; it is built by php bin/openapi.php into docs/openapi.json, and a test fails when an endpoint is missing from it.

Checking it

vendor/bin/php-cs-fixer fix --dry-run --diff         # code style (without --dry-run it fixes it)
vendor/bin/phpstan analyse --memory-limit=1G         # static analysis
php bin/check_templates.php                          # every Twig template compiles
vendor/bin/phpunit                                   # unit tests, and integration tests against a *_test database
php tests/smoke.php --url=http://127.0.0.1:8080 --user=admin --password=…
npm ci && npx playwright install chromium              # once, for the browser tests
CX_URL=http://127.0.0.1:8080/ CX_USER=admin CX_PASSWORD=… npx playwright test

The unit tests need nothing but PHP: the permission catalog and the default matrix, every Hungarian sentence having its English one, labels for every permission and audit action. The integration tests need a database of their own, named in config/test.ini, whose name must end in _test — they empty it before every test: document numbering, VAT per line, stock-outs and shortages, storno, partial payments and cash vouchers, the open-items buckets, price rule resolution, the permission matrix, stock locks and stocktaking, quotes to orders, the pipeline's sums and moves, repeating to-dos, tags, dormant customers and the CRM report's figures.

The browser tests (Playwright, tests/e2e) check what only a browser can: an order typed in with the search-as-you-type pickers, its lines priced as they are picked and the totals following; a quote sent and made an order of; deals dragged across the pipeline board; the week calendar; the barcode collector; setting up and turning off two-step sign-in; the column headers, the sidebar and the theme. They clean up after themselves.

The smoke test walks a running installation over real HTTP: it signs in through the form, opens every page, downloads a CSV, checks that the API wants a token, and signs out. CI runs all of it on every push, against a real MariaDB and a freshly migrated, seeded installation.

Layout

bin/            stylesheet build, cache clearing, the exchange-rate job, the dev router
config/         config.ini.dist — the real config.ini is never committed
database/       the migrations as .sql files, the runner, the first admin, the demo seed
docs/           the pictures in this README
src/Core/       config, router, session, CSRF, auth, access, audit log, currencies, languages, …
src/Controller/ one class per area, and the API under Controller/Api
src/Language/   hu/ and en/, one file per area
src/Model/      everything that touches the database, by area
src/View/       Twig templates, and the CSS sources
tests/          unit and integration tests, and the smoke test over HTTP
web/            the document root: the front controller and the assets

License

GNU AGPLv3 — © 2026 szabolevi98

About

Cloud-based, modular ERP for trading companies: products, partners, orders through to invoices, purchasing, multi-warehouse stock and CRM. PHP 8.4.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages