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 95  
96 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 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 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 103 - **Package root** is `org.vibeerp`.
104 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 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 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 20 | **Plug-ins serving HTTP** | 1 (reference printing-shop, 7 endpoints + own DB schema + own metadata) |
21 21 | **Production-ready?** | **No.** Foundation is solid; many capabilities still deferred. |
22 22  
23 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 29 ## Total scope (the v1.0 cut line)
30 30  
31 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 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 79 | # | Unit | Status |
80 80 |---|---|---|
81 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 83 | P5.3 | `pbc-inventory` — stock items, lots, locations, movements | 🔜 Pending |
84 84 | P5.4 | `pbc-warehousing` — receipts, picks, transfers, counts | 🔜 Pending |
85 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 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 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 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 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 170 - **Job scheduler.** No Quartz. Periodic jobs don't have a home.
171 171 - **OIDC.** Built-in JWT only. OIDC client (Keycloak-compatible) is P4.2.
172 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 174 - **Web SPA.** No React app. The framework is API-only today.
175 175 - **MCP server.** The architecture leaves room for it; the implementation is v1.1.
176 176 - **Mobile.** v2.
... ... @@ -212,6 +212,7 @@ platform/platform-plugins PF4J host, linter, Liquibase runner, endpoint disp
212 212  
213 213 pbc/pbc-identity User entity end-to-end + auth + bootstrap admin
214 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 217 reference-customer/plugin-printing-shop
217 218 Reference plug-in: own DB schema (plate, ink_recipe),
... ... @@ -220,7 +221,7 @@ reference-customer/plugin-printing-shop
220 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 226 ## Where to look next
226 227  
... ...
README.md
... ... @@ -77,7 +77,7 @@ vibe-erp/
77 77 ## Building
78 78  
79 79 ```bash
80   -# Build everything (compiles 11 modules, runs 92 unit tests)
  80 +# Build everything (compiles 12 modules, runs 107 unit tests)
81 81 ./gradlew build
82 82  
83 83 # Bring up Postgres + the reference plug-in JAR
... ... @@ -88,17 +88,17 @@ docker compose up -d db
88 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 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 102 | Cross-cutting services live | 7 |
103 103 | Plug-ins serving HTTP | 1 (reference printing-shop) |
104 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 27 implementation(project(":platform:platform-metadata"))
28 28 implementation(project(":pbc:pbc-identity"))
29 29 implementation(project(":pbc:pbc-catalog"))
  30 + implementation(project(":pbc:pbc-partners"))
30 31  
31 32 implementation(libs.spring.boot.starter)
32 33 implementation(libs.spring.boot.starter.web)
... ...
distribution/src/main/resources/db/changelog/master.xml
... ... @@ -15,4 +15,5 @@
15 15 <include file="classpath:db/changelog/pbc-identity/001-identity-init.xml"/>
16 16 <include file="classpath:db/changelog/pbc-identity/002-identity-credential.xml"/>
17 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 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 46 include(":pbc:pbc-catalog")
47 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 52 // ─── Reference customer plug-in (NOT loaded by default) ─────────────
50 53 include(":reference-customer:plugin-printing-shop")
51 54 project(":reference-customer:plugin-printing-shop").projectDir = file("reference-customer/plugin-printing-shop")
... ...