• 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 framework's authorization layer is now live. Until now, every
    authenticated user could do everything; the framework had only an
    authentication gate. This chunk adds method-level @RequirePermission
    annotations enforced by a Spring AOP aspect that consults the JWT's
    roles claim and a metadata-driven role-permission map.
    
    What landed
    -----------
    * New `Role` and `UserRole` JPA entities mapping the existing
      identity__role + identity__user_role tables (the schema was
      created in the original identity init but never wired to JPA).
      RoleJpaRepository + UserRoleJpaRepository with a JPQL query that
      returns a user's role codes in one round-trip.
    * `JwtIssuer.issueAccessToken(userId, username, roles)` now accepts a
      Set<String> of role codes and encodes them as a `roles` JWT claim
      (sorted for deterministic tests). Refresh tokens NEVER carry roles
      by design — see the rationale on `JwtIssuer.issueRefreshToken`. A
      role revocation propagates within one access-token lifetime
      (15 min default).
    * `JwtVerifier` reads the `roles` claim into `DecodedToken.roles`.
      Missing claim → empty set, NOT an error (refresh tokens, system
      tokens, and pre-P4.3 tokens all legitimately omit it).
    * `AuthService.login` now calls `userRoles.findRoleCodesByUserId(...)`
      before minting the access token. `AuthService.refresh` re-reads
      the user's roles too — so a refresh always picks up the latest
      set, since refresh tokens deliberately don't carry roles.
    * New `AuthorizationContext` ThreadLocal in `platform-security.authz`
      carrying an `AuthorizedPrincipal(id, username, roles)`. Separate
      from `PrincipalContext` (which lives in platform-persistence and
      carries only the principal id, for the audit listener). The two
      contexts coexist because the audit listener has no business
      knowing what roles a user has.
    * `PrincipalContextFilter` now populates BOTH contexts on every
      authenticated request, reading the JWT's `username` and `roles`
      claims via `Jwt.getClaimAsStringList("roles")`. The filter is the
      one and only place that knows about Spring Security types AND
      about both vibe_erp contexts; everything downstream uses just the
      Spring-free abstractions.
    * `PermissionEvaluator` Spring bean: takes a role set + permission
      key, returns boolean. Resolution chain:
      1. The literal `admin` role short-circuits to `true` for every
         key (the wildcard exists so the bootstrap admin can do
         everything from the very first boot without seeding a complete
         role-permission mapping).
      2. Otherwise consults an in-memory `Map<role, Set<permission>>`
         loaded from `metadata__role_permission` rows. The cache is
         rebuilt by `refresh()`, called from `VibeErpPluginManager`
         after the initial core load AND after every plug-in load.
      3. Empty role set is always denied. No implicit grants.
    * `@RequirePermission("...")` annotation in `platform-security.authz`.
      `RequirePermissionAspect` is a Spring AOP @Aspect with @Around
      advice that intercepts every annotated method, reads the current
      request's `AuthorizationContext`, calls
      `PermissionEvaluator.has(...)`, and either proceeds or throws
      `PermissionDeniedException`.
    * New `PermissionDeniedException` carrying the offending key.
      `GlobalExceptionHandler` maps it to HTTP 403 Forbidden with
      `"permission denied: 'partners.partner.deactivate'"` as the
      detail. The key IS surfaced to the caller (unlike the 401's
      generic "invalid credentials") because the SPA needs it to
      render a useful "your role doesn't include X" message and
      callers are already authenticated, so it's not an enumeration
      vector.
    * `BootstrapAdminInitializer` now creates the wildcard `admin`
      role on first boot and grants it to the bootstrap admin user.
    * `@RequirePermission` applied to four sensitive endpoints as the
      demo: `PartnerController.deactivate`,
      `StockBalanceController.adjust`, `SalesOrderController.confirm`,
      `SalesOrderController.cancel`. More endpoints will gain
      annotations as additional roles are introduced; v1 keeps the
      blast radius narrow.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, verified:
    * Admin login → JWT length 265 (was 241), decoded claims include
      `"roles":["admin"]`
    * Admin POST /sales-orders/{id}/confirm → 200, status DRAFT → CONFIRMED
      (admin wildcard short-circuits the permission check)
    * Inserted a 'powerless' user via raw SQL with no role assignments
      but copied the admin's password hash so login works
    * Powerless login → JWT length 247, decoded claims have NO roles
      field at all
    * Powerless POST /sales-orders/{id}/cancel → **403 Forbidden** with
      `"permission denied: 'orders.sales.cancel'"` in the body
    * Powerless DELETE /partners/{id} → **403 Forbidden** with
      `"permission denied: 'partners.partner.deactivate'"`
    * Powerless GET /sales-orders, /partners, /catalog/items → all 200
      (read endpoints have no @RequirePermission)
    * Admin regression: catalog uoms, identity users, inventory
      locations, printing-shop plates with i18n, metadata custom-fields
      endpoint — all still HTTP 2xx
    
    Build
    -----
    * `./gradlew build`: 15 subprojects, 163 unit tests (was 153),
      all green. The 10 new tests cover:
      - PermissionEvaluator: empty roles deny, admin wildcard, explicit
        role-permission grant, multi-role union, unknown role denial,
        malformed payload tolerance, currentHas with no AuthorizationContext,
        currentHas with bound context (8 tests).
      - JwtRoundTrip: roles claim round-trips through the access token,
        refresh token never carries roles even when asked (2 tests).
    
    What was deferred
    -----------------
    * **OIDC integration (P4.2)**. Built-in JWT only. The Keycloak-
      compatible OIDC client will reuse the same authorization layer
      unchanged — the roles will come from OIDC ID tokens instead of
      the local user store.
    * **Permission key validation at boot.** The framework does NOT
      yet check that every `@RequirePermission` value matches a
      declared metadata permission key. The plug-in linter is the
      natural place for that check to land later.
    * **Role hierarchy**. Roles are flat in v1; a role with permission
      X cannot inherit from another role. Adding a `parent_role` field
      on the role row is a non-breaking change later.
    * **Resource-aware permissions** ("the user owns THIS partner").
      v1 only checks the operation, not the operand. Resource-aware
      checks are post-v1.
    * **Composite (AND/OR) permission requirements**. A single key
      per call site keeps the contract simple. Composite requirements
      live in service code that calls `PermissionEvaluator.currentHas`
      directly.
    * **Role management UI / REST**. The framework can EVALUATE
      permissions but has no first-class endpoints for "create a
      role", "grant a permission to a role", "assign a role to a
      user". v1 expects these to be done via direct DB writes or via
      the future SPA's role editor (P3.x); the wiring above is
      intentionally policy-only, not management.
    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 keystone of the framework's "key user adds fields without code"
    promise. A YAML declaration is now enough to add a typed custom field
    to an existing entity, validate it on every save, and surface it to
    the SPA / OpenAPI / AI agent. This is the bit that makes vibe_erp a
    *framework* instead of a fork-per-customer app.
    
    What landed
    -----------
    * Extended `MetadataYamlFile` with a top-level `customFields:` section
      and `CustomFieldYaml` + `CustomFieldTypeYaml` wire-format DTOs. The
      YAML uses a flat `kind: decimal / scale: 2` discriminator instead of
      Jackson polymorphic deserialization so plug-in authors don't have to
      nest type configs and api.v1's `FieldType` stays free of Jackson
      imports.
    * `MetadataLoader.doLoad` now upserts `metadata__custom_field` rows
      alongside entities/permissions/menus, with the same delete-by-source
      idempotency. `LoadResult` carries the count for the boot log.
    * `CustomFieldRegistry` reads every `metadata__custom_field` row from
      the database and builds an in-memory `Map<entityName, List<CustomField>>`
      for the validator's hot path. `refresh()` is called by
      `VibeErpPluginManager` after the initial core load AND after every
      plug-in load, so a freshly-installed plug-in's custom fields are
      immediately enforceable. ConcurrentHashMap under the hood; reads
      are lock-free.
    * `ExtJsonValidator` is the on-save half: takes (entityName, ext map),
      walks the declared fields, coerces each value to its native type,
      and returns the canonicalised map (or throws IllegalArgumentException
      with ALL violations joined for a single 400). Per-FieldType rules:
      - String: maxLength enforced.
      - Integer: accepts Number and numeric String, rejects garbage.
      - Decimal: precision/scale enforced; preserved as plain string in
        canonical form so JSON encoding doesn't lose trailing zeros.
      - Boolean: accepts true/false (case-insensitive).
      - Date / DateTime: ISO-8601 parse via java.time.
      - Uuid: java.util.UUID parse.
      - Enum: must be in declared `allowedValues`.
      - Money / Quantity / Json / Reference: pass-through (Reference target
        existence check pending the cross-PBC EntityRegistry seam).
      Unknown ext keys are rejected with the entity's name and the keys
      themselves listed. ALL violations are returned in one response, not
      failing on the first, so a form submitter fixes everything in one
      round-trip.
    * `Partner` is the first PBC entity to wire ext through the validator:
      `CreatePartnerRequest` and `UpdatePartnerRequest` accept an
      `ext: Map<String, Any?>?`; `PartnerService.create/update` calls
      `extValidator.validate("Partner", ext)` and persists the canonical
      JSON to the existing `partners__partner.ext` JSONB column;
      `PartnerResponse` parses it back so callers see what they wrote.
    * `partners.yml` now declares two custom fields on Partner —
      `partners_credit_limit` (Decimal precision=14, scale=2) and
      `partners_industry` (Enum of printing/publishing/packaging/other) —
      with English and Chinese labels. Tagged `source=core` so the
      framework has something to demo from a fresh boot.
    * New public `GET /api/v1/_meta/metadata/custom-fields/{entityName}`
      endpoint serves the api.v1 runtime view of declarations from the
      in-memory registry (so it reflects every refresh) for the SPA's form
      builder, the OpenAPI generator, and the AI agent function catalog.
      The existing `GET /api/v1/_meta/metadata` endpoint also gained a
      `customFields` list.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, verified:
    * Boot log: `CustomFieldRegistry: refreshed 2 custom fields across 1
      entities (0 malformed rows skipped)` — twice (after core load and
      after plug-in load).
    * `GET /api/v1/_meta/metadata/custom-fields/Partner` → both declarations
      with their labels.
    * `POST /api/v1/partners/partners` with `ext = {credit_limit: "50000.00",
      industry: "printing"}` → 201; the response echoes the canonical map.
    * `POST` with `ext = {credit_limit: "1.234", industry: "unicycles",
      rogue: "x"}` → 400 with all THREE violations in one body:
      "ext contains undeclared key(s) for 'Partner': [rogue]; ext.partners_credit_limit:
      decimal scale 3 exceeds declared scale 2; ext.partners_industry:
      value 'unicycles' is not in allowed set [printing, publishing,
      packaging, other]".
    * `SELECT ext FROM partners__partner WHERE code = 'CUST-EXT-OK'` →
      `{"partners_industry": "printing", "partners_credit_limit": "50000.00"}`
      — the canonical JSON is in the JSONB column verbatim.
    * Regression: catalog uoms, identity users, partners list, and the
      printing-shop plug-in's POST /plates (with i18n via Accept-Language:
      zh-CN) all still HTTP 2xx.
    
    Build
    -----
    * `./gradlew build`: 13 subprojects, 129 unit tests (was 118), all
      green. The 11 new tests cover each FieldType variant, multi-violation
      reporting, required missing rejection, unknown key rejection, and the
      null-value-dropped case.
    
    What was deferred
    -----------------
    * JPA listener auto-validation. Right now PartnerService explicitly
      calls extValidator.validate(...) before save; Item, Uom, User and
      the plug-in tables don't yet. Promoting the validator to a JPA
      PrePersist/PreUpdate listener attached to a marker interface
      HasExt is the right next step but is its own focused chunk.
    * Reference target existence check. FieldType.Reference passes
      through unchanged because the cross-PBC EntityRegistry that would
      let the validator look up "does an instance with this id exist?"
      is a separate seam landing later.
    * The customization UI (Tier 1 self-service add a field via the SPA)
      is P3.3 — the runtime enforcement is done, the editor is not.
    * Custom-field-aware permissions. The `pii: true` flag is in the
      metadata but not yet read by anything; the DSAR/erasure pipeline
      that will consume it is post-v1.0.
    zichun authored
     
    Browse Code »
  • The eighth cross-cutting platform service is live: plug-ins and PBCs
    now have a real Translator and LocaleProvider instead of the
    UnsupportedOperationException stubs that have shipped since v0.5.
    
    What landed
    -----------
    * New Gradle subproject `platform/platform-i18n` (13 modules total).
    * `IcuTranslator` — backed by ICU4J's MessageFormat (named placeholders,
      plurals, gender, locale-aware number/date/currency formatting), the
      format every modern translation tool speaks natively. JDK ResourceBundle
      handles per-locale fallback (zh_CN → zh → root).
    * The translator takes a list of `BundleLocation(classLoader, baseName)`
      pairs and tries them in order. For the **core** translator the chain
      is just `[(host, "messages")]`; for a **per-plug-in** translator
      constructed by VibeErpPluginManager it's
      `[(pluginClassLoader, "META-INF/vibe-erp/i18n/messages"), (host, "messages")]`
      so plug-in keys override host keys, but plug-ins still inherit
      shared keys like `errors.not_found`.
    * Critical detail: the plug-in baseName uses a path the host does NOT
      publish, because PF4J's `PluginClassLoader` is parent-first — a
      `getResource("messages.properties")` against the plug-in classloader
      would find the HOST bundle through the parent chain, defeating the
      per-plug-in override entirely. Naming the plug-in resource somewhere
      the host doesn't claim sidesteps the trap.
    * The translator disables `ResourceBundle.Control`'s automatic JVM-default
      locale fallback. The default control walks `requested → root → JVM
      default → root` which would silently serve German strings to a
      Japanese-locale request just because the German bundle exists. The
      fallback chain stops at root within a bundle, then moves to the next
      bundle location, then returns the key string itself.
    * `RequestLocaleProvider` reads the active HTTP request's
      Accept-Language via `RequestContextHolder` + the servlet container's
      `getLocale()`. Outside an HTTP request (background jobs, workflow
      tasks, MCP agents) it falls back to the configured default locale
      (`vibeerp.i18n.defaultLocale`, default `en`). Importantly, when an
      HTTP request HAS no Accept-Language header it ALSO falls back to the
      configured default — never to the JVM's locale.
    * `I18nConfiguration` exposes `coreTranslator` and `coreLocaleProvider`
      beans. Per-plug-in translators are NOT beans — they're constructed
      imperatively per plug-in start in VibeErpPluginManager because each
      needs its own classloader at the front of the resolution chain.
    * `DefaultPluginContext` now wires `translator` and `localeProvider`
      for real instead of throwing `UnsupportedOperationException`.
    
    Bundles
    -------
    * Core: `platform-i18n/src/main/resources/messages.properties` (English),
      `messages_zh_CN.properties` (Simplified Chinese), `messages_de.properties`
      (German). Six common keys (errors, ok/cancel/save/delete) and an ICU
      plural example for `counts.items`. Java 9+ JEP 226 reads .properties
      files as UTF-8 by default, so Chinese characters are written directly
      rather than as `\\uXXXX` escapes.
    * Reference plug-in: moved from the broken `i18n/messages_en-US.properties`
      / `messages_zh-CN.properties` (wrong path, hyphen-locale filenames
      ResourceBundle ignores) to the canonical
      `META-INF/vibe-erp/i18n/messages.properties` /
      `messages_zh_CN.properties` paths with underscore locale tags.
      Added a new `printingshop.plate.created` key with an ICU plural for
      `ink_count` to demonstrate non-trivial argument substitution.
    
    End-to-end smoke test
    ---------------------
    Reset Postgres, booted the app, hit POST /api/v1/plugins/printing-shop/plates
    with three different Accept-Language headers:
    * (no header)         → "Plate 'PLATE-001' created with no inks." (en-US, plug-in base bundle)
    * `Accept-Language: zh-CN` → "已创建印版 'PLATE-002' (无油墨)。" (zh-CN, plug-in zh_CN bundle)
    * `Accept-Language: de`    → "Plate 'PLATE-003' created with no inks." (de, but the plug-in
                                  ships no German bundle so it falls back to the plug-in base
                                  bundle — correct, the key is plug-in-specific)
    Regression: identity, catalog, partners, and `GET /plates` all still
    HTTP 200 after the i18n wiring change.
    
    Build
    -----
    * `./gradlew build`: 13 subprojects, 118 unit tests (was 107 / 12),
      all green. The 11 new tests cover ICU plural rendering, named-arg
      substitution, locale fallback (zh_CN → root, ja → root via NO_FALLBACK),
      cross-classloader override (a real JAR built in /tmp at test time),
      and RequestLocaleProvider's three resolution paths
      (no request → default; Accept-Language present → request locale;
      request without Accept-Language → default, NOT JVM locale).
    * The architectural rule still enforced: platform-plugins now imports
      platform-i18n, which is a platform-* dependency (allowed), not a
      pbc-* dependency (forbidden).
    
    What was deferred
    -----------------
    * User-preferred locale from the authenticated user's profile row is
      NOT in the resolution chain yet — the `LocaleProvider` interface
      leaves room for it but the implementation only consults
      Accept-Language and the configured default. Adding it slots in
      between request and default without changing the api.v1 surface.
    * The metadata translation overrides table (`metadata__translation`)
      is also deferred — the `Translator` JavaDoc mentions it as the
      first lookup source, but right now keys come from .properties files
      only. Once Tier 1 customisation lands (P3.x), key users will be able
      to override any string from the SPA without touching code.
    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 README and CLAUDE.md "Repository state" sections were both stale —
    they still claimed "v0.1 skeleton, one PBC implemented" and "no source
    code yet" when in reality the framework is at v0.6+P1.5 with 92 unit
    tests, 11 modules, 7 cross-cutting platform services live, 2 PBCs, and
    a real customer-style plug-in serving HTTP from its own DB tables.
    
    What landed:
    
    * New PROGRESS.md at the repo root. Single-page progress tracker that
      enumerates every implementation-plan unit (P1.x, P2.x, P3.x, P4.x,
      P5.x, R, REF.x, H/A/M.x) with status badges, commit refs for the
      done units, and the "next priority" call. Includes a snapshot table
      (modules / tests / PBCs / plug-ins / cross-cutting services), a
      "what's live right now" table per service, a "what the reference
      plug-in proves end-to-end" walkthrough, the "what's not yet live"
      deferred list, and a "how to run what exists today" runbook.
    
    * README.md "Building" + "Status" rewritten. Drops the obsolete
      "v0.1 skeleton" claim. New status table shows current counts.
      Adds the dev workflow (`installToDev` → `bootRun`) and points at
      PROGRESS.md for the per-feature view.
    
    * CLAUDE.md "Repository state" section rewritten from "no source
      code, build system, package manifest, test suite, or CI yet" to
      the actual current state: 11 subprojects, 92 tests, 7 services
      live, 2 PBCs, build commands, package root, and a pointer at
      PROGRESS.md.
    
    * docs/customer-onboarding/guide.md, docs/workflow-authoring/guide.md,
      docs/form-authoring/guide.md: replaced "v0.1: API only" annotations
      with "current: API only". The version label was conflating
      "the v0.1 skeleton" with "the current build" — accurate in spirit
      (the UI layer still hasn't shipped) but misleading when readers
      see the framework is on commit 18, not commit 1. Pointed each
      guide at PROGRESS.md for live status.
    
    Build: ./gradlew build still green (no source touched, but verified
    that nothing in the docs change broke anything).
    
    The deeper how-to docs (architecture/overview.md, plugin-api/overview.md,
    plugin-author/getting-started.md, i18n/guide.md, docs/index.md) were
    left as-is. They describe HOW the framework is supposed to work
    architecturally, and the architecture has not changed. Only the
    status / version / scope statements needed updating.
    vibe_erp authored
     
    Browse Code »