From fb216c47693111d8996de80a400b9056cd61995f Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 24 Feb 2025 19:34:30 +0100 Subject: [PATCH 1/3] multiple debitors and subsequent memberships --- .../5100-hs-office-membership.sql | 30 ++++++++++ .../5108-hs-office-membership-test-data.sql | 11 ++-- ...iceMembershipControllerAcceptanceTest.java | 34 +++++------ ...ceMembershipRepositoryIntegrationTest.java | 41 ++++++++++--- .../scenarios/HsOfficeScenarioTests.java | 59 +++++++++++++++---- .../scenarios/debitor/DeleteDebitor.java | 2 +- .../hsadminng/rbac/test/JpaAttempt.java | 37 ++++++------ 7 files changed, 154 insertions(+), 60 deletions(-) diff --git a/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql b/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql index d8d64559..d4c20536 100644 --- a/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql +++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql @@ -32,6 +32,36 @@ create table if not exists hs_office.membership --// +-- ============================================================================ +--changeset michael.hoennig:hs-office-membership-SINGLE-MEMBERSHIP-CHECK endDelimiter:--// +-- ---------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION hs_office.validate_membership_validity() + RETURNS trigger AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM hs_office.membership + WHERE partnerUuid = NEW.partnerUuid + AND uuid <> NEW.uuid + AND NEW.validity && validity + ) THEN + RAISE EXCEPTION 'Membership validity ranges overlap for partnerUuid %', NEW.partnerUuid; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_validate_membership_validity + BEFORE INSERT OR UPDATE ON hs_office.membership + FOR EACH ROW +EXECUTE FUNCTION hs_office.validate_membership_validity(); + + +--// + + -- ============================================================================ --changeset michael.hoennig:hs-office-membership-MAIN-TABLE-JOURNAL endDelimiter:--// -- ---------------------------------------------------------------------------- diff --git a/src/main/resources/db/changelog/5-hs-office/510-membership/5108-hs-office-membership-test-data.sql b/src/main/resources/db/changelog/5-hs-office/510-membership/5108-hs-office-membership-test-data.sql index b5355871..381a74fd 100644 --- a/src/main/resources/db/changelog/5-hs-office/510-membership/5108-hs-office-membership-test-data.sql +++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5108-hs-office-membership-test-data.sql @@ -10,7 +10,8 @@ */ create or replace procedure hs_office.membership_create_test_data( forPartnerNumber numeric(5), - newMemberNumberSuffix char(2) ) + newMemberNumberSuffix char(2), + validity daterange) language plpgsql as $$ declare relatedPartner hs_office.partner; @@ -22,7 +23,7 @@ begin raise notice '- using partner (%): %', relatedPartner.uuid, relatedPartner; insert into hs_office.membership (uuid, partneruuid, memberNumberSuffix, validity, status) - values (uuid_generate_v4(), relatedPartner.uuid, newMemberNumberSuffix, daterange('20221001' , null, '[]'), 'ACTIVE'); + values (uuid_generate_v4(), relatedPartner.uuid, newMemberNumberSuffix, validity, 'ACTIVE'); end; $$; --// @@ -35,9 +36,9 @@ do language plpgsql $$ begin call base.defineContext('creating Membership test-data', null, 'superuser-alex@hostsharing.net', 'rbac.global#global:ADMIN'); - call hs_office.membership_create_test_data(10001, '01'); - call hs_office.membership_create_test_data(10002, '02'); - call hs_office.membership_create_test_data(10003, '03'); + call hs_office.membership_create_test_data(10001, '01', daterange('20221001' , '20241231', '[)')); + call hs_office.membership_create_test_data(10002, '02', daterange('20221001' , '20251231', '[]')); + call hs_office.membership_create_test_data(10003, '03', daterange('20221001' , null, '[]')); end; $$; --// diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java index 08c88284..98a63be4 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipControllerAcceptanceTest.java @@ -86,7 +86,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle "memberNumber": "M-1000101", "memberNumberSuffix": "01", "validFrom": "2022-10-01", - "validTo": null, + "validTo": "2024-12-30", "status": "ACTIVE" }, { @@ -94,7 +94,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle "memberNumber": "M-1000202", "memberNumberSuffix": "02", "validFrom": "2022-10-01", - "validTo": null, + "validTo": "2025-12-31", "status": "ACTIVE" }, { @@ -133,7 +133,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle "memberNumber": "M-1000101", "memberNumberSuffix": "01", "validFrom": "2022-10-01", - "validTo": null, + "validTo": "2024-12-30", "status": "ACTIVE" } ] @@ -161,7 +161,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle "memberNumber": "M-1000202", "memberNumberSuffix": "02", "validFrom": "2022-10-01", - "validTo": null, + "validTo": "2025-12-31", "status": "ACTIVE" } ] @@ -177,7 +177,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle void globalAdmin_canAddMembership() { context.define("superuser-alex@hostsharing.net"); - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("Third").get(0); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").getFirst(); final var givenMemberSuffix = TEMP_MEMBER_NUMBER_SUFFIX; final var expectedMemberNumber = Integer.parseInt(givenPartner.getPartnerNumber() + TEMP_MEMBER_NUMBER_SUFFIX); @@ -189,7 +189,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle { "partner.uuid": "%s", "memberNumberSuffix": "%s", - "validFrom": "2022-10-13", + "validFrom": "2025-02-13", "membershipFeeBillable": "true" } """.formatted(givenPartner.getUuid(), givenMemberSuffix)) @@ -200,10 +200,10 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle .statusCode(201) .contentType(ContentType.JSON) .body("uuid", isUuidValid()) - .body("partner.partnerNumber", is("P-10003")) + .body("partner.partnerNumber", is("P-10001")) .body("memberNumber", is("M-" + expectedMemberNumber)) .body("memberNumberSuffix", is(givenMemberSuffix)) - .body("validFrom", is("2022-10-13")) + .body("validFrom", is("2025-02-13")) .body("validTo", equalTo(null)) .header("Location", startsWith("http://localhost")) .extract().header("Location"); // @formatter:on @@ -239,7 +239,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle "memberNumber": "M-1000101", "memberNumberSuffix": "01", "validFrom": "2022-10-01", - "validTo": null, + "validTo": "2024-12-30", "status": "ACTIVE" } """)); // @formatter:on @@ -297,13 +297,13 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle context.define("superuser-alex@hostsharing.net"); final var givenMembership = givenSomeTemporaryMembershipBessler("First"); - final var location = RestAssured // @formatter:off + RestAssured // @formatter:off .given() .header("current-subject", "superuser-alex@hostsharing.net") .contentType(ContentType.JSON) .body(""" { - "validTo": "2023-12-31", + "validTo": "2025-12-31", "status": "CANCELLED" } """) @@ -316,8 +316,8 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle .body("uuid", isUuidValid()) .body("partner.partnerNumber", is("P-" + givenMembership.getPartner().getPartnerNumber())) .body("memberNumberSuffix", is(givenMembership.getMemberNumberSuffix())) - .body("validFrom", is("2022-11-01")) - .body("validTo", is("2023-12-31")) + .body("validFrom", is("2025-02-01")) + .body("validTo", is("2025-12-31")) .body("status", is("CANCELLED")); // @formatter:on @@ -326,7 +326,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle .matches(mandate -> { assertThat(mandate.getPartner().toShortString()).isEqualTo("P-10001"); assertThat(mandate.getMemberNumberSuffix()).isEqualTo(givenMembership.getMemberNumberSuffix()); - assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2024-01-01)"); + assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2026-01-01)"); assertThat(mandate.getStatus()).isEqualTo(CANCELLED); return true; }); @@ -348,7 +348,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle .contentType(ContentType.JSON) .body(""" { - "validTo": "2024-01-01", + "validTo": "2025-12-31", "status": "CANCELLED" } """) @@ -361,7 +361,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle // finally, the Membership is actually updated assertThat(membershipRepo.findByUuid(givenMembership.getUuid())).isPresent().get() .matches(mandate -> { - assertThat(mandate.getValidity().asString()).isEqualTo("[2022-11-01,2024-01-02)"); + assertThat(mandate.getValidity().asString()).isEqualTo("[2025-02-01,2026-01-01)"); assertThat(mandate.getStatus()).isEqualTo(CANCELLED); return true; }); @@ -434,7 +434,7 @@ class HsOfficeMembershipControllerAcceptanceTest extends ContextBasedTestWithCle final var newMembership = HsOfficeMembershipEntity.builder() .partner(givenPartner) .memberNumberSuffix(TEMP_MEMBER_NUMBER_SUFFIX) - .validity(Range.closedInfinite(LocalDate.parse("2022-11-01"))) + .validity(Range.closedInfinite(LocalDate.parse("2025-02-01"))) .status(ACTIVE) .membershipFeeBillable(true) .build(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java index d078a01c..57823a20 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java @@ -12,6 +12,7 @@ import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import org.postgresql.util.PSQLException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -74,7 +75,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl final var newMembership = HsOfficeMembershipEntity.builder() .memberNumberSuffix("11") .partner(givenPartner) - .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) + .validity(Range.closedInfinite(LocalDate.parse("2025-01-01"))) .membershipFeeBillable(true) .build(); return toCleanup(membershipRepo.save(newMembership).load()); @@ -87,6 +88,28 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl assertThat(membershipRepo.count()).isEqualTo(count + 1); } + @Test + public void creatingMembershipForSamePartnerIsDisallowedIfAnotherOneIsStillActive() { + // given + context("superuser-alex@hostsharing.net"); + final var count = membershipRepo.count(); + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").getFirst(); + + // when + final var result = attempt(em, () -> { + final var newMembership = HsOfficeMembershipEntity.builder() + .memberNumberSuffix("11") + .partner(givenPartner) + .validity(Range.closedInfinite(LocalDate.parse("2024-01-01"))) + .membershipFeeBillable(true) + .build(); + return toCleanup(membershipRepo.save(newMembership).load()); + }); + + // then + result.assertExceptionWithRootCauseMessage(PSQLException.class, "Membership validity ranges overlap for partnerUuid " + givenPartner.getUuid()); + } + @Test public void createsAndGrantsRoles() { // given @@ -102,7 +125,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl final var newMembership = HsOfficeMembershipEntity.builder() .memberNumberSuffix("17") .partner(givenPartner) - .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) + .validity(Range.closedInfinite(LocalDate.parse("2025-01-01"))) .membershipFeeBillable(true) .build(); return toCleanup(membershipRepo.save(newMembership)); @@ -163,8 +186,8 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl // then exactlyTheseMembershipsAreReturned( result, - "Membership(M-1000101, P-10001, [2022-10-01,), ACTIVE)", - "Membership(M-1000202, P-10002, [2022-10-01,), ACTIVE)", + "Membership(M-1000101, P-10001, [2022-10-01,2024-12-31), ACTIVE)", + "Membership(M-1000202, P-10002, [2022-10-01,2026-01-01), ACTIVE)", "Membership(M-1000303, P-10003, [2022-10-01,), ACTIVE)"); } @@ -179,7 +202,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl // then exactlyTheseMembershipsAreReturned(result, - "Membership(M-1000101, P-10001, [2022-10-01,), ACTIVE)"); + "Membership(M-1000101, P-10001, [2022-10-01,2024-12-31), ACTIVE)"); } @Test @@ -194,7 +217,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl assertThat(result) .isNotNull() .extracting(Object::toString) - .isEqualTo("Membership(M-1000202, P-10002, [2022-10-01,), ACTIVE)"); + .isEqualTo("Membership(M-1000202, P-10002, [2022-10-01,2026-01-01), ACTIVE)"); } @Test @@ -209,7 +232,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl assertThat(result) .isNotNull() .extracting(Object::toString) - .isEqualTo("Membership(M-1000202, P-10002, [2022-10-01,), ACTIVE)"); + .isEqualTo("Membership(M-1000202, P-10002, [2022-10-01,2026-01-01), ACTIVE)"); } @Test @@ -222,7 +245,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl // then exactlyTheseMembershipsAreReturned(result, - "Membership(M-1000202, P-10002, [2022-10-01,), ACTIVE)"); + "Membership(M-1000202, P-10002, [2022-10-01,2026-01-01), ACTIVE)"); } } @@ -388,7 +411,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl final var newMembership = HsOfficeMembershipEntity.builder() .memberNumberSuffix(memberNumberSuffix) .partner(givenPartner) - .validity(Range.closedInfinite(LocalDate.parse("2020-01-01"))) + .validity(Range.closedInfinite(LocalDate.parse("2025-02-01"))) .membershipFeeBillable(true) .build(); diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java index 2f4ee9e5..646896e9 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/HsOfficeScenarioTests.java @@ -287,12 +287,12 @@ class HsOfficeScenarioTests extends ScenarioTest { @Test @Order(2011) - @Requires("Person: Test AG") - @Produces("Debitor: D-3101001 - Test AG - main debitor") - void shouldCreateExternalDebitorForPartner() { - new CreateExternalDebitorForPartner(scenarioTest) + @Requires("Debitor: D-3101000 - Test AG - main debitor") + @Produces("Debitor: D-3101001 - Test AG - additional debitor") + void shouldCreateAdditionDebitorForPartner() { + new CreateSelfDebitorForPartner(scenarioTest) .given("partnerPersonTradeName", "Test AG") - .given("billingContactCaption", "Billing GmbH - billing department") + .given("billingContactCaption", "Test AG - billing department") .given("billingContactEmailAddress", "billing@test-ag.example.org") .given("debitorNumberSuffix", "01") .given("billable", true) @@ -305,10 +305,30 @@ class HsOfficeScenarioTests extends ScenarioTest { .keep(); } + @Test + @Order(2012) + @Requires("Person: Test AG") + @Produces("Debitor: D-3101002 - Test AG - external debitor") + void shouldCreateExternalDebitorForPartner() { + new CreateExternalDebitorForPartner(scenarioTest) + .given("partnerPersonTradeName", "Test AG") + .given("billingContactCaption", "Billing GmbH - billing department") + .given("billingContactEmailAddress", "billing@test-ag.example.org") + .given("debitorNumberSuffix", "02") + .given("billable", true) + .given("vatId", "VAT123456") + .given("vatCountryCode", "DE") + .given("vatBusiness", true) + .given("vatReverseCharge", false) + .given("defaultPrefix", "tsy") + .doRun() + .keep(); + } + @Test @Order(2020) @Requires("Person: Test AG") - @Produces(explicitly = "Debitor: D-3101000 - Test AG - delete debitor", permanent = false) + @Produces(explicitly = "Debitor: D-3101002 - Test AG - delete debitor", permanent = false) void shouldDeleteDebitor() { new DeleteDebitor(scenarioTest) .given("partnerNumber", "P-31020") @@ -317,7 +337,7 @@ class HsOfficeScenarioTests extends ScenarioTest { } @Test - @Order(2020) + @Order(2021) @Requires("Debitor: D-3101000 - Test AG - main debitor") @Disabled("see TODO.spec in DontDeleteDefaultDebitor") void shouldNotDeleteDefaultDebitor() { @@ -387,7 +407,7 @@ class HsOfficeScenarioTests extends ScenarioTest { void shouldCreateMembershipForPartner() { new CreateMembership(scenarioTest) .given("partnerName", "Test AG") - .given("validFrom", "2024-10-15") + .given("validFrom", "2020-10-15") .given("newStatus", "ACTIVE") .given("membershipFeeBillable", "true") .doRun() @@ -395,14 +415,31 @@ class HsOfficeScenarioTests extends ScenarioTest { } @Test - @Order(4090) + @Order(4080) @Requires("Membership: M-3101000 - Test AG") + @Produces("Membership: M-3101000 - Test AG - cancelled") void shouldCancelMembershipOfPartner() { new CancelMembership(scenarioTest) .given("memberNumber", "M-3101000") - .given("validTo", "2025-12-30") + .given("validTo", "2023-12-31") .given("newStatus", "CANCELLED") - .doRun(); + .doRun() + .keep(); + } + + @Test + @Order(4090) + @Requires("Membership: M-3101000 - Test AG - cancelled") + @Produces("Membership: M-3101001 - Test AG") + void shouldCreateSubsequentMembershipOfPartner() { + new CreateMembership(scenarioTest) + .given("partnerName", "Test AG") + .given("memberNumberSuffix", "01") + .given("validFrom", "2025-02-24") + .given("newStatus", "ACTIVE") + .given("membershipFeeBillable", "true") + .doRun() + .keep(); } } diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java index 2ff0484d..63bda9de 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/scenarios/debitor/DeleteDebitor.java @@ -19,7 +19,7 @@ public class DeleteDebitor extends UseCase { .given("vatCountryCode", "DE") .given("vatBusiness", true) .given("vatReverseCharge", false) - .given("defaultPrefix", "tsy")); + .given("defaultPrefix", "tsz")); } @Override diff --git a/src/test/java/net/hostsharing/hsadminng/rbac/test/JpaAttempt.java b/src/test/java/net/hostsharing/hsadminng/rbac/test/JpaAttempt.java index dcf31c5d..ff057391 100644 --- a/src/test/java/net/hostsharing/hsadminng/rbac/test/JpaAttempt.java +++ b/src/test/java/net/hostsharing/hsadminng/rbac/test/JpaAttempt.java @@ -1,6 +1,6 @@ package net.hostsharing.hsadminng.rbac.test; -import org.assertj.core.api.ObjectAssert; +import lombok.SneakyThrows; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.NestedExceptionUtils; import org.springframework.stereotype.Service; @@ -78,9 +78,9 @@ public class JpaAttempt { public static class JpaResult { private final T value; - private final RuntimeException exception; + private final Throwable exception; - private JpaResult(final T value, final RuntimeException exception) { + private JpaResult(final T value, final Throwable exception) { this.value = value; this.exception = exception; } @@ -93,7 +93,7 @@ public class JpaAttempt { return new JpaResult<>(value, null); } - public static JpaResult forException(final RuntimeException exception) { + public static JpaResult forException(final Throwable exception) { return new JpaResult<>(null, exception); } @@ -105,20 +105,23 @@ public class JpaAttempt { return value; } - public ObjectAssert assertThatResult() { - assertSuccessful(); - return assertThat(returnedValue()); - } - - public RuntimeException caughtException() { + public Throwable caughtException() { return exception; } - @SuppressWarnings("unchecked") - public E caughtException(final Class expectedExceptionClass) { + public E caughtException(final Class expectedExceptionClass) { + //noinspection unchecked + return caughtException((E) exception, expectedExceptionClass); + } + + public static E caughtException(final Throwable exception, final Class expectedExceptionClass) { if (expectedExceptionClass.isAssignableFrom(exception.getClass())) { + //noinspection unchecked return (E) exception; } + if(exception.getCause() != null && exception.getCause() != exception ) { + return caughtException(exception.getCause(), expectedExceptionClass); + } throw new AssertionError("expected " + expectedExceptionClass + " but got " + exception); } @@ -127,7 +130,7 @@ public class JpaAttempt { } public void assertExceptionWithRootCauseMessage( - final Class expectedExceptionClass, + final Class expectedExceptionClass, final String... expectedRootCauseMessages) { assertThat(wasSuccessful()).as("wasSuccessful").isFalse(); final String firstRootCauseMessageLine = firstRootCauseMessageLineOf(caughtException(expectedExceptionClass)); @@ -136,11 +139,11 @@ public class JpaAttempt { } } - public JpaResult reThrowException() { + @SneakyThrows + public void reThrowException() { if (exception != null) { throw exception; } - return this; } public JpaResult assumeSuccessful() { @@ -158,9 +161,9 @@ public class JpaAttempt { return this; } - private String firstRootCauseMessageLineOf(final RuntimeException exception) { + private String firstRootCauseMessageLineOf(final Throwable exception) { final var rootCause = NestedExceptionUtils.getRootCause(exception); - return Optional.ofNullable(rootCause) + return Optional.ofNullable(rootCause != null ? rootCause : exception) .map(Throwable::getMessage) .map(message -> message.split("\\r|\\n|\\r\\n", 0)[0]) .orElse(null); -- 2.39.5 From 73509990e17c8c17088a4e900ee37c21f359d127 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Mon, 24 Feb 2025 19:35:54 +0100 Subject: [PATCH 2/3] business-glossary entries for Coop-Asset-Transactions-Types and Coop-Share-Transactions-Types --- doc/business-glossary-de.md | 34 +++++++++++++++++++++++++++++++++ doc/hs-office-data-structure.md | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/doc/business-glossary-de.md b/doc/business-glossary-de.md index b0f3002d..3f9de41a 100644 --- a/doc/business-glossary-de.md +++ b/doc/business-glossary-de.md @@ -108,6 +108,40 @@ der Person des _Subscriber-Contact_ (_Holder_) zur repräsentierten Person (_Anc Zusätzlich wird diese Relation mit dem Kurznamen der abonnierten Mailingliste markiert. +### Coop-Asset-Transactions (Geschäftsguthabens-Transaktionen) + +- positiver Wert => Geschäftsguthaben nehmen zu +- negativer Wert => Geschäftsguthaben nehmen ab + +**REVERSAL**: **Korrekturbuchung** einer fehlerhaften Buchung, positiver oder negativer Wert ist möglich + +**DEPOSIT**: **Zahlungseingang** vom Mitglied nach Beteiligung mit Geschäftsanteilen, immer positiver Wert + +**DISBURSAL**: **Zahlungsausgang** an Mitglied nach Kündigung von Geschäftsanteilen, immer negativer Wert + +**TRANSFER**: **Übertragung** von Geschäftsguthaben an ein anderes Mitglied, immer negativer Wert + +**ADOPTION**: **Übernahme** von Geschäftsguthaben von einem anderen Mitglied, immer positiver Wert + +**CLEARING**: **Verrechnung** von Geschäftsguthaben mit Schulden des Mitglieds, immer negativer Wert + +**LOSS**: **Verlust** von Geschäftsguthaben bei Zuweisung Eigenkapitalverlust nach Kündigung von Geschäftsanteilen, immer negativer Wert + +**LIMITATION**: **Verjährung** von Geschäftsguthaben, wenn Auszahlung innerhalb der Frist nicht möglich war. + + +### Coop-Share-Transactions (Geschäftsanteil-Transaktionen) + +- positiver Wert => Geschäftsanteile nehmen zu +- negativer Wert => Geschäftsanteile nehmen ab +- +**REVERSAL**: **Korrekturbuchung** einer fehlerhaften Buchung, positiver oder negativer Wert ist möglich + +**SUBSCRIPTION**: **Beteiligung** mit Geschäftsanteilen, z.B. durch Beitrittserklärung, immer positiver Wert + +**CANCELLATION**: **Kündigung** von Geschäftsanteilen, z.B. durch Austritt, immer negativer Wert + + #### Anchor / Relation-Anchor siehe [Relation](#Relation) diff --git a/doc/hs-office-data-structure.md b/doc/hs-office-data-structure.md index 2844189a..112307a1 100644 --- a/doc/hs-office-data-structure.md +++ b/doc/hs-office-data-structure.md @@ -116,7 +116,7 @@ classDiagram +BankAccount refundBankAccount +String defaultPrefix: mei } - debitor-MeierGmbH o-- partner-MeierGmbH + debitor-MeierGmbH o.. partner-MeierGmbH debitor-MeierGmbH *-- rel-MeierGmbH-Buha class contactData-MeierGmbH-Buha { -- 2.39.5 From d2881884a159a5619f31d5cb148c9b3ecbc93992 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 25 Feb 2025 09:07:11 +0100 Subject: [PATCH 3/3] improved error message with partnerNumber additionally to just the UUID --- .../5100-hs-office-membership.sql | 7 +- ...ceMembershipRepositoryIntegrationTest.java | 90 ++++++++++--------- 2 files changed, 56 insertions(+), 41 deletions(-) diff --git a/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql b/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql index d4c20536..3c1be77c 100644 --- a/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql +++ b/src/main/resources/db/changelog/5-hs-office/510-membership/5100-hs-office-membership.sql @@ -38,6 +38,8 @@ create table if not exists hs_office.membership CREATE OR REPLACE FUNCTION hs_office.validate_membership_validity() RETURNS trigger AS $$ +DECLARE + partnerNumber int; BEGIN IF EXISTS ( SELECT 1 @@ -46,7 +48,10 @@ BEGIN AND uuid <> NEW.uuid AND NEW.validity && validity ) THEN - RAISE EXCEPTION 'Membership validity ranges overlap for partnerUuid %', NEW.partnerUuid; + SELECT p.partnerNumber INTO partnerNumber + FROM hs_office.partner AS p + WHERE p.uuid = NEW.partnerUuid; + RAISE EXCEPTION 'Membership validity ranges overlap for partnerUuid %, partnerNumber %', NEW.partnerUuid, partnerNumber; END IF; RETURN NEW; diff --git a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java index 57823a20..c05c1a34 100644 --- a/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java +++ b/src/test/java/net/hostsharing/hsadminng/hs/office/membership/HsOfficeMembershipRepositoryIntegrationTest.java @@ -4,10 +4,10 @@ import io.hypersistence.utils.hibernate.type.range.Range; import net.hostsharing.hsadminng.context.Context; import net.hostsharing.hsadminng.hs.office.debitor.HsOfficeDebitorRepository; import net.hostsharing.hsadminng.hs.office.partner.HsOfficePartnerRealRepository; -import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; +import net.hostsharing.hsadminng.mapper.Array; import net.hostsharing.hsadminng.rbac.grant.RawRbacGrantRepository; import net.hostsharing.hsadminng.rbac.role.RawRbacRoleRepository; -import net.hostsharing.hsadminng.mapper.Array; +import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup; import net.hostsharing.hsadminng.rbac.test.JpaAttempt; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Tag; @@ -15,9 +15,9 @@ import org.junit.jupiter.api.Test; import org.postgresql.util.PSQLException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.context.annotation.Import; import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -32,7 +32,7 @@ import static net.hostsharing.hsadminng.rbac.test.JpaAttempt.attempt; import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -@Import( { Context.class, JpaAttempt.class }) +@Import({ Context.class, JpaAttempt.class }) @Tag("officeIntegrationTest") class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCleanup { @@ -71,15 +71,16 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); // when - final var result = attempt(em, () -> { - final var newMembership = HsOfficeMembershipEntity.builder() - .memberNumberSuffix("11") - .partner(givenPartner) - .validity(Range.closedInfinite(LocalDate.parse("2025-01-01"))) - .membershipFeeBillable(true) - .build(); - return toCleanup(membershipRepo.save(newMembership).load()); - }); + final var result = attempt( + em, () -> { + final var newMembership = HsOfficeMembershipEntity.builder() + .memberNumberSuffix("11") + .partner(givenPartner) + .validity(Range.closedInfinite(LocalDate.parse("2025-01-01"))) + .membershipFeeBillable(true) + .build(); + return toCleanup(membershipRepo.save(newMembership).load()); + }); // then result.assertSuccessful(); @@ -92,22 +93,25 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl public void creatingMembershipForSamePartnerIsDisallowedIfAnotherOneIsStillActive() { // given context("superuser-alex@hostsharing.net"); - final var count = membershipRepo.count(); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").getFirst(); // when - final var result = attempt(em, () -> { - final var newMembership = HsOfficeMembershipEntity.builder() - .memberNumberSuffix("11") - .partner(givenPartner) - .validity(Range.closedInfinite(LocalDate.parse("2024-01-01"))) - .membershipFeeBillable(true) - .build(); - return toCleanup(membershipRepo.save(newMembership).load()); - }); + final var result = attempt( + em, () -> { + final var newMembership = HsOfficeMembershipEntity.builder() + .memberNumberSuffix("11") + .partner(givenPartner) + .validity(Range.closedInfinite(LocalDate.parse("2024-01-01"))) + .membershipFeeBillable(true) + .build(); + return toCleanup(membershipRepo.save(newMembership).load()); + }); // then - result.assertExceptionWithRootCauseMessage(PSQLException.class, "Membership validity ranges overlap for partnerUuid " + givenPartner.getUuid()); + result.assertExceptionWithRootCauseMessage( + PSQLException.class, + "Membership validity ranges overlap for partnerUuid " + givenPartner.getUuid() + + ", partnerNumber " + givenPartner.getPartnerNumber()); } @Test @@ -120,16 +124,17 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl .toList(); // when - attempt(em, () -> { - final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); - final var newMembership = HsOfficeMembershipEntity.builder() - .memberNumberSuffix("17") - .partner(givenPartner) - .validity(Range.closedInfinite(LocalDate.parse("2025-01-01"))) - .membershipFeeBillable(true) - .build(); - return toCleanup(membershipRepo.save(newMembership)); - }).assertSuccessful(); + attempt( + em, () -> { + final var givenPartner = partnerRepo.findPartnerByOptionalNameLike("First").get(0); + final var newMembership = HsOfficeMembershipEntity.builder() + .memberNumberSuffix("17") + .partner(givenPartner) + .validity(Range.closedInfinite(LocalDate.parse("2025-01-01"))) + .membershipFeeBillable(true) + .build(); + return toCleanup(membershipRepo.save(newMembership)); + }).assertSuccessful(); // then final var all = rawRoleRepo.findAll(); @@ -168,7 +173,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl private void assertThatMembershipIsPersisted(final HsOfficeMembershipEntity saved) { final var found = membershipRepo.findByUuid(saved.getUuid()); - assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()) ; + assertThat(found).isNotEmpty().get().extracting(Object::toString).isEqualTo(saved.toString()); } } @@ -201,7 +206,8 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl final var result = membershipRepo.findMembershipsByPartnerUuid(givenPartner.getUuid()); // then - exactlyTheseMembershipsAreReturned(result, + exactlyTheseMembershipsAreReturned( + result, "Membership(M-1000101, P-10001, [2022-10-01,2024-12-31), ACTIVE)"); } @@ -244,7 +250,8 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl final var result = membershipRepo.findMembershipsByPartnerNumber(10002); // then - exactlyTheseMembershipsAreReturned(result, + exactlyTheseMembershipsAreReturned( + result, "Membership(M-1000202, P-10002, [2022-10-01,2026-01-01), ACTIVE)"); } } @@ -256,7 +263,7 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl public void globalAdmin_canUpdateValidityOfArbitraryMembership() { // given context("superuser-alex@hostsharing.net"); - final var givenMembership = givenSomeTemporaryMembership("First", "11"); + final var givenMembership = givenSomeTemporaryMembership("First", "11"); assertThatMembershipExistsAndIsAccessibleToCurrentContext(givenMembership); final var newValidityEnd = LocalDate.now(); @@ -296,7 +303,8 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl }); // then - result.assertExceptionWithRootCauseMessage(JpaSystemException.class, + result.assertExceptionWithRootCauseMessage( + JpaSystemException.class, "[403] Subject ", " is not allowed to update hs_office.membership uuid"); } @@ -404,7 +412,9 @@ class HsOfficeMembershipRepositoryIntegrationTest extends ContextBasedTestWithCl "[creating Membership test-data, hs_office.membership, INSERT, 03]"); } - private HsOfficeMembershipEntity givenSomeTemporaryMembership(final String partnerTradeName, final String memberNumberSuffix) { + private HsOfficeMembershipEntity givenSomeTemporaryMembership( + final String partnerTradeName, + final String memberNumberSuffix) { return jpaAttempt.transacted(() -> { context("superuser-alex@hostsharing.net"); final var givenPartner = partnerRepo.findPartnerByOptionalNameLike(partnerTradeName).get(0); -- 2.39.5