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.