Skip to content

v2.0.0 — Tax model, accounting sync, manufacturing-aware reorder

Latest

Choose a tag to compare

@OneTwo3D OneTwo3D released this 12 Jun 19:14
· 161 commits to main since this release
e487595

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, every BomItem.componentProductId adds (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 with reorderQty = 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 sales when daily demand > 0, BOM <parent SKU> for each contributing parent BOM (sorted + deduped), Stock policy fallback when neither applies.
  • One-click "Generate POs + draft MOs for visible rows" toolbar on the Reorder Planning page. Splits the visible rows by productType and routes purchased rows to createReorderPOs (one draft PO per supplier) and manufactured rows to createReorderMOs (one draft ProductionOrder per BOM product, copying manufacturerId + warehouseId from the product's latest MO, picking the most recently-updated active Bom as parent). Operator scopes via the existing filter form.
  • Replenishment CSV export gains the neededFor column (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. TaxRate becomes a full tax profile: ordered TaxRateComponent rows for compound or multi-element taxes (e.g. Canada GST + PST compounding to 12.35%), a reverseCharge flag, an isCompound flag, and a reportingCategory (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 (accountingInvoiceId present) enqueues a SALES_INVOICE_UPDATE instead 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 logs sales_invoice_update_skipped_unsupported_connector WARNING instead.
  • Purchase bill edits sync back to Xero. Unpaid bills can be edited from the PO detail page; saving content changes enqueues PURCHASE_INVOICE_UPDATE with 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.reverseCharge is true now post to Xero / QBO with the line's taxType swapped 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 parent accountingTaxType.
  • IMS → Xero TaxRate sync with TaxComponents. Multi-component IMS TaxRate rows now auto-sync to Xero via POST /TaxRates with the matching TaxComponents payload (idempotent by Name) so the VAT return shows the component breakdown without operator hand-configuration. Gated by xero_sync_tax_rate setting. QBO has no equivalent API; the trigger logs tax_rate_sync_skipped_unsupported_connector instead.
  • Rejected accounting-update alerts. Failed SALES_INVOICE_UPDATE and PURCHASE_INVOICE_UPDATE sync 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-encoded X-IMS-Export-Metadata response 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=redis with REDIS_URL for 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_LIMIT eligible 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 schedule GET /api/cron/account-balance-snapshot daily, 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_DIR stores branding/avatar assets. Local defaults
    preserve the previous ./uploads and ./public/uploads behavior, 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 default disabled mode preserves existing upload behavior.
  • Decimal boundary guard. Added npm run check:decimal-boundaries and wired it into npm run validate plus GitHub Actions so guarded domain/accounting paths must document any direct decimalToNumber import with a decimal-boundary-ok: rationale. The leading rationale token is now a closed vocabulary, and current legacy-pre-stage-4 annotations 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/manufacturing with 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.Decimal until 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-existing Math.round behavior 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 for unitCostBase. 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.Decimal through 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_INBOUND idempotency 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 those RETURN_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 taxForeign per 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.qty is widened to DECIMAL(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_events and return 202 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=true is 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_SIZE controls how many persisted booked-in events one sweeper run drains; the default remains 250.
  • Mintsoft booked-in webhook retry state is now queryable. wms_inbound_receipt_events stores typed processing status, retry attempts, next retry time, dead-letter timestamp, and last error, replacing the old processingError retry-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 as Backorder or Unallocated in 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_recorded action 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_SECRET will receive 401 in production. This is an operator-facing breaking change and should ship in the next major release (2.0.0 under the release scheme above).

  • Before deploying this change, update existing production crontabs. Replace bare localhost cron calls with commands that read CRON_SECRET from 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 the CRON_SECRET= line from ${APP_DIR}/.env at runtime, keeping the cron secret in the existing imsapp:imsapp mode-600 environment 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_SECRET is not configured and ALLOW_LOCALHOST_CRON_BYPASS=true is set. Do not use this on shared or externally reachable deployments.