Commerce
The optical vertical
Generated from OPTICAL_MODULE.md in the AstroBaaS repository. The repository is the source of truth; this page is a copy of it.
Status: EXTRACTED TO A PRIVATE REPOSITORY (2026-08-24). It is no longer in this repository and must never come back.
Owner decision, 2026-08-08, unchanged:
- Commerce stays in the core. It is not being extracted into a plugin.
- The optometry / eyewear features are a separate module, enabled for
gr-optics.comandoptikigwnia.gr, and licensed for everyone else. - No licensing code is to be written yet.
Where it went, and why it had to
astrobaas-commercial/packages/optical, private, proprietary licence.
The reason is mechanical rather than a preference: a file committed to this repository is GPL-3.0 licensed permanently. Once published, anyone may fork, modify and redistribute it, and no later decision withdraws that. Selling a module depends entirely on its never having been published under the GPL — so the module had to leave before this repository goes public.
The GPL binds everyone who receives the code; it does not bind its author. Copyright here is held by one person, so that person may license the same work commercially — which is exactly how Magento and Adobe Commerce are split. That asymmetry only survives while the proprietary copy stays out of the public tree.
Nothing from that repository may be copied back here. Code flows one direction only.
What replaced it in the core
| Piece | Where |
|---|---|
| Loading a compiled plugin from outside | src/plugins/external.ts, driven by ASTROBAAS_PLUGINS |
| The order-line extension point | PLUGIN_HOOKS.ORDER_LINE_EXTRAS |
| Vertical-published schemas | PLUGIN_HOOKS.COMMERCE_SCHEMA — core serves the routes, a plugin supplies the content |
| Prescription SHAPE + display | src/core/models.ts — data outlives the plugin that understood it |
| Degradation, asserted | tests/smoke.mjs, all three storage drivers |
The two schema routes still exist at their original URLs and now ask the hook, so a storefront sees no change when the module is present and an honest 404 when it is not.
Prescription, EyeRx and summarisePrescription() stayed in core on purpose.
A prescription is frozen onto an order line at purchase, and that record must
keep meaning something after the module is removed — an order is what a customer
actually bought, and uninstalling a plugin must never rewrite it. The SHAPE is
core; every clinical JUDGEMENT is the module’s.
The failure this is built to avoid
A shop whose database says optical is active, redeployed without the package,
stops validating prescriptions — and the sale still completes, because that is
the core’s correct behaviour without a vertical. Silent, and it reaches a lab
with nothing to grind.
So bootstrap reports it on every boot:
[astrobaas] plugin "optical" is ACTIVE in the database but no implementation is
loaded. Anything it provides is silently not happening. The optical module ships
separately: install @astrobaas/optical and set ASTROBAAS_PLUGINS.
Verified by booting a real database through both states.
---|
| The module | src/plugins/optical/ — a bundled, compiled-in plugin, id optical |
| Prescription rules | src/plugins/optical/prescription.ts (moved from lib/commerce/) |
| Frame geometry, face measurement, fit | src/plugins/optical/frame-size.ts (new) |
| The one hook core exposes | PLUGIN_HOOKS.ORDER_LINE_EXTRAS |
| Is the module live? | src/lib/optical-runtime.ts — one function, three callers |
| Public schemas | /api/commerce/prescription-schema, /api/commerce/frame-schema (both 404 when inactive) |
| Upgrade safety | migration v11, activate-optical-module-for-existing-optical-shops |
How the extraction was done
Core used to validate prescriptions inline in checkout — commerce-service.ts
knew what a cylinder axis was. Core now asks one generic question:
PLUGIN_HOOKS.ORDER_LINE_EXTRAS
filter(result, { line, product, name }) -> { ok: true, patch } | { ok: false, message }
…and the optical plugin is what makes the answer optical. A future vertical answers the same question its own way; core never grows a second hole in checkout.
The data stays in core, the logic moved. Product.requires_prescription,
prescription_type and the Prescription type on the order line remain in
core/models.ts on purpose: shape is data, and data has to outlive the plugin
that understood it. Deactivating the module must never rewrite what a customer
already bought.
Degradation — the contract, and it is tested
With the module INACTIVE the core behaves exactly as it does for a shop that
never bought it. Asserted in tests/smoke.mjs on all three storage drivers:
- both schema endpoints return 404;
- an Rx product sells without a prescription — the flag becomes inert data, and nothing 500s;
- a prescription already frozen onto an order line is still there.
Upgrade safety — the bug this nearly shipped with
Bundled plugins seed inactive. Without a migration, upgrading an existing optical shop would have silently stopped prescription validation: a spectacle lens would sell with no prescription and reach the lab with nothing to grind, and nothing would error.
Migration v11 switches the module on for any install with evidence it was
already selling optical goods — a product flagged requires_prescription, or a
historical order line carrying a prescription. A general shop is left alone.
It also seeds its own plugin record, and that detail is the whole fix.
Migrations run inside LocalDB.init(), which ensurePluginsBootstrapped()
calls before it seeds bundled plugins — so at migration time there is normally
no optical record at all. The first version bailed on “no record”, did
nothing, and the boot then seeded the plugin inactive. Caught by booting a real
v10 database with the new build and watching an Rx lens sell with no
prescription. Verified fixed on lowdb, libSQL doc-blob and relational.
Why commerce stays in the core
The earlier plan was to extract commerce into a paid plugin. Measured against
the code, that is a large piece of work: 10 files under src/lib/commerce/,
9 API route directories, 3 admin screens, 12 interfaces in core/models.ts, and
25 references in the Storage interface — commerce is woven into the
storage contract every driver implements.
Extracting it means inventing a storage extension point, which is the kind of abstraction that is easy to get wrong once and then live with. Meanwhile the two live shops need commerce and would gain nothing from the split.
So: commerce is part of what AstroBaaS is. The paid boundary moves up a level, to the vertical.
Why the optical features are the right thing to sell
They are genuinely separable in a way commerce is not, and they are worth more to fewer people — which is exactly the shape of a paid module.
What is in scope (all currently in the core, to be moved):
| Piece | Where it lives today |
|---|---|
| Prescription parsing, validation, summarising | src/lib/commerce/prescription.ts |
requires_prescription, prescription_type on a product | core/models.ts, the Inventory tab of the product form |
| Prescription capture at checkout and on the order | order model + admin/orders.astro |
| Dioptre / PD / axis integer handling | lib/commerce/prescription.ts |
| Lens-configurator work (not yet built) | — |
The units matter and are already right: dioptres in hundredths, PD in tenths of a millimetre, all integers. That work does not need redoing, only moving.
The shape it should take
The plugin dependency mechanism already exists (src/core/semver-range.ts,
src/lib/plugin-dependencies.ts, capabilities + dependencies in a manifest).
It was built for exactly this and is tested. What it needs is something to
depend on — and with commerce staying in the core, the dependency is on the
HOST version rather than on a commerce plugin:
{
"id": "optical",
"name": "Optical & Eyewear",
"version": "1.0.0",
"astrobaasApi": "^1.0.0", // already enforced
"capabilities": { /* sections, content types, … */ }
}
A vertical that needs more than a manifest can express (prescription validation is real logic, not data) is a compiled-in plugin, not a declarative one. That is the tier that can register hooks and server logic.
Licensing — deliberately not designed here
The commercial model itself is analysed in LICENSING.md: the Magento shape (free community edition + paid commercial edition) is available because the copyright is held by one person, and the single time-critical action is adding a CLA before the first external contribution.
Recording here only the constraints that a future design must not violate:
- The two live shops must never be gated. Whatever the mechanism, an outage
or an expiry must not stop
gr-optics.comoroptikigwnia.grfrom selling. Fail-open for existing installs, always. - No phone-home on the request path. A licence check inside a page render couples a shop’s uptime to a licence server’s uptime. Whatever is built, it is checked out of band and cached.
- The core stays GPL-3.0 and honest about it. A paid module sitting on a GPL host has licensing implications that need a real answer before any code is written, not after.
- Nothing in the core should reference the licence. If the optical module is absent, the core must behave exactly as it does for a shop that never bought it — which is the same degradation rule sections already follow.
What to do first, when this is picked up
- Move the optical pieces out of
src/lib/commerce/into a plugin, with the core keeping generic hooks where they attached. - Confirm the two live shops still work with the plugin ACTIVE — that is the only acceptable definition of “no regression” here.
- Confirm they degrade sanely with it INACTIVE: prescriptions become plain stored data, nothing 500s.
- Only then think about licensing.
Do not start at step 4.