• 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 »

  • 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 »
  • vibe_erp authored
     
    Browse Code »