Reorder Planning & manufacturing (cycle PRs #188–#190)
- Reorder report covers manufactured products end-to-end. BOM-typed products no longer show "Unassigned" in the supplier column; they display Manufactured by [name] using the manufacturer set on the product's most recent
ProductionOrder, or Manufactured in-house when none has been set. - Raw materials inherit demand from BOM parents that need replenishment. For each BOM row whose own
suggestedReorderQty > 0, everyBomItem.componentProductIdadds(bomSuggestedQty × bomItem.qty)to its component's reorder point. Raw materials used by multiple BOMs aggregate demand across all parents; raw materials that are also sellable add BOM demand on top of their sales-driven demand. Products withreorderQty = 0(explicit opt-out) stay out of the report unless a BOM is driving demand into them. - New "Needed for" column lists the demand sources per row:
Direct saleswhen daily demand > 0,BOM <parent SKU>for each contributing parent BOM (sorted + deduped),Stock policyfallback when neither applies. - One-click "Generate POs + draft MOs for visible rows" toolbar on the Reorder Planning page. Splits the visible rows by
productTypeand routes purchased rows tocreateReorderPOs(one draft PO per supplier) and manufactured rows tocreateReorderMOs(one draftProductionOrderper BOM product, copyingmanufacturerId+warehouseIdfrom the product's latest MO, picking the most recently-updated activeBomas parent). Operator scopes via the existing filter form. - Replenishment CSV export gains the
neededForcolumn (semicolon-joined). Consumers with pinned column maps need updating. - Analytics report description + methodology notices fold into a single (i) tooltip next to each report title. The paragraph below the title and the amber notices box lower on the page are gone; the same content shows on hover or keyboard focus, and the data table sits ~24px higher on every report page.
Tax & accounting (cycle PRs #169–#186)
- Tax rate profiles.
TaxRatebecomes a full tax profile: orderedTaxRateComponentrows for compound or multi-element taxes (e.g. Canada GST + PST compounding to 12.35%), areverseChargeflag, anisCompoundflag, and areportingCategory(DOMESTIC/REVERSE_CHARGE/EC_SALES/OSS). The single effective rate is still snapshotted on each document line so historical documents stay stable; the profile metadata drives connector mapping and VAT reporting. - VAT analytics report groups by reporting category. A 20% UK domestic sale and a 20% EU OSS sale now appear as separate rows. A new Reporting category dropdown filter scopes the page to one category for OSS or reverse-charge return preparation; the filter round-trips through the URL so CSV exports and pagination links preserve it.
- Sales invoice edit syncs back to Xero. Editing a sales order that has already pushed to Xero (
accountingInvoiceIdpresent) enqueues aSALES_INVOICE_UPDATEinstead of silently dropping the change. The payload uses the same builder as the create path; a payload-derived idempotency key prevents duplicates on unchanged re-saves. QuickBooks does not support invoice updates and logssales_invoice_update_skipped_unsupported_connectorWARNING instead. - Purchase bill edits sync back to Xero. Unpaid bills can be edited from the PO detail page; saving content changes enqueues
PURCHASE_INVOICE_UPDATEwith the same idempotency-key semantics as the sales-side flow. Editability is re-checked inside the transaction to handle concurrent payment correctly. - Reverse-charge accounting tax-type swap. Lines whose
TaxRate.reverseChargeis true now post to Xero / QBO with the line'staxTypeswapped to a configurable code (accounting_reverse_charge_sales_tax_type, typical Xero:ECOUTPUTSERVICES;accounting_reverse_charge_purchase_tax_type, typical Xero:REVERSECHARGES) so the VAT return classifies them on box 1 / box 8. Empty settings preserve existing posting via the parentaccountingTaxType. - IMS → Xero TaxRate sync with
TaxComponents. Multi-component IMSTaxRaterows now auto-sync to Xero viaPOST /TaxRateswith the matchingTaxComponentspayload (idempotent byName) so the VAT return shows the component breakdown without operator hand-configuration. Gated byxero_sync_tax_ratesetting. QBO has no equivalent API; the trigger logstax_rate_sync_skipped_unsupported_connectorinstead. - Rejected accounting-update alerts. Failed
SALES_INVOICE_UPDATEandPURCHASE_INVOICE_UPDATEsync rows surface as an amber alert on the related sales-order or purchase-order detail page with connector, timestamp, retry count, and a safely truncated error message. The raw sync payload is never displayed. - PO FX rebase preserves consistency. Currency- or rate-only edits on a DRAFT purchase order recompute every persisted base amount (header + lines + freight cost lines) inside a single transaction. If any subsequent step fails (tax resolution, validation), the entire rebase rolls back — lines never drift away from the parent header.
Developer-facing
- Integration settings now require a successful connection test before enablement. Xero, WooCommerce, Mintsoft, and SMTP connection tests persist their latest result, timestamp, and configuration fingerprint in the settings store. Newly enabling sync features, activating Mintsoft, or saving SMTP transport settings now fails until the current connection settings have passed their test.
- Report CSV exports no longer repeat report-level metadata on each data row. Stock-position, inventory-ledger, and inventory-costing exports move fields such as
asOf,source,generatedAt, date windows, and opening/closing totals out of per-row schemas. The CSV file now keeps metadata once in trailing#comment rows, while API clients can read the same data from the base64url-encodedX-IMS-Export-Metadataresponse header. Consumers with hardcoded column maps should update them to the new row schemas and read metadata from the comment rows or decoded header. - Cron endpoint rate limits now include high-frequency headroom and source-IP scoping. Daily/hourly jobs default to one accepted run per hour, 5-minute jobs allow 15 runs per hour, and 15-minute jobs allow 6 runs per hour so normal scheduling jitter is not denied. Multi-replica deployments must use
RATE_LIMIT_BACKEND=rediswithREDIS_URLfor cluster-wide login/TOTP and cron throttles. - High-volume Xero daily batch journals can split into multiple entries per day. Tenants posting more than
XERO_DAILY_BATCH_LIMITeligible orders or shipments in a daily-batch group receive multiple Xero journals for the same business date. References include deterministic hash suffixes, and reconciliation should sum entries by the shared date/group prefix or payload metadata (batchDate,batchGroup,batchReferenceId). - Account-balance snapshots now have a daily cron dependency. Installations
should scheduleGET /api/cron/account-balance-snapshotdaily, before
accounting reports are reviewed, so inventory and COGS GL variance reports
have previous-day Xero Trial Balance snapshots available. The production
rollout readiness check treats a missing or stale run as a blocker. - Upload storage roots are now environment-configured.
UPLOAD_STORAGE_DIR
stores private uploads such as supplier invoice PDFs and
PUBLIC_UPLOAD_STORAGE_DIRstores branding/avatar assets. Local defaults
preserve the previous./uploadsand./public/uploadsbehavior, while
production logs a warning if either root is unset. Avatar URLs keep the
historical/uploads/avatars/*shape; branding uploads now rotate filenames
instead of relying on query-string-only cache busting. - Invoice PDF uploads can be scanned before storage.
FILE_SCAN_MODE=command
writes invoice PDFs to quarantine, runs a configured scanner command, and
moves only clean files into final upload storage. Scanner processes receive an
explicit environment allowlist, rejected quarantine files are deleted for disk
hygiene, and audit metadata records scan status without scanner output or file
paths. The defaultdisabledmode preserves existing upload behavior. - Decimal boundary guard. Added
npm run check:decimal-boundariesand wired it intonpm run validateplus GitHub Actions so guarded domain/accounting paths must document any directdecimalToNumberimport with adecimal-boundary-ok:rationale. The leading rationale token is now a closed vocabulary, and currentlegacy-pre-stage-4annotations mark Decimal conversion follow-up work planned for Stage 4. Developers rebasing older branches that touch guarded paths may need to add or narrow these comments before validation passes. - Integration outbox payload registry. WooCommerce stock-sync, Xero accounting-post, and Mintsoft booked-in outbox operations now have registered Zod payload schemas. Registered payloads are validated and normalized on enqueue and again by connector processors before execution, while unknown outbox operations remain backwards-compatible for existing rows and future connectors.
- Integration outbox retry backoff. Retryable IntegrationOutbox failures now use configurable exponential backoff with tail jitter (
OUTBOX_RETRY_BASE_MS,OUTBOX_RETRY_MAX_MS,OUTBOX_RETRY_JITTER_MS) instead of the fixed default delay. WooCommerce stock-sync retries now follow the shared 5/10/20/40/60-minute capped curve, extending the 12-attempt time-to-permanent-failure from roughly 6.5 hours to roughly 9.25 hours before jitter. Connector-specific explicit retry delays, such as rate-limit backoff, remain supported. - Inventory invariant SQL collector. Production inventory invariant reports now use a SQL-backed collector with cursor pagination, product/warehouse/severity filters, and bounded cron defaults. The pure row evaluator remains available for fixture tests; scheduled checks surface cap exhaustion as a critical truncation finding and can be tuned with
INVARIANT_CHECK_PAGE_SIZE/INVARIANT_CHECK_MAX_FINDINGS. - Manufacturing domain helpers extracted. Manufacturing costing, disassembly component-consumption planning, and production-order state decisions now live under
lib/domain/manufacturingwith focused unit coverage. Server actions remain responsible for auth, transactions, stock writes, accounting queueing, and UI revalidation.
Fixes (landed-cost precision)
- Retrospective landed-cost deltas now use Decimal arithmetic internally. Cost-layer revaluation now keeps unit-cost deltas, consumed quantities, COGS deltas, and inventory-in-transit deltas as
Prisma.Decimaluntil the accounting payload or snapshot-refresh boundary. This removes binary floating-point drift from fractional landed-cost recalculations while preserving existing journal payload rounding, including the pre-existingMath.roundbehavior for negative half-cent deltas. Existing journal entries are not retroactively re-rounded. - Landed-cost snapshot refreshes now keep returned quantities and replacement unit costs Decimal-safe until serialization. Customer and supplier return helpers now return
Prisma.Decimal, and cost-layer snapshot refreshes accept Decimal inputs before explicitly preserving the existing JSON number shape forunitCostBase. Manufacturing cost-layer recalculation now also keeps unit deltas, consumed quantities, COGS deltas, and inventory deltas Decimal-safe until the journal/server-action return boundary. - Landed-cost revaluations now write audit runs. Direct and linked landed-cost recalculations persist before/after cost-layer snapshots, affected COGS refresh counts, planned accounting adjustment idempotency keys, captured warnings such as BY_WEIGHT fallback, and the triggering user where a server action initiated the recalculation.
Fixes (allocation precision)
- Allocation availability now uses Decimal arithmetic internally. Sales allocation stock maps, product graph component quantities, kit requirement expansion, coverage checks, and reservation deltas now keep fractional quantities as
Prisma.Decimalthrough the allocation service. This avoids binary floating-point drift when allocating fractional kit/component quantities while preserving existing UI and report number boundaries.
Fixes (refund correctness)
- Refund return-stock idempotency now includes the return warehouse. Split returns of the same refund line to different warehouses no longer collide on the same
RETURN_INBOUNDidempotency key. Because IMS is not live yet, no old-key compatibility path is kept; persistent dev or staging databases with old-shape refund return keys should reset thoseRETURN_INBOUND:refund:*:line:*movement idempotency keys or rebuild the database before replaying refund returns. - Refund restocking now requires shipped-stock evidence. Allocation-only refund rows no longer silently restock from unshipped reservations when no shipment line exists on the original order. Those attempts now return a clear operator message and should be processed as cash-only refunds or corrected to refund a shipped line.
Fixes (VAT and tax correctness)
- Sales-order creation now validates caller-supplied line tax assertions. Import/API callers may pass
taxForeignper line as an assertion; IMS rejects values that do not match the resolved line tax rate and inclusive/exclusive pricing mode before creating the order. VAT report coverage now separately pins inclusive taxable-base reporting (totalBase - taxBase) and exclusive taxable-base reporting (totalBase).
Fixes (manufacturing valuation)
- Manufacturing WIP and cost-layer timing now align with finance expectations. WIP reporting includes both consumed component value and manufacturing cost-line totals, production-created cost layers receive the production completion timestamp for FIFO ordering, and manufacturing cost-line parsing now drops sub-half-penny negative rounding dust while still rejecting real credit-style negative adjustments.
Fixes (purchase cancellation and freight)
- Purchase-order cancellation and linked freight receipt guards are stricter. Cancelling a partially received PO reverses remaining receipt cost layers, and repeated cancellation of an already-cancelled PO is now idempotent. Linked freight POs can only auto-receive from committed states (
PO_SENT,SHIPPED,PARTIALLY_RECEIVED,RECEIVED), blocking draft, RFQ, and quote freight orders.
Fixes (stock and cost-layer precision)
- Stock removals require FIFO cost-layer coverage. Negative stock movements using strict FIFO consumption now fail before stock levels are written when cost-layer evidence cannot cover the removal.
- Purchase receipts reject invalid unit costs before stock writes. Receipt costs must be finite and zero or greater; zero-cost receipts remain allowed for replacement, sample, or consigned stock.
- Cost-layer snapshots use Decimal-safe six-decimal strings. Transfer, shipment, refund, WMS, and Mintsoft snapshot writers now share one serializer for persisted FIFO snapshot quantities and unit costs.
- Landed-cost recalculation adjustments carry freight PO attribution. Adjustment idempotency now separates different linked freight POs; non-live deployments can rebuild freely, but live queues should be drained or rewritten before deploying this key-shape change.
- COGS entries preserve six-decimal consumed quantities.
CogsEntry.qtyis widened toDECIMAL(14,6)and COGS writers now share one Decimal-safe create-data helper.
Fixes (Mintsoft webhook security)
- Mintsoft ASN booked-in webhooks now bind the freshness timestamp into the HMAC signature. IMS verifies
HMAC_SHA256(secret, "${timestamp}.${rawBody}")and rejects body-only signatures. - Mintsoft ASN booked-in webhooks now acknowledge after durable persistence. Accepted webhooks are stored idempotently in
wms_inbound_receipt_eventsand return202 Accepted; the Mintsoft webhook sweeper applies stock and purchase-order effects asynchronously. - Mintsoft booked-in reconciliation now uses direct ASN lookup. The processor fetches the referenced ASN by id through the WMS connector instead of listing all ASNs and searching client-side.
MINTSOFT_USE_BULK_ASN_LOOKUP=trueis available as a temporary rollback flag if Mintsoft endpoint discovery shows a different direct endpoint shape. - Mintsoft booked-in webhook sweeping is configurable.
MINTSOFT_WEBHOOK_SWEEPER_PAGE_SIZEcontrols how many persisted booked-in events one sweeper run drains; the default remains250. - Mintsoft booked-in webhook retry state is now queryable.
wms_inbound_receipt_eventsstores typed processing status, retry attempts, next retry time, dead-letter timestamp, and last error, replacing the oldprocessingErrorretry-state mirror so the sweeper no longer scans or parses encoded retry JSON.
User-facing (sales allocation and backorders)
- Sales allocation now distinguishes physical reservations from backorder demand. Auto-allocation reserves only stock that physically exists; oversell-eligible shortfalls remain unallocated and appear as backorder demand instead of inflating
reservedQty. Operators may see existing phantom over-reservations corrected on the next re-allocation, with affected lines shown asBackorderorUnallocatedin the sales-order allocation panel. - Allocation activity logs include backorder details. Allocation entries can now include unallocated quantities and per-line backorder metadata. The activity-log UI has no action allowlist, but downstream log parsers should expect the new
backorder_recordedaction and the longer allocation descriptions.
Breaking operator change (cron authentication)
-
Production cron endpoints now require bearer authentication by default. Localhost-only cron calls without
Authorization: Bearer $CRON_SECRETwill receive401in production. This is an operator-facing breaking change and should ship in the next major release (2.0.0under the release scheme above). -
Before deploying this change, update existing production crontabs. Replace bare localhost cron calls with commands that read
CRON_SECRETfrom the protected app environment file and send the cron bearer token, for example:CRON_SECRET=$(grep -m 1 '^CRON_SECRET=' /opt/one-two-inventory/.env | cut -d= -f2-) && curl -fsS -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/fx-rates
-
Fresh installs are handled by
scripts/install.sh. Installer-generated cron entries now read only theCRON_SECRET=line from${APP_DIR}/.envat runtime, keeping the cron secret in the existingimsapp:imsappmode-600environment file instead of duplicating it into the crontab or sourcing the full environment file. -
Emergency bypass is explicit and narrow. Production localhost bypass is only available when
CRON_SECRETis not configured andALLOW_LOCALHOST_CRON_BYPASS=trueis set. Do not use this on shared or externally reachable deployments.