Tables, cascades and what a delete really removes
The eleven E-commerce tables, the columns that carry money and delivery, which deletes are soft, and the two cascade paths that destroy paid orders without warning.
E-commerce adds eleven tables to the same MySQL schema core already uses. No second database, no separate connection, no migration files — the tables are synced from the model definitions on boot.
This page is for the three jobs that need the schema rather than the screens: writing a report against the database, planning a backup or a migration, and working out what a Permanent delete button is actually about to remove.
API and data carries two claims about these tables that are wrong, and both matter to anyone writing SQL:
- Stock does not decrement "when an order is PAID". It decrements inside the
checkout transaction, at the moment the order row is written, under a
conditional update. A
PENDINGphysical order has taken its stock. - The money columns are not
DECIMAL. Every money column onecommerce_orderisDOUBLE, and the driver returns those as JavaScript numbers, not strings. The DECIMAL-as-string trap is real elsewhere on the platform — core'swalletandtransactiontables — but not here.
Both are corrected in full below.
The eleven tables
| Model | Table | Soft delete | Timestamps | What it holds |
|---|---|---|---|---|
ecommerceCategory |
ecommerce_category |
Yes | Yes | Catalogue categories |
ecommerceProduct |
ecommerce_product |
Yes | Yes | Products — price, type, currency, stock |
ecommerceOrder |
ecommerce_order |
Yes | Yes | One order per product per checkout, with its money |
ecommerceOrderItem |
ecommerce_order_item |
No | No | The line: quantity, and the digital delivery |
ecommerceReview |
ecommerce_review |
Yes | Yes | One review per customer per product |
ecommerceDiscount |
ecommerce_discount |
Yes | Yes | Discount codes, each scoped to one product |
ecommerceUserDiscount |
ecommerce_user_discount |
No | No | Who has used which code — the one-use record |
ecommerceShipping |
ecommerce_shipping |
Yes | Yes | Your consignment records |
ecommerceShippingAddress |
ecommerce_shipping_address |
Yes | Yes | One address per order |
ecommerceWishlist |
ecommerce_wishlist |
Yes | Yes | One wishlist per customer |
ecommerceWishlistItem |
ecommerce_wishlist_item |
Yes | Yes | Products on a wishlist |
Every primary key is a CHAR(36) UUID with a v4 default. Nine of the eleven are
paranoid — a delete stamps deletedAt and the row stays. The two that are
not (ecommerce_order_item, ecommerce_user_discount) have no deletedAt
column at all, so any delete that reaches them is permanent.
ecommerce_order_item, ecommerce_user_discount, ecommerce_shipping_address
and ecommerce_wishlist_item are not tables you can browse. They are reached
only through the order screen, the discount screen or the wishlist screen, and
ecommerce_user_discount has no admin surface whatsoever — it is written by
checkout and read by checkout. If you need to see one, you are querying the
database.
The order carries the money; the order item carries the goods
This split is the single most important thing on the page, because the two halves have different delete rules.
ecommerce_order
| Column | Type | Notes |
|---|---|---|
id |
UUID | This is the order number. It is what the confirmation email prints |
userId |
UUID | The buyer. FK to user |
status |
ENUM | PENDING, COMPLETED, CANCELLED, REJECTED. Default PENDING |
subtotal |
DOUBLE | price × quantity, before discount |
discount |
DOUBLE | Amount taken off. Default 0 |
shippingCost |
DOUBLE | Charged once per checkout, so it lands on one order of a multi-product cart. Default 0 |
tax |
DOUBLE | Applied to the discounted subtotal. Default 0 |
total |
DOUBLE | What the buyer was actually debited |
currency |
VARCHAR(191) | Copied from the product at sale time |
walletType |
VARCHAR(50) | FIAT, SPOT or ECO — the wallet that paid |
shippingId |
UUID, nullable | The shipment assigned to it. FK to ecommerce_shipping |
createdAt / updatedAt / deletedAt |
DATETIME |
There is no orderNumber, no reference, no paymentId and no product column
on the order. A cart of three products produces three independent order rows
with three unrelated UUIDs and no parent record joining them.
Checkout writes subtotal, discount, shippingCost, tax and total from
the settings and prices in force at that moment, and nothing ever recalculates
them. That is deliberate: an earlier build re-derived receipts from current
settings and emailed coupon users the full price.
So a report must read these columns, not multiply the current product price by the quantity. Refunds quote the row. Receipts quote the row. Changing the tax rate on Store settings does not move a single historic figure — see Orders and fulfilment.
ecommerce_order_item
| Column | Type | Notes |
|---|---|---|
id |
UUID | |
orderId |
UUID | FK to ecommerce_order |
productId |
UUID | FK to ecommerce_product |
quantity |
INTEGER | Minimum 1 |
key |
VARCHAR(191), nullable | The licence key handed to the buyer |
filePath |
VARCHAR(191), nullable | The download — either an https:// URL or a path under the uploads root |
instructions |
TEXT, nullable | Delivery notes shown with the download |
Unique on (orderId, productId). No timestamps, no deletedAt.
key, filePath and instructions are written per order item by
PUT /api/admin/ecommerce/order/{id}/download — they are what an operator
attaches after a digital sale. Nothing equivalent exists on the product row.
That is why a permanent product delete destroys a purchase somebody has
already paid for: the cascade takes the order item, and the licence key and the
download path go with it. The order row survives, holding the money, with
nothing attached to it. There is no undo — the order item table has no
deletedAt to restore from.
See Digital delivery.
The one-use-per-customer record
ecommerce_user_discount is four columns and easy to miss:
| Column | Type | Notes |
|---|---|---|
id |
UUID | |
userId |
UUID | FK to user |
discountId |
UUID | FK to ecommerce_discount |
status |
BOOLEAN | Default true |
Unique on (userId, discountId). Written as an upsert inside the checkout
transaction, immediately after the wallet debit, whenever a discount was
applied. It is the only place a code's usage is recorded.
Checkout reads it twice:
SELECT COUNT(*) WHERE discountId = ? AND status = trueagainst the discount'smaxUses, andSELECT ... WHERE userId = ? AND discountId = ? AND status = truefor "You have already used this discount".
Deleting the row — or flipping status to false — makes the customer eligible
for that code again and gives back one of its maxUses slots. There is no
admin screen for this table, so it can only be done in SQL, and nothing logs it.
That is occasionally what you want (a customer whose order you cancelled and who
should not lose their coupon), and it is otherwise a hole in your promotion
budget. Treat a DELETE here as a money operation.
Shipment records: your figures, not the buyer's
ecommerce_shipping is a log of a consignment. It is not connected to what
anyone was charged.
| Column | Type | Required | Notes |
|---|---|---|---|
loadId |
VARCHAR | Yes | Your reference or the carrier's. Free text, not unique |
loadStatus |
ENUM | Yes | PENDING, TRANSIT, DELIVERED, CANCELLED |
shipper |
VARCHAR | Yes | Free text |
transporter |
VARCHAR | Yes | Free text — no carrier integration exists |
vehicle |
VARCHAR | Yes | Free text |
goodsType |
VARCHAR | Yes | Free text |
description |
VARCHAR | Yes | Free text |
weight |
FLOAT | Yes | Unitless |
volume |
FLOAT | Yes | Unitless |
cost |
FLOAT | No | What the shipment costs you |
tax |
FLOAT | No | Tax on that cost |
deliveryDate |
DATETIME | No | Expected or actual |
cost and tax here are your own carrier figures — what the consignment cost
you. They are not what the buyer was charged: that is
ecommerce_order.shippingCost and ecommerce_order.tax, written by checkout
from ecommerceDefaultShippingCost and the tax rate in force at sale time.
They are not private, though. GET /api/ecommerce/order/{id} eager-loads the
whole shipment row, and the customer's own order page renders shipping.cost as
its Shipping line and shipping.tax as its Tax line, then adds both into
the Total it displays. The admin order page prints the same two values with a
$ in front of them, whatever currency the carrier invoiced you in. So whatever
you type into a shipment's cost and tax is shown to every buyer whose order is
assigned to that shipment, and the total that page displays will not match
ecommerce_order.total — that column is what was actually debited, and it is the
only figure a report, a receipt or a refund should use.
Note the precision difference: shipment money is FLOAT, order money is
DOUBLE. Do not sum the two together and expect them to agree.
Orders point at a shipment through ecommerce_order.shippingId, so the
relationship is many orders to one shipment. Operational detail is on
Shipping and fulfilment records.
The rest, briefly
| Table | Worth knowing |
|---|---|
ecommerce_category |
name, slug, description (all NOT NULL), image, status. The slug is generated from the name if left blank, but there is no unique index on it |
ecommerce_product |
slug is unique across the whole catalogue. type is DOWNLOADABLE or PHYSICAL, price is DOUBLE, walletType is FIAT/SPOT/ECO, inventoryQuantity is an INTEGER with a floor of 0. gallery is a JSON list of upload paths, capped at 12 and filtered to /uploads/ or /img/ |
ecommerce_review |
rating 1–5, comment capped at 191 characters, status is the moderation flag. Unique on (productId, userId) |
ecommerce_discount |
code is unique. type is PERCENTAGE/FIXED/FREE_SHIPPING, percentage is an INTEGER 0–100, amount is DOUBLE, maxUses is nullable, validUntil is NOT NULL and validated as a future date on write |
ecommerce_shipping_address |
One per order. phone is validated against E.164 (+ then 7–15 digits) and normalised on both write paths, so whatever the buyer typed is accepted |
ecommerce_wishlist / ecommerce_wishlist_item |
One wishlist row per customer, created on first use; items unique on (wishlistId, productId) |
Stock, correctly
Both order doors — POST /api/ecommerce/cart/checkout and
POST /api/ecommerce/order — decrement stock inside the transaction that
creates the order, with a conditional update:
UPDATE ecommerce_product
SET inventoryQuantity = inventoryQuantity - :quantity
WHERE id = :productId
AND inventoryQuantity >= :quantityIf that update touches zero rows, the handler throws Product inventory changed during checkout and the whole transaction rolls back — no order, no debit, no
stock movement. It is what makes two simultaneous buyers of the last unit safe.
Three consequences for anyone reading the table:
- A
PENDINGphysical order has already taken its stock. Reserved and sold are the same state here. - Only
PHYSICALproducts are touched.inventoryQuantityon aDOWNLOADABLEproduct is inert — it is stored and ignored. - Cancelling or rejecting an order puts the stock back, in the same transaction as the refund.
The cart is not a table
There is no cart table and no cart endpoint that stores anything. The cart lives
in the browser's localStorage, under the key ecommerce-storage, and only
cart and wishlist are persisted from that store.
- Nothing is reserved when a customer adds to the cart.
- Clearing site data empties it, and it does not follow the customer to another device.
- The wishlist is the one thing that lives in both places: the browser copy
is a mirror, and
ecommerce_wishlist/ecommerce_wishlist_itemare the server-side record. - A cart persisted by an older build can hold product entries missing
walletType; the store repairs those on load rather than failing checkout.
So "how many abandoned carts do we have" is not a question this schema can answer. There is no row until checkout succeeds.
Soft delete, permanent delete and restore
Every store table with a deletedAt is wired the same way. The admin tables
expose all three doors:
| Action | Request | Effect |
|---|---|---|
| Delete | DELETE .../{id} |
UPDATE … SET deletedAt = NOW(). The row stays, and no cascade fires |
| Restore | DELETE .../{id}?restore=true |
Clears deletedAt. Not gated behind a confirmation — it is additive |
| Permanent delete | DELETE .../{id}?force=true |
A real SQL DELETE. Every cascade fires |
On the screens: the Show deleted button in the table toolbar reveals soft-deleted rows and turns the row menu into Restore and Permanent delete. Permanent delete raises a confirmation dialog; restore does not.
The button renders on every paranoid table, which is all seven store screens.
Permissions decide whether it works, not whether it appears: a role missing
either the view. or the delete. key sees it greyed out, with a tooltip
saying they need both. "The Show deleted button does nothing" is a permissions
answer, not a bug.
deletedAt does not release a unique index. Two cases bite:
- Product slugs.
slugis unique onecommerce_product, and the slug generator only looks at live rows. Soft-delete "Blue Widget", create it again, and the generator hands backblue-widgetunchanged — straight into the unique index the deleted row still holds. Restore it, rename the new one, or permanently delete the old one. - Discount codes.
codeis unique onecommerce_discount. A soft-deletedSUMMER20keeps the string reserved. The same applies to a customer's soft-deleted review of a product, which is unique on(productId, userId).
Category slugs are the exception — that table has no unique index on slug, so
duplicates are possible there and the storefront resolves whichever row is live.
The cascade map
Every foreign key in this addon is declared ON DELETE CASCADE. A soft delete
never reaches them; a permanent delete reaches all of them, in the database,
below every application guard.
user ─┬─> ecommerce_order ─┬─> ecommerce_order_item
│ └─> ecommerce_shipping_address
├─> ecommerce_review
├─> ecommerce_wishlist ─> ecommerce_wishlist_item
└─> ecommerce_user_discount
ecommerce_category ─> ecommerce_product ─┬─> ecommerce_review
├─> ecommerce_discount ─> ecommerce_user_discount
├─> ecommerce_order_item
└─> ecommerce_wishlist_item
ecommerce_shipping ─> ecommerce_order ─┬─> ecommerce_order_item
└─> ecommerce_shipping_addressTwo of those paths are data-loss events, and neither warns you beyond the generic confirmation dialog.
Permanently deleting a category
ecommerce_category → ecommerce_product is a cascade, so one permanent category
delete removes every product in it, and each product then removes its
reviews, its discount codes (and their usage records), its wishlist entries and
its order items.
The order rows survive. Their items do not. Afterwards you hold orders that recorded a charge for a product that no longer exists, with the buyer's licence key and download path gone, and no soft-delete copy of either.
Setting status = false takes it off the storefront and leaves everything
intact. A soft delete is also safe — it stamps deletedAt and cascades nothing.
Only ?force=true is destructive, and it is destructive several tables deep.
Permanently deleting a shipment
This is the one nobody expects. ecommerce_shipping.id is the parent of
ecommerce_order.shippingId, and that FK is ON DELETE CASCADE too. So a
permanent delete of a shipment record deletes every order assigned to it —
and, through those orders, their items and shipping addresses.
The orders table refuses to delete a paid, unrefunded order:
Order
<id>has been paid for and not refunded. Cancel or reject it first — that refunds the buyer and restores the stock — then delete it.
That guard lives in the order delete handler. A cascade from the shipping table never calls it. Since a shipment is deliberately shared across a courier run, one permanent delete can take out a whole batch of paid orders.
Soft-delete it, or set its loadStatus to CANCELLED. Those are the only safe
options, because nothing in the addon can unassign an order from a shipment.
The one writer of ecommerce_order.shippingId is
PUT /api/admin/ecommerce/order/{id}/shipment, and it only ever sets the column:
shipmentId is required on the request and the handler 404s Shipment not found
if it does not resolve, so it cannot be used to clear the field. The order
screen's shipment picker is rendered only while the order has no shipment, so
once assigned there is no control to change or remove it, and
PUT /api/admin/ecommerce/order/{id} accepts nothing but status and its
allowRefundFromCompleted override. Clearing shippingId is an
UPDATE … SET shippingId = NULL in SQL, or nothing.
Deleting a customer
Deleting a user cascades into their orders, reviews, wishlist, shipping
addresses and discount usage records. The store keeps no anonymised copy of a
sale. If you delete a customer for a data-erasure request, your revenue history
loses those orders — export first.
Checking your own database
The cascades come from the model definitions, which are what created the tables. To confirm what your install actually has:
SELECT TABLE_NAME, COLUMN_NAME, REFERENCED_TABLE_NAME, CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND REFERENCED_TABLE_NAME LIKE 'ecommerce\_%';SELECT CONSTRAINT_NAME, TABLE_NAME, REFERENCED_TABLE_NAME, DELETE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME LIKE 'ecommerce\_%';Reporting and backup notes
- Exclude soft-deleted rows. Nine of the eleven tables have
deletedAt, and the admin screens hide those rows by default. A rawSELECTdoes not — addWHERE deletedAt IS NULLor your revenue figures will include deleted orders. - Sum
total, in one currency at a time. Orders carry their owncurrencyandwalletType. Adding a USD order to a USDT order produces a number that means nothing. - Join on the order item for product-level reporting. The order row does not
name a product;
ecommerce_order_item.productIddoes. ecommerce_order.statusis the entitlement. A digital download is served only for aCOMPLETEDorder. Do not infer it from the transaction ledger.- Back up the uploads directory as well as the database. Self-hosted download files and every product and category image live on disk; the tables hold paths only. Core's Backup and restore covers the commands.
- Nothing in this schema needs a manual migration. Tables are synced from the models at boot, so an extension update restarts into the new shape.
Related: API and data for the endpoints, Permissions and roles for who can reach the delete doors, Categories and products and Orders and fulfilment for the screens that drive all of the above.