• The buying-side mirror of pbc-orders-sales. Adds the 6th real PBC
    and closes the loop: the framework now does both directions of the
    inventory flow through the same `InventoryApi.recordMovement` facade.
    Buy stock with a PO that hits RECEIVED, ship stock with a SO that
    hits SHIPPED, both feed the same `inventory__stock_movement` ledger.
    
    What landed
    -----------
    * New Gradle subproject `pbc/pbc-orders-purchase` (16 modules total
      now). Same dependency set as pbc-orders-sales, same architectural
      enforcement — no direct dependency on any other PBC; cross-PBC
      references go through `api.v1.ext.<pbc>` facades at runtime.
    * Two JPA entities mirroring SalesOrder / SalesOrderLine:
      - `PurchaseOrder` (header) — code, partner_code (varchar, NOT a
        UUID FK), status enum DRAFT/CONFIRMED/RECEIVED/CANCELLED,
        order_date, expected_date (nullable, the supplier's promised
        delivery date), currency_code, total_amount, ext jsonb.
      - `PurchaseOrderLine` — purchase_order_id FK, line_no, item_code,
        quantity, unit_price, currency_code. Same shape as the sales
        order line; the api.v1 facade reuses `SalesOrderLineRef` rather
        than declaring a duplicate type.
    * `PurchaseOrderService.create` performs three cross-PBC validations
      in one transaction:
      1. PartnersApi.findPartnerByCode → reject if null.
      2. The partner's `type` must be SUPPLIER or BOTH (a CUSTOMER-only
         partner cannot be the supplier of a purchase order — the
         mirror of the sales-order rule that rejects SUPPLIER-only
         partners as customers).
      3. CatalogApi.findItemByCode for EVERY line.
      Then validates: at least one line, no duplicate line numbers,
      positive quantity, non-negative price, currency matches header.
      The header total is RECOMPUTED from the lines (caller's value
      ignored — never trust a financial aggregate sent over the wire).
    * State machine enforced by `confirm()`, `cancel()`, and `receive()`:
      - DRAFT → CONFIRMED   (confirm)
      - DRAFT → CANCELLED   (cancel)
      - CONFIRMED → CANCELLED (cancel before receipt)
      - CONFIRMED → RECEIVED  (receive — increments inventory)
      - RECEIVED → ×          (terminal; cancellation requires a
                                return-to-supplier flow)
    * `receive(id, receivingLocationCode)` walks every line and calls
      `inventoryApi.recordMovement(... +line.quantity reason="PURCHASE_RECEIPT"
      reference="PO:<order_code>")`. The whole operation runs in ONE
      transaction so a failure on any line rolls back EVERY line's
      already-written movement AND the order status change. The
      customer cannot end up with "5 of 7 lines received, status
      still CONFIRMED, ledger half-written".
    * New `POST /api/v1/orders/purchase-orders/{id}/receive` endpoint
      with body `{"receivingLocationCode": "WH-MAIN"}`, gated by
      `orders.purchase.receive`. The single-arg DTO has the same
      Jackson `@JsonCreator(mode = PROPERTIES)` workaround as
      `ShipSalesOrderRequest` (the trap is documented in the class
      KDoc with a back-reference to ShipSalesOrderRequest).
    * Confirm/cancel/receive endpoints carry `@RequirePermission`
      annotations (`orders.purchase.confirm`, `orders.purchase.cancel`,
      `orders.purchase.receive`). All three keys declared in the new
      `orders-purchase.yml` metadata.
    * New api.v1 facade `org.vibeerp.api.v1.ext.orders.PurchaseOrdersApi`
      + `PurchaseOrderRef`. Reuses the existing `SalesOrderLineRef`
      type for the line shape — buying and selling lines carry the
      same fields, so duplicating the ref type would be busywork.
    * `PurchaseOrdersApiAdapter` — sixth `*ApiAdapter` after Identity,
      Catalog, Partners, Inventory, SalesOrders.
    * `orders-purchase.yml` metadata declaring 2 entities, 6 permission
      keys, 1 menu entry under "Purchasing".
    
    End-to-end smoke test (the full demo loop)
    ------------------------------------------
    Reset Postgres, booted the app, ran:
    * Login as admin
    * POST /catalog/items → PAPER-A4
    * POST /partners → SUP-PAPER (SUPPLIER)
    * POST /inventory/locations → WH-MAIN
    * GET /inventory/balances?itemCode=PAPER-A4 → [] (no stock)
    * POST /orders/purchase-orders → PO-2026-0001 for 5000 sheets
      @ $0.04 = total $200.00 (recomputed from the line)
    * POST /purchase-orders/{id}/confirm → status CONFIRMED
    * POST /purchase-orders/{id}/receive body={"receivingLocationCode":"WH-MAIN"}
      → status RECEIVED
    * GET /inventory/balances?itemCode=PAPER-A4 → quantity=5000
    * GET /inventory/movements?itemCode=PAPER-A4 →
      PURCHASE_RECEIPT delta=5000 ref=PO:PO-2026-0001
    
    Then the FULL loop with the sales side from the previous chunk:
    * POST /partners → CUST-ACME (CUSTOMER)
    * POST /orders/sales-orders → SO-2026-0001 for 50 sheets
    * confirm + ship from WH-MAIN
    * GET /inventory/balances?itemCode=PAPER-A4 → quantity=4950 (5000-50)
    * GET /inventory/movements?itemCode=PAPER-A4 →
      PURCHASE_RECEIPT delta=5000  ref=PO:PO-2026-0001
      SALES_SHIPMENT   delta=-50   ref=SO:SO-2026-0001
    
    The framework's `InventoryApi.recordMovement` facade now has TWO
    callers — pbc-orders-sales (negative deltas, SALES_SHIPMENT) and
    pbc-orders-purchase (positive deltas, PURCHASE_RECEIPT) — feeding
    the same ledger from both sides.
    
    Failure paths verified:
    * Re-receive a RECEIVED PO → 400 "only CONFIRMED orders can be received"
    * Cancel a RECEIVED PO → 400 "issue a return-to-supplier flow instead"
    * Create a PO from a CUSTOMER-only partner → 400 "partner 'CUST-ONLY'
      is type CUSTOMER and cannot be the supplier of a purchase order"
    
    Regression: catalog uoms, identity users, partners, inventory,
    sales orders, purchase orders, printing-shop plates with i18n,
    metadata entities (15 now, was 13) — all still HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 16 subprojects, 186 unit tests (was 175),
      all green. The 11 new tests cover the same shapes as the
      sales-order tests but inverted: unknown supplier, CUSTOMER-only
      rejection, BOTH-type acceptance, unknown item, empty lines,
      total recomputation, confirm/cancel state machine,
      receive-rejects-non-CONFIRMED, receive-walks-lines-with-positive-
      delta, cancel-rejects-RECEIVED, cancel-CONFIRMED-allowed.
    
    What was deferred
    -----------------
    * **RFQs** (request for quotation) and **supplier price catalogs**
      — both lay alongside POs but neither is in v1.
    * **Partial receipts**. v1's RECEIVED is "all-or-nothing"; the
      supplier delivering 4500 of 5000 sheets is not yet modelled.
    * **Supplier returns / refunds**. The cancel-RECEIVED rejection
      message says "issue a return-to-supplier flow" — that flow
      doesn't exist yet.
    * **Three-way matching** (PO + receipt + invoice). Lands with
      pbc-finance.
    * **Multi-leg transfers**. TRANSFER_IN/TRANSFER_OUT exist in the
      movement enum but no service operation yet writes both legs
      in one transaction.
    zichun authored
     
    Browse Code »
  • The killer demo finally works: place a sales order, ship it, watch
    inventory drop. This chunk lands the two pieces that close the loop:
    the inventory movement ledger (the audit-grade history of every
    stock change) and the sales-order /ship endpoint that calls
    InventoryApi.recordMovement to atomically debit stock for every line.
    
    This is the framework's FIRST cross-PBC WRITE flow. Every earlier
    cross-PBC call was a read (CatalogApi.findItemByCode,
    PartnersApi.findPartnerByCode, InventoryApi.findStockBalance).
    Shipping inverts that: pbc-orders-sales synchronously writes to
    inventory's tables (via the api.v1 facade) as a side effect of
    changing its own state, all in ONE Spring transaction.
    
    What landed
    -----------
    * New `inventory__stock_movement` table — append-only ledger
      (id, item_code, location_id FK, signed delta, reason enum,
      reference, occurred_at, audit cols). CHECK constraint
      `delta <> 0` rejects no-op rows. Indexes on item_code,
      location_id, the (item, location) composite, reference, and
      occurred_at. Migration is in its own changelog file
      (002-inventory-movement-ledger.xml) per the project convention
      that each new schema cut is a new file.
    * New `StockMovement` JPA entity + repository + `MovementReason`
      enum (RECEIPT, ISSUE, ADJUSTMENT, SALES_SHIPMENT, PURCHASE_RECEIPT,
      TRANSFER_OUT, TRANSFER_IN). Each value carries a documented sign
      convention; the service rejects mismatches (a SALES_SHIPMENT
      with positive delta is a caller bug, not silently coerced).
    * New `StockMovementService.record(...)` — the ONE entry point for
      changing inventory. Cross-PBC item validation via CatalogApi,
      local location validation, sign-vs-reason enforcement, and
      negative-balance rejection all happen BEFORE the write. The
      ledger row insert AND the balance row update happen in the
      SAME database transaction so the two cannot drift.
    * `StockBalanceService.adjust` refactored to delegate: it computes
      delta = newQty - oldQty and calls record(... ADJUSTMENT). The
      REST endpoint keeps its absolute-quantity semantics — operators
      type "the shelf has 47" not "decrease by 3" — but every
      adjustment now writes a ledger row too. A no-op adjustment
      (re-saving the same value) does NOT write a row, so the audit
      log doesn't fill with noise from operator clicks that didn't
      change anything.
    * New `StockMovementController` at `/api/v1/inventory/movements`:
      GET filters by itemCode, locationId, or reference (for "all
      movements caused by SO-2026-0001"); POST records a manual
      movement. Both protected by `inventory.stock.adjust`.
    * `InventoryApi` facade extended with `recordMovement(itemCode,
      locationCode, delta, reason: String, reference)`. The reason is
      a String in the api.v1 surface (not the local enum) so plug-ins
      don't import inventory's internal types — the closed set is
      documented on the interface. The adapter parses the string with
      a meaningful error on unknown values.
    * New `SHIPPED` status on `SalesOrderStatus`. Transitions:
      DRAFT → CONFIRMED → SHIPPED (terminal). Cancelling a SHIPPED
      order is rejected with "issue a return / refund flow instead".
    * New `SalesOrderService.ship(id, shippingLocationCode)`: walks
      every line, calls `inventoryApi.recordMovement(... -line.quantity
      reason="SALES_SHIPMENT" reference="SO:{order_code}")`, flips
      status to SHIPPED. The whole operation runs in ONE transaction
      so a failure on any line — bad item, bad location, would push
      balance negative — rolls back the order status change AND every
      other line's already-written movement. The customer never ends
      up with "5 of 7 lines shipped, status still CONFIRMED, ledger
      half-written".
    * New `POST /api/v1/orders/sales-orders/{id}/ship` endpoint with
      body `{"shippingLocationCode": "WH-MAIN"}`, gated by the new
      `orders.sales.ship` permission key.
    * `ShipSalesOrderRequest` is a single-arg Kotlin data class — same
      Jackson deserialization trap as `RefreshRequest`. Fixed with
      `@JsonCreator(mode = PROPERTIES) + @param:JsonProperty`. The
      trap is documented in the class KDoc.
    
    End-to-end smoke test (the killer demo)
    ---------------------------------------
    Reset Postgres, booted the app, ran:
    * Login as admin
    * POST /catalog/items → PAPER-A4
    * POST /partners → CUST-ACME
    * POST /inventory/locations → WH-MAIN
    * POST /inventory/balances/adjust → quantity=1000
      (now writes a ledger row via the new path)
    * GET /inventory/movements?itemCode=PAPER-A4 →
      ADJUSTMENT delta=1000 ref=null
    * POST /orders/sales-orders → SO-2026-0001 (50 units of PAPER-A4)
    * POST /sales-orders/{id}/confirm → status CONFIRMED
    * POST /sales-orders/{id}/ship body={"shippingLocationCode":"WH-MAIN"}
      → status SHIPPED
    * GET /inventory/balances?itemCode=PAPER-A4 → quantity=950
      (1000 - 50)
    * GET /inventory/movements?itemCode=PAPER-A4 →
      ADJUSTMENT     delta=1000   ref=null
      SALES_SHIPMENT delta=-50    ref=SO:SO-2026-0001
    
    Failure paths verified:
    * Re-ship a SHIPPED order → 400 "only CONFIRMED orders can be shipped"
    * Cancel a SHIPPED order → 400 "issue a return / refund flow instead"
    * Place a 10000-unit order, confirm, try to ship from a 950-stock
      warehouse → 400 "stock movement would push balance for 'PAPER-A4'
      at location ... below zero (current=950.0000, delta=-10000.0000)";
      balance unchanged after the rollback (transaction integrity
      verified)
    
    Regression: catalog uoms, identity users, inventory locations,
    printing-shop plates with i18n, metadata entities — all still
    HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 175 unit tests (was 163),
      all green. The 12 new tests cover:
      - StockMovementServiceTest (8): zero-delta rejection, positive
        SALES_SHIPMENT rejection, negative RECEIPT rejection, both
        signs allowed on ADJUSTMENT, unknown item via CatalogApi seam,
        unknown location, would-push-balance-negative rejection,
        new-row + existing-row balance update.
      - StockBalanceServiceTest, rewritten (5): negative-quantity
        early reject, delegation with computed positive delta,
        delegation with computed negative delta, no-op adjustment
        short-circuit (NO ledger row written), no-op on missing row
        creates an empty row at zero.
      - SalesOrderServiceTest, additions (3): ship rejects non-CONFIRMED,
        ship walks lines and calls recordMovement with negated quantity
        + correct reference, cancel rejects SHIPPED.
    
    What was deferred
    -----------------
    * **Event publication.** A `StockMovementRecorded` event would
      let pbc-finance and pbc-production react to ledger writes
      without polling. The event bus has been wired since P1.7 but
      no real cross-PBC flow uses it yet — that's the natural next
      chunk and the chunk after this commit.
    * **Multi-leg transfers.** TRANSFER_OUT and TRANSFER_IN are in
      the enum but no service operation atomically writes both legs
      yet (both legs in one transaction is required to keep total
      on-hand invariant).
    * **Reservation / pick lists.** "Reserve 50 of PAPER-A4 for an
      unconfirmed order" is its own concept that lands later.
    * **Shipped-order returns / refunds.** The cancel-SHIPPED rule
      points the user at "use a return flow" — that flow doesn't
      exist yet. v1 says shipments are terminal.
    zichun authored
     
    Browse Code »
  • The fifth real PBC and the first business workflow PBC. pbc-inventory
    proved a PBC could consume ONE cross-PBC facade (CatalogApi).
    pbc-orders-sales consumes TWO simultaneously (PartnersApi for the
    customer, CatalogApi for every line's item) in a single transaction —
    the most rigorous test of the modular monolith story so far. Neither
    source PBC is on the compile classpath; the Gradle build refuses any
    direct dependency. Spring DI wires the api.v1 interfaces to their
    concrete adapters at runtime.
    
    What landed
    -----------
    * New Gradle subproject `pbc/pbc-orders-sales` (15 modules total).
    * Two JPA entities, both extending `AuditedJpaEntity`:
      - `SalesOrder` (header) — code, partner_code (varchar, NOT a UUID
        FK to partners), status enum DRAFT/CONFIRMED/CANCELLED, order_date,
        currency_code (varchar(3)), total_amount numeric(18,4),
        ext jsonb. Eager-loaded `lines` collection because every read of
        the header is followed by a read of the lines in practice.
      - `SalesOrderLine` — sales_order_id FK, line_no, item_code (varchar,
        NOT a UUID FK to catalog), quantity, unit_price, currency_code.
        Per-line currency in the schema even though v1 enforces all-lines-
        match-header (so multi-currency relaxation is later schema-free).
        No `ext` jsonb on lines: lines are facts, not master records;
        custom fields belong on the header.
    * `SalesOrderService.create` performs **three independent
      cross-PBC validations** in one transaction:
      1. PartnersApi.findPartnerByCode → reject if null (covers unknown
         AND inactive partners; the facade hides them).
      2. PartnersApi result.type must be CUSTOMER or BOTH (a SUPPLIER-only
         partner cannot be the customer of a sales order).
      3. CatalogApi.findItemByCode for EVERY line → reject if null.
      Then it ALSO validates: at least one line, no duplicate line numbers,
      positive quantity, non-negative price, currency matches header.
      The header total is RECOMPUTED from the lines — the caller's value
      is intentionally ignored. Never trust a financial aggregate sent
      over the wire.
    * State machine enforced by `confirm()` and `cancel()`:
      - DRAFT → CONFIRMED   (confirm)
      - DRAFT → CANCELLED   (cancel from draft)
      - CONFIRMED → CANCELLED (cancel a confirmed order)
      Anything else throws with a descriptive message. CONFIRMED orders
      are immutable except for cancellation — the `update` method refuses
      to mutate a non-DRAFT order.
    * `update` with line items REPLACES the existing lines wholesale
      (PUT semantics for lines, PATCH for header columns). Partial line
      edits are not modelled because the typical "edit one line" UI
      gesture renders to a full re-send anyway.
    * REST: `/api/v1/orders/sales-orders` (CRUD + `/confirm` + `/cancel`).
      State transitions live on dedicated POST endpoints rather than
      PATCH-based status writes — they have side effects (lines become
      immutable, downstream PBCs will receive events in future versions),
      and sentinel-status writes hide that.
    * New api.v1 facade `org.vibeerp.api.v1.ext.orders.SalesOrdersApi`
      with `findByCode`, `findById`, `SalesOrderRef`, `SalesOrderLineRef`.
      Fifth ext.* package after identity, catalog, partners, inventory.
      Sets up the next consumers: pbc-production for work orders, pbc-finance
      for invoicing, the printing-shop reference plug-in for the
      quote-to-job-card workflow.
    * `SalesOrdersApiAdapter` runtime implementation. Cancelled orders ARE
      returned by the facade (unlike inactive items / partners which are
      hidden) because downstream consumers may legitimately need to react
      to a cancellation — release a production slot, void an invoice, etc.
    * `orders-sales.yml` metadata declaring 2 entities, 5 permission keys,
      1 menu entry.
    
    Build enforcement (still load-bearing)
    --------------------------------------
    The root `build.gradle.kts` STILL refuses any direct dependency from
    `pbc-orders-sales` to either `pbc-partners` or `pbc-catalog`. Try
    adding either as `implementation(project(...))` and the build fails
    at configuration time with the architectural violation. The
    cross-PBC interfaces live in api-v1; the concrete adapters live in
    their owning PBCs; Spring DI assembles them at runtime via the
    bootstrap @ComponentScan. pbc-orders-sales sees only the api.v1
    interfaces.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, hit:
    * POST /api/v1/catalog/items × 2  → PAPER-A4, INK-CYAN
    * POST /api/v1/partners/partners → CUST-ACME (CUSTOMER), SUP-ONLY (SUPPLIER)
    * POST /api/v1/orders/sales-orders → 201, two lines, total 386.50
      (5000 × 0.05 + 3 × 45.50 = 250.00 + 136.50, correctly recomputed)
    * POST .../sales-orders with FAKE-PARTNER → 400 with the meaningful
      message "partner code 'FAKE-PARTNER' is not in the partners
      directory (or is inactive)"
    * POST .../sales-orders with SUP-ONLY → 400 "partner 'SUP-ONLY' is
      type SUPPLIER and cannot be the customer of a sales order"
    * POST .../sales-orders with FAKE-ITEM line → 400 "line 1: item code
      'FAKE-ITEM' is not in the catalog (or is inactive)"
    * POST /{id}/confirm → status DRAFT → CONFIRMED
    * PATCH the CONFIRMED order → 400 "only DRAFT orders are mutable"
    * Re-confirm a CONFIRMED order → 400 "only DRAFT can be confirmed"
    * POST /{id}/cancel a CONFIRMED order → status CANCELLED (allowed)
    * SELECT * FROM orders_sales__sales_order — single row, total
      386.5000, status CANCELLED
    * SELECT * FROM orders_sales__sales_order_line — two rows in line_no
      order with the right items and quantities
    * GET /api/v1/_meta/metadata/entities → 13 entities now (was 11)
    * Regression: catalog uoms, identity users, partners, inventory
      locations, printing-shop plates with i18n (Accept-Language: zh-CN)
      all still HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 153 unit tests (was 139),
      all green. The 14 new tests cover: unknown/SUPPLIER-only/BOTH-type
      partner paths, unknown item path, empty/duplicate-lineno line
      arrays, negative-quantity early reject (verifies CatalogApi NOT
      consulted), currency mismatch reject, total recomputation, all
      three state-machine transitions and the rejected ones.
    
    What was deferred
    -----------------
    * **Sales-order shipping**. Confirmed orders cannot yet ship, because
      shipping requires atomically debiting inventory — which needs the
      movement ledger that was deferred from P5.3. The pair of chunks
      (movement ledger + sales-order shipping flow) is the natural next
      combination.
    * **Multi-currency lines**. The schema column is per-line but the
      service enforces all-lines-match-header in v1. Relaxing this is a
      service-only change.
    * **Quotes** (DRAFT-but-customer-visible) and **deliveries** (the
      thing that triggers shipping). v1 only models the order itself.
    * **Pricing engine / discounts**. v1 takes the unit price the caller
      sends. A real ERP has a price book lookup, customer-specific
      pricing, volume discounts, promotional pricing — all of which slot
      in BEFORE the line price is set, leaving the schema unchanged.
    * **Tax**. v1 totals are pre-tax. Tax calculation is its own PBC
      (and a regulatory minefield) that lands later.
    zichun authored
     
    Browse Code »
  • The fourth real PBC, and the first one that CONSUMES another PBC's
    api.v1.ext facade. Until now every PBC was a *provider* of an
    ext.<pbc> interface (identity, catalog, partners). pbc-inventory is
    the first *consumer*: it injects org.vibeerp.api.v1.ext.catalog.CatalogApi
    to validate item codes before adjusting stock. This proves the
    cross-PBC contract works in both directions, exactly as guardrail #9
    requires.
    
    What landed
    -----------
    * New Gradle subproject `pbc/pbc-inventory` (14 modules total now).
    * Two JPA entities, both extending `AuditedJpaEntity`:
      - `Location` — code, name, type (WAREHOUSE/BIN/VIRTUAL), active,
        ext jsonb. Single table for all location levels with a type
        discriminator (no recursive self-reference in v1; YAGNI for the
        "one warehouse, handful of bins" shape every printing shop has).
      - `StockBalance` — item_code (varchar, NOT a UUID FK), location_id
        FK, quantity numeric(18,4). The item_code is deliberately a
        string FK that references nothing because pbc-inventory has no
        compile-time link to pbc-catalog — the cross-PBC link goes
        through CatalogApi at runtime. UNIQUE INDEX on
        (item_code, location_id) is the primary integrity guarantee;
        UUID id is the addressable PK. CHECK (quantity >= 0).
    * `LocationService` and `StockBalanceService` with full CRUD +
      adjust semantics. ext jsonb on Location goes through ExtJsonValidator
      (P3.4 — Tier 1 customisation).
    * `StockBalanceService.adjust(itemCode, locationId, quantity)`:
      1. Reject negative quantity.
      2. **Inject CatalogApi**, call `findItemByCode(itemCode)`, reject
         if null with a meaningful 400. THIS is the cross-PBC seam test.
      3. Verify the location exists.
      4. SELECT-then-save upsert on (item_code, location_id) — single
         row per cell, mutated in place when the row exists, created
         when it doesn't. Single-instance deployment makes the
         read-modify-write race window academic.
    * REST: `/api/v1/inventory/locations` (CRUD), `/api/v1/inventory/balances`
      (GET with itemCode or locationId filters, POST /adjust).
    * New api.v1 facade `org.vibeerp.api.v1.ext.inventory` with
      `InventoryApi.findStockBalance(itemCode, locationCode)` +
      `totalOnHand(itemCode)` + `StockBalanceRef`. Fourth ext.* package
      after identity, catalog, partners. Sets up the next consumers
      (sales orders, purchase orders, the printing-shop plug-in's
      "do we have enough paper for this job?").
    * `InventoryApiAdapter` runtime implementation in pbc-inventory.
    * `inventory.yml` metadata declaring 2 entities, 6 permission keys,
      2 menu entries.
    
    Build enforcement (the load-bearing bit)
    ----------------------------------------
    The root build.gradle.kts STILL refuses any direct dependency from
    pbc-inventory to pbc-catalog. Try adding `implementation(project(
    ":pbc:pbc-catalog"))` to pbc-inventory's build.gradle.kts and the
    build fails at configuration time with "Architectural violation in
    :pbc:pbc-inventory: depends on :pbc:pbc-catalog". The CatalogApi
    interface is in api-v1; the CatalogApiAdapter implementation is in
    pbc-catalog; Spring DI wires them at runtime via the bootstrap
    @ComponentScan. pbc-inventory only ever sees the interface.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, hit:
    * POST /api/v1/inventory/locations → 201, "WH-MAIN" warehouse
    * POST /api/v1/catalog/items → 201, "PAPER-A4" sheet item
    * POST /api/v1/inventory/balances/adjust with itemCode=PAPER-A4 → 200,
      the cross-PBC catalog lookup succeeded
    * POST .../adjust with itemCode=FAKE-ITEM → 400 with the meaningful
      message "item code 'FAKE-ITEM' is not in the catalog (or is inactive)"
      — the cross-PBC seam REJECTS unknown items as designed
    * POST .../adjust with quantity=-5 → 400 "stock quantity must be
      non-negative", caught BEFORE the CatalogApi mock would be invoked
    * POST .../adjust again with quantity=7500 → 200; SELECT shows ONE
      row with id unchanged and quantity = 7500 (upsert mutates, not
      duplicates)
    * GET /api/v1/inventory/balances?itemCode=PAPER-A4 → the row, with
      scale-4 numeric serialised verbatim
    * GET /api/v1/_meta/metadata/entities → 11 entities now (was 9 before
      Location + StockBalance landed)
    * Regression: catalog uoms, identity users, partners, printing-shop
      plates with i18n (Accept-Language: zh-CN), Location custom-fields
      endpoint all still HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 14 subprojects, 139 unit tests (was 129),
      all green. The 10 new tests cover Location CRUD + the StockBalance
      adjust path with mocked CatalogApi: unknown item rejection, unknown
      location rejection, negative-quantity early reject (verifies
      CatalogApi is NOT consulted), happy-path create, and upsert
      (existing row mutated, save() not called because @Transactional
      flushes the JPA-managed entity on commit).
    
    What was deferred
    -----------------
    * `inventory__stock_movement` append-only ledger. The current operation
      is "set the quantity"; receipts/issues/transfers as discrete events
      with audit trail land in a focused follow-up. The balance row will
      then be regenerated from the ledger via a Liquibase backfill.
    * Negative-balance / over-issue prevention. The CHECK constraint
      blocks SET to a negative value, but there's no concept of "you
      cannot ISSUE more than is on hand" yet because there is no
      separate ISSUE operation — only absolute SET.
    * Lots, batches, serial numbers, expiry dates. Plenty of printing
      shops need none of these; the ones that do can either wait for
      the lot/serial chunk later or add the columns via Tier 1 custom
      fields on Location for now.
    * Cross-warehouse transfer atomicity (debit one, credit another in
      one transaction). Same — needs the ledger.
    zichun authored
     
    Browse Code »
  • The third real PBC. Validates the modular-monolith template against a
    parent-with-children aggregate (Partner → Addresses → Contacts), where
    the previous two PBCs only had single-table or two-independent-table
    shapes.
    
    What landed
    -----------
    * New Gradle subproject `pbc/pbc-partners` (12 modules total now).
    * Three JPA entities, all extending `AuditedJpaEntity`:
      - `Partner` — code, name, type (CUSTOMER/SUPPLIER/BOTH), tax_id,
        website, email, phone, active, ext jsonb. Single-table for both
        customers and suppliers because the role flag is a property of
        the relationship, not the organisation.
      - `Address` — partner_id FK, address_type (BILLING/SHIPPING/OTHER),
        line1/line2/city/region/postal_code/country_code (ISO 3166-1),
        is_primary. Two free address lines + structured city/region/code
        is the smallest set that round-trips through every postal system.
      - `Contact` — partner_id FK, full_name, role, email, phone, active.
        PII-tagged in metadata YAML for the future audit/export tooling.
    * Spring Data JPA repos, application services with full CRUD and the
      invariants below, REST controllers under
      `/api/v1/partners/partners` (+ nested addresses, contacts).
    * `partners-init.xml` Liquibase changelog with the three tables, FKs,
      GIN index on `partner.ext`, indexes on type/active/country.
    * New api.v1 facade `org.vibeerp.api.v1.ext.partners` with
      `PartnersApi` + `PartnerRef`. Third `ext.<pbc>` after identity and
      catalog. Inactive partners hidden at the facade boundary.
    * `PartnersApiAdapter` runtime implementation in pbc-partners, never
      leaking JPA entity types.
    * `partners.yml` metadata declaring all 3 entities, 12 permission
      keys, 1 menu entry. Picked up automatically by `MetadataLoader`.
    * 15 new unit tests across `PartnerServiceTest`, `AddressServiceTest`
      and `ContactServiceTest` (mockk-based, mirroring catalog tests).
    
    Invariants enforced in code (not blindly delegated to the DB)
    -------------------------------------------------------------
    * Partner code uniqueness — explicit check produces a 400 with a real
      message instead of a 500 from the unique-index violation.
    * Partner code is NOT updatable — every external reference uses code,
      so renaming is a data-migration concern, not an API call.
    * Partner deactivate cascades to contacts (also flipped to inactive).
      Addresses are NOT touched (no `active` column — they exist or they
      don't). Verified end-to-end against Postgres.
    * "Primary" flag is at most one per (partner, address_type). When a
      new/updated address is marked primary, all OTHER primaries of the
      same type for the same partner are demoted in the same transaction.
    * Addresses and contacts reject operations on unknown partners
      up-front to give better errors than the FK-violation.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, hit:
    * POST /api/v1/auth/login (admin) → JWT
    * POST /api/v1/partners/partners (CUSTOMER, SUPPLIER) → 201
    * GET  /api/v1/partners/partners → lists both
    * GET  /api/v1/partners/partners/by-code/CUST-ACME → resolves
    * POST /api/v1/partners/partners (dup code) → 400 with real message
    * POST .../{id}/addresses (BILLING, primary) → 201
    * POST .../{id}/contacts → 201
    * DELETE /api/v1/partners/partners/{id} → 204; partner active=false
    * GET  .../contacts → contact ALSO active=false (cascade verified)
    * GET  /api/v1/_meta/metadata/entities → 3 partners entities present
    * GET  /api/v1/_meta/metadata/permissions → 12 partners permissions
    * Regression: catalog UoMs/items, identity users, printing-shop
      plug-in plates all still HTTP 200.
    
    Build
    -----
    * `./gradlew build`: 12 subprojects, 107 unit tests, all green
      (was 11 / 92 before this commit).
    * The architectural rule still enforced: pbc-partners depends on
      api-v1 + platform-persistence + platform-security only — no
      cross-PBC dep, no platform-bootstrap dep.
    
    What was deferred
    -----------------
    * Permission enforcement on contact endpoints (P4.3). Currently plain
      authenticated; the metadata declares the planned `partners.contact.*`
      keys for when @RequirePermission lands.
    * Per-country address structure layered on top via metadata forms
      (P3.x). The current schema is the smallest universal subset.
    * `deletePartnerCompletely` — out of scope for v1; should be a
      separate "data scrub" admin tool, not a routine API call.
    zichun authored
     
    Browse Code »
  • The reference printing-shop plug-in graduates from "hello world" to a
    real customer demonstration: it now ships its own Liquibase changelog,
    owns its own database tables, and exposes a real domain (plates and
    ink recipes) via REST that goes through `context.jdbc` — a new
    typed-SQL surface in api.v1 — without ever touching Spring's
    `JdbcTemplate` or any other host internal type. A bytecode linter
    that runs before plug-in start refuses to load any plug-in that tries
    to import `org.vibeerp.platform.*` or `org.vibeerp.pbc.*` classes.
    
    What landed:
    
    * api.v1 (additive, binary-compatible):
      - PluginJdbc — typed SQL access with named parameters. Methods:
        query, queryForObject, update, inTransaction. No Spring imports
        leaked. Forces plug-ins to use named params (no positional ?).
      - PluginRow — typed nullable accessors over a single result row:
        string, int, long, uuid, bool, instant, bigDecimal. Hides
        java.sql.ResultSet entirely.
      - PluginContext.jdbc getter with default impl that throws
        UnsupportedOperationException so older builds remain binary
        compatible per the api.v1 stability rules.
    
    * platform-plugins — three new sub-packages:
      - jdbc/DefaultPluginJdbc backed by Spring's NamedParameterJdbcTemplate.
        ResultSetPluginRow translates each accessor through ResultSet.wasNull()
        so SQL NULL round-trips as Kotlin null instead of the JDBC defaults
        (0 for int, false for bool, etc. — bug factories).
      - jdbc/PluginJdbcConfiguration provides one shared PluginJdbc bean
        for the whole process. Per-plugin isolation lands later.
      - migration/PluginLiquibaseRunner looks for
        META-INF/vibe-erp/db/changelog.xml inside the plug-in JAR via
        the PF4J classloader and applies it via Liquibase against the
        host's shared DataSource. The unique META-INF path matters:
        plug-ins also see the host's parent classpath, where the host's
        own db/changelog/master.xml lives, and a collision causes
        Liquibase ChangeLogParseException at install time.
      - lint/PluginLinter walks every .class entry in the plug-in JAR
        via java.util.jar.JarFile + ASM ClassReader, visits every type/
        method/field/instruction reference, rejects on any reference to
        `org/vibeerp/platform/` or `org/vibeerp/pbc/` packages.
    
    * VibeErpPluginManager lifecycle is now load → lint → migrate → start:
      - lint runs immediately after PF4J's loadPlugins(); rejected
        plug-ins are unloaded with a per-violation error log and never
        get to run any code
      - migrate runs the plug-in's own Liquibase changelog; failure
        means the plug-in is loaded but skipped (loud warning, framework
        boots fine)
      - then PF4J's startPlugins() runs the no-arg start
      - then we walk loaded plug-ins and call vibe_erp's start(context)
        with a fully-wired DefaultPluginContext (logger + endpoints +
        eventBus + jdbc). The plug-in's tables are guaranteed to exist
        by the time its lambdas run.
    
    * DefaultPluginContext.jdbc is no longer a stub. Plug-ins inject the
      shared PluginJdbc and use it to talk to their own tables.
    
    * Reference plug-in (PrintingShopPlugin):
      - Ships META-INF/vibe-erp/db/changelog.xml with two changesets:
        plugin_printingshop__plate (id, code, name, width_mm, height_mm,
        status) and plugin_printingshop__ink_recipe (id, code, name,
        cmyk_c/m/y/k).
      - Now registers seven endpoints:
          GET  /ping          — health
          GET  /echo/{name}   — path variable demo
          GET  /plates        — list
          GET  /plates/{id}   — fetch
          POST /plates        — create (with race-conditiony existence
                                check before INSERT, since plug-ins
                                can't import Spring's DataAccessException)
          GET  /inks
          POST /inks
      - All CRUD lambdas use context.jdbc with named parameters. The
        plug-in still imports nothing from org.springframework.* in its
        own code (it does reach the host's Jackson via reflection for
        JSON parsing — a deliberate v0.6 shortcut documented inline).
    
    Tests: 5 new PluginLinterTest cases use ASM ClassWriter to synthesize
    in-memory plug-in JARs (clean class, forbidden platform ref, forbidden
    pbc ref, allowed api.v1 ref, multiple violations) and a mocked
    PluginWrapper to avoid touching the real PF4J loader. Total now
    **81 unit tests** across 10 modules, all green.
    
    End-to-end smoke test against fresh Postgres with the plug-in loaded
    (every assertion green):
    
      Boot logs:
        PluginLiquibaseRunner: plug-in 'printing-shop' has changelog.xml
        Liquibase: ChangeSet printingshop-init-001 ran successfully
        Liquibase: ChangeSet printingshop-init-002 ran successfully
        Liquibase migrations applied successfully
        plugin.printing-shop: registered 7 endpoints
    
      HTTP smoke:
        \dt plugin_printingshop*                  → both tables exist
        GET /api/v1/plugins/printing-shop/plates  → []
        POST plate A4                              → 201 + UUID
        POST plate A3                              → 201 + UUID
        POST duplicate A4                          → 409 + clear msg
        GET plates                                 → 2 rows
        GET /plates/{id}                           → A4 details
        psql verifies both rows in plugin_printingshop__plate
        POST ink CYAN                              → 201
        POST ink MAGENTA                           → 201
        GET inks                                   → 2 inks with nested CMYK
        GET /ping                                  → 200 (existing endpoint)
        GET /api/v1/catalog/uoms                   → 15 UoMs (no regression)
        GET /api/v1/identity/users                 → 1 user (no regression)
    
    Bug encountered and fixed during the smoke test:
    
      • The plug-in initially shipped its changelog at db/changelog/master.xml,
        which collides with the HOST's db/changelog/master.xml. The plug-in
        classloader does parent-first lookup (PF4J default), so Liquibase's
        ClassLoaderResourceAccessor found BOTH files and threw
        ChangeLogParseException ("Found 2 files with the path"). Fixed by
        moving the plug-in changelog to META-INF/vibe-erp/db/changelog.xml,
        a path the host never uses, and updating PluginLiquibaseRunner.
        The unique META-INF prefix is now part of the documented plug-in
        convention.
    
    What is explicitly NOT in this chunk (deferred):
    
      • Per-plugin Spring child contexts — plug-ins still instantiate via
        PF4J's classloader without their own Spring beans
      • Per-plugin datasource isolation — one shared host pool today
      • Plug-in changelog table-prefix linter — convention only, runtime
        enforcement comes later
      • Rollback on plug-in uninstall — uninstall is operator-confirmed
        and rare; running dropAll() during stop() would lose data on
        accidental restart
      • Subscription auto-scoping on plug-in stop — plug-ins still close
        their own subscriptions in stop()
      • Real customer-grade JSON parsing in plug-in lambdas — the v0.6
        reference plug-in uses reflection to find the host's Jackson; a
        real plug-in author would ship their own JSON library or use a
        future api.v1 typed-DTO surface
    
    Implementation plan refreshed: P1.2, P1.3, P1.4, P1.7, P4.1, P5.1
    all marked DONE in
    docs/superpowers/specs/2026-04-07-vibe-erp-implementation-plan.md.
    Next priority candidates: P1.5 (metadata seeder) and P5.2 (pbc-partners).
    vibe_erp authored
     
    Browse Code »
  • The reference printing-shop plug-in now actually does something:
    its main class registers two HTTP endpoints during start(context),
    and a real curl to /api/v1/plugins/printing-shop/ping returns the
    JSON the plug-in's lambda produced. End-to-end smoke test 10/10
    green. This is the chunk that turns vibe_erp from "an ERP app
    that has a plug-in folder" into "an ERP framework whose plug-ins
    can serve traffic".
    
    What landed:
    
    * api.v1 — additive (binary-compatible per the api.v1 stability rule):
      - org.vibeerp.api.v1.plugin.HttpMethod (enum)
      - org.vibeerp.api.v1.plugin.PluginRequest (path params, query, body)
      - org.vibeerp.api.v1.plugin.PluginResponse (status + body)
      - org.vibeerp.api.v1.plugin.PluginEndpointHandler (fun interface)
      - org.vibeerp.api.v1.plugin.PluginEndpointRegistrar (per-plugin
        scoped, register(method, path, handler))
      - PluginContext.endpoints getter with default impl that throws
        UnsupportedOperationException so the addition is binary-compatible
        with plug-ins compiled against earlier api.v1 builds.
    
    * platform-plugins — three new files:
      - PluginEndpointRegistry: process-wide registration storage. Uses
        Spring's AntPathMatcher so {var} extracts path variables.
        Synchronized mutation. Exact-match fast path before pattern loop.
        Rejects duplicate (method, path) per plug-in. unregisterAll(plugin)
        on shutdown.
      - ScopedPluginEndpointRegistrar: per-plugin wrapper that tags every
        register() call with the right plugin id. Plug-ins cannot register
        under another plug-in's namespace.
      - PluginEndpointDispatcher: single Spring @RestController at
        /api/v1/plugins/{pluginId}/** that catches GET/POST/PUT/PATCH/DELETE,
        asks the registry for a match, builds a PluginRequest, calls the
        handler, serializes the response. 404 on no match, 500 on handler
        throw (logged with stack trace).
      - DefaultPluginContext: implements PluginContext with a real
        SLF4J-backed logger (every line tagged with the plug-in id) and
        the scoped endpoint registrar. The other six services
        (eventBus, transaction, translator, localeProvider,
        permissionCheck, entityRegistry) throw UnsupportedOperationException
        with messages pointing at the implementation plan unit that will
        land each one. Loud failure beats silent no-op.
    
    * VibeErpPluginManager — after PF4J's startPlugins() now walks every
      loaded plug-in, casts the wrapper instance to api.v1.plugin.Plugin,
      and calls start(context) with a freshly-built DefaultPluginContext.
      Tracks the started set so destroy() can call stop() and
      unregisterAll() in reverse order. Catches plug-in start failures
      loudly without bringing the framework down.
    
    * Reference plug-in (PrintingShopPlugin):
      - Now extends BOTH org.pf4j.Plugin (so PF4J's loader can instantiate
        it via the Plugin-Class manifest entry) AND
        org.vibeerp.api.v1.plugin.Plugin (so the host's vibe_erp lifecycle
        hook can call start(context)). Uses Kotlin import aliases to
        disambiguate the two `Plugin` simple names.
      - In start(context), registers two endpoints:
          GET /ping — returns {plugin, version, ok, message}
          GET /echo/{name} — extracts path variable, echoes it back
      - The /echo handler proves path-variable extraction works end-to-end.
    
    * Build infrastructure:
      - reference-customer/plugin-printing-shop now has an `installToDev`
        Gradle task that builds the JAR and stages it into <repo>/plugins-dev/.
        The task wipes any previous staged copies first so renaming the JAR
        on a version bump doesn't leave PF4J trying to load two versions.
      - distribution's `bootRun` task now (a) depends on `installToDev`
        so the staging happens automatically and (b) sets workingDir to
        the repo root so application-dev.yaml's relative
        `vibeerp.plugins.directory: ./plugins-dev` resolves to the right
        place. Without (b) bootRun's CWD was distribution/ and PF4J found
        "No plugins" — which is exactly the bug that surfaced in the first
        smoke run.
      - .gitignore now excludes /plugins-dev/ and /files-dev/.
    
    Tests: 12 new unit tests for PluginEndpointRegistry covering literal
    paths, single/multi path variables, duplicate registration rejection,
    literal-vs-pattern precedence, cross-plug-in isolation, method
    matching, and unregisterAll. Total now 61 unit tests across the
    framework, all green.
    
    End-to-end smoke test against fresh Postgres + the plug-in JAR
    loaded by PF4J at boot (10/10 passing):
      GET /api/v1/plugins/printing-shop/ping (no auth)         → 401
      POST /api/v1/auth/login                                  → access token
      GET /api/v1/plugins/printing-shop/ping (Bearer)          → 200
                                                                 {plugin, version, ok, message}
      GET /api/v1/plugins/printing-shop/echo/hello             → 200, echoed=hello
      GET /api/v1/plugins/printing-shop/echo/world             → 200, echoed=world
      GET /api/v1/plugins/printing-shop/nonexistent            → 404 (no handler)
      GET /api/v1/plugins/missing-plugin/ping                  → 404 (no plugin)
      POST /api/v1/plugins/printing-shop/ping                  → 404 (wrong method)
      GET /api/v1/catalog/uoms (Bearer)                        → 200, 15 UoMs
      GET /api/v1/identity/users (Bearer)                      → 200, 1 user
    
    PF4J resolved the JAR, started the plug-in, the host called
    vibe_erp's start(context), the plug-in registered two endpoints, and
    the dispatcher routed real HTTP traffic to the plug-in's lambdas.
    The boot log shows the full chain.
    
    What is explicitly NOT in this chunk and remains for later:
      • plug-in linter (P1.2) — bytecode scan for forbidden imports
      • plug-in Liquibase application (P1.4) — plug-in-owned schemas
      • per-plug-in Spring child context — currently we just instantiate
        the plug-in via PF4J's classloader; there is no Spring context
        for the plug-in's own beans
      • PluginContext.eventBus / transaction / translator / etc. — they
        still throw UnsupportedOperationException with TODO messages
      • Path-template precedence between multiple competing patterns
        (only literal-beats-pattern is implemented, not most-specific-pattern)
      • Permission checks at the dispatcher (Spring Security still
        catches plug-in endpoints with the global "anyRequest authenticated"
        rule, which is the right v0.5 behavior)
      • Hot reload of plug-ins (cold restart only)
    
    Bug encountered and fixed during the smoke test:
    
      • application-dev.yaml has `vibeerp.plugins.directory: ./plugins-dev`,
        a relative path. Gradle's `bootRun` task by default uses the
        subproject's directory as the working directory, so the relative
        path resolved to <repo>/distribution/plugins-dev/ instead of
        <repo>/plugins-dev/. PF4J reported "No plugins" because that
        directory was empty. Fixed by setting bootRun.workingDir =
        rootProject.layout.projectDirectory.asFile.
    
      • One KDoc comment in PluginEndpointDispatcher contained the literal
        string `/api/v1/plugins/{pluginId}/**` inside backticks. The
        Kotlin lexer doesn't treat backticks as comment-suppressing, so
        `/**` opened a nested KDoc comment that was never closed and the
        file failed to compile. Same root cause as the AuthController bug
        earlier in the session. Rewrote the line to avoid the literal
        `/**` sequence.
    vibe_erp authored
     
    Browse Code »
  • Adds the second core PBC, validating that the pbc-identity template is
    actually clonable and that the Gradle dependency rule fires correctly
    for a real second PBC.
    
    What landed:
    
    * New `pbc/pbc-catalog/` Gradle subproject. Same shape as pbc-identity:
      api-v1 + platform-persistence + platform-security only (no
      platform-bootstrap, no other pbc). The architecture rule in the root
      build.gradle.kts now has two real PBCs to enforce against.
    
    * `Uom` entity (catalog__uom) — code, name, dimension, ext jsonb.
      Code is the natural key (stable, human-readable). UomService rejects
      duplicate codes and refuses to update the code itself (would invalidate
      every Item FK referencing it). UomController at /api/v1/catalog/uoms
      exposes list, get-by-id, get-by-code, create, update.
    
    * `Item` entity (catalog__item) — code, name, description, item_type
      (GOOD/SERVICE/DIGITAL enum), base_uom_code FK, active flag, ext jsonb.
      ItemService validates the referenced UoM exists at the application
      layer (better error message than the DB FK alone), refuses to update
      code or baseUomCode (data-migration operations, not edits), supports
      soft delete via deactivate. ItemController at /api/v1/catalog/items
      with full CRUD.
    
    * `org.vibeerp.api.v1.ext.catalog.CatalogApi` — second cross-PBC facade
      in api.v1 (after IdentityApi). Exposes findItemByCode(code) and
      findUomByCode(code) returning safe ItemRef/UomRef DTOs. Inactive items
      are filtered to null at the boundary so callers cannot accidentally
      reference deactivated catalog rows.
    
    * `CatalogApiAdapter` in pbc-catalog — concrete @Component
      implementing CatalogApi. Maps internal entities to api.v1 DTOs without
      leaking storage types.
    
    * Liquibase changeset (catalog-init-001..003) creates both tables with
      unique indexes on code, GIN indexes on ext, and seeds 15 canonical
      units of measure: kg/g/t (mass), m/cm/mm/km (length), m2 (area),
      l/ml (volume), ea/sheet/pack (count), h/min (time). Tagged
      created_by='__seed__' so a future metadata uninstall sweep can
      identify them.
    
    Tests: 11 new unit tests (UomServiceTest x5, ItemServiceTest x6),
    total now 49 unit tests across the framework, all green.
    
    End-to-end smoke test against fresh Postgres via docker-compose
    (14/14 passing):
      GET /api/v1/catalog/items (no auth)            → 401
      POST /api/v1/auth/login                        → access token
      GET /api/v1/catalog/uoms (Bearer)              → 15 seeded UoMs
      GET /api/v1/catalog/uoms/by-code/kg            → 200
      POST custom UoM 'roll'                         → 201
      POST duplicate UoM 'kg'                        → 400 + clear message
      GET items                                       → []
      POST item with unknown UoM                     → 400 + clear message
      POST item with valid UoM                       → 201
      catalog__item.created_by                       → admin user UUID
                                                       (NOT __system__)
      GET /by-code/INK-CMYK-CYAN                     → 200
      PATCH item name + description                  → 200
      DELETE item                                    → 204
      GET item                                       → active=false
    
    The principal-context bridge from P4.1 keeps working without any
    additional wiring in pbc-catalog: every PBC inherits the audit
    behavior for free by extending AuditedJpaEntity. That is exactly the
    "PBCs follow a recipe, the framework provides the cross-cutting
    machinery" promise from the architecture spec.
    
    Architectural rule enforcement still active: confirmed by reading the
    build.gradle.kts and observing that pbc-catalog declares no
    :platform:platform-bootstrap and no :pbc:pbc-identity dependency. The
    build refuses to load on either violation.
    vibe_erp authored
     
    Browse Code »

  • Design change: vibe_erp deliberately does NOT support multiple companies in
    one process. Each running instance serves exactly one company against an
    isolated Postgres database. Hosting many customers means provisioning many
    independent instances, not multiplexing them.
    
    Why: most ERP/EBC customers will not accept a SaaS where their data shares
    a database with other companies. The single-tenant-per-instance model is
    what the user actually wants the product to look like, and it dramatically
    simplifies the framework.
    
    What changed:
    - CLAUDE.md guardrail #5 rewritten from "multi-tenant from day one" to
      "single-tenant per instance, isolated database"
    - api.v1: removed TenantId value class entirely; removed tenantId from
      Entity, AuditedEntity, Principal, DomainEvent, RequestContext,
      TaskContext, IdentityApi.UserRef, Repository
    - platform-persistence: deleted TenantContext, HibernateTenantResolver,
      TenantAwareJpaTransactionManager, TenancyJpaConfiguration; removed
      @TenantId and tenant_id column from AuditedJpaEntity
    - platform-bootstrap: deleted TenantResolutionFilter; dropped
      vibeerp.instance.mode and default-tenant from properties; added
      vibeerp.instance.company-name; added VibeErpApplication @EnableJpaRepositories
      and @EntityScan so PBC repositories outside the main package are wired;
      added GlobalExceptionHandler that maps IllegalArgumentException → 400
      and NoSuchElementException → 404 (RFC 7807 ProblemDetail)
    - pbc-identity: removed tenant_id from User, repository, controller, DTOs,
      IdentityApiAdapter; updated UserService duplicate-username message and
      the matching test
    - distribution: dropped multiTenancy=DISCRIMINATOR and
      tenant_identifier_resolver from application.yaml; configured Spring Boot
      mainClass on the springBoot extension (not just bootJar) so bootRun works
    - Liquibase: rewrote platform-init changelog to drop platform__tenant and
      the tenant_id columns on every metadata__* table; rewrote
      pbc-identity init to drop tenant_id columns, the (tenant_id, *)
      composite indexes, and the per-table RLS policies
    - IdentifiersTest replaced with Id<T> tests since the TenantId tests
      no longer apply
    
    Verified end-to-end against a real Postgres via docker-compose:
      POST /api/v1/identity/users   → 201 Created
      GET  /api/v1/identity/users   → list works
      GET  /api/v1/identity/users/X → fetch by id works
      POST duplicate username       → 400 Bad Request (was 500)
      PATCH bogus id                → 404 Not Found (was 500)
      PATCH alice                   → 200 OK
      DELETE alice                  → 204, alice now disabled
    
    All 18 unit tests pass.
    vibe_erp authored
     
    Browse Code »
  • BLOCKER: wire Hibernate multi-tenancy
    - application.yaml: set hibernate.tenant_identifier_resolver and
      hibernate.multiTenancy=DISCRIMINATOR so HibernateTenantResolver is
      actually installed into the SessionFactory
    - AuditedJpaEntity.tenantId: add @org.hibernate.annotations.TenantId so
      every PBC entity inherits the discriminator
    - AuditedJpaEntityListener.onCreate: throw if a caller pre-set tenantId
      to a different value than the current TenantContext, instead of
      silently overwriting (defense against cross-tenant write bugs)
    
    IMPORTANT: dependency hygiene
    - pbc-identity no longer depends on platform-bootstrap (wrong direction;
      bootstrap assembles PBCs at the top of the stack)
    - root build.gradle.kts: tighten the architectural-rule enforcement to
      also reject :pbc:* -> platform-bootstrap; switch plug-in detection
      from a fragile pathname heuristic to an explicit
      extra["vibeerp.module-kind"] = "plugin" marker; reference plug-in
      declares the marker
    
    IMPORTANT: api.v1 surface additions (all non-breaking)
    - Repository: documented closed exception set; new
      PersistenceExceptions.kt declares OptimisticLockConflictException,
      UniqueConstraintViolationException, EntityValidationException, and
      EntityNotFoundException so plug-ins never see Hibernate types
    - TaskContext: now exposes tenantId(), principal(), locale(),
      correlationId() so workflow handlers (which run outside an HTTP
      request) can pass tenant-aware calls back into api.v1
    - EventBus: subscribe() now returns a Subscription with close() so
      long-lived subscribers can deregister explicitly; added a
      subscribe(topic: String, ...) overload for cross-classloader event
      routing where Class<E> equality is unreliable
    - IdentityApi.findUserById: tightened from Id<*> to PrincipalId so the
      type system rejects "wrong-id-kind" mistakes at the cross-PBC boundary
    
    NITs:
    - HealthController.kt -> MetaController.kt (file name now matches the
      class name); added TODO(v0.2) for reading implementationVersion from
      the Spring Boot BuildProperties bean
    vibe_erp authored
     
    Browse Code »
  • … i18n, http, plugin lifecycle
    vibe_erp authored
     
    Browse Code »