Commit 986f02ce64aaa7eb5db436ef8023643f9a27bd2d

Authored by zichun
1 parent 9106d9ac

refactor(metadata): HasExt marker + applyTo/parseExt helpers on ExtJsonValidator

Removes the ext-handling copy/paste that had grown across four PBCs
(partners, inventory, orders-sales, orders-purchase). Every service
that wrote the JSONB `ext` column was manually doing the same
four-step sequence: validate, null-check, serialize with a local
ObjectMapper, assign to the entity. And every response mapper was
doing the inverse: check-if-blank, parse, cast, swallow errors.

Net: ~15 lines saved per PBC, one place to change the ext contract
later (e.g. PII redaction, audit tagging, field-level events), and
a stable plug-in opt-in mechanism — any plug-in entity that
implements `HasExt` automatically participates.

New api.v1 surface:

  interface HasExt {
      val extEntityName: String     // key into metadata__custom_field
      var ext: String               // the serialized JSONB column
  }

Lives in `org.vibeerp.api.v1.entity` so plug-ins can opt their own
entities into the same validation path. Zero Spring/Jackson
dependencies — api.v1 stays clean.

Extended `ExtJsonValidator` (platform-metadata) with two helpers:

  fun applyTo(entity: HasExt, ext: Map<String, Any?>?)
      — null-safe; validates; writes canonical JSON to entity.ext.
        Replaces the validate + writeValueAsString + assign triplet
        in every service's create() and update().

  fun parseExt(entity: HasExt): Map<String, Any?>
      — returns empty map on blank/corrupt column; response
        mappers never 500 on bad data. Replaces the four identical
        parseExt local functions.

ExtJsonValidator now takes an ObjectMapper via constructor
injection (Spring Boot's auto-configured bean).

Entities that now implement HasExt (override val extEntityName;
override var ext; companion object const val ENTITY_NAME):
  - Partner (`partners.Partner` → "Partner")
  - Location (`inventory.Location` → "Location")
  - SalesOrder (`orders_sales.SalesOrder` → "SalesOrder")
  - PurchaseOrder (`orders_purchase.PurchaseOrder` → "PurchaseOrder")

Deliberately NOT converted this chunk:
  - WorkOrder (pbc-production) — its ext column has no declared
    fields yet; a follow-up that adds declarations AND the
    HasExt implementation is cleaner than splitting the two.
  - JournalEntry (pbc-finance) — derived state, no ext column.

Services lose:
  - The `jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()`
    field (four copies eliminated)
  - The `parseExt(entity): Map` helper function (four copies)
  - The `companion object { const val ENTITY_NAME = ... }` constant
    (moved onto the entity where it belongs)
  - The `val canonicalExt = extValidator.validate(...)` +
    `.also { it.ext = jsonMapper.writeValueAsString(canonicalExt) }`
    create pattern (replaced with one applyTo call)
  - The `if (command.ext != null) { ... }` update pattern
    (applyTo is null-safe)

Unit tests: 6 new cases on ExtJsonValidatorTest cover applyTo and
parseExt (null-safe path, happy path, failure path, blank column,
round-trip, malformed JSON). Existing service tests just swap the
mock setup from stubbing `validate` to stubbing `applyTo` and
`parseExt` with no-ops.

Smoke verified end-to-end against real Postgres:
  - POST /partners with valid ext (partners_credit_limit,
    partners_industry) → 201, canonical form persisted.
  - GET /partners/by-code/X → 200, ext round-trips.
  - POST with invalid enum value → 400 "value 'x' is not in
    allowed set [printing, publishing, packaging, other]".
  - POST with undeclared key → 400 "ext contains undeclared
    key(s) for 'Partner': [rogue_field]".
  - PATCH with new ext → 200, ext updated.
  - PATCH WITHOUT ext field → 200, prior ext preserved (null-safe
    applyTo).
  - POST /orders/sales-orders with no ext → 201, the create path
    via the shared helper still works.

246 unit tests (+6 over 240), 18 Gradle subprojects.
CLAUDE.md
@@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -96,7 +96,7 @@ plugins (incl. ref) depend on: api/api-v1 only
96 **Foundation complete; first business surface in place.** As of the latest commit: 96 **Foundation complete; first business surface in place.** As of the latest commit:
97 97
98 - **18 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`. 98 - **18 Gradle subprojects** built and tested. The dependency rule (PBCs never import each other; plug-ins only see `api.v1`) is enforced at configuration time by the root `build.gradle.kts`.
99 -- **240 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build. 99 +- **246 unit tests across 18 modules**, all green. `./gradlew build` is the canonical full build.
100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge. 100 - **All 9 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), authorization with `@RequirePermission` + JWT roles claim + AOP enforcement (P4.3), plug-in HTTP endpoints (P1.3), plug-in linter (P1.2), plug-in-owned Liquibase + typed SQL (P1.4), event bus + transactional outbox (P1.7), metadata loader + custom-field validation (P1.5 + P3.4), ICU4J translator with per-plug-in locale chain (P1.6), plus the audit/principal context bridge.
101 - **8 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`). **The full buy-sell-make loop works**: a purchase order receives stock via `PURCHASE_RECEIPT`, a sales order ships stock via `SALES_SHIPMENT`, and a work order produces stock via `PRODUCTION_RECEIPT`. All three PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade. 101 - **8 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`). **The full buy-sell-make loop works**: a purchase order receives stock via `PURCHASE_RECEIPT`, a sales order ships stock via `SALES_SHIPMENT`, and a work order produces stock via `PRODUCTION_RECEIPT`. All three PBCs feed the same `inventory__stock_movement` ledger via the same `InventoryApi.recordMovement` facade.
102 - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events. 102 - **Event-driven cross-PBC integration is live in BOTH directions and the consumer reacts to the full lifecycle.** Six typed events under `org.vibeerp.api.v1.event.orders.*` are published from `SalesOrderService` and `PurchaseOrderService` inside the same `@Transactional` method as their state changes. **pbc-finance** subscribes to ALL SIX of them via the api.v1 `EventBus.subscribe(eventType, listener)` typed-class overload: confirm events POST AR/AP rows; ship/receive events SETTLE them; cancel events REVERSE them. Each transition is idempotent under at-least-once delivery — re-applying the same destination status is a no-op. Cancel-from-DRAFT is a clean no-op because no `*ConfirmedEvent` was ever published. pbc-finance has no source dependency on pbc-orders-*; it reaches them only through events.
PROGRESS.md
@@ -10,11 +10,11 @@ @@ -10,11 +10,11 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.19.0 (pbc-production v2 — IN_PROGRESS + BOM + scrap) |  
14 -| **Latest commit** | `75a75ba feat(production): P5.7 v2 — IN_PROGRESS state + BOM auto-issue + scrap flow` | 13 +| **Latest version** | v0.19.1 (HasExt marker interface + ExtJsonValidator helpers) |
  14 +| **Latest commit** | `TBD refactor(metadata): HasExt marker + applyTo/parseExt helpers on ExtJsonValidator` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 | **Modules** | 18 | 16 | **Modules** | 18 |
17 -| **Unit tests** | 240, all green | 17 +| **Unit tests** | 246, all green |
18 | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). | 18 | **End-to-end smoke runs** | An SO confirmed with 2 lines auto-spawns 2 draft work orders via `SalesOrderConfirmedSubscriber`; completing one credits the finished-good stock via `PRODUCTION_RECEIPT`, cancelling the other flips its status, and a manual WO can still be created with no source SO. All in one run: 6 outbox rows DISPATCHED across `orders_sales.SalesOrder` and `production.WorkOrder` topics; pbc-finance still writes its AR row for the underlying SO; the `inventory__stock_movement` ledger carries the production receipt tagged `WO:<code>`. First PBC that REACTS to another PBC's events by creating new business state (not just derived reporting state). |
19 | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) | 19 | **Real PBCs implemented** | 8 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`, `pbc-inventory`, `pbc-orders-sales`, `pbc-orders-purchase`, `pbc-finance`, `pbc-production`) |
20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) | 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata + own i18n bundles) |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/entity/HasExt.kt 0 → 100644
  1 +package org.vibeerp.api.v1.entity
  2 +
  3 +/**
  4 + * Marker interface for every entity that participates in Tier 1
  5 + * customization — i.e. any entity with a JSONB `ext` column that
  6 + * must be validated against [CustomField] declarations on save and
  7 + * decoded for response DTOs on read.
  8 + *
  9 + * **Why this is in api.v1, not platform.metadata:** plug-ins define
  10 + * their own entities and need to opt into the same validation and
  11 + * serialization path as the framework's core PBCs. Keeping the
  12 + * marker in api.v1 means a plug-in's `Plate` or `InkRecipe` can
  13 + * implement `HasExt` and automatically get:
  14 + * - validation against its `metadata__custom_field` declarations,
  15 + * - canonical JSON serialization into the column,
  16 + * - safe parse-back into a `Map<String, Any?>` for response DTOs,
  17 + * all through the same `ExtJsonValidator.applyTo(...)` /
  18 + * `.parseExt(...)` helpers that the core PBCs use. No plug-in code
  19 + * should need to reach into `platform.metadata.internal.*` for ext
  20 + * handling.
  21 + *
  22 + * **Interface surface is deliberately tiny** — two members.
  23 + *
  24 + * - [extEntityName] is the stable string key used to look up
  25 + * declared fields in the custom field registry. The convention is
  26 + * `<pbc>.<Entity>` (e.g. `"partners.Partner"`, `"inventory.Location"`).
  27 + * Plug-ins are expected to namespace theirs under the plug-in id
  28 + * (e.g. `"printing-shop.Plate"`).
  29 + * - [ext] is the serialized JSONB column. Entities should back this
  30 + * with a `@Column(columnDefinition = "jsonb") var ext: String =
  31 + * "{}"`. The `ExtJsonValidator.applyTo(...)` helper writes the
  32 + * canonical form here; `ExtJsonValidator.parseExt(...)` reads it
  33 + * back.
  34 + *
  35 + * **Why `ext` is a `String`, not a `Map`:** Hibernate writes the JSON
  36 + * column as a string (or its `@JdbcTypeCode(SqlTypes.JSON)` wrapper
  37 + * — effectively the same thing). Exposing it as a Map here would
  38 + * force plug-ins to depend on Jackson, which `api.v1` explicitly
  39 + * does NOT. Keeping it a String means the only Jackson touchpoint
  40 + * lives inside `ExtJsonValidator`, which is already in
  41 + * `platform.metadata`.
  42 + *
  43 + * **Does NOT extend [Entity].** Not every Entity has custom fields;
  44 + * not every HasExt needs an Id (though in practice they always do).
  45 + * Keeping the two marker interfaces orthogonal means a plug-in can
  46 + * choose which behaviors it opts into without inheritance bloat.
  47 + */
  48 +public interface HasExt {
  49 + /**
  50 + * The stable entity-name key under which this entity's custom
  51 + * fields are declared in `metadata__custom_field`. Convention:
  52 + * `<pbc>.<Entity>` for core; `<plugin-id>.<Entity>` for plug-ins.
  53 + */
  54 + public val extEntityName: String
  55 +
  56 + /**
  57 + * The serialized JSONB column. Managed by
  58 + * `ExtJsonValidator.applyTo(...)` on save and decoded by
  59 + * `ExtJsonValidator.parseExt(...)` on read. Should default to
  60 + * `"{}"` on the entity class so an un-customized row still
  61 + * round-trips through JSON cleanly.
  62 + */
  63 + public var ext: String
  64 +}
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/application/LocationService.kt
1 package org.vibeerp.pbc.inventory.application 1 package org.vibeerp.pbc.inventory.application
2 2
3 -import com.fasterxml.jackson.databind.ObjectMapper  
4 -import com.fasterxml.jackson.module.kotlin.registerKotlinModule  
5 import org.springframework.stereotype.Service 3 import org.springframework.stereotype.Service
6 import org.springframework.transaction.annotation.Transactional 4 import org.springframework.transaction.annotation.Transactional
7 import org.vibeerp.pbc.inventory.domain.Location 5 import org.vibeerp.pbc.inventory.domain.Location
@@ -32,8 +30,6 @@ class LocationService( @@ -32,8 +30,6 @@ class LocationService(
32 private val extValidator: ExtJsonValidator, 30 private val extValidator: ExtJsonValidator,
33 ) { 31 ) {
34 32
35 - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()  
36 -  
37 @Transactional(readOnly = true) 33 @Transactional(readOnly = true)
38 fun list(): List<Location> = locations.findAll() 34 fun list(): List<Location> = locations.findAll()
39 35
@@ -47,17 +43,14 @@ class LocationService( @@ -47,17 +43,14 @@ class LocationService(
47 require(!locations.existsByCode(command.code)) { 43 require(!locations.existsByCode(command.code)) {
48 "location code '${command.code}' is already taken" 44 "location code '${command.code}' is already taken"
49 } 45 }
50 - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)  
51 - return locations.save(  
52 - Location(  
53 - code = command.code,  
54 - name = command.name,  
55 - type = command.type,  
56 - active = command.active,  
57 - ).also {  
58 - it.ext = jsonMapper.writeValueAsString(canonicalExt)  
59 - }, 46 + val location = Location(
  47 + code = command.code,
  48 + name = command.name,
  49 + type = command.type,
  50 + active = command.active,
60 ) 51 )
  52 + extValidator.applyTo(location, command.ext)
  53 + return locations.save(location)
61 } 54 }
62 55
63 fun update(id: UUID, command: UpdateLocationCommand): Location { 56 fun update(id: UUID, command: UpdateLocationCommand): Location {
@@ -67,10 +60,7 @@ class LocationService( @@ -67,10 +60,7 @@ class LocationService(
67 command.name?.let { location.name = it } 60 command.name?.let { location.name = it }
68 command.type?.let { location.type = it } 61 command.type?.let { location.type = it }
69 command.active?.let { location.active = it } 62 command.active?.let { location.active = it }
70 - if (command.ext != null) {  
71 - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)  
72 - location.ext = jsonMapper.writeValueAsString(canonicalExt)  
73 - } 63 + extValidator.applyTo(location, command.ext)
74 return location 64 return location
75 } 65 }
76 66
@@ -81,17 +71,14 @@ class LocationService( @@ -81,17 +71,14 @@ class LocationService(
81 location.active = false 71 location.active = false
82 } 72 }
83 73
84 - @Suppress("UNCHECKED_CAST")  
85 - fun parseExt(location: Location): Map<String, Any?> = try {  
86 - if (location.ext.isBlank()) emptyMap()  
87 - else jsonMapper.readValue(location.ext, Map::class.java) as Map<String, Any?>  
88 - } catch (ex: Throwable) {  
89 - emptyMap()  
90 - }  
91 -  
92 - companion object {  
93 - const val ENTITY_NAME: String = "Location"  
94 - } 74 + /**
  75 + * Convenience passthrough for response mappers — delegates to
  76 + * [ExtJsonValidator.parseExt]. Kept here as a thin wrapper so
  77 + * the controller's mapper doesn't have to inject the validator
  78 + * directly.
  79 + */
  80 + fun parseExt(location: Location): Map<String, Any?> =
  81 + extValidator.parseExt(location)
95 } 82 }
96 83
97 data class CreateLocationCommand( 84 data class CreateLocationCommand(
pbc/pbc-inventory/src/main/kotlin/org/vibeerp/pbc/inventory/domain/Location.kt
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
7 import jakarta.persistence.Table 7 import jakarta.persistence.Table
8 import org.hibernate.annotations.JdbcTypeCode 8 import org.hibernate.annotations.JdbcTypeCode
9 import org.hibernate.type.SqlTypes 9 import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.api.v1.entity.HasExt
10 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity 11 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
11 12
12 /** 13 /**
@@ -50,7 +51,7 @@ class Location( @@ -50,7 +51,7 @@ class Location(
50 name: String, 51 name: String,
51 type: LocationType, 52 type: LocationType,
52 active: Boolean = true, 53 active: Boolean = true,
53 -) : AuditedJpaEntity() { 54 +) : AuditedJpaEntity(), HasExt {
54 55
55 @Column(name = "code", nullable = false, length = 64) 56 @Column(name = "code", nullable = false, length = 64)
56 var code: String = code 57 var code: String = code
@@ -67,10 +68,17 @@ class Location( @@ -67,10 +68,17 @@ class Location(
67 68
68 @Column(name = "ext", nullable = false, columnDefinition = "jsonb") 69 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
69 @JdbcTypeCode(SqlTypes.JSON) 70 @JdbcTypeCode(SqlTypes.JSON)
70 - var ext: String = "{}" 71 + override var ext: String = "{}"
  72 +
  73 + override val extEntityName: String get() = ENTITY_NAME
71 74
72 override fun toString(): String = 75 override fun toString(): String =
73 "Location(id=$id, code='$code', type=$type, active=$active)" 76 "Location(id=$id, code='$code', type=$type, active=$active)"
  77 +
  78 + companion object {
  79 + /** Key under which Location's custom fields are declared in `metadata__custom_field`. */
  80 + const val ENTITY_NAME: String = "Location"
  81 + }
74 } 82 }
75 83
76 /** 84 /**
pbc/pbc-inventory/src/test/kotlin/org/vibeerp/pbc/inventory/application/LocationServiceTest.kt
@@ -6,12 +6,15 @@ import assertk.assertions.hasMessage @@ -6,12 +6,15 @@ import assertk.assertions.hasMessage
6 import assertk.assertions.isEqualTo 6 import assertk.assertions.isEqualTo
7 import assertk.assertions.isFalse 7 import assertk.assertions.isFalse
8 import assertk.assertions.isInstanceOf 8 import assertk.assertions.isInstanceOf
  9 +import io.mockk.Runs
9 import io.mockk.every 10 import io.mockk.every
  11 +import io.mockk.just
10 import io.mockk.mockk 12 import io.mockk.mockk
11 import io.mockk.slot 13 import io.mockk.slot
12 import io.mockk.verify 14 import io.mockk.verify
13 import org.junit.jupiter.api.BeforeEach 15 import org.junit.jupiter.api.BeforeEach
14 import org.junit.jupiter.api.Test 16 import org.junit.jupiter.api.Test
  17 +import org.vibeerp.api.v1.entity.HasExt
15 import org.vibeerp.pbc.inventory.domain.Location 18 import org.vibeerp.pbc.inventory.domain.Location
16 import org.vibeerp.pbc.inventory.domain.LocationType 19 import org.vibeerp.pbc.inventory.domain.LocationType
17 import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository 20 import org.vibeerp.pbc.inventory.infrastructure.LocationJpaRepository
@@ -29,7 +32,11 @@ class LocationServiceTest { @@ -29,7 +32,11 @@ class LocationServiceTest {
29 fun setUp() { 32 fun setUp() {
30 locations = mockk() 33 locations = mockk()
31 extValidator = mockk() 34 extValidator = mockk()
32 - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } 35 + // applyTo is a void method with side effects on the entity;
  36 + // for unit tests we treat it as a no-op. Tests that exercise
  37 + // the ext path directly can stub concrete behavior.
  38 + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs
  39 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
33 service = LocationService(locations, extValidator) 40 service = LocationService(locations, extValidator)
34 } 41 }
35 42
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderService.kt
1 package org.vibeerp.pbc.orders.purchase.application 1 package org.vibeerp.pbc.orders.purchase.application
2 2
3 -import com.fasterxml.jackson.databind.ObjectMapper  
4 -import com.fasterxml.jackson.module.kotlin.registerKotlinModule  
5 import org.springframework.stereotype.Service 3 import org.springframework.stereotype.Service
6 import org.springframework.transaction.annotation.Transactional 4 import org.springframework.transaction.annotation.Transactional
7 import org.vibeerp.api.v1.event.EventBus 5 import org.vibeerp.api.v1.event.EventBus
@@ -55,8 +53,6 @@ class PurchaseOrderService( @@ -55,8 +53,6 @@ class PurchaseOrderService(
55 private val eventBus: EventBus, 53 private val eventBus: EventBus,
56 ) { 54 ) {
57 55
58 - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()  
59 -  
60 @Transactional(readOnly = true) 56 @Transactional(readOnly = true)
61 fun list(): List<PurchaseOrder> = orders.findAll() 57 fun list(): List<PurchaseOrder> = orders.findAll()
62 58
@@ -112,8 +108,6 @@ class PurchaseOrderService( @@ -112,8 +108,6 @@ class PurchaseOrderService(
112 acc + (line.quantity * line.unitPrice) 108 acc + (line.quantity * line.unitPrice)
113 } 109 }
114 110
115 - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)  
116 -  
117 val order = PurchaseOrder( 111 val order = PurchaseOrder(
118 code = command.code, 112 code = command.code,
119 partnerCode = command.partnerCode, 113 partnerCode = command.partnerCode,
@@ -122,9 +116,8 @@ class PurchaseOrderService( @@ -122,9 +116,8 @@ class PurchaseOrderService(
122 expectedDate = command.expectedDate, 116 expectedDate = command.expectedDate,
123 currencyCode = command.currencyCode, 117 currencyCode = command.currencyCode,
124 totalAmount = total, 118 totalAmount = total,
125 - ).also {  
126 - it.ext = jsonMapper.writeValueAsString(canonicalExt)  
127 - } 119 + )
  120 + extValidator.applyTo(order, command.ext)
128 for (line in command.lines) { 121 for (line in command.lines) {
129 order.lines += PurchaseOrderLine( 122 order.lines += PurchaseOrderLine(
130 purchaseOrder = order, 123 purchaseOrder = order,
@@ -160,9 +153,8 @@ class PurchaseOrderService( @@ -160,9 +153,8 @@ class PurchaseOrderService(
160 command.expectedDate?.let { order.expectedDate = it } 153 command.expectedDate?.let { order.expectedDate = it }
161 command.currencyCode?.let { order.currencyCode = it } 154 command.currencyCode?.let { order.currencyCode = it }
162 155
163 - if (command.ext != null) {  
164 - order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext))  
165 - } 156 + // applyTo() is null-safe — a null command.ext is a no-op.
  157 + extValidator.applyTo(order, command.ext)
166 158
167 if (command.lines != null) { 159 if (command.lines != null) {
168 require(command.lines.isNotEmpty()) { 160 require(command.lines.isNotEmpty()) {
@@ -309,17 +301,13 @@ class PurchaseOrderService( @@ -309,17 +301,13 @@ class PurchaseOrderService(
309 return order 301 return order
310 } 302 }
311 303
312 - @Suppress("UNCHECKED_CAST")  
313 - fun parseExt(order: PurchaseOrder): Map<String, Any?> = try {  
314 - if (order.ext.isBlank()) emptyMap()  
315 - else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?>  
316 - } catch (ex: Throwable) {  
317 - emptyMap()  
318 - }  
319 -  
320 - companion object {  
321 - const val ENTITY_NAME: String = "PurchaseOrder"  
322 - } 304 + /**
  305 + * Convenience passthrough for response mappers — delegates to
  306 + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty
  307 + * or unparseable column so response rendering never 500s.
  308 + */
  309 + fun parseExt(order: PurchaseOrder): Map<String, Any?> =
  310 + extValidator.parseExt(order)
323 } 311 }
324 312
325 data class CreatePurchaseOrderCommand( 313 data class CreatePurchaseOrderCommand(
pbc/pbc-orders-purchase/src/main/kotlin/org/vibeerp/pbc/orders/purchase/domain/PurchaseOrder.kt
@@ -11,6 +11,7 @@ import jakarta.persistence.OrderBy @@ -11,6 +11,7 @@ import jakarta.persistence.OrderBy
11 import jakarta.persistence.Table 11 import jakarta.persistence.Table
12 import org.hibernate.annotations.JdbcTypeCode 12 import org.hibernate.annotations.JdbcTypeCode
13 import org.hibernate.type.SqlTypes 13 import org.hibernate.type.SqlTypes
  14 +import org.vibeerp.api.v1.entity.HasExt
14 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity 15 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
15 import java.math.BigDecimal 16 import java.math.BigDecimal
16 import java.time.LocalDate 17 import java.time.LocalDate
@@ -70,7 +71,7 @@ class PurchaseOrder( @@ -70,7 +71,7 @@ class PurchaseOrder(
70 expectedDate: LocalDate? = null, 71 expectedDate: LocalDate? = null,
71 currencyCode: String, 72 currencyCode: String,
72 totalAmount: BigDecimal = BigDecimal.ZERO, 73 totalAmount: BigDecimal = BigDecimal.ZERO,
73 -) : AuditedJpaEntity() { 74 +) : AuditedJpaEntity(), HasExt {
74 75
75 @Column(name = "code", nullable = false, length = 64) 76 @Column(name = "code", nullable = false, length = 64)
76 var code: String = code 77 var code: String = code
@@ -96,7 +97,9 @@ class PurchaseOrder( @@ -96,7 +97,9 @@ class PurchaseOrder(
96 97
97 @Column(name = "ext", nullable = false, columnDefinition = "jsonb") 98 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
98 @JdbcTypeCode(SqlTypes.JSON) 99 @JdbcTypeCode(SqlTypes.JSON)
99 - var ext: String = "{}" 100 + override var ext: String = "{}"
  101 +
  102 + override val extEntityName: String get() = ENTITY_NAME
100 103
101 @OneToMany( 104 @OneToMany(
102 mappedBy = "purchaseOrder", 105 mappedBy = "purchaseOrder",
@@ -109,6 +112,11 @@ class PurchaseOrder( @@ -109,6 +112,11 @@ class PurchaseOrder(
109 112
110 override fun toString(): String = 113 override fun toString(): String =
111 "PurchaseOrder(id=$id, code='$code', supplier='$partnerCode', status=$status, total=$totalAmount $currencyCode)" 114 "PurchaseOrder(id=$id, code='$code', supplier='$partnerCode', status=$status, total=$totalAmount $currencyCode)"
  115 +
  116 + companion object {
  117 + /** Key under which PurchaseOrder's custom fields are declared in `metadata__custom_field`. */
  118 + const val ENTITY_NAME: String = "PurchaseOrder"
  119 + }
112 } 120 }
113 121
114 /** 122 /**
pbc/pbc-orders-purchase/src/test/kotlin/org/vibeerp/pbc/orders/purchase/application/PurchaseOrderServiceTest.kt
@@ -16,6 +16,7 @@ import io.mockk.verify @@ -16,6 +16,7 @@ import io.mockk.verify
16 import org.junit.jupiter.api.BeforeEach 16 import org.junit.jupiter.api.BeforeEach
17 import org.junit.jupiter.api.Test 17 import org.junit.jupiter.api.Test
18 import org.vibeerp.api.v1.core.Id 18 import org.vibeerp.api.v1.core.Id
  19 +import org.vibeerp.api.v1.entity.HasExt
19 import org.vibeerp.api.v1.event.EventBus 20 import org.vibeerp.api.v1.event.EventBus
20 import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent 21 import org.vibeerp.api.v1.event.orders.PurchaseOrderCancelledEvent
21 import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent 22 import org.vibeerp.api.v1.event.orders.PurchaseOrderConfirmedEvent
@@ -54,7 +55,8 @@ class PurchaseOrderServiceTest { @@ -54,7 +55,8 @@ class PurchaseOrderServiceTest {
54 inventoryApi = mockk() 55 inventoryApi = mockk()
55 extValidator = mockk() 56 extValidator = mockk()
56 eventBus = mockk() 57 eventBus = mockk()
57 - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } 58 + every { extValidator.applyTo(any<HasExt>(), any()) } just runs
  59 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
58 every { orders.existsByCode(any()) } returns false 60 every { orders.existsByCode(any()) } returns false
59 every { orders.save(any<PurchaseOrder>()) } answers { firstArg() } 61 every { orders.save(any<PurchaseOrder>()) } answers { firstArg() }
60 every { eventBus.publish(any()) } just runs 62 every { eventBus.publish(any()) } just runs
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderService.kt
1 package org.vibeerp.pbc.orders.sales.application 1 package org.vibeerp.pbc.orders.sales.application
2 2
3 -import com.fasterxml.jackson.databind.ObjectMapper  
4 -import com.fasterxml.jackson.module.kotlin.registerKotlinModule  
5 import org.springframework.stereotype.Service 3 import org.springframework.stereotype.Service
6 import org.springframework.transaction.annotation.Transactional 4 import org.springframework.transaction.annotation.Transactional
7 import org.vibeerp.api.v1.event.EventBus 5 import org.vibeerp.api.v1.event.EventBus
@@ -78,8 +76,6 @@ class SalesOrderService( @@ -78,8 +76,6 @@ class SalesOrderService(
78 private val eventBus: EventBus, 76 private val eventBus: EventBus,
79 ) { 77 ) {
80 78
81 - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()  
82 -  
83 @Transactional(readOnly = true) 79 @Transactional(readOnly = true)
84 fun list(): List<SalesOrder> = orders.findAll() 80 fun list(): List<SalesOrder> = orders.findAll()
85 81
@@ -138,8 +134,6 @@ class SalesOrderService( @@ -138,8 +134,6 @@ class SalesOrderService(
138 acc + (line.quantity * line.unitPrice) 134 acc + (line.quantity * line.unitPrice)
139 } 135 }
140 136
141 - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)  
142 -  
143 val order = SalesOrder( 137 val order = SalesOrder(
144 code = command.code, 138 code = command.code,
145 partnerCode = command.partnerCode, 139 partnerCode = command.partnerCode,
@@ -147,9 +141,8 @@ class SalesOrderService( @@ -147,9 +141,8 @@ class SalesOrderService(
147 orderDate = command.orderDate, 141 orderDate = command.orderDate,
148 currencyCode = command.currencyCode, 142 currencyCode = command.currencyCode,
149 totalAmount = total, 143 totalAmount = total,
150 - ).also {  
151 - it.ext = jsonMapper.writeValueAsString(canonicalExt)  
152 - } 144 + )
  145 + extValidator.applyTo(order, command.ext)
153 for (line in command.lines) { 146 for (line in command.lines) {
154 order.lines += SalesOrderLine( 147 order.lines += SalesOrderLine(
155 salesOrder = order, 148 salesOrder = order,
@@ -184,9 +177,8 @@ class SalesOrderService( @@ -184,9 +177,8 @@ class SalesOrderService(
184 command.orderDate?.let { order.orderDate = it } 177 command.orderDate?.let { order.orderDate = it }
185 command.currencyCode?.let { order.currencyCode = it } 178 command.currencyCode?.let { order.currencyCode = it }
186 179
187 - if (command.ext != null) {  
188 - order.ext = jsonMapper.writeValueAsString(extValidator.validate(ENTITY_NAME, command.ext))  
189 - } 180 + // applyTo() is null-safe — a null command.ext is a no-op.
  181 + extValidator.applyTo(order, command.ext)
190 182
191 if (command.lines != null) { 183 if (command.lines != null) {
192 require(command.lines.isNotEmpty()) { 184 require(command.lines.isNotEmpty()) {
@@ -347,17 +339,13 @@ class SalesOrderService( @@ -347,17 +339,13 @@ class SalesOrderService(
347 return order 339 return order
348 } 340 }
349 341
350 - @Suppress("UNCHECKED_CAST")  
351 - fun parseExt(order: SalesOrder): Map<String, Any?> = try {  
352 - if (order.ext.isBlank()) emptyMap()  
353 - else jsonMapper.readValue(order.ext, Map::class.java) as Map<String, Any?>  
354 - } catch (ex: Throwable) {  
355 - emptyMap()  
356 - }  
357 -  
358 - companion object {  
359 - const val ENTITY_NAME: String = "SalesOrder"  
360 - } 342 + /**
  343 + * Convenience passthrough for response mappers — delegates to
  344 + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty
  345 + * or unparseable column so response rendering never 500s.
  346 + */
  347 + fun parseExt(order: SalesOrder): Map<String, Any?> =
  348 + extValidator.parseExt(order)
361 } 349 }
362 350
363 data class CreateSalesOrderCommand( 351 data class CreateSalesOrderCommand(
pbc/pbc-orders-sales/src/main/kotlin/org/vibeerp/pbc/orders/sales/domain/SalesOrder.kt
@@ -12,6 +12,7 @@ import jakarta.persistence.OrderBy @@ -12,6 +12,7 @@ import jakarta.persistence.OrderBy
12 import jakarta.persistence.Table 12 import jakarta.persistence.Table
13 import org.hibernate.annotations.JdbcTypeCode 13 import org.hibernate.annotations.JdbcTypeCode
14 import org.hibernate.type.SqlTypes 14 import org.hibernate.type.SqlTypes
  15 +import org.vibeerp.api.v1.entity.HasExt
15 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity 16 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
16 import java.math.BigDecimal 17 import java.math.BigDecimal
17 import java.time.LocalDate 18 import java.time.LocalDate
@@ -76,7 +77,7 @@ class SalesOrder( @@ -76,7 +77,7 @@ class SalesOrder(
76 orderDate: LocalDate, 77 orderDate: LocalDate,
77 currencyCode: String, 78 currencyCode: String,
78 totalAmount: BigDecimal = BigDecimal.ZERO, 79 totalAmount: BigDecimal = BigDecimal.ZERO,
79 -) : AuditedJpaEntity() { 80 +) : AuditedJpaEntity(), HasExt {
80 81
81 @Column(name = "code", nullable = false, length = 64) 82 @Column(name = "code", nullable = false, length = 64)
82 var code: String = code 83 var code: String = code
@@ -99,7 +100,9 @@ class SalesOrder( @@ -99,7 +100,9 @@ class SalesOrder(
99 100
100 @Column(name = "ext", nullable = false, columnDefinition = "jsonb") 101 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
101 @JdbcTypeCode(SqlTypes.JSON) 102 @JdbcTypeCode(SqlTypes.JSON)
102 - var ext: String = "{}" 103 + override var ext: String = "{}"
  104 +
  105 + override val extEntityName: String get() = ENTITY_NAME
103 106
104 /** 107 /**
105 * The order's line items, eagerly loaded. Cascade ALL because 108 * The order's line items, eagerly loaded. Cascade ALL because
@@ -119,6 +122,11 @@ class SalesOrder( @@ -119,6 +122,11 @@ class SalesOrder(
119 122
120 override fun toString(): String = 123 override fun toString(): String =
121 "SalesOrder(id=$id, code='$code', partner='$partnerCode', status=$status, total=$totalAmount $currencyCode)" 124 "SalesOrder(id=$id, code='$code', partner='$partnerCode', status=$status, total=$totalAmount $currencyCode)"
  125 +
  126 + companion object {
  127 + /** Key under which SalesOrder's custom fields are declared in `metadata__custom_field`. */
  128 + const val ENTITY_NAME: String = "SalesOrder"
  129 + }
122 } 130 }
123 131
124 /** 132 /**
pbc/pbc-orders-sales/src/test/kotlin/org/vibeerp/pbc/orders/sales/application/SalesOrderServiceTest.kt
@@ -17,6 +17,7 @@ import io.mockk.verify @@ -17,6 +17,7 @@ import io.mockk.verify
17 import org.junit.jupiter.api.BeforeEach 17 import org.junit.jupiter.api.BeforeEach
18 import org.junit.jupiter.api.Test 18 import org.junit.jupiter.api.Test
19 import org.vibeerp.api.v1.core.Id 19 import org.vibeerp.api.v1.core.Id
  20 +import org.vibeerp.api.v1.entity.HasExt
20 import org.vibeerp.api.v1.event.EventBus 21 import org.vibeerp.api.v1.event.EventBus
21 import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent 22 import org.vibeerp.api.v1.event.orders.SalesOrderCancelledEvent
22 import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent 23 import org.vibeerp.api.v1.event.orders.SalesOrderConfirmedEvent
@@ -54,7 +55,10 @@ class SalesOrderServiceTest { @@ -54,7 +55,10 @@ class SalesOrderServiceTest {
54 inventoryApi = mockk() 55 inventoryApi = mockk()
55 extValidator = mockk() 56 extValidator = mockk()
56 eventBus = mockk() 57 eventBus = mockk()
57 - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } 58 + // applyTo is a void with side effects; default to a no-op so
  59 + // tests that don't touch ext don't need to set expectations.
  60 + every { extValidator.applyTo(any<HasExt>(), any()) } just runs
  61 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
58 every { orders.existsByCode(any()) } returns false 62 every { orders.existsByCode(any()) } returns false
59 every { orders.save(any<SalesOrder>()) } answers { firstArg() } 63 every { orders.save(any<SalesOrder>()) } answers { firstArg() }
60 // The bus is fire-and-forget from the service's perspective. 64 // The bus is fire-and-forget from the service's perspective.
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt
1 package org.vibeerp.pbc.partners.application 1 package org.vibeerp.pbc.partners.application
2 2
3 -import com.fasterxml.jackson.databind.ObjectMapper  
4 -import com.fasterxml.jackson.module.kotlin.registerKotlinModule  
5 import org.springframework.stereotype.Service 3 import org.springframework.stereotype.Service
6 import org.springframework.transaction.annotation.Transactional 4 import org.springframework.transaction.annotation.Transactional
7 import org.vibeerp.pbc.partners.domain.Partner 5 import org.vibeerp.pbc.partners.domain.Partner
@@ -50,8 +48,6 @@ class PartnerService( @@ -50,8 +48,6 @@ class PartnerService(
50 private val extValidator: ExtJsonValidator, 48 private val extValidator: ExtJsonValidator,
51 ) { 49 ) {
52 50
53 - private val jsonMapper: ObjectMapper = ObjectMapper().registerKotlinModule()  
54 -  
55 @Transactional(readOnly = true) 51 @Transactional(readOnly = true)
56 fun list(): List<Partner> = partners.findAll() 52 fun list(): List<Partner> = partners.findAll()
57 53
@@ -65,24 +61,22 @@ class PartnerService( @@ -65,24 +61,22 @@ class PartnerService(
65 require(!partners.existsByCode(command.code)) { 61 require(!partners.existsByCode(command.code)) {
66 "partner code '${command.code}' is already taken" 62 "partner code '${command.code}' is already taken"
67 } 63 }
68 - // Validate the ext JSONB before save (P3.4 — Tier 1 customization).  
69 - // Throws IllegalArgumentException with all violations on failure;  
70 - // GlobalExceptionHandler maps it to 400 Bad Request.  
71 - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)  
72 - return partners.save(  
73 - Partner(  
74 - code = command.code,  
75 - name = command.name,  
76 - type = command.type,  
77 - taxId = command.taxId,  
78 - website = command.website,  
79 - email = command.email,  
80 - phone = command.phone,  
81 - active = command.active,  
82 - ).also {  
83 - it.ext = jsonMapper.writeValueAsString(canonicalExt)  
84 - }, 64 + // P3.4 — Tier 1 customization. applyTo() validates, canonicalises,
  65 + // and writes to partner.ext. Throws IllegalArgumentException with
  66 + // all violations joined if validation fails; GlobalExceptionHandler
  67 + // maps that to 400 Bad Request.
  68 + val partner = Partner(
  69 + code = command.code,
  70 + name = command.name,
  71 + type = command.type,
  72 + taxId = command.taxId,
  73 + website = command.website,
  74 + email = command.email,
  75 + phone = command.phone,
  76 + active = command.active,
85 ) 77 )
  78 + extValidator.applyTo(partner, command.ext)
  79 + return partners.save(partner)
86 } 80 }
87 81
88 fun update(id: UUID, command: UpdatePartnerCommand): Partner { 82 fun update(id: UUID, command: UpdatePartnerCommand): Partner {
@@ -102,37 +96,18 @@ class PartnerService( @@ -102,37 +96,18 @@ class PartnerService(
102 // updates would force callers to merge with the existing JSON 96 // updates would force callers to merge with the existing JSON
103 // before sending, which is the kind of foot-gun the framework 97 // before sending, which is the kind of foot-gun the framework
104 // should not normalise. PATCH is for top-level columns only. 98 // should not normalise. PATCH is for top-level columns only.
105 - if (command.ext != null) {  
106 - val canonicalExt = extValidator.validate(ENTITY_NAME, command.ext)  
107 - partner.ext = jsonMapper.writeValueAsString(canonicalExt)  
108 - } 99 + // applyTo() is null-safe — a null command.ext is a no-op.
  100 + extValidator.applyTo(partner, command.ext)
109 return partner 101 return partner
110 } 102 }
111 103
112 /** 104 /**
113 - * Parse the entity's `ext` JSON string back into a Map for the  
114 - * REST response. Returns an empty map when the column is empty  
115 - * or unparseable — the latter shouldn't happen because every  
116 - * write goes through [extValidator], but cleaning up bad rows  
117 - * from old data isn't this method's responsibility. 105 + * Convenience passthrough for response mappers — delegates to
  106 + * [ExtJsonValidator.parseExt]. Returns an empty map on an empty
  107 + * or unparseable column so response rendering never 500s.
118 */ 108 */
119 - @Suppress("UNCHECKED_CAST")  
120 - fun parseExt(partner: Partner): Map<String, Any?> = try {  
121 - if (partner.ext.isBlank()) emptyMap()  
122 - else jsonMapper.readValue(partner.ext, Map::class.java) as Map<String, Any?>  
123 - } catch (ex: Throwable) {  
124 - emptyMap()  
125 - }  
126 -  
127 - companion object {  
128 - /**  
129 - * The entity name the partner aggregate is registered under in  
130 - * `metadata__entity` (and therefore the key the [ExtJsonValidator]  
131 - * looks up). Kept as a constant so the controller and tests  
132 - * can reference the same string without hard-coding it twice.  
133 - */  
134 - const val ENTITY_NAME: String = "Partner"  
135 - } 109 + fun parseExt(partner: Partner): Map<String, Any?> =
  110 + extValidator.parseExt(partner)
136 111
137 /** 112 /**
138 * Deactivate the partner and all its contacts. Addresses are left 113 * Deactivate the partner and all its contacts. Addresses are left
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Partner.kt
@@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated @@ -7,6 +7,7 @@ import jakarta.persistence.Enumerated
7 import jakarta.persistence.Table 7 import jakarta.persistence.Table
8 import org.hibernate.annotations.JdbcTypeCode 8 import org.hibernate.annotations.JdbcTypeCode
9 import org.hibernate.type.SqlTypes 9 import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.api.v1.entity.HasExt
10 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity 11 import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
11 12
12 /** 13 /**
@@ -58,7 +59,7 @@ class Partner( @@ -58,7 +59,7 @@ class Partner(
58 email: String? = null, 59 email: String? = null,
59 phone: String? = null, 60 phone: String? = null,
60 active: Boolean = true, 61 active: Boolean = true,
61 -) : AuditedJpaEntity() { 62 +) : AuditedJpaEntity(), HasExt {
62 63
63 @Column(name = "code", nullable = false, length = 64) 64 @Column(name = "code", nullable = false, length = 64)
64 var code: String = code 65 var code: String = code
@@ -87,10 +88,17 @@ class Partner( @@ -87,10 +88,17 @@ class Partner(
87 88
88 @Column(name = "ext", nullable = false, columnDefinition = "jsonb") 89 @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
89 @JdbcTypeCode(SqlTypes.JSON) 90 @JdbcTypeCode(SqlTypes.JSON)
90 - var ext: String = "{}" 91 + override var ext: String = "{}"
  92 +
  93 + override val extEntityName: String get() = ENTITY_NAME
91 94
92 override fun toString(): String = 95 override fun toString(): String =
93 "Partner(id=$id, code='$code', type=$type, active=$active)" 96 "Partner(id=$id, code='$code', type=$type, active=$active)"
  97 +
  98 + companion object {
  99 + /** Key under which Partner's custom fields are declared in `metadata__custom_field`. */
  100 + const val ENTITY_NAME: String = "Partner"
  101 + }
94 } 102 }
95 103
96 /** 104 /**
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt
@@ -6,12 +6,15 @@ import assertk.assertions.hasMessage @@ -6,12 +6,15 @@ import assertk.assertions.hasMessage
6 import assertk.assertions.isEqualTo 6 import assertk.assertions.isEqualTo
7 import assertk.assertions.isFalse 7 import assertk.assertions.isFalse
8 import assertk.assertions.isInstanceOf 8 import assertk.assertions.isInstanceOf
  9 +import io.mockk.Runs
9 import io.mockk.every 10 import io.mockk.every
  11 +import io.mockk.just
10 import io.mockk.mockk 12 import io.mockk.mockk
11 import io.mockk.slot 13 import io.mockk.slot
12 import io.mockk.verify 14 import io.mockk.verify
13 import org.junit.jupiter.api.BeforeEach 15 import org.junit.jupiter.api.BeforeEach
14 import org.junit.jupiter.api.Test 16 import org.junit.jupiter.api.Test
  17 +import org.vibeerp.api.v1.entity.HasExt
15 import org.vibeerp.pbc.partners.domain.Contact 18 import org.vibeerp.pbc.partners.domain.Contact
16 import org.vibeerp.pbc.partners.domain.Partner 19 import org.vibeerp.pbc.partners.domain.Partner
17 import org.vibeerp.pbc.partners.domain.PartnerType 20 import org.vibeerp.pbc.partners.domain.PartnerType
@@ -36,9 +39,10 @@ class PartnerServiceTest { @@ -36,9 +39,10 @@ class PartnerServiceTest {
36 addresses = mockk() 39 addresses = mockk()
37 contacts = mockk() 40 contacts = mockk()
38 extValidator = mockk() 41 extValidator = mockk()
39 - // Default: validator returns the input map unchanged. Tests  
40 - // that exercise ext directly override this expectation.  
41 - every { extValidator.validate(any(), any()) } answers { secondArg() ?: emptyMap() } 42 + // Default: validator treats applyTo as a no-op. Tests that
  43 + // exercise ext directly override this expectation.
  44 + every { extValidator.applyTo(any<HasExt>(), any()) } just Runs
  45 + every { extValidator.parseExt(any<HasExt>()) } returns emptyMap()
42 service = PartnerService(partners, addresses, contacts, extValidator) 46 service = PartnerService(partners, addresses, contacts, extValidator)
43 } 47 }
44 48
platform/platform-metadata/src/main/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidator.kt
1 package org.vibeerp.platform.metadata.customfield 1 package org.vibeerp.platform.metadata.customfield
2 2
  3 +import com.fasterxml.jackson.databind.ObjectMapper
3 import org.springframework.stereotype.Component 4 import org.springframework.stereotype.Component
4 import org.vibeerp.api.v1.entity.CustomField 5 import org.vibeerp.api.v1.entity.CustomField
5 import org.vibeerp.api.v1.entity.FieldType 6 import org.vibeerp.api.v1.entity.FieldType
  7 +import org.vibeerp.api.v1.entity.HasExt
6 import java.math.BigDecimal 8 import java.math.BigDecimal
7 import java.time.LocalDate 9 import java.time.LocalDate
8 import java.time.OffsetDateTime 10 import java.time.OffsetDateTime
@@ -53,9 +55,53 @@ import java.util.UUID @@ -53,9 +55,53 @@ import java.util.UUID
53 @Component 55 @Component
54 class ExtJsonValidator( 56 class ExtJsonValidator(
55 private val registry: CustomFieldRegistry, 57 private val registry: CustomFieldRegistry,
  58 + private val jsonMapper: ObjectMapper,
56 ) { 59 ) {
57 60
58 /** 61 /**
  62 + * Validate [ext], serialize the canonical form, and write it to
  63 + * [entity]'s ext column. Null [ext] is a no-op (the column keeps
  64 + * its prior value, which means PATCH-style "don't touch ext"
  65 + * works out of the box).
  66 + *
  67 + * This is the single entry point every core PBC service and
  68 + * every plug-in uses to stage an ext update. It replaces the
  69 + * four-line sequence (validate / writeValueAsString / assign to
  70 + * column) that used to live in every service — removing a lot
  71 + * of boilerplate and giving the framework one place to change
  72 + * the ext-save contract later (e.g. PII redaction, audit
  73 + * tagging, eventing on field-level changes).
  74 + *
  75 + * Throws [IllegalArgumentException] with all violations joined
  76 + * by `; ` if validation fails. The framework's
  77 + * `GlobalExceptionHandler` maps that to 400 Bad Request.
  78 + */
  79 + fun applyTo(entity: HasExt, ext: Map<String, Any?>?) {
  80 + if (ext == null) return
  81 + val canonical = validate(entity.extEntityName, ext)
  82 + entity.ext = jsonMapper.writeValueAsString(canonical)
  83 + }
  84 +
  85 + /**
  86 + * Parse an entity's serialized `ext` column back into a
  87 + * `Map<String, Any?>` for response DTOs. Returns an empty map
  88 + * for any parse failure — response mappers should NOT fail a
  89 + * successful read because of a bad serialized ext value; if the
  90 + * column is corrupt, the fix is a save, not a 500 on read.
  91 + *
  92 + * This replaces the `parseExt(...)` helper that was duplicated
  93 + * verbatim in every core PBC service.
  94 + */
  95 + @Suppress("UNCHECKED_CAST")
  96 + fun parseExt(entity: HasExt): Map<String, Any?> =
  97 + try {
  98 + if (entity.ext.isBlank()) emptyMap()
  99 + else jsonMapper.readValue(entity.ext, Map::class.java) as Map<String, Any?>
  100 + } catch (ex: Throwable) {
  101 + emptyMap()
  102 + }
  103 +
  104 + /**
59 * Validate (and canonicalise) the [ext] map for [entityName]. 105 * Validate (and canonicalise) the [ext] map for [entityName].
60 * 106 *
61 * @return a fresh map containing only the declared fields, with 107 * @return a fresh map containing only the declared fields, with
platform/platform-metadata/src/test/kotlin/org/vibeerp/platform/metadata/customfield/ExtJsonValidatorTest.kt
@@ -9,12 +9,15 @@ import assertk.assertions.isEmpty @@ -9,12 +9,15 @@ import assertk.assertions.isEmpty
9 import assertk.assertions.isEqualTo 9 import assertk.assertions.isEqualTo
10 import assertk.assertions.isInstanceOf 10 import assertk.assertions.isInstanceOf
11 import assertk.assertions.messageContains 11 import assertk.assertions.messageContains
  12 +import com.fasterxml.jackson.databind.ObjectMapper
  13 +import com.fasterxml.jackson.module.kotlin.registerKotlinModule
12 import io.mockk.every 14 import io.mockk.every
13 import io.mockk.mockk 15 import io.mockk.mockk
14 import org.junit.jupiter.api.BeforeEach 16 import org.junit.jupiter.api.BeforeEach
15 import org.junit.jupiter.api.Test 17 import org.junit.jupiter.api.Test
16 import org.vibeerp.api.v1.entity.CustomField 18 import org.vibeerp.api.v1.entity.CustomField
17 import org.vibeerp.api.v1.entity.FieldType 19 import org.vibeerp.api.v1.entity.FieldType
  20 +import org.vibeerp.api.v1.entity.HasExt
18 21
19 class ExtJsonValidatorTest { 22 class ExtJsonValidatorTest {
20 23
@@ -24,7 +27,7 @@ class ExtJsonValidatorTest { @@ -24,7 +27,7 @@ class ExtJsonValidatorTest {
24 @BeforeEach 27 @BeforeEach
25 fun setUp() { 28 fun setUp() {
26 registry = mockk() 29 registry = mockk()
27 - validator = ExtJsonValidator(registry) 30 + validator = ExtJsonValidator(registry, ObjectMapper().registerKotlinModule())
28 } 31 }
29 32
30 private fun stub(vararg fields: CustomField) { 33 private fun stub(vararg fields: CustomField) {
@@ -170,6 +173,72 @@ class ExtJsonValidatorTest { @@ -170,6 +173,72 @@ class ExtJsonValidatorTest {
170 assertThat(canonical.keys).hasSize(0) 173 assertThat(canonical.keys).hasSize(0)
171 } 174 }
172 175
  176 + // ─── applyTo + parseExt (HasExt convenience helpers) ─────────────
  177 +
  178 + /**
  179 + * Minimal [HasExt] fixture. Services will mix this marker into
  180 + * their JPA entities; the test just needs something concrete.
  181 + */
  182 + private class FakeEntity(
  183 + override val extEntityName: String = ENTITY,
  184 + override var ext: String = "{}",
  185 + ) : HasExt
  186 +
  187 + @Test
  188 + fun `applyTo with null ext is a no-op`() {
  189 + val fake = FakeEntity()
  190 + validator.applyTo(fake, null)
  191 + assertThat(fake.ext).isEqualTo("{}")
  192 + }
  193 +
  194 + @Test
  195 + fun `applyTo validates and writes canonical JSON to entity ext`() {
  196 + stub(
  197 + CustomField("label", ENTITY, FieldType.String(maxLength = 16)),
  198 + CustomField("count", ENTITY, FieldType.Integer),
  199 + )
  200 + val fake = FakeEntity()
  201 +
  202 + validator.applyTo(fake, mapOf("label" to "ok", "count" to 3))
  203 +
  204 + // Canonical form — label first (declared order), count as Long.
  205 + assertThat(fake.ext).isEqualTo("""{"label":"ok","count":3}""")
  206 + }
  207 +
  208 + @Test
  209 + fun `applyTo surfaces validation failures as IllegalArgumentException`() {
  210 + stub(CustomField("known", ENTITY, FieldType.Integer))
  211 + val fake = FakeEntity()
  212 +
  213 + assertFailure { validator.applyTo(fake, mapOf("rogue" to "x")) }
  214 + .isInstanceOf(IllegalArgumentException::class)
  215 + .messageContains("undeclared")
  216 + // Ext was NOT touched on failure.
  217 + assertThat(fake.ext).isEqualTo("{}")
  218 + }
  219 +
  220 + @Test
  221 + fun `parseExt returns empty map for blank ext column`() {
  222 + val fake = FakeEntity(ext = "")
  223 + assertThat(validator.parseExt(fake)).isEmpty()
  224 + }
  225 +
  226 + @Test
  227 + fun `parseExt round-trips a previously-canonicalised ext value`() {
  228 + val fake = FakeEntity(ext = """{"label":"round-trip","count":42}""")
  229 + val parsed = validator.parseExt(fake)
  230 + assertThat(parsed.keys).contains("label")
  231 + assertThat(parsed.keys).contains("count")
  232 + assertThat(parsed["label"]).isEqualTo("round-trip")
  233 + }
  234 +
  235 + @Test
  236 + fun `parseExt swallows malformed JSON and returns empty map`() {
  237 + val fake = FakeEntity(ext = "this is not json")
  238 + // Response mappers should NOT 500 on a corrupt row.
  239 + assertThat(validator.parseExt(fake)).isEmpty()
  240 + }
  241 +
173 companion object { 242 companion object {
174 const val ENTITY: String = "TestEntity" 243 const val ENTITY: String = "TestEntity"
175 } 244 }