Commit 3e40cae90164dc1355e280831555a8bc889aea4c

Authored by zichun
1 parent 0090e3af

feat(pbc): P5.2 — pbc-partners (customers, suppliers, addresses, contacts)

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.
Showing 26 changed files with 1904 additions and 22 deletions
CLAUDE.md
@@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only @@ -95,10 +95,10 @@ plugins (incl. ref) depend on: api/api-v1 only
95 95
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 -- **11 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 -- **92 unit tests across 11 modules**, all green. `./gradlew build` is the canonical full build. 98 +- **12 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 +- **107 unit tests across 12 modules**, all green. `./gradlew build` is the canonical full build.
100 - **All 7 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), 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 (P1.5), plus the audit/principal context bridge. 100 - **All 7 cross-cutting platform services live and smoke-tested end-to-end** against real Postgres: auth (P4.1), 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 (P1.5), plus the audit/principal context bridge.
101 -- **2 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`) — they validate the recipe every future PBC clones. 101 +- **3 of 10 core PBCs implemented end-to-end** (`pbc-identity`, `pbc-catalog`, `pbc-partners`) — they validate the recipe every future PBC clones across three different aggregate shapes (single entity, two-entity catalog, parent-with-children partners+addresses+contacts).
102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework. 102 - **Reference printing-shop plug-in** owns its own DB schema, CRUDs plates and ink recipes via REST through `context.jdbc`, ships its own metadata YAML, and registers 7 HTTP endpoints. It is the executable acceptance test for the framework.
103 - **Package root** is `org.vibeerp`. 103 - **Package root** is `org.vibeerp`.
104 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot. 104 - **Build commands:** `./gradlew build` (full), `./gradlew test` (unit tests), `./gradlew :distribution:bootRun` (run dev profile, stages plug-in JAR automatically), `docker compose up -d db` (Postgres). The bootstrap admin password is printed to the application logs on first boot.
PROGRESS.md
@@ -10,27 +10,27 @@ @@ -10,27 +10,27 @@
10 10
11 | | | 11 | | |
12 |---|---| 12 |---|---|
13 -| **Latest version** | v0.6 (post-P1.5) |  
14 -| **Latest commit** | `1ead32d feat(metadata): P1.5 — metadata store seeding` | 13 +| **Latest version** | v0.7 (post-P5.2) |
  14 +| **Latest commit** | `feat(pbc): P5.2 — pbc-partners (customers, suppliers, addresses, contacts)` |
15 | **Repo** | https://github.com/reporkey/vibe-erp | 15 | **Repo** | https://github.com/reporkey/vibe-erp |
16 -| **Modules** | 11 |  
17 -| **Unit tests** | 92, all green |  
18 -| **End-to-end smoke runs** | All cross-cutting services verified against real Postgres |  
19 -| **Real PBCs implemented** | 2 of 10 (`pbc-identity`, `pbc-catalog`) | 16 +| **Modules** | 12 |
  17 +| **Unit tests** | 107, all green |
  18 +| **End-to-end smoke runs** | All cross-cutting services + all 3 PBCs verified against real Postgres |
  19 +| **Real PBCs implemented** | 3 of 10 (`pbc-identity`, `pbc-catalog`, `pbc-partners`) |
20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata) | 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata) |
21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. | 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22
23 ## Current stage 23 ## Current stage
24 24
25 -**Foundation complete; first business surface in place.** All seven cross-cutting platform services that PBCs and plug-ins depend on are now live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader). Two real PBCs validate the modular-monolith template. The reference printing-shop plug-in has graduated from "hello world" to a real customer demonstration: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, and would be rejected at install time if it tried to import any internal framework class. 25 +**Foundation complete; business surface area growing.** All seven cross-cutting platform services that PBCs and plug-ins depend on are live (auth, plug-in lifecycle, plug-in HTTP, plug-in linter, plug-in DB schemas, event bus + outbox, metadata loader). Three real PBCs (identity, catalog, partners) validate the modular-monolith template against three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain through the api.v1 typed-SQL surface, registers its own HTTP endpoints, and would be rejected at install time if it tried to import any internal framework class.
26 26
27 -The next phase is **building business surface area**: more PBCs (partners, inventory, sales orders), the workflow engine (Flowable), the metadata-driven custom fields and forms layer (Tier 1 customization), and eventually the React SPA. 27 +The next phase continues **building business surface area**: more PBCs (inventory, sales orders), the workflow engine (Flowable), the metadata-driven custom fields and forms layer (Tier 1 customization), and eventually the React SPA.
28 28
29 ## Total scope (the v1.0 cut line) 29 ## Total scope (the v1.0 cut line)
30 30
31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar. 31 The framework reaches v1.0 when, on a fresh Postgres, an operator can `docker run` the image, log in, drop a customer plug-in JAR into `./plugins/`, restart, and walk a real workflow end-to-end without writing any code — and the **same** image, against a different Postgres pointed at a different company, also serves a different customer with a different plug-in. See the implementation plan for the full v1.0 acceptance bar.
32 32
33 -That target breaks down into roughly 30 work units across 8 phases. About **15 are done** as of today. Below is the full list with status. 33 +That target breaks down into roughly 30 work units across 8 phases. About **16 are done** as of today. Below is the full list with status.
34 34
35 ### Phase 1 — Platform completion (foundation) 35 ### Phase 1 — Platform completion (foundation)
36 36
@@ -79,7 +79,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **15 a @@ -79,7 +79,7 @@ That target breaks down into roughly 30 work units across 8 phases. About **15 a
79 | # | Unit | Status | 79 | # | Unit | Status |
80 |---|---|---| 80 |---|---|---|
81 | P5.1 | `pbc-catalog` — items, units of measure | ✅ DONE — `69f3daa` | 81 | P5.1 | `pbc-catalog` — items, units of measure | ✅ DONE — `69f3daa` |
82 -| P5.2 | `pbc-partners` — customers, suppliers, contacts | ⏳ Next priority candidate | 82 +| P5.2 | `pbc-partners` — customers, suppliers, contacts, addresses | ✅ DONE — `<this commit>` |
83 | P5.3 | `pbc-inventory` — stock items, lots, locations, movements | 🔜 Pending | 83 | P5.3 | `pbc-inventory` — stock items, lots, locations, movements | 🔜 Pending |
84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending | 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 | P5.5 | `pbc-orders-sales` — quotes, sales orders, deliveries | 🔜 Pending | 85 | P5.5 | `pbc-orders-sales` — quotes, sales orders, deliveries | 🔜 Pending |
@@ -126,7 +126,7 @@ These are the cross-cutting platform services already wired into the running fra @@ -126,7 +126,7 @@ These are the cross-cutting platform services already wired into the running fra
126 | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. | 126 | **Plug-in DB schemas** (P1.4) | `platform-plugins` | Each plug-in ships its own `META-INF/vibe-erp/db/changelog.xml`. The host's `PluginLiquibaseRunner` applies it against the shared host datasource at plug-in start. Plug-ins query their own tables via the api.v1 `PluginJdbc` typed-SQL surface — no Spring or Hibernate types ever leak. |
127 | **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. | 127 | **Event bus + outbox** (P1.7) | `platform-events` | Synchronous in-process delivery PLUS a transactional outbox row in the same DB transaction. `Propagation.MANDATORY` so the bus refuses to publish outside an active transaction (no publish-and-rollback leaks). `OutboxPoller` flips PENDING → DISPATCHED every 5s. Wildcard `**` topic for the audit subscriber; topic-string and class-based subscribe. |
128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. | 128 | **Metadata loader** (P1.5) | `platform-metadata` | Walks the host classpath and each plug-in JAR for `META-INF/vibe-erp/metadata/*.yml`, upserts entities/permissions/menus into `metadata__*` tables tagged by source. Idempotent (delete-by-source then insert). User-edited metadata (`source='user'`) is never touched. Public `GET /api/v1/_meta/metadata` returns the full set for SPA + AI-agent + OpenAPI introspection. |
129 -| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog` | Two real PBCs prove the recipe: domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. | 129 +| **PBC pattern** (P5.x recipe) | `pbc-identity`, `pbc-catalog`, `pbc-partners` | Three real PBCs prove the recipe across three aggregate shapes (single-entity user, two-entity catalog, parent-with-children partners+addresses+contacts): domain entity extending `AuditedJpaEntity` → Spring Data JPA repository → application service → REST controller under `/api/v1/<pbc>/<resource>` → cross-PBC facade in `api.v1.ext.<pbc>` → adapter implementation. Architecture rule enforced by the Gradle build: PBCs never import each other, never import `platform-bootstrap`. |
130 130
131 ## What the reference plug-in proves end-to-end 131 ## What the reference plug-in proves end-to-end
132 132
@@ -170,7 +170,7 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework, @@ -170,7 +170,7 @@ This is **real**: a JAR file dropped into a directory, loaded by the framework,
170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home. 170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home.
171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2. 171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2.
172 - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3. 172 - **Permission checks.** `Principal` is bound; `metadata__permission` is seeded; the actual `@RequirePermission` enforcement layer is P4.3.
173 -- **More PBCs.** Only identity and catalog exist. Partners, inventory, warehousing, orders, production, quality, finance are all pending. 173 +- **More PBCs.** Identity, catalog and partners exist. Inventory, warehousing, orders, production, quality, finance are all pending.
174 - **Web SPA.** No React app. The framework is API-only today. 174 - **Web SPA.** No React app. The framework is API-only today.
175 - **MCP server.** The architecture leaves room for it; the implementation is v1.1. 175 - **MCP server.** The architecture leaves room for it; the implementation is v1.1.
176 - **Mobile.** v2. 176 - **Mobile.** v2.
@@ -212,6 +212,7 @@ platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint disp @@ -212,6 +212,7 @@ platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint disp
212 212
213 pbc/pbc-identity User entity end-to-end + auth + bootstrap admin 213 pbc/pbc-identity User entity end-to-end + auth + bootstrap admin
214 pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade 214 pbc/pbc-catalog Item + Uom entities + cross-PBC CatalogApi facade
  215 +pbc/pbc-partners Partner + Address + Contact entities + cross-PBC PartnersApi facade
215 216
216 reference-customer/plugin-printing-shop 217 reference-customer/plugin-printing-shop
217 Reference plug-in: own DB schema (plate, ink_recipe), 218 Reference plug-in: own DB schema (plate, ink_recipe),
@@ -220,7 +221,7 @@ reference-customer/plugin-printing-shop @@ -220,7 +221,7 @@ reference-customer/plugin-printing-shop
220 distribution Bootable Spring Boot fat-jar assembly 221 distribution Bootable Spring Boot fat-jar assembly
221 ``` 222 ```
222 223
223 -11 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time. 224 +12 Gradle subprojects. Architectural dependency rule (PBCs never import each other; plug-ins only see api.v1) is enforced by the root `build.gradle.kts` at configuration time.
224 225
225 ## Where to look next 226 ## Where to look next
226 227
README.md
@@ -77,7 +77,7 @@ vibe-erp/ @@ -77,7 +77,7 @@ vibe-erp/
77 ## Building 77 ## Building
78 78
79 ```bash 79 ```bash
80 -# Build everything (compiles 11 modules, runs 92 unit tests) 80 +# Build everything (compiles 12 modules, runs 107 unit tests)
81 ./gradlew build 81 ./gradlew build
82 82
83 # Bring up Postgres + the reference plug-in JAR 83 # Bring up Postgres + the reference plug-in JAR
@@ -88,17 +88,17 @@ docker compose up -d db @@ -88,17 +88,17 @@ docker compose up -d db
88 ./gradlew :distribution:bootRun 88 ./gradlew :distribution:bootRun
89 ``` 89 ```
90 90
91 -The bootstrap admin password is printed to the application logs on first boot. After login, REST endpoints are live under `/api/v1/identity/users`, `/api/v1/catalog/uoms`, `/api/v1/catalog/items`, `/api/v1/plugins/printing-shop/*`, and the public introspection endpoint at `/api/v1/_meta/metadata`. 91 +The bootstrap admin password is printed to the application logs on first boot. After login, REST endpoints are live under `/api/v1/identity/users`, `/api/v1/catalog/uoms`, `/api/v1/catalog/items`, `/api/v1/partners/partners`, `/api/v1/plugins/printing-shop/*`, and the public introspection endpoint at `/api/v1/_meta/metadata`.
92 92
93 ## Status 93 ## Status
94 94
95 -**Current stage: foundation complete; first business surface in place.** All seven cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, and the metadata seeder. Two real PBCs (identity + catalog) validate the modular-monolith template. The reference printing-shop plug-in has graduated from "hello world" to a real customer demonstration: it owns its own DB schema, CRUDs its own domain via REST, and would be rejected at install time if it tried to import any internal framework class. 95 +**Current stage: foundation complete; business surface area growing.** All seven cross-cutting platform services are live and verified end-to-end against a real Postgres: auth (JWT + Argon2id + bootstrap admin), plug-in HTTP endpoints, plug-in linter (ASM bytecode scan), plug-in-owned DB schemas + typed SQL, event bus + transactional outbox, and the metadata seeder. Three real PBCs (identity + catalog + partners) validate the modular-monolith template across three different aggregate shapes. The reference printing-shop plug-in is the executable acceptance test: it owns its own DB schema, CRUDs its own domain via REST, and would be rejected at install time if it tried to import any internal framework class.
96 96
97 | | | 97 | | |
98 |---|---| 98 |---|---|
99 -| Modules | 11 |  
100 -| Unit tests | 92, all green |  
101 -| Real PBCs | 2 of 10 | 99 +| Modules | 12 |
  100 +| Unit tests | 107, all green |
  101 +| Real PBCs | 3 of 10 |
102 | Cross-cutting services live | 7 | 102 | Cross-cutting services live | 7 |
103 | Plug-ins serving HTTP | 1 (reference printing-shop) | 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending | 104 | Production-ready | **No** — workflow engine, forms, web SPA, more PBCs all still pending |
api/api-v1/src/main/kotlin/org/vibeerp/api/v1/ext/partners/PartnersApi.kt 0 → 100644
  1 +package org.vibeerp.api.v1.ext.partners
  2 +
  3 +import org.vibeerp.api.v1.core.Id
  4 +
  5 +/**
  6 + * Cross-PBC facade for the partners bounded context.
  7 + *
  8 + * The third `api.v1.ext.*` package after `ext.identity` and `ext.catalog`.
  9 + * Together they establish the pattern the rest of the framework will
  10 + * follow: every PBC that other code needs to refer to gets an
  11 + * `ext.<pbc>` facade here in api.v1, and its runtime implementation
  12 + * lives next to the PBC's domain code. Guardrail #9 ("PBC boundaries
  13 + * are sacred") is enforced by the Gradle build — `pbc-orders-sales`
  14 + * cannot declare `pbc-partners` as a dependency. The only way
  15 + * `pbc-orders-sales` can ask "is this customer code real" or "what
  16 + * country does this supplier ship to" is by injecting [PartnersApi] —
  17 + * an interface declared here, in api.v1, and implemented by
  18 + * `pbc-partners` at runtime.
  19 + *
  20 + * **Lookups are by code**, not by id. Codes are stable, human-readable,
  21 + * and the natural key plug-ins use to refer to partners from their own
  22 + * metadata files and from sales/purchase order templates. Adding
  23 + * id-based lookups later is non-breaking; removing the code-based ones
  24 + * would be.
  25 + *
  26 + * **Inactive partners are hidden at the facade boundary.** If a caller
  27 + * resolves `findPartnerByCode("CUST-0042")` and that partner is marked
  28 + * inactive, the facade returns `null`. Orders that were placed BEFORE
  29 + * the partner was deactivated still resolve the id — the facade cannot
  30 + * retroactively hide historical data — but new work refuses to
  31 + * reference an inactive partner. This matches the [CatalogApi] pattern
  32 + * for inactive items.
  33 + *
  34 + * **What this facade does NOT expose:**
  35 + * • Contacts (those are PII; fetching them crosses the
  36 + * `partners.contact.read` permission boundary, which the facade
  37 + * has no way to check. Callers that need a contact must use the
  38 + * REST endpoint with the user's token.)
  39 + * • Addresses (orders should SNAPSHOT address fields at order time
  40 + * rather than reference them by id — see the rationale on Address
  41 + * in pbc-partners).
  42 + * • JSONB custom fields on Partner (the api.v1 contract is typed;
  43 + * callers that want custom fields must read them through the
  44 + * metadata API with schema awareness).
  45 + *
  46 + * If a future caller needs a field that isn't on [PartnerRef], growing
  47 + * the DTO is an api.v1 change with a major-version cost. Not an
  48 + * accident.
  49 + */
  50 +interface PartnersApi {
  51 +
  52 + /**
  53 + * Look up a partner by its unique partner code (e.g. "CUST-0042",
  54 + * "SUP-PAPER-MERIDIAN"). Returns `null` when no such partner exists
  55 + * or when the partner is inactive — the framework treats inactive
  56 + * partners as "do not use" for downstream callers.
  57 + */
  58 + fun findPartnerByCode(code: String): PartnerRef?
  59 +
  60 + /**
  61 + * Look up a partner by its primary-key id. Returns `null` when no
  62 + * such partner exists or when the partner is inactive. Id-based
  63 + * lookups exist alongside code-based lookups because historical
  64 + * rows (sales orders, invoices) store the id — they need to
  65 + * resolve it even after the code has been kept stable.
  66 + */
  67 + fun findPartnerById(id: Id<PartnerRef>): PartnerRef?
  68 +}
  69 +
  70 +/**
  71 + * Minimal, safe-to-publish view of a partner.
  72 + *
  73 + * Notably absent: contacts, addresses, `ext` JSONB, credit limits,
  74 + * payment terms, anything from future PBC-specific columns. Anything
  75 + * that does not appear here cannot be observed cross-PBC, and that is
  76 + * intentional — adding a field is a deliberate api.v1 change with a
  77 + * major-version cost.
  78 + *
  79 + * The `type` field is a free-form string ("CUSTOMER", "SUPPLIER",
  80 + * "BOTH") rather than a typed enum because plug-ins should not have
  81 + * to recompile when the framework adds a new partner type later. A
  82 + * plug-in that only understands the CUSTOMER/SUPPLIER set can still
  83 + * read "BOTH" as a value it ignores, rather than failing to
  84 + * deserialise.
  85 + */
  86 +data class PartnerRef(
  87 + val id: Id<PartnerRef>,
  88 + val code: String,
  89 + val name: String,
  90 + val type: String, // CUSTOMER | SUPPLIER | BOTH — string for plug-in compatibility
  91 + val taxId: String?,
  92 + val active: Boolean,
  93 +)
distribution/build.gradle.kts
@@ -27,6 +27,7 @@ dependencies { @@ -27,6 +27,7 @@ dependencies {
27 implementation(project(":platform:platform-metadata")) 27 implementation(project(":platform:platform-metadata"))
28 implementation(project(":pbc:pbc-identity")) 28 implementation(project(":pbc:pbc-identity"))
29 implementation(project(":pbc:pbc-catalog")) 29 implementation(project(":pbc:pbc-catalog"))
  30 + implementation(project(":pbc:pbc-partners"))
30 31
31 implementation(libs.spring.boot.starter) 32 implementation(libs.spring.boot.starter)
32 implementation(libs.spring.boot.starter.web) 33 implementation(libs.spring.boot.starter.web)
distribution/src/main/resources/db/changelog/master.xml
@@ -15,4 +15,5 @@ @@ -15,4 +15,5 @@
15 <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/> 15 <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/>
16 <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/> 16 <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/>
17 <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/> 17 <include file="classpath:db/changelog/pbc-catalog/001-catalog-init.xml"/>
  18 + <include file="classpath:db/changelog/pbc-partners/001-partners-init.xml"/>
18 </databaseChangeLog> 19 </databaseChangeLog>
distribution/src/main/resources/db/changelog/pbc-partners/001-partners-init.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
  3 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  4 + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
  5 + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
  6 +
  7 + <!--
  8 + pbc-partners initial schema.
  9 +
  10 + Owns: partners__partner, partners__address, partners__contact.
  11 +
  12 + vibe_erp is single-tenant per instance — there are no tenant_id
  13 + columns and no Row-Level Security policies on these tables.
  14 +
  15 + Conventions enforced for every business table in vibe_erp:
  16 + • UUID primary key
  17 + • Audit columns: created_at, created_by, updated_at, updated_by
  18 + • Optimistic-locking version column
  19 + • ext jsonb NOT NULL DEFAULT '{}' for key-user custom fields
  20 + (on the aggregate root only — Partner — not on children)
  21 + • GIN index on ext for fast custom-field queries
  22 + -->
  23 +
  24 + <changeSet id="partners-init-001" author="vibe_erp">
  25 + <comment>Create partners__partner table</comment>
  26 + <sql>
  27 + CREATE TABLE partners__partner (
  28 + id uuid PRIMARY KEY,
  29 + code varchar(64) NOT NULL,
  30 + name varchar(256) NOT NULL,
  31 + type varchar(16) NOT NULL,
  32 + tax_id varchar(64),
  33 + website varchar(256),
  34 + email varchar(256),
  35 + phone varchar(64),
  36 + active boolean NOT NULL DEFAULT true,
  37 + ext jsonb NOT NULL DEFAULT '{}'::jsonb,
  38 + created_at timestamptz NOT NULL,
  39 + created_by varchar(128) NOT NULL,
  40 + updated_at timestamptz NOT NULL,
  41 + updated_by varchar(128) NOT NULL,
  42 + version bigint NOT NULL DEFAULT 0
  43 + );
  44 + CREATE UNIQUE INDEX partners__partner_code_uk ON partners__partner (code);
  45 + CREATE INDEX partners__partner_type_idx ON partners__partner (type);
  46 + CREATE INDEX partners__partner_active_idx ON partners__partner (active);
  47 + CREATE INDEX partners__partner_ext_gin ON partners__partner USING GIN (ext jsonb_path_ops);
  48 + </sql>
  49 + <rollback>
  50 + DROP TABLE partners__partner;
  51 + </rollback>
  52 + </changeSet>
  53 +
  54 + <changeSet id="partners-init-002" author="vibe_erp">
  55 + <comment>Create partners__address table (FK to partners__partner)</comment>
  56 + <sql>
  57 + CREATE TABLE partners__address (
  58 + id uuid PRIMARY KEY,
  59 + partner_id uuid NOT NULL REFERENCES partners__partner(id),
  60 + address_type varchar(16) NOT NULL,
  61 + line1 varchar(256) NOT NULL,
  62 + line2 varchar(256),
  63 + city varchar(128) NOT NULL,
  64 + region varchar(128),
  65 + postal_code varchar(32),
  66 + country_code varchar(2) NOT NULL,
  67 + is_primary boolean NOT NULL DEFAULT false,
  68 + created_at timestamptz NOT NULL,
  69 + created_by varchar(128) NOT NULL,
  70 + updated_at timestamptz NOT NULL,
  71 + updated_by varchar(128) NOT NULL,
  72 + version bigint NOT NULL DEFAULT 0
  73 + );
  74 + CREATE INDEX partners__address_partner_idx ON partners__address (partner_id);
  75 + CREATE INDEX partners__address_country_idx ON partners__address (country_code);
  76 + </sql>
  77 + <rollback>
  78 + DROP TABLE partners__address;
  79 + </rollback>
  80 + </changeSet>
  81 +
  82 + <changeSet id="partners-init-003" author="vibe_erp">
  83 + <comment>Create partners__contact table (FK to partners__partner, PII-tagged)</comment>
  84 + <sql>
  85 + CREATE TABLE partners__contact (
  86 + id uuid PRIMARY KEY,
  87 + partner_id uuid NOT NULL REFERENCES partners__partner(id),
  88 + full_name varchar(256) NOT NULL,
  89 + role varchar(128),
  90 + email varchar(256),
  91 + phone varchar(64),
  92 + active boolean NOT NULL DEFAULT true,
  93 + created_at timestamptz NOT NULL,
  94 + created_by varchar(128) NOT NULL,
  95 + updated_at timestamptz NOT NULL,
  96 + updated_by varchar(128) NOT NULL,
  97 + version bigint NOT NULL DEFAULT 0
  98 + );
  99 + CREATE INDEX partners__contact_partner_idx ON partners__contact (partner_id);
  100 + CREATE INDEX partners__contact_active_idx ON partners__contact (active);
  101 + </sql>
  102 + <rollback>
  103 + DROP TABLE partners__contact;
  104 + </rollback>
  105 + </changeSet>
  106 +
  107 +</databaseChangeLog>
pbc/pbc-partners/build.gradle.kts 0 → 100644
  1 +plugins {
  2 + alias(libs.plugins.kotlin.jvm)
  3 + alias(libs.plugins.kotlin.spring)
  4 + alias(libs.plugins.kotlin.jpa)
  5 + alias(libs.plugins.spring.dependency.management)
  6 +}
  7 +
  8 +description = "vibe_erp pbc-partners — customers, suppliers, contacts, addresses. INTERNAL Packaged Business Capability."
  9 +
  10 +java {
  11 + toolchain {
  12 + languageVersion.set(JavaLanguageVersion.of(21))
  13 + }
  14 +}
  15 +
  16 +kotlin {
  17 + jvmToolchain(21)
  18 + compilerOptions {
  19 + freeCompilerArgs.add("-Xjsr305=strict")
  20 + }
  21 +}
  22 +
  23 +allOpen {
  24 + annotation("jakarta.persistence.Entity")
  25 + annotation("jakarta.persistence.MappedSuperclass")
  26 + annotation("jakarta.persistence.Embeddable")
  27 +}
  28 +
  29 +// CRITICAL: pbc-partners may depend on api-v1, platform-persistence and
  30 +// platform-security but NEVER on platform-bootstrap, NEVER on another
  31 +// pbc-*. The root build.gradle.kts enforces this; if you add a
  32 +// `:pbc:pbc-foo` or `:platform:platform-bootstrap` dependency below,
  33 +// the build will refuse to load with an architectural-violation error.
  34 +dependencies {
  35 + api(project(":api:api-v1"))
  36 + implementation(project(":platform:platform-persistence"))
  37 + implementation(project(":platform:platform-security"))
  38 +
  39 + implementation(libs.kotlin.stdlib)
  40 + implementation(libs.kotlin.reflect)
  41 +
  42 + implementation(libs.spring.boot.starter)
  43 + implementation(libs.spring.boot.starter.web)
  44 + implementation(libs.spring.boot.starter.data.jpa)
  45 + implementation(libs.spring.boot.starter.validation)
  46 + implementation(libs.jackson.module.kotlin)
  47 +
  48 + testImplementation(libs.spring.boot.starter.test)
  49 + testImplementation(libs.junit.jupiter)
  50 + testImplementation(libs.assertk)
  51 + testImplementation(libs.mockk)
  52 +}
  53 +
  54 +tasks.test {
  55 + useJUnitPlatform()
  56 +}
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/AddressService.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.pbc.partners.domain.Address
  6 +import org.vibeerp.pbc.partners.domain.AddressType
  7 +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository
  8 +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  9 +import java.util.UUID
  10 +
  11 +/**
  12 + * Application service for address CRUD scoped to a parent partner.
  13 + *
  14 + * Address operations always carry a `partnerId` because addresses don't
  15 + * exist outside their owning partner. The service rejects operations on
  16 + * unknown partners up-front to give a better error than the database
  17 + * FK violation would.
  18 + *
  19 + * **The "primary" flag is at most one per (partner, address type).**
  20 + * That invariant is enforced here in the service: when an address is
  21 + * marked primary on create or update, all OTHER addresses of the same
  22 + * type for the same partner are demoted in the same transaction. The
  23 + * database has no unique constraint for it because the simple form
  24 + * (`UNIQUE (partner_id, address_type) WHERE is_primary`) is a partial
  25 + * index, and partial unique indexes don't compose well with the JPA
  26 + * change-detection flow that briefly has two rows in the primary state
  27 + * before the next flush. Doing it in code is simpler and survives the
  28 + * obvious race in single-instance deployments.
  29 + */
  30 +@Service
  31 +@Transactional
  32 +class AddressService(
  33 + private val addresses: AddressJpaRepository,
  34 + private val partners: PartnerJpaRepository,
  35 +) {
  36 +
  37 + @Transactional(readOnly = true)
  38 + fun listFor(partnerId: UUID): List<Address> = addresses.findByPartnerId(partnerId)
  39 +
  40 + @Transactional(readOnly = true)
  41 + fun findById(id: UUID): Address? = addresses.findById(id).orElse(null)
  42 +
  43 + fun create(partnerId: UUID, command: CreateAddressCommand): Address {
  44 + require(partners.existsById(partnerId)) {
  45 + "partner not found: $partnerId"
  46 + }
  47 + val saved = addresses.save(
  48 + Address(
  49 + partnerId = partnerId,
  50 + addressType = command.addressType,
  51 + line1 = command.line1,
  52 + line2 = command.line2,
  53 + city = command.city,
  54 + region = command.region,
  55 + postalCode = command.postalCode,
  56 + countryCode = command.countryCode,
  57 + isPrimary = command.isPrimary,
  58 + ),
  59 + )
  60 + if (command.isPrimary) {
  61 + demoteOtherPrimaries(partnerId, command.addressType, exceptId = saved.id)
  62 + }
  63 + return saved
  64 + }
  65 +
  66 + fun update(id: UUID, command: UpdateAddressCommand): Address {
  67 + val address = addresses.findById(id).orElseThrow {
  68 + NoSuchElementException("address not found: $id")
  69 + }
  70 + command.line1?.let { address.line1 = it }
  71 + command.line2?.let { address.line2 = it }
  72 + command.city?.let { address.city = it }
  73 + command.region?.let { address.region = it }
  74 + command.postalCode?.let { address.postalCode = it }
  75 + command.countryCode?.let { address.countryCode = it }
  76 + command.addressType?.let { address.addressType = it }
  77 + if (command.isPrimary == true) {
  78 + address.isPrimary = true
  79 + demoteOtherPrimaries(address.partnerId, address.addressType, exceptId = address.id)
  80 + } else if (command.isPrimary == false) {
  81 + address.isPrimary = false
  82 + }
  83 + return address
  84 + }
  85 +
  86 + fun delete(id: UUID) {
  87 + val address = addresses.findById(id).orElseThrow {
  88 + NoSuchElementException("address not found: $id")
  89 + }
  90 + addresses.delete(address)
  91 + }
  92 +
  93 + private fun demoteOtherPrimaries(partnerId: UUID, addressType: AddressType, exceptId: UUID) {
  94 + addresses.findByPartnerId(partnerId)
  95 + .filter { it.id != exceptId && it.addressType == addressType && it.isPrimary }
  96 + .forEach { it.isPrimary = false }
  97 + }
  98 +}
  99 +
  100 +data class CreateAddressCommand(
  101 + val addressType: AddressType,
  102 + val line1: String,
  103 + val line2: String? = null,
  104 + val city: String,
  105 + val region: String? = null,
  106 + val postalCode: String? = null,
  107 + val countryCode: String,
  108 + val isPrimary: Boolean = false,
  109 +)
  110 +
  111 +data class UpdateAddressCommand(
  112 + val addressType: AddressType? = null,
  113 + val line1: String? = null,
  114 + val line2: String? = null,
  115 + val city: String? = null,
  116 + val region: String? = null,
  117 + val postalCode: String? = null,
  118 + val countryCode: String? = null,
  119 + val isPrimary: Boolean? = null,
  120 +)
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/ContactService.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.pbc.partners.domain.Contact
  6 +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository
  7 +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  8 +import java.util.UUID
  9 +
  10 +/**
  11 + * Application service for partner contact CRUD.
  12 + *
  13 + * Same shape as [AddressService]: contacts are scoped to a parent
  14 + * partner, the service rejects unknown partners up-front, and
  15 + * deactivation (not deletion) is the supported way to retire someone.
  16 + *
  17 + * **PII boundary.** Contact rows hold personal data. Reads of this
  18 + * service should eventually be permission-gated (`partners.contact.read`)
  19 + * and audited at a higher level than ordinary partner reads. Until the
  20 + * permission system lands (P4.3), the controller is plain authenticated
  21 + * — that's a known temporary state, NOT the long-term posture.
  22 + */
  23 +@Service
  24 +@Transactional
  25 +class ContactService(
  26 + private val contacts: ContactJpaRepository,
  27 + private val partners: PartnerJpaRepository,
  28 +) {
  29 +
  30 + @Transactional(readOnly = true)
  31 + fun listFor(partnerId: UUID): List<Contact> = contacts.findByPartnerId(partnerId)
  32 +
  33 + @Transactional(readOnly = true)
  34 + fun findById(id: UUID): Contact? = contacts.findById(id).orElse(null)
  35 +
  36 + fun create(partnerId: UUID, command: CreateContactCommand): Contact {
  37 + require(partners.existsById(partnerId)) {
  38 + "partner not found: $partnerId"
  39 + }
  40 + return contacts.save(
  41 + Contact(
  42 + partnerId = partnerId,
  43 + fullName = command.fullName,
  44 + role = command.role,
  45 + email = command.email,
  46 + phone = command.phone,
  47 + active = command.active,
  48 + ),
  49 + )
  50 + }
  51 +
  52 + fun update(id: UUID, command: UpdateContactCommand): Contact {
  53 + val contact = contacts.findById(id).orElseThrow {
  54 + NoSuchElementException("contact not found: $id")
  55 + }
  56 + command.fullName?.let { contact.fullName = it }
  57 + command.role?.let { contact.role = it }
  58 + command.email?.let { contact.email = it }
  59 + command.phone?.let { contact.phone = it }
  60 + command.active?.let { contact.active = it }
  61 + return contact
  62 + }
  63 +
  64 + fun deactivate(id: UUID) {
  65 + val contact = contacts.findById(id).orElseThrow {
  66 + NoSuchElementException("contact not found: $id")
  67 + }
  68 + contact.active = false
  69 + }
  70 +}
  71 +
  72 +data class CreateContactCommand(
  73 + val fullName: String,
  74 + val role: String? = null,
  75 + val email: String? = null,
  76 + val phone: String? = null,
  77 + val active: Boolean = true,
  78 +)
  79 +
  80 +data class UpdateContactCommand(
  81 + val fullName: String? = null,
  82 + val role: String? = null,
  83 + val email: String? = null,
  84 + val phone: String? = null,
  85 + val active: Boolean? = null,
  86 +)
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/application/PartnerService.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.application
  2 +
  3 +import org.springframework.stereotype.Service
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.pbc.partners.domain.Partner
  6 +import org.vibeerp.pbc.partners.domain.PartnerType
  7 +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository
  8 +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository
  9 +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  10 +import java.util.UUID
  11 +
  12 +/**
  13 + * Application service for partner CRUD.
  14 + *
  15 + * Like the catalog services, this lives behind a thin REST controller
  16 + * and a thin api.v1 facade — the entity itself never leaves this layer.
  17 + *
  18 + * **Why `code` is not updatable** after create: every external system
  19 + * that references a partner uses the code. Allowing rename means every
  20 + * downstream system (sales orders, purchase orders, invoices, the
  21 + * customer's accounting export) needs a coordinated update — a degree
  22 + * of work that doesn't belong in a vanilla update endpoint. If a
  23 + * customer truly needs to rename a partner code, that's a data
  24 + * migration ticket, not an API call.
  25 + *
  26 + * **Deactivation, not deletion.** Like every business entity in
  27 + * vibe_erp, partners are deactivated rather than deleted so historical
  28 + * orders, invoices, and audit rows continue to resolve their
  29 + * `partner_id` references. The api.v1 facade hides inactive partners
  30 + * from cross-PBC callers — see `PartnersApiAdapter`.
  31 + *
  32 + * **Cascade on deactivate.** When a partner is deactivated, its
  33 + * contacts and addresses stay in the database (FK references survive)
  34 + * but the contacts are also marked inactive. Addresses are not flipped
  35 + * because they have no `active` flag — they're either there or they're
  36 + * not. Hard-deletion of related rows happens only via the dedicated
  37 + * `deletePartnerCompletely` operation, which is **not** in v1 and is
  38 + * deliberately omitted: it's the kind of thing that should require a
  39 + * separate "data scrub" admin tool, not a routine API call.
  40 + */
  41 +@Service
  42 +@Transactional
  43 +class PartnerService(
  44 + private val partners: PartnerJpaRepository,
  45 + private val addresses: AddressJpaRepository,
  46 + private val contacts: ContactJpaRepository,
  47 +) {
  48 +
  49 + @Transactional(readOnly = true)
  50 + fun list(): List<Partner> = partners.findAll()
  51 +
  52 + @Transactional(readOnly = true)
  53 + fun findById(id: UUID): Partner? = partners.findById(id).orElse(null)
  54 +
  55 + @Transactional(readOnly = true)
  56 + fun findByCode(code: String): Partner? = partners.findByCode(code)
  57 +
  58 + fun create(command: CreatePartnerCommand): Partner {
  59 + require(!partners.existsByCode(command.code)) {
  60 + "partner code '${command.code}' is already taken"
  61 + }
  62 + return partners.save(
  63 + Partner(
  64 + code = command.code,
  65 + name = command.name,
  66 + type = command.type,
  67 + taxId = command.taxId,
  68 + website = command.website,
  69 + email = command.email,
  70 + phone = command.phone,
  71 + active = command.active,
  72 + ),
  73 + )
  74 + }
  75 +
  76 + fun update(id: UUID, command: UpdatePartnerCommand): Partner {
  77 + val partner = partners.findById(id).orElseThrow {
  78 + NoSuchElementException("partner not found: $id")
  79 + }
  80 + // `code` is deliberately not updatable here — every external
  81 + // reference uses the code, so renaming is a migration concern.
  82 + command.name?.let { partner.name = it }
  83 + command.type?.let { partner.type = it }
  84 + command.taxId?.let { partner.taxId = it }
  85 + command.website?.let { partner.website = it }
  86 + command.email?.let { partner.email = it }
  87 + command.phone?.let { partner.phone = it }
  88 + command.active?.let { partner.active = it }
  89 + return partner
  90 + }
  91 +
  92 + /**
  93 + * Deactivate the partner and all its contacts. Addresses are left
  94 + * alone (they have no `active` flag — see [Address]).
  95 + */
  96 + fun deactivate(id: UUID) {
  97 + val partner = partners.findById(id).orElseThrow {
  98 + NoSuchElementException("partner not found: $id")
  99 + }
  100 + partner.active = false
  101 + contacts.findByPartnerId(id).forEach { it.active = false }
  102 + }
  103 +}
  104 +
  105 +data class CreatePartnerCommand(
  106 + val code: String,
  107 + val name: String,
  108 + val type: PartnerType,
  109 + val taxId: String? = null,
  110 + val website: String? = null,
  111 + val email: String? = null,
  112 + val phone: String? = null,
  113 + val active: Boolean = true,
  114 +)
  115 +
  116 +data class UpdatePartnerCommand(
  117 + val name: String? = null,
  118 + val type: PartnerType? = null,
  119 + val taxId: String? = null,
  120 + val website: String? = null,
  121 + val email: String? = null,
  122 + val phone: String? = null,
  123 + val active: Boolean? = null,
  124 +)
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Address.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  9 +import java.util.UUID
  10 +
  11 +/**
  12 + * A postal address attached to a [Partner].
  13 + *
  14 + * Stored in its own table so that one partner can have many addresses
  15 + * — billing, shipping, head office, branch — and so each can be tagged
  16 + * by purpose. Sales orders pick a shipping address and a billing
  17 + * address by id; if there were a single denormalised "address" field on
  18 + * Partner, every order would either need its own snapshot or every
  19 + * partner would be limited to one address. Both are wrong.
  20 + *
  21 + * **Why country_code is varchar(2) and not a free-form `country`
  22 + * string:** an ISO 3166-1 alpha-2 code is unambiguous, fits in a single
  23 + * column, and is the contract every other PBC and every plug-in can
  24 + * rely on for tax, shipping cost, and currency lookups. The display
  25 + * name ("United States", "United Kingdom") is a render-time concern,
  26 + * not a stored field.
  27 + *
  28 + * **Why no `street_number` / `street_name` split:** address line
  29 + * structure varies wildly across countries (Japan has no street names,
  30 + * the UK puts the number first, France has named buildings). Two free
  31 + * lines + city + region + postal code + country is the smallest set
  32 + * that round-trips through every postal system in the world. The form
  33 + * editor (P3.x) can layer per-country structure on top via metadata.
  34 + *
  35 + * No `ext` column on this table — addresses are simple enough that
  36 + * customers needing extra fields ("delivery instructions", "gate code")
  37 + * can add them via the `Partner.ext` JSONB instead. Keeping address
  38 + * minimal lets us index it cleanly and keeps copies-into-sales-orders
  39 + * cheap.
  40 + */
  41 +@Entity
  42 +@Table(name = "partners__address")
  43 +class Address(
  44 + partnerId: UUID,
  45 + addressType: AddressType,
  46 + line1: String,
  47 + line2: String? = null,
  48 + city: String,
  49 + region: String? = null,
  50 + postalCode: String? = null,
  51 + countryCode: String,
  52 + isPrimary: Boolean = false,
  53 +) : AuditedJpaEntity() {
  54 +
  55 + @Column(name = "partner_id", nullable = false)
  56 + var partnerId: UUID = partnerId
  57 +
  58 + @Enumerated(EnumType.STRING)
  59 + @Column(name = "address_type", nullable = false, length = 16)
  60 + var addressType: AddressType = addressType
  61 +
  62 + @Column(name = "line1", nullable = false, length = 256)
  63 + var line1: String = line1
  64 +
  65 + @Column(name = "line2", nullable = true, length = 256)
  66 + var line2: String? = line2
  67 +
  68 + @Column(name = "city", nullable = false, length = 128)
  69 + var city: String = city
  70 +
  71 + @Column(name = "region", nullable = true, length = 128)
  72 + var region: String? = region
  73 +
  74 + @Column(name = "postal_code", nullable = true, length = 32)
  75 + var postalCode: String? = postalCode
  76 +
  77 + @Column(name = "country_code", nullable = false, length = 2)
  78 + var countryCode: String = countryCode
  79 +
  80 + @Column(name = "is_primary", nullable = false)
  81 + var isPrimary: Boolean = isPrimary
  82 +
  83 + override fun toString(): String =
  84 + "Address(id=$id, partnerId=$partnerId, type=$addressType, city='$city', country='$countryCode')"
  85 +}
  86 +
  87 +/**
  88 + * The purpose an [Address] serves for its partner.
  89 + *
  90 + * - **BILLING** — invoices and statements go here
  91 + * - **SHIPPING** — physical goods go here
  92 + * - **OTHER** — any other use (head office, registered seat, etc.)
  93 + *
  94 + * Stored as string for the same reason every other vibe_erp enum is:
  95 + * adding a value later is a non-breaking schema change.
  96 + */
  97 +enum class AddressType {
  98 + BILLING,
  99 + SHIPPING,
  100 + OTHER,
  101 +}
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Contact.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.Table
  6 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  7 +import java.util.UUID
  8 +
  9 +/**
  10 + * A named human at a [Partner] organisation.
  11 + *
  12 + * Distinct from [org.vibeerp.pbc.identity.domain.User] (which lives in
  13 + * pbc-identity and represents people who LOG IN to vibe_erp): a contact
  14 + * is just a person at a customer or supplier you might want to call,
  15 + * email, or address an invoice to. They typically have no account.
  16 + *
  17 + * **Why a separate table from Partner.email/Partner.phone:** the
  18 + * `Partner` columns hold the organisation-level switchboard. Real life
  19 + * is "speak to Marie in accounts about invoices, Jack in production
  20 + * about job changes". The contact list is the per-organisation rolodex.
  21 + *
  22 + * **PII tagging.** Contact fields are personal data under GDPR and most
  23 + * regional privacy laws. The metadata YAML for this table flags it as
  24 + * `pii: true` so the future audit/export tooling (R) and the consent UI
  25 + * know which rows to redact. The flag is in metadata, not in code, so
  26 + * customers can extend it (or relax it) without forking.
  27 + *
  28 + * No `ext` column on this table for the same reason as [Address]:
  29 + * customers wanting extra contact attributes ("birthday", "preferred
  30 + * language") can add them via Partner.ext or via metadata custom fields
  31 + * once P3.4 lands.
  32 + */
  33 +@Entity
  34 +@Table(name = "partners__contact")
  35 +class Contact(
  36 + partnerId: UUID,
  37 + fullName: String,
  38 + role: String? = null,
  39 + email: String? = null,
  40 + phone: String? = null,
  41 + active: Boolean = true,
  42 +) : AuditedJpaEntity() {
  43 +
  44 + @Column(name = "partner_id", nullable = false)
  45 + var partnerId: UUID = partnerId
  46 +
  47 + @Column(name = "full_name", nullable = false, length = 256)
  48 + var fullName: String = fullName
  49 +
  50 + @Column(name = "role", nullable = true, length = 128)
  51 + var role: String? = role
  52 +
  53 + @Column(name = "email", nullable = true, length = 256)
  54 + var email: String? = email
  55 +
  56 + @Column(name = "phone", nullable = true, length = 64)
  57 + var phone: String? = phone
  58 +
  59 + @Column(name = "active", nullable = false)
  60 + var active: Boolean = active
  61 +
  62 + override fun toString(): String =
  63 + "Contact(id=$id, partnerId=$partnerId, fullName='$fullName', active=$active)"
  64 +}
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/domain/Partner.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.domain
  2 +
  3 +import jakarta.persistence.Column
  4 +import jakarta.persistence.Entity
  5 +import jakarta.persistence.EnumType
  6 +import jakarta.persistence.Enumerated
  7 +import jakarta.persistence.Table
  8 +import org.hibernate.annotations.JdbcTypeCode
  9 +import org.hibernate.type.SqlTypes
  10 +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity
  11 +
  12 +/**
  13 + * The canonical Partner entity for the partners PBC.
  14 + *
  15 + * A "partner" is any external organisation the running vibe_erp instance
  16 + * does business with: a customer the company sells to, a supplier the
  17 + * company buys from, or both at once. The deliberately generic word
  18 + * "partner" (instead of separate `Customer` and `Supplier` tables) means
  19 + * one row can play either role over time without a data migration when a
  20 + * supplier becomes a customer or vice versa, which happens routinely in
  21 + * the printing industry (a paper merchant who also buys finished proofs).
  22 + *
  23 + * **Why a single table with a [PartnerType] discriminator** instead of
  24 + * `partners__customer` and `partners__supplier`:
  25 + * - 80%+ of fields (code, name, tax_id, contact methods, addresses) are
  26 + * identical between customers and suppliers. Splitting the table would
  27 + * duplicate every column and double every cross-PBC join.
  28 + * - The role flag (CUSTOMER/SUPPLIER/BOTH) is a property of the
  29 + * relationship, not the organisation. Modeling it as an enum on the
  30 + * row keeps the truth on one side.
  31 + * - Future PBCs (orders-sales, orders-purchase) join against the same
  32 + * `partners__partner` table; they filter by `type` themselves rather
  33 + * than picking the right table at query time.
  34 + *
  35 + * **Why `code` instead of just the UUID `id`:**
  36 + * Partner codes (e.g. `CUST-0042`, `SUP-PAPER-MERIDIAN`) are stable,
  37 + * human-readable, and how every other PBC and every plug-in addresses a
  38 + * partner. The UUID is the database primary key; the code is the natural
  39 + * key external systems use. Both are unique; the code is the one humans
  40 + * type into invoices.
  41 + *
  42 + * **Why `ext` JSONB instead of typed columns** for things like
  43 + * "credit limit", "preferred currency", "delivery instructions":
  44 + * Most of those vary per customer of vibe_erp. The metadata-driven
  45 + * custom field machinery (P3.4) writes into `ext`, so a key user can add
  46 + * a "Tax exempt status" field to Partner without a code change or
  47 + * migration. Only the fields EVERY printing-shop customer will need go
  48 + * in typed columns.
  49 + */
  50 +@Entity
  51 +@Table(name = "partners__partner")
  52 +class Partner(
  53 + code: String,
  54 + name: String,
  55 + type: PartnerType,
  56 + taxId: String? = null,
  57 + website: String? = null,
  58 + email: String? = null,
  59 + phone: String? = null,
  60 + active: Boolean = true,
  61 +) : AuditedJpaEntity() {
  62 +
  63 + @Column(name = "code", nullable = false, length = 64)
  64 + var code: String = code
  65 +
  66 + @Column(name = "name", nullable = false, length = 256)
  67 + var name: String = name
  68 +
  69 + @Enumerated(EnumType.STRING)
  70 + @Column(name = "type", nullable = false, length = 16)
  71 + var type: PartnerType = type
  72 +
  73 + @Column(name = "tax_id", nullable = true, length = 64)
  74 + var taxId: String? = taxId
  75 +
  76 + @Column(name = "website", nullable = true, length = 256)
  77 + var website: String? = website
  78 +
  79 + @Column(name = "email", nullable = true, length = 256)
  80 + var email: String? = email
  81 +
  82 + @Column(name = "phone", nullable = true, length = 64)
  83 + var phone: String? = phone
  84 +
  85 + @Column(name = "active", nullable = false)
  86 + var active: Boolean = active
  87 +
  88 + @Column(name = "ext", nullable = false, columnDefinition = "jsonb")
  89 + @JdbcTypeCode(SqlTypes.JSON)
  90 + var ext: String = "{}"
  91 +
  92 + override fun toString(): String =
  93 + "Partner(id=$id, code='$code', type=$type, active=$active)"
  94 +}
  95 +
  96 +/**
  97 + * The role a [Partner] plays relative to the company running vibe_erp.
  98 + *
  99 + * Stored as a string in the DB so adding values later is non-breaking
  100 + * for clients reading the column with raw SQL. The enum is small on
  101 + * purpose: anything more granular ("preferred supplier", "VIP customer")
  102 + * belongs in metadata custom fields, not in this discriminator.
  103 + *
  104 + * - **CUSTOMER** — the company sells to this partner
  105 + * - **SUPPLIER** — the company buys from this partner
  106 + * - **BOTH** — the partner appears on both sides; both order PBCs
  107 + * may reference this row
  108 + */
  109 +enum class PartnerType {
  110 + CUSTOMER,
  111 + SUPPLIER,
  112 + BOTH,
  113 +}
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/ext/PartnersApiAdapter.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.ext
  2 +
  3 +import org.springframework.stereotype.Component
  4 +import org.springframework.transaction.annotation.Transactional
  5 +import org.vibeerp.api.v1.core.Id
  6 +import org.vibeerp.api.v1.ext.partners.PartnerRef
  7 +import org.vibeerp.api.v1.ext.partners.PartnersApi
  8 +import org.vibeerp.pbc.partners.domain.Partner
  9 +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  10 +
  11 +/**
  12 + * Concrete [PartnersApi] implementation. The ONLY thing other PBCs and
  13 + * plug-ins are allowed to inject for "give me a partner reference".
  14 + *
  15 + * Why this file exists: see the rationale on [PartnersApi]. The Gradle
  16 + * build forbids cross-PBC dependencies; this adapter is the runtime
  17 + * fulfillment of the api.v1 contract that lets the rest of the world
  18 + * ask partner questions without importing partner types.
  19 + *
  20 + * **What this adapter MUST NOT do:**
  21 + * • leak `org.vibeerp.pbc.partners.domain.Partner` (the JPA entity)
  22 + * to callers
  23 + * • return inactive partners (the contract says inactive → null)
  24 + * • expose contacts, addresses, or the `ext` JSONB
  25 + *
  26 + * If a caller needs more fields, the answer is to grow `PartnerRef`
  27 + * deliberately (an api.v1 change with a major-version cost), not to
  28 + * hand them the entity.
  29 + */
  30 +@Component
  31 +@Transactional(readOnly = true)
  32 +class PartnersApiAdapter(
  33 + private val partners: PartnerJpaRepository,
  34 +) : PartnersApi {
  35 +
  36 + override fun findPartnerByCode(code: String): PartnerRef? =
  37 + partners.findByCode(code)
  38 + ?.takeIf { it.active }
  39 + ?.toRef()
  40 +
  41 + override fun findPartnerById(id: Id<PartnerRef>): PartnerRef? =
  42 + partners.findById(id.value).orElse(null)
  43 + ?.takeIf { it.active }
  44 + ?.toRef()
  45 +
  46 + private fun Partner.toRef(): PartnerRef = PartnerRef(
  47 + id = Id<PartnerRef>(this.id),
  48 + code = this.code,
  49 + name = this.name,
  50 + type = this.type.name,
  51 + taxId = this.taxId,
  52 + active = this.active,
  53 + )
  54 +}
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/AddressController.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.Size
  6 +import org.springframework.http.HttpStatus
  7 +import org.springframework.http.ResponseEntity
  8 +import org.springframework.web.bind.annotation.DeleteMapping
  9 +import org.springframework.web.bind.annotation.GetMapping
  10 +import org.springframework.web.bind.annotation.PatchMapping
  11 +import org.springframework.web.bind.annotation.PathVariable
  12 +import org.springframework.web.bind.annotation.PostMapping
  13 +import org.springframework.web.bind.annotation.RequestBody
  14 +import org.springframework.web.bind.annotation.RequestMapping
  15 +import org.springframework.web.bind.annotation.ResponseStatus
  16 +import org.springframework.web.bind.annotation.RestController
  17 +import org.vibeerp.pbc.partners.application.AddressService
  18 +import org.vibeerp.pbc.partners.application.CreateAddressCommand
  19 +import org.vibeerp.pbc.partners.application.UpdateAddressCommand
  20 +import org.vibeerp.pbc.partners.domain.Address
  21 +import org.vibeerp.pbc.partners.domain.AddressType
  22 +import java.util.UUID
  23 +
  24 +/**
  25 + * REST API for partner addresses.
  26 + *
  27 + * Mounted at `/api/v1/partners/partners/{partnerId}/addresses` so that
  28 + * the URL itself encodes the parent-child relationship — every
  29 + * operation is implicitly scoped to a specific partner. Address ids
  30 + * remain unique across partners (UUIDs are global) so direct
  31 + * `GET .../addresses/{id}` lookups are also supported under the same
  32 + * tree for symmetry.
  33 + */
  34 +@RestController
  35 +@RequestMapping("/api/v1/partners/partners/{partnerId}/addresses")
  36 +class AddressController(
  37 + private val addressService: AddressService,
  38 +) {
  39 +
  40 + @GetMapping
  41 + fun list(@PathVariable partnerId: UUID): List<AddressResponse> =
  42 + addressService.listFor(partnerId).map { it.toResponse() }
  43 +
  44 + @GetMapping("/{id}")
  45 + fun get(
  46 + @PathVariable partnerId: UUID,
  47 + @PathVariable id: UUID,
  48 + ): ResponseEntity<AddressResponse> {
  49 + val address = addressService.findById(id) ?: return ResponseEntity.notFound().build()
  50 + if (address.partnerId != partnerId) return ResponseEntity.notFound().build()
  51 + return ResponseEntity.ok(address.toResponse())
  52 + }
  53 +
  54 + @PostMapping
  55 + @ResponseStatus(HttpStatus.CREATED)
  56 + fun create(
  57 + @PathVariable partnerId: UUID,
  58 + @RequestBody @Valid request: CreateAddressRequest,
  59 + ): AddressResponse =
  60 + addressService.create(
  61 + partnerId,
  62 + CreateAddressCommand(
  63 + addressType = request.addressType,
  64 + line1 = request.line1,
  65 + line2 = request.line2,
  66 + city = request.city,
  67 + region = request.region,
  68 + postalCode = request.postalCode,
  69 + countryCode = request.countryCode,
  70 + isPrimary = request.isPrimary ?: false,
  71 + ),
  72 + ).toResponse()
  73 +
  74 + @PatchMapping("/{id}")
  75 + fun update(
  76 + @PathVariable partnerId: UUID,
  77 + @PathVariable id: UUID,
  78 + @RequestBody @Valid request: UpdateAddressRequest,
  79 + ): AddressResponse =
  80 + addressService.update(
  81 + id,
  82 + UpdateAddressCommand(
  83 + addressType = request.addressType,
  84 + line1 = request.line1,
  85 + line2 = request.line2,
  86 + city = request.city,
  87 + region = request.region,
  88 + postalCode = request.postalCode,
  89 + countryCode = request.countryCode,
  90 + isPrimary = request.isPrimary,
  91 + ),
  92 + ).toResponse()
  93 +
  94 + @DeleteMapping("/{id}")
  95 + @ResponseStatus(HttpStatus.NO_CONTENT)
  96 + fun delete(
  97 + @PathVariable partnerId: UUID,
  98 + @PathVariable id: UUID,
  99 + ) {
  100 + addressService.delete(id)
  101 + }
  102 +}
  103 +
  104 +// ─── DTOs ────────────────────────────────────────────────────────────
  105 +
  106 +data class CreateAddressRequest(
  107 + val addressType: AddressType,
  108 + @field:NotBlank @field:Size(max = 256) val line1: String,
  109 + @field:Size(max = 256) val line2: String? = null,
  110 + @field:NotBlank @field:Size(max = 128) val city: String,
  111 + @field:Size(max = 128) val region: String? = null,
  112 + @field:Size(max = 32) val postalCode: String? = null,
  113 + @field:NotBlank @field:Size(min = 2, max = 2) val countryCode: String,
  114 + val isPrimary: Boolean? = false,
  115 +)
  116 +
  117 +data class UpdateAddressRequest(
  118 + val addressType: AddressType? = null,
  119 + @field:Size(max = 256) val line1: String? = null,
  120 + @field:Size(max = 256) val line2: String? = null,
  121 + @field:Size(max = 128) val city: String? = null,
  122 + @field:Size(max = 128) val region: String? = null,
  123 + @field:Size(max = 32) val postalCode: String? = null,
  124 + @field:Size(min = 2, max = 2) val countryCode: String? = null,
  125 + val isPrimary: Boolean? = null,
  126 +)
  127 +
  128 +data class AddressResponse(
  129 + val id: UUID,
  130 + val partnerId: UUID,
  131 + val addressType: AddressType,
  132 + val line1: String,
  133 + val line2: String?,
  134 + val city: String,
  135 + val region: String?,
  136 + val postalCode: String?,
  137 + val countryCode: String,
  138 + val isPrimary: Boolean,
  139 +)
  140 +
  141 +private fun Address.toResponse() = AddressResponse(
  142 + id = this.id,
  143 + partnerId = this.partnerId,
  144 + addressType = this.addressType,
  145 + line1 = this.line1,
  146 + line2 = this.line2,
  147 + city = this.city,
  148 + region = this.region,
  149 + postalCode = this.postalCode,
  150 + countryCode = this.countryCode,
  151 + isPrimary = this.isPrimary,
  152 +)
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/ContactController.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.Size
  6 +import org.springframework.http.HttpStatus
  7 +import org.springframework.http.ResponseEntity
  8 +import org.springframework.web.bind.annotation.DeleteMapping
  9 +import org.springframework.web.bind.annotation.GetMapping
  10 +import org.springframework.web.bind.annotation.PatchMapping
  11 +import org.springframework.web.bind.annotation.PathVariable
  12 +import org.springframework.web.bind.annotation.PostMapping
  13 +import org.springframework.web.bind.annotation.RequestBody
  14 +import org.springframework.web.bind.annotation.RequestMapping
  15 +import org.springframework.web.bind.annotation.ResponseStatus
  16 +import org.springframework.web.bind.annotation.RestController
  17 +import org.vibeerp.pbc.partners.application.ContactService
  18 +import org.vibeerp.pbc.partners.application.CreateContactCommand
  19 +import org.vibeerp.pbc.partners.application.UpdateContactCommand
  20 +import org.vibeerp.pbc.partners.domain.Contact
  21 +import java.util.UUID
  22 +
  23 +/**
  24 + * REST API for partner contact people.
  25 + *
  26 + * Mounted at `/api/v1/partners/partners/{partnerId}/contacts`. Like
  27 + * [AddressController], the URL itself encodes the parent-child
  28 + * relationship — every operation is implicitly scoped to a specific
  29 + * partner.
  30 + *
  31 + * **PII boundary.** Contact data is personal information. Once the
  32 + * permission system lands (P4.3) this controller will be guarded by
  33 + * `partners.contact.read` etc. Until then, plain authentication is the
  34 + * only gate, and that is a known short-term posture, NOT the long-term
  35 + * one — see the metadata YAML for the planned permission keys.
  36 + */
  37 +@RestController
  38 +@RequestMapping("/api/v1/partners/partners/{partnerId}/contacts")
  39 +class ContactController(
  40 + private val contactService: ContactService,
  41 +) {
  42 +
  43 + @GetMapping
  44 + fun list(@PathVariable partnerId: UUID): List<ContactResponse> =
  45 + contactService.listFor(partnerId).map { it.toResponse() }
  46 +
  47 + @GetMapping("/{id}")
  48 + fun get(
  49 + @PathVariable partnerId: UUID,
  50 + @PathVariable id: UUID,
  51 + ): ResponseEntity<ContactResponse> {
  52 + val contact = contactService.findById(id) ?: return ResponseEntity.notFound().build()
  53 + if (contact.partnerId != partnerId) return ResponseEntity.notFound().build()
  54 + return ResponseEntity.ok(contact.toResponse())
  55 + }
  56 +
  57 + @PostMapping
  58 + @ResponseStatus(HttpStatus.CREATED)
  59 + fun create(
  60 + @PathVariable partnerId: UUID,
  61 + @RequestBody @Valid request: CreateContactRequest,
  62 + ): ContactResponse =
  63 + contactService.create(
  64 + partnerId,
  65 + CreateContactCommand(
  66 + fullName = request.fullName,
  67 + role = request.role,
  68 + email = request.email,
  69 + phone = request.phone,
  70 + active = request.active ?: true,
  71 + ),
  72 + ).toResponse()
  73 +
  74 + @PatchMapping("/{id}")
  75 + fun update(
  76 + @PathVariable partnerId: UUID,
  77 + @PathVariable id: UUID,
  78 + @RequestBody @Valid request: UpdateContactRequest,
  79 + ): ContactResponse =
  80 + contactService.update(
  81 + id,
  82 + UpdateContactCommand(
  83 + fullName = request.fullName,
  84 + role = request.role,
  85 + email = request.email,
  86 + phone = request.phone,
  87 + active = request.active,
  88 + ),
  89 + ).toResponse()
  90 +
  91 + @DeleteMapping("/{id}")
  92 + @ResponseStatus(HttpStatus.NO_CONTENT)
  93 + fun deactivate(
  94 + @PathVariable partnerId: UUID,
  95 + @PathVariable id: UUID,
  96 + ) {
  97 + contactService.deactivate(id)
  98 + }
  99 +}
  100 +
  101 +// ─── DTOs ────────────────────────────────────────────────────────────
  102 +
  103 +data class CreateContactRequest(
  104 + @field:NotBlank @field:Size(max = 256) val fullName: String,
  105 + @field:Size(max = 128) val role: String? = null,
  106 + @field:Size(max = 256) val email: String? = null,
  107 + @field:Size(max = 64) val phone: String? = null,
  108 + val active: Boolean? = true,
  109 +)
  110 +
  111 +data class UpdateContactRequest(
  112 + @field:Size(max = 256) val fullName: String? = null,
  113 + @field:Size(max = 128) val role: String? = null,
  114 + @field:Size(max = 256) val email: String? = null,
  115 + @field:Size(max = 64) val phone: String? = null,
  116 + val active: Boolean? = null,
  117 +)
  118 +
  119 +data class ContactResponse(
  120 + val id: UUID,
  121 + val partnerId: UUID,
  122 + val fullName: String,
  123 + val role: String?,
  124 + val email: String?,
  125 + val phone: String?,
  126 + val active: Boolean,
  127 +)
  128 +
  129 +private fun Contact.toResponse() = ContactResponse(
  130 + id = this.id,
  131 + partnerId = this.partnerId,
  132 + fullName = this.fullName,
  133 + role = this.role,
  134 + email = this.email,
  135 + phone = this.phone,
  136 + active = this.active,
  137 +)
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/http/PartnerController.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.http
  2 +
  3 +import jakarta.validation.Valid
  4 +import jakarta.validation.constraints.NotBlank
  5 +import jakarta.validation.constraints.Size
  6 +import org.springframework.http.HttpStatus
  7 +import org.springframework.http.ResponseEntity
  8 +import org.springframework.web.bind.annotation.DeleteMapping
  9 +import org.springframework.web.bind.annotation.GetMapping
  10 +import org.springframework.web.bind.annotation.PatchMapping
  11 +import org.springframework.web.bind.annotation.PathVariable
  12 +import org.springframework.web.bind.annotation.PostMapping
  13 +import org.springframework.web.bind.annotation.RequestBody
  14 +import org.springframework.web.bind.annotation.RequestMapping
  15 +import org.springframework.web.bind.annotation.ResponseStatus
  16 +import org.springframework.web.bind.annotation.RestController
  17 +import org.vibeerp.pbc.partners.application.CreatePartnerCommand
  18 +import org.vibeerp.pbc.partners.application.PartnerService
  19 +import org.vibeerp.pbc.partners.application.UpdatePartnerCommand
  20 +import org.vibeerp.pbc.partners.domain.Partner
  21 +import org.vibeerp.pbc.partners.domain.PartnerType
  22 +import java.util.UUID
  23 +
  24 +/**
  25 + * REST API for the partners PBC's Partner aggregate.
  26 + *
  27 + * Mounted at `/api/v1/partners/partners`. Authenticated; the framework's
  28 + * Spring Security filter chain rejects anonymous requests with 401.
  29 + *
  30 + * The DTO layer (request/response) is intentionally separate from the
  31 + * JPA entity. Sales-order DTOs and purchase-order DTOs in future PBCs
  32 + * will quote a `partnerCode` field that resolves through the api.v1
  33 + * facade rather than embedding the entity directly.
  34 + */
  35 +@RestController
  36 +@RequestMapping("/api/v1/partners/partners")
  37 +class PartnerController(
  38 + private val partnerService: PartnerService,
  39 +) {
  40 +
  41 + @GetMapping
  42 + fun list(): List<PartnerResponse> =
  43 + partnerService.list().map { it.toResponse() }
  44 +
  45 + @GetMapping("/{id}")
  46 + fun get(@PathVariable id: UUID): ResponseEntity<PartnerResponse> {
  47 + val partner = partnerService.findById(id) ?: return ResponseEntity.notFound().build()
  48 + return ResponseEntity.ok(partner.toResponse())
  49 + }
  50 +
  51 + @GetMapping("/by-code/{code}")
  52 + fun getByCode(@PathVariable code: String): ResponseEntity<PartnerResponse> {
  53 + val partner = partnerService.findByCode(code) ?: return ResponseEntity.notFound().build()
  54 + return ResponseEntity.ok(partner.toResponse())
  55 + }
  56 +
  57 + @PostMapping
  58 + @ResponseStatus(HttpStatus.CREATED)
  59 + fun create(@RequestBody @Valid request: CreatePartnerRequest): PartnerResponse =
  60 + partnerService.create(
  61 + CreatePartnerCommand(
  62 + code = request.code,
  63 + name = request.name,
  64 + type = request.type,
  65 + taxId = request.taxId,
  66 + website = request.website,
  67 + email = request.email,
  68 + phone = request.phone,
  69 + active = request.active ?: true,
  70 + ),
  71 + ).toResponse()
  72 +
  73 + @PatchMapping("/{id}")
  74 + fun update(
  75 + @PathVariable id: UUID,
  76 + @RequestBody @Valid request: UpdatePartnerRequest,
  77 + ): PartnerResponse =
  78 + partnerService.update(
  79 + id,
  80 + UpdatePartnerCommand(
  81 + name = request.name,
  82 + type = request.type,
  83 + taxId = request.taxId,
  84 + website = request.website,
  85 + email = request.email,
  86 + phone = request.phone,
  87 + active = request.active,
  88 + ),
  89 + ).toResponse()
  90 +
  91 + @DeleteMapping("/{id}")
  92 + @ResponseStatus(HttpStatus.NO_CONTENT)
  93 + fun deactivate(@PathVariable id: UUID) {
  94 + partnerService.deactivate(id)
  95 + }
  96 +}
  97 +
  98 +// ─── DTOs ────────────────────────────────────────────────────────────
  99 +
  100 +data class CreatePartnerRequest(
  101 + @field:NotBlank @field:Size(max = 64) val code: String,
  102 + @field:NotBlank @field:Size(max = 256) val name: String,
  103 + val type: PartnerType,
  104 + @field:Size(max = 64) val taxId: String? = null,
  105 + @field:Size(max = 256) val website: String? = null,
  106 + @field:Size(max = 256) val email: String? = null,
  107 + @field:Size(max = 64) val phone: String? = null,
  108 + val active: Boolean? = true,
  109 +)
  110 +
  111 +data class UpdatePartnerRequest(
  112 + @field:Size(max = 256) val name: String? = null,
  113 + val type: PartnerType? = null,
  114 + @field:Size(max = 64) val taxId: String? = null,
  115 + @field:Size(max = 256) val website: String? = null,
  116 + @field:Size(max = 256) val email: String? = null,
  117 + @field:Size(max = 64) val phone: String? = null,
  118 + val active: Boolean? = null,
  119 +)
  120 +
  121 +data class PartnerResponse(
  122 + val id: UUID,
  123 + val code: String,
  124 + val name: String,
  125 + val type: PartnerType,
  126 + val taxId: String?,
  127 + val website: String?,
  128 + val email: String?,
  129 + val phone: String?,
  130 + val active: Boolean,
  131 +)
  132 +
  133 +private fun Partner.toResponse() = PartnerResponse(
  134 + id = this.id,
  135 + code = this.code,
  136 + name = this.name,
  137 + type = this.type,
  138 + taxId = this.taxId,
  139 + website = this.website,
  140 + email = this.email,
  141 + phone = this.phone,
  142 + active = this.active,
  143 +)
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/infrastructure/AddressJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.partners.domain.Address
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [Address].
  10 + *
  11 + * Lookups are by `partner_id`. Addresses don't have a natural key of
  12 + * their own — they're identified relative to the partner they belong to.
  13 + * Sales orders that snapshot an address at order time should COPY the
  14 + * fields, not store the id, because addresses can change after the
  15 + * order is placed and the order needs to remember the original.
  16 + */
  17 +@Repository
  18 +interface AddressJpaRepository : JpaRepository<Address, UUID> {
  19 +
  20 + fun findByPartnerId(partnerId: UUID): List<Address>
  21 +
  22 + fun deleteByPartnerId(partnerId: UUID)
  23 +}
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/infrastructure/ContactJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.partners.domain.Contact
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [Contact].
  10 + *
  11 + * Lookups are by `partner_id`. Like addresses, contacts have no natural
  12 + * key of their own — they're identified by the partner they belong to.
  13 + */
  14 +@Repository
  15 +interface ContactJpaRepository : JpaRepository<Contact, UUID> {
  16 +
  17 + fun findByPartnerId(partnerId: UUID): List<Contact>
  18 +
  19 + fun deleteByPartnerId(partnerId: UUID)
  20 +}
pbc/pbc-partners/src/main/kotlin/org/vibeerp/pbc/partners/infrastructure/PartnerJpaRepository.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.infrastructure
  2 +
  3 +import org.springframework.data.jpa.repository.JpaRepository
  4 +import org.springframework.stereotype.Repository
  5 +import org.vibeerp.pbc.partners.domain.Partner
  6 +import java.util.UUID
  7 +
  8 +/**
  9 + * Spring Data JPA repository for [Partner].
  10 + *
  11 + * Lookups are predominantly by `code` because partner codes are stable,
  12 + * human-meaningful, and the natural key every other PBC and every plug-in
  13 + * uses to reference a partner ("CUST-0042" is more meaningful than a
  14 + * UUID and survives database re-imports).
  15 + */
  16 +@Repository
  17 +interface PartnerJpaRepository : JpaRepository<Partner, UUID> {
  18 +
  19 + fun findByCode(code: String): Partner?
  20 +
  21 + fun existsByCode(code: String): Boolean
  22 +}
pbc/pbc-partners/src/main/resources/META-INF/vibe-erp/metadata/partners.yml 0 → 100644
  1 +# pbc-partners metadata.
  2 +#
  3 +# Loaded at boot by MetadataLoader, tagged source='core'.
  4 +# This declares what the partners PBC offers to the rest of the
  5 +# framework and the SPA: which entities exist, which permissions guard
  6 +# them, which menu entries point at them.
  7 +
  8 +entities:
  9 + - name: Partner
  10 + pbc: partners
  11 + table: partners__partner
  12 + description: An external organisation the company does business with (customer, supplier, or both)
  13 +
  14 + - name: Address
  15 + pbc: partners
  16 + table: partners__address
  17 + description: A postal address attached to a partner (billing, shipping, other)
  18 +
  19 + - name: Contact
  20 + pbc: partners
  21 + table: partners__contact
  22 + description: A named human at a partner organisation (PII — GDPR-relevant)
  23 +
  24 +permissions:
  25 + - key: partners.partner.read
  26 + description: Read partner records
  27 + - key: partners.partner.create
  28 + description: Create partners
  29 + - key: partners.partner.update
  30 + description: Update partners
  31 + - key: partners.partner.deactivate
  32 + description: Deactivate partners (preserved for historical references)
  33 +
  34 + - key: partners.address.read
  35 + description: Read partner addresses
  36 + - key: partners.address.create
  37 + description: Add an address to a partner
  38 + - key: partners.address.update
  39 + description: Update a partner address
  40 + - key: partners.address.delete
  41 + description: Remove a partner address
  42 +
  43 + - key: partners.contact.read
  44 + description: Read partner contact people (PII — elevated)
  45 + - key: partners.contact.create
  46 + description: Add a contact to a partner
  47 + - key: partners.contact.update
  48 + description: Update a partner contact
  49 + - key: partners.contact.deactivate
  50 + description: Deactivate a partner contact
  51 +
  52 +menus:
  53 + - path: /partners/partners
  54 + label: Partners
  55 + icon: users
  56 + section: Partners
  57 + order: 300
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/AddressServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isFalse
  8 +import assertk.assertions.isInstanceOf
  9 +import assertk.assertions.isTrue
  10 +import io.mockk.Runs
  11 +import io.mockk.every
  12 +import io.mockk.just
  13 +import io.mockk.mockk
  14 +import io.mockk.slot
  15 +import org.junit.jupiter.api.BeforeEach
  16 +import org.junit.jupiter.api.Test
  17 +import org.vibeerp.pbc.partners.domain.Address
  18 +import org.vibeerp.pbc.partners.domain.AddressType
  19 +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository
  20 +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  21 +import java.util.Optional
  22 +import java.util.UUID
  23 +
  24 +class AddressServiceTest {
  25 +
  26 + private lateinit var addresses: AddressJpaRepository
  27 + private lateinit var partners: PartnerJpaRepository
  28 + private lateinit var service: AddressService
  29 +
  30 + @BeforeEach
  31 + fun setUp() {
  32 + addresses = mockk()
  33 + partners = mockk()
  34 + service = AddressService(addresses, partners)
  35 + }
  36 +
  37 + @Test
  38 + fun `create rejects unknown partner id`() {
  39 + val partnerId = UUID.randomUUID()
  40 + every { partners.existsById(partnerId) } returns false
  41 +
  42 + assertFailure {
  43 + service.create(
  44 + partnerId,
  45 + CreateAddressCommand(
  46 + addressType = AddressType.BILLING,
  47 + line1 = "1 Example St",
  48 + city = "Springfield",
  49 + countryCode = "US",
  50 + ),
  51 + )
  52 + }
  53 + .isInstanceOf(IllegalArgumentException::class)
  54 + .hasMessage("partner not found: $partnerId")
  55 + }
  56 +
  57 + @Test
  58 + fun `create persists with generated id`() {
  59 + val partnerId = UUID.randomUUID()
  60 + val saved = slot<Address>()
  61 + every { partners.existsById(partnerId) } returns true
  62 + every { addresses.save(capture(saved)) } answers { saved.captured }
  63 +
  64 + val result = service.create(
  65 + partnerId,
  66 + CreateAddressCommand(
  67 + addressType = AddressType.SHIPPING,
  68 + line1 = "42 Factory Rd",
  69 + line2 = "Bldg 3",
  70 + city = "Shenzhen",
  71 + region = "Guangdong",
  72 + postalCode = "518000",
  73 + countryCode = "CN",
  74 + isPrimary = false,
  75 + ),
  76 + )
  77 +
  78 + assertThat(result.partnerId).isEqualTo(partnerId)
  79 + assertThat(result.addressType).isEqualTo(AddressType.SHIPPING)
  80 + assertThat(result.city).isEqualTo("Shenzhen")
  81 + assertThat(result.countryCode).isEqualTo("CN")
  82 + assertThat(result.isPrimary).isFalse()
  83 + }
  84 +
  85 + @Test
  86 + fun `create as primary demotes other primaries of the same type`() {
  87 + val partnerId = UUID.randomUUID()
  88 + val existingPrimary = Address(
  89 + partnerId = partnerId,
  90 + addressType = AddressType.BILLING,
  91 + line1 = "old primary",
  92 + city = "Old",
  93 + countryCode = "US",
  94 + isPrimary = true,
  95 + ).also { it.id = UUID.randomUUID() }
  96 + val otherType = Address(
  97 + partnerId = partnerId,
  98 + addressType = AddressType.SHIPPING,
  99 + line1 = "other type",
  100 + city = "Other",
  101 + countryCode = "US",
  102 + isPrimary = true,
  103 + ).also { it.id = UUID.randomUUID() }
  104 +
  105 + val saved = slot<Address>()
  106 + every { partners.existsById(partnerId) } returns true
  107 + every { addresses.save(capture(saved)) } answers {
  108 + saved.captured.also { it.id = UUID.randomUUID() }
  109 + }
  110 + every { addresses.findByPartnerId(partnerId) } returns listOf(existingPrimary, otherType)
  111 +
  112 + val result = service.create(
  113 + partnerId,
  114 + CreateAddressCommand(
  115 + addressType = AddressType.BILLING,
  116 + line1 = "new primary",
  117 + city = "New",
  118 + countryCode = "US",
  119 + isPrimary = true,
  120 + ),
  121 + )
  122 +
  123 + assertThat(result.isPrimary).isTrue()
  124 + assertThat(existingPrimary.isPrimary).isFalse() // demoted
  125 + assertThat(otherType.isPrimary).isTrue() // different type — untouched
  126 + }
  127 +
  128 + @Test
  129 + fun `delete throws NoSuchElementException when missing`() {
  130 + val id = UUID.randomUUID()
  131 + every { addresses.findById(id) } returns Optional.empty()
  132 +
  133 + assertFailure { service.delete(id) }
  134 + .isInstanceOf(NoSuchElementException::class)
  135 + }
  136 +
  137 + @Test
  138 + fun `delete removes existing address`() {
  139 + val id = UUID.randomUUID()
  140 + val existing = Address(
  141 + partnerId = UUID.randomUUID(),
  142 + addressType = AddressType.BILLING,
  143 + line1 = "x",
  144 + city = "x",
  145 + countryCode = "US",
  146 + ).also { it.id = id }
  147 + every { addresses.findById(id) } returns Optional.of(existing)
  148 + every { addresses.delete(existing) } just Runs
  149 +
  150 + service.delete(id)
  151 + }
  152 +}
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/ContactServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isFalse
  8 +import assertk.assertions.isInstanceOf
  9 +import io.mockk.every
  10 +import io.mockk.mockk
  11 +import io.mockk.slot
  12 +import org.junit.jupiter.api.BeforeEach
  13 +import org.junit.jupiter.api.Test
  14 +import org.vibeerp.pbc.partners.domain.Contact
  15 +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository
  16 +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  17 +import java.util.Optional
  18 +import java.util.UUID
  19 +
  20 +class ContactServiceTest {
  21 +
  22 + private lateinit var contacts: ContactJpaRepository
  23 + private lateinit var partners: PartnerJpaRepository
  24 + private lateinit var service: ContactService
  25 +
  26 + @BeforeEach
  27 + fun setUp() {
  28 + contacts = mockk()
  29 + partners = mockk()
  30 + service = ContactService(contacts, partners)
  31 + }
  32 +
  33 + @Test
  34 + fun `create rejects unknown partner id`() {
  35 + val partnerId = UUID.randomUUID()
  36 + every { partners.existsById(partnerId) } returns false
  37 +
  38 + assertFailure {
  39 + service.create(
  40 + partnerId,
  41 + CreateContactCommand(fullName = "Alice"),
  42 + )
  43 + }
  44 + .isInstanceOf(IllegalArgumentException::class)
  45 + .hasMessage("partner not found: $partnerId")
  46 + }
  47 +
  48 + @Test
  49 + fun `create persists on the happy path`() {
  50 + val partnerId = UUID.randomUUID()
  51 + val saved = slot<Contact>()
  52 + every { partners.existsById(partnerId) } returns true
  53 + every { contacts.save(capture(saved)) } answers { saved.captured }
  54 +
  55 + val result = service.create(
  56 + partnerId,
  57 + CreateContactCommand(
  58 + fullName = "Marie Durand",
  59 + role = "Accounts Payable",
  60 + email = "marie@acme.example",
  61 + phone = "+33 1 23 45 67 89",
  62 + ),
  63 + )
  64 +
  65 + assertThat(result.partnerId).isEqualTo(partnerId)
  66 + assertThat(result.fullName).isEqualTo("Marie Durand")
  67 + assertThat(result.role).isEqualTo("Accounts Payable")
  68 + assertThat(result.email).isEqualTo("marie@acme.example")
  69 + }
  70 +
  71 + @Test
  72 + fun `update partial fields without clobbering others`() {
  73 + val id = UUID.randomUUID()
  74 + val existing = Contact(
  75 + partnerId = UUID.randomUUID(),
  76 + fullName = "Original Name",
  77 + role = "CFO",
  78 + email = "a@example.com",
  79 + phone = "+1 555 0100",
  80 + ).also { it.id = id }
  81 + every { contacts.findById(id) } returns Optional.of(existing)
  82 +
  83 + val updated = service.update(
  84 + id,
  85 + UpdateContactCommand(email = "b@example.com"),
  86 + )
  87 +
  88 + assertThat(updated.email).isEqualTo("b@example.com")
  89 + assertThat(updated.fullName).isEqualTo("Original Name") // untouched
  90 + assertThat(updated.role).isEqualTo("CFO") // untouched
  91 + assertThat(updated.phone).isEqualTo("+1 555 0100") // untouched
  92 + }
  93 +
  94 + @Test
  95 + fun `deactivate flips active without deleting`() {
  96 + val id = UUID.randomUUID()
  97 + val existing = Contact(
  98 + partnerId = UUID.randomUUID(),
  99 + fullName = "x",
  100 + active = true,
  101 + ).also { it.id = id }
  102 + every { contacts.findById(id) } returns Optional.of(existing)
  103 +
  104 + service.deactivate(id)
  105 +
  106 + assertThat(existing.active).isFalse()
  107 + }
  108 +
  109 + @Test
  110 + fun `deactivate throws NoSuchElementException when missing`() {
  111 + val id = UUID.randomUUID()
  112 + every { contacts.findById(id) } returns Optional.empty()
  113 +
  114 + assertFailure { service.deactivate(id) }
  115 + .isInstanceOf(NoSuchElementException::class)
  116 + }
  117 +}
pbc/pbc-partners/src/test/kotlin/org/vibeerp/pbc/partners/application/PartnerServiceTest.kt 0 → 100644
  1 +package org.vibeerp.pbc.partners.application
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.hasMessage
  6 +import assertk.assertions.isEqualTo
  7 +import assertk.assertions.isFalse
  8 +import assertk.assertions.isInstanceOf
  9 +import io.mockk.every
  10 +import io.mockk.mockk
  11 +import io.mockk.slot
  12 +import io.mockk.verify
  13 +import org.junit.jupiter.api.BeforeEach
  14 +import org.junit.jupiter.api.Test
  15 +import org.vibeerp.pbc.partners.domain.Contact
  16 +import org.vibeerp.pbc.partners.domain.Partner
  17 +import org.vibeerp.pbc.partners.domain.PartnerType
  18 +import org.vibeerp.pbc.partners.infrastructure.AddressJpaRepository
  19 +import org.vibeerp.pbc.partners.infrastructure.ContactJpaRepository
  20 +import org.vibeerp.pbc.partners.infrastructure.PartnerJpaRepository
  21 +import java.util.Optional
  22 +import java.util.UUID
  23 +
  24 +class PartnerServiceTest {
  25 +
  26 + private lateinit var partners: PartnerJpaRepository
  27 + private lateinit var addresses: AddressJpaRepository
  28 + private lateinit var contacts: ContactJpaRepository
  29 + private lateinit var service: PartnerService
  30 +
  31 + @BeforeEach
  32 + fun setUp() {
  33 + partners = mockk()
  34 + addresses = mockk()
  35 + contacts = mockk()
  36 + service = PartnerService(partners, addresses, contacts)
  37 + }
  38 +
  39 + @Test
  40 + fun `create rejects duplicate code`() {
  41 + every { partners.existsByCode("CUST-1") } returns true
  42 +
  43 + assertFailure {
  44 + service.create(
  45 + CreatePartnerCommand(
  46 + code = "CUST-1",
  47 + name = "Acme",
  48 + type = PartnerType.CUSTOMER,
  49 + ),
  50 + )
  51 + }
  52 + .isInstanceOf(IllegalArgumentException::class)
  53 + .hasMessage("partner code 'CUST-1' is already taken")
  54 + }
  55 +
  56 + @Test
  57 + fun `create persists on the happy path`() {
  58 + val saved = slot<Partner>()
  59 + every { partners.existsByCode("CUST-2") } returns false
  60 + every { partners.save(capture(saved)) } answers { saved.captured }
  61 +
  62 + val result = service.create(
  63 + CreatePartnerCommand(
  64 + code = "CUST-2",
  65 + name = "Acme Printing Co.",
  66 + type = PartnerType.CUSTOMER,
  67 + taxId = "US-12-3456789",
  68 + email = "orders@acme.example",
  69 + ),
  70 + )
  71 +
  72 + assertThat(result.code).isEqualTo("CUST-2")
  73 + assertThat(result.name).isEqualTo("Acme Printing Co.")
  74 + assertThat(result.type).isEqualTo(PartnerType.CUSTOMER)
  75 + assertThat(result.taxId).isEqualTo("US-12-3456789")
  76 + assertThat(result.email).isEqualTo("orders@acme.example")
  77 + assertThat(result.active).isEqualTo(true)
  78 + verify(exactly = 1) { partners.save(any()) }
  79 + }
  80 +
  81 + @Test
  82 + fun `update never changes code`() {
  83 + val id = UUID.randomUUID()
  84 + val existing = Partner(
  85 + code = "CUST-orig",
  86 + name = "Old name",
  87 + type = PartnerType.CUSTOMER,
  88 + ).also { it.id = id }
  89 + every { partners.findById(id) } returns Optional.of(existing)
  90 +
  91 + val updated = service.update(
  92 + id,
  93 + UpdatePartnerCommand(
  94 + name = "New name",
  95 + type = PartnerType.BOTH,
  96 + email = "new@example.com",
  97 + ),
  98 + )
  99 +
  100 + assertThat(updated.name).isEqualTo("New name")
  101 + assertThat(updated.type).isEqualTo(PartnerType.BOTH)
  102 + assertThat(updated.email).isEqualTo("new@example.com")
  103 + assertThat(updated.code).isEqualTo("CUST-orig") // not touched
  104 + }
  105 +
  106 + @Test
  107 + fun `deactivate flips partner active and cascades to contacts`() {
  108 + val id = UUID.randomUUID()
  109 + val existing = Partner(
  110 + code = "CUST-x",
  111 + name = "x",
  112 + type = PartnerType.CUSTOMER,
  113 + active = true,
  114 + ).also { it.id = id }
  115 + val contact1 = Contact(partnerId = id, fullName = "Alice", active = true)
  116 + val contact2 = Contact(partnerId = id, fullName = "Bob", active = true)
  117 + every { partners.findById(id) } returns Optional.of(existing)
  118 + every { contacts.findByPartnerId(id) } returns listOf(contact1, contact2)
  119 +
  120 + service.deactivate(id)
  121 +
  122 + assertThat(existing.active).isFalse()
  123 + assertThat(contact1.active).isFalse()
  124 + assertThat(contact2.active).isFalse()
  125 + }
  126 +
  127 + @Test
  128 + fun `deactivate throws NoSuchElementException when missing`() {
  129 + val id = UUID.randomUUID()
  130 + every { partners.findById(id) } returns Optional.empty()
  131 +
  132 + assertFailure { service.deactivate(id) }
  133 + .isInstanceOf(NoSuchElementException::class)
  134 + }
  135 +}
settings.gradle.kts
@@ -46,6 +46,9 @@ project(&quot;:pbc:pbc-identity&quot;).projectDir = file(&quot;pbc/pbc-identity&quot;) @@ -46,6 +46,9 @@ project(&quot;:pbc:pbc-identity&quot;).projectDir = file(&quot;pbc/pbc-identity&quot;)
46 include(":pbc:pbc-catalog") 46 include(":pbc:pbc-catalog")
47 project(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog") 47 project(":pbc:pbc-catalog").projectDir = file("pbc/pbc-catalog")
48 48
  49 +include(":pbc:pbc-partners")
  50 +project(":pbc:pbc-partners").projectDir = file("pbc/pbc-partners")
  51 +
49 // ─── Reference customer plug-in (NOT loaded by default) ───────────── 52 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
50 include(":reference-customer:plugin-printing-shop") 53 include(":reference-customer:plugin-printing-shop")
51 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop") 54 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")