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