Feat/platform extensions
PR
- Have you followed the contributing guidelines?
- Have you explained what your changes do, and why they add value?
Please note: we will close your PR without comment if you do not check the boxes above and provide ALL requested information.
Summary by cubic
Adds 16 installable commerce plugins to Ever Gauzy (catalog, pricing, tax, inventory, warehouse, cart, order, payment, promotion, fulfillment, returns, subscription, purchasing, entitlement, marketplace, search) with the platform kernels beneath them — including a transactional event outbox, exact-decimal money, optimistic concurrency via If-Match, durable operations, idempotency, numbering, and a dependency-ordered plugin framework. The branch brings MikroORM to feature parity with TypeORM, locks down the platform against adversarial review, and enforces everything with a multi-gate static CI suite. The promised order verbs (generate/quote/send/accept/decline/approval request) are now served on REST and GraphQL through new ORDER_INVOICING / ORDER_APPROVAL ports over core's invoice and request-approval services; a concurrent accept and decline race through the finance document's own answer flag, which admits exactly one. Hard deletes are gone from the API: order records, payment collections and webhook events, subscriptions and their billing cycles, and the returns/entitlement family all retire through deletedAt instead. The outbox now feeds typed subscriptions — orderChanged, checkoutCompleted, orderReturnChanged — scoped to the subscriber's tenant and organization.
Roll-out notes: REPORT_CATALOGUE_AUTHORING_ENABLED (off by default) gates writes to tenant-shared report tables; purchasing's receive-purchase-order route now requires an Idempotency-Key header; Node 26, TypeScript 6, Nest 12, Angular 22, Electron 44, Jest 30 and Sentry 11 land here with Sentry's new default data collection disabled. The Jest split runs Node projects under --experimental-vm-modules and Angular projects without it; Nebular is vendored (no npm release for Angular 22); each desktop app installs its own dependencies before electron-builder packs it. A new Playwright suite drives the packaged desktop apps (the timer's main flow and every app's first start) against a real API, and its run uncovered desktop fixes now in: the timer's embedded job queue works again on Electron 44 (nedb's removed util checks restored), empty screenshots are never captured or uploaded (the API refuses them instead of losing them silently, and a file the storage cannot read back is a 500, not a silent 201), and the 260 MB webpack stats.json no longer ships inside the timer installer. Remaining items are tracked in PLATFORM-EXTENSIONS.md.
Correctness and hardening
- Every REST write route is mirrored by a GraphQL mutation under the same guard chain and permission, with a parity gate keeping the backlog at zero. Core reads earned twins too: task retire/recover and
taskByNumber, terms acceptance and history, expense and invoice statistics (exact Decimal totals per currency, never summed across currencies), the publicinvoiceByTokenlink,reportMenuItemswith SUPER_ADMIN report-catalogue authoring,bulkCreateTenantRoles, payroll items, team-member addition, email-reset history (never the secret), and tenant API-key rename/revoke. The order verbs answer through the order's version-predicated writes; a stale version is refused at the read, before a document is issued. GraphQL delete fields now answer what their delete did — false orNOT_FOUNDfor an id that names no row — instead of a constant, and an entitlement key is shown once (410 ENTITLEMENT_KEY_SHOWN_ONCEon re-display). Sidebar favorites now open the record itself (full-width task page, document page/folder/file, invoice/estimate, sprint/module, expense, income, payment, equipment, vendor, team), and invoices, estimates, expenses, income, payments, proposals, equipment and inventory items are favoritable with the details read gated by the type's own permission. Docs content saves now stamp a strictly newer concurrency token (millisecond resolution, never the token being replaced), closing a stale-token accept; a document favorite opens at full width and leaving it restores the editor layout. A partial PUT no longer writes the create DTO's defaults back — the update bodies start empty, so a payment-collection metadata edit no longer zeroes the money totals, and server-owned members (the totals, status, completedAt, orderId, cartId) are refused withPAYMENT_COLLECTION_FIELD_NOT_WRITABLE— andGRAPHQL_INTROSPECTION=truenow actually serves the schema it configures. - Closed security holes: GraphQL permission gaps and anonymous subscription sockets, SSRF-capable webhooks (now scoped to the caller's tenant, with the address policy reading IPv4-mapped/6to4/NAT64 forms, one pinned DNS resolution per attempt, and a bounded response body that answers a body of exactly the limit at once), cross-tenant soft-delete/recover and bulk writes, collection set writes and inventory reservation/adjustment/transfer/count verbs that could rewrite another tenant's rows, prototype-polluting paths (now refused, not written), catastrophic and injectable regexes (replaced by linear-time scanners, including the connection
like/ilikefilter), credentials logged in the clear, a product bulk write that copied undeclared body keys, and a seller scope that could open an account for any party. The soft-delete/recover pair now requires the controller's edit grant (REST and GraphQL alike), so a view-only role can no longer retire or restore rows; comment and reaction soft-delete/recover follow the ownership rule their edit enforces, and the custom-SMTP family gained aCUSTOM_SMTP_EDITwrite grant. Role names are reserved identities: no create or rename into a platform role (an ADMIN could otherwise escalate to SUPER_ADMIN via a rename within the role cache's minute), a name is unique in its tenant, a withdrawn role's name stays taken, andisSystemis not a caller's to change. GraphQL enforces the REST organization-membership rule on every one of the 449 fields that take anorganizationId, through one interceptor.recoverSellerreplaces the deprecatedrestoreSeller. Plugin subscription streams deliver only the subscriber's own tenant's events — promotion, entitlement and payment events now carry the tenant and are refused on arrival —Subscription.eventsand the payment lifecycle subscriptions are served, and each outbox-backed plugin declares the events it streams.?withDeleted=falsereads as false, and the credential decides a request's tenant over any header. - Money is exact end to end:
Decimal,DateTimeandJSONGraphQL scalars, half-up default division with guard digits, and every money movement — gift-card balance, payment capture, authorization and void, collection totals, refund approval, promotion reversal — commits with its ledger row in one locked, retried transaction. Marketplace commission tiers and seller-balance buckets are Decimal too, the money gate matching money vocabulary by whole word. Transaction paths now read the rows they write under a row lock on PostgreSQL/MySQL (snapshot reads lost concurrent cancelations), the PostgreSQL lock timeout is set throughset_config(a rawSET LOCAL ... $1failed on every stock movement), and failed Mikael rollbacks are proven on a real MikroORM store. - Order-line counters move by single relative statements; order-line and return counters are statement-guarded by the I-12 invariants (received + dismissed ≤ requested ≤ fulfilled) and receipts measure combined entries against a line's ceiling, while a call's own reversal restores the line exactly without the guard. A call of several counter moves runs in one transaction — a refusal is a rollback, not an undo. Receipts and reversals commit whole or not at all, and take units out of the position the receipt put them in; a cancellation landing mid-step is never lost; a reconciliation adopts a ledger-less level as an opening balance instead of zeroing it; and immutable rows refuse state changes without the ledger movement they require. The order's re-derivation can join the caller's transaction, so a failed fulfilment cancelation rolls back instead of committing a cancellation without its totals; on MySQL the cancelation's transaction runs READ COMMITTED so a queued cancelation sees the first one's counters, and its order-line locks are taken in id order to avoid deadlocks. Posted goods receipts and their lines, live stock reservations, and billing cycles that record money cannot be corrected or hard-deleted; DELETE routes for order records, payment collections, subscriptions and billing cycles, returns, claims, exchanges, entitlements, activation keys, seller offerings, refunds and refund reasons now retire or withdraw instead — a refund that moved money refuses retirement with
REFUND_NOT_RETIRABLE, and ledger rows and captured payments are append-only. Idempotent GraphQL replays re-raise the stored refusal, the version guard yields only inside a replay's window (a yielded retry keeps the version it stated, with one version expectation per GraphQL field), the hourly sweep drains in batches, and GraphQL throttling keys each client to its own bucket and sets the rate-limit headers. - The API boots on Postgres, MySQL and SQLite again: dialect-correct column types via
@JsonColumnand nolengthon enums, one timestamp per migration (every migration audited, a shipped seed restored byte for byte), a slug backfill that respects the unique indexes and a contact-email lookup freed of a uniqueness rule, and optional extension fields accepting absent request bodies. PostgreSQL foreign-key checks bind$nplaceholders (a bare?stopped every PostgreSQL boot), the pricing migration's MySQL branch no longer re-declares columns (and its generatedpriceListKeyis VIRTUAL), PostgreSQL/SQLite indexes useIF NOT EXISTSso a caught error cannot abort the migration transaction (a failed statement used to poison PostgreSQL's), and the commerce migrations' SQLite table rebuilds replay the tables' indexes instead of dropping them with the backup table. Organizations created at runtime get the commerce numbering and pricing defaults a migration writes for pre-existing ones, the seller-extended price-tier index folds its null keys again so duplicate base prices are refused, the saved-list slug index is scoped to merchandising collections, and clearing a variant's thumbnail clears the old primary before the new one lands. - The static CI gates fail loudly: a pinned GraphQL schema snapshot whose breaking-change check compares a push with the commit it replaced, a design-name triage keeping the 81 phantom design names decided, a subscription-parity gate (promised vs served vs able to fire; backlogs shrink only), a delete-answer gate, a runtime-dependency check (every package an image loads — followed through the workspaces' sources — is installed in it; the API image was missing
rimrafand OTel instrumentations), a duplicate-column gate that reads CREATE TABLEs to their closing parenthesis, a dependency lockfile check, a barrel-import ban, a migration-timestamp audit, an update-DTO initializer check, and gates for virtual Jest mocks, entity column types, and GraphQL organization-membership reachability. An upgrade test applies the branch's migrations on develop's schema and data — a database develop's own image seeds — on SQLite and PostgreSQL, and a docker-build-check builds and boots each deploy image and checks it serves, pushing nothing.
MikroORM parity
- The platform boots, seeds and runs under
DB_ORM=mikro-orm: tenant-aware CRUD, operations, sequences, outbox and dispatcher, entitlement lifecycle, warehouse services, token and role flows, custom entity fields, plugin registry persistence (pinned to TypeORM, whose closure trees MikroORM can't map) and request context all work. - MikroORM matches TypeORM semantics, including on one shared SQLite file:
save()loads and assigns;update()bumpsversion; relation-id mirrors and custom fields register before discovery and write through; cascaded rows get primary keys on SQLite and MySQL; unloaded relations and dates are serialized and compared as TypeORM treats them;And(...)/Not(...)and the bare range operators (LessThan,Between, ...) keep every condition; and serialized rows answer domain-method calls through the entity prototype. - TypeORM is unchanged, proved by captured-statement comparison and a dual-ORM metadata gate keeping both mappings equal across all 342 entities.