diff --git a/CLAUDE.md b/CLAUDE.md index d2b48b2f..129612af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,16 +4,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -OpenESPI-GreenButton-Java is a monorepo implementation of the NAESB Energy Services Provider Interface (ESPI) 4.0 specification for Green Button energy data standards. The project has been migrated to Java 21, Jakarta EE 9+, and Spring Boot 3.5. +OpenESPI-GreenButton-Java is a monorepo implementation of the NAESB Energy Services Provider Interface (ESPI) 4.0 specification for Green Button energy data standards. The project has been migrated to Java 25, Jakarta EE 10+, and Spring Boot 4.0. ## Build and Test Commands ### Build All Modules ```bash -# From repository root +# From repository root - builds all modules (common, datacustodian, thirdparty, authserver) mvn clean install -# Build only fully-migrated Spring Boot 3.5 modules (excludes thirdparty) +# Build only core modules (common, datacustodian, thirdparty) - omits authserver mvn clean install -Pspring-boot-only ``` @@ -43,7 +43,7 @@ cd openespi-datacustodian && mvn spring-boot:run -Dspring-boot.run.profiles=dev- # Authorization Server cd openespi-authserver && mvn spring-boot:run -# Third Party (when migration complete) +# Third Party cd openespi-thirdparty && mvn spring-boot:run ``` @@ -122,7 +122,7 @@ REST controllers are in `openespi-datacustodian/src/main/java/org/greenbuttonall - **web/custodian/** - Data custodian-specific endpoints - **web/customer/** - Retail customer portal endpoints -Note: Many REST controllers have `.disabled` extension during the Spring Boot 3.5 migration. They need to be re-enabled and tested after core functionality is validated. +Note: Many REST controllers have `.disabled` extension during the Spring Boot 4.0 migration. They need to be re-enabled and tested after core functionality is validated. ### DTO and Mapping Layer The project uses MapStruct for entity-to-DTO mappings: @@ -158,15 +158,15 @@ IMPORTANT: When adding/modifying entities, ensure Flyway migration scripts are u ## Key Technologies -### Spring Boot 3.5 Stack -- **Spring Boot**: 3.5.0 -- **Spring Security**: 6.x (OAuth2 Resource Server and Client) -- **Spring Data JPA**: 3.x with Hibernate 6.x +### Spring Boot 4.0 Stack +- **Spring Boot**: 4.0.1 +- **Spring Security**: 7.x (OAuth2 Resource Server and Client) +- **Spring Data JPA**: 4.x with Hibernate 7.x - **Spring Authorization Server**: Latest (for openespi-authserver only) ### Persistence -- **JPA/Hibernate**: 6.x with Jakarta Persistence API -- **UUID Primary Keys**: All entities use UUID instead of Long IDs +- **JPA/Hibernate**: 7.x with Jakarta Persistence API 3.2 +- **UUID Primary Keys**: All entities use UUID Version 5 instead of Long IDs - **Flyway**: Database migration management - **HikariCP**: Connection pooling @@ -178,8 +178,8 @@ IMPORTANT: When adding/modifying entities, ensure Flyway migration scripts are u ### Build Tools - **Maven**: 3.9+ -- **MapStruct**: 1.6.0 for DTO mapping -- **Lombok**: 1.18.34 for reducing boilerplate +- **MapStruct**: 1.6.3 for DTO mapping +- **Lombok**: 1.18.42 for reducing boilerplate ## ESPI 4.0 Compliance @@ -259,25 +259,30 @@ ESPI uses Atom XML feeds for data exchange. Key patterns: ## Migration Status -The codebase is actively being migrated to Spring Boot 3.5. Key migration achievements: -- Java 21 upgrade complete across all modules -- Jakarta EE 9+ migration complete (javax → jakarta namespace) -- Spring Boot 3.5 migration complete for common, datacustodian, authserver -- UUID primary keys migrated from Long IDs -- OAuth2 modernized with Spring Security 6.x patterns +The codebase has been migrated to Spring Boot 4.0.1 and Java 25. Key migration achievements: +- Java 25 upgrade complete across all modules +- Jakarta EE 10+ migration complete (javax → jakarta namespace) +- Spring Boot 4.0.1 migration complete for common, datacustodian, authserver +- UUID Version 5 primary keys migrated from Long IDs +- OAuth2 modernized with Spring Security 7.x patterns - RestTemplate replaced with WebClient +- Spring Data JPA 4.x with Hibernate 7.x ### Known Issues Check migration status documents for current issues: -- `2025-07-15_Claude_Code_Spring_Boot_3.5_Migration_Plan.md` - Overall migration plan -- `openespi-common/SPRING_BOOT_3.5_MIGRATION_STATUS.md` - Common module status +- `2025-07-15_Claude_Code_Spring_Boot_3.5_Migration_Plan.md` - Historical migration plan +- `openespi-common/SPRING_BOOT_3.5_MIGRATION_STATUS.md` - Historical common module status - `openespi-authserver/MIGRATION_ROADMAP.md` - Auth server status -## Future Updates +## Current Technology Stack -Planned technology upgrades: -- **Java 25**: Upgrade from Java 21 to Java 25 LTS when released -- **Spring Boot 4.0**: Migrate from Spring Boot 3.5 to Spring Boot 4.0 +Current versions: +- **Java**: 25 +- **Spring Boot**: 4.0.1 +- **Spring Security**: 7.x +- **Spring Data JPA**: 4.x +- **Hibernate**: 7.x +- **Jakarta EE**: 10+ ## OAuth2 Security @@ -297,7 +302,7 @@ The system implements OAuth2 authorization code flow: ## Troubleshooting ### Build Failures -- Ensure Java 21 is installed: `java -version` +- Ensure Java 25 is installed: `java -version` - Clean build: `mvn clean install` - Check for profile-specific issues: review active Spring profile @@ -322,7 +327,7 @@ The system implements OAuth2 authorization code flow: - **README.md** - Project overview and quick start - **BRANCH_STRATEGY.md** - Git workflow and branching strategy -- **2025-07-15_Claude_Code_Spring_Boot_3.5_Migration_Plan.md** - Migration status +- **2025-07-15_Claude_Code_Spring_Boot_3.5_Migration_Plan.md** - Historical migration status (Spring Boot 3.5 to 4.0) - **openespi-common/CONTRIBUTING.md** - Contribution guidelines - **openespi-authserver/DEPLOYMENT_GUIDE.md** - Production deployment - **openespi-authserver/CERTIFICATE_AUTHENTICATION.md** - Client certificate auth \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/AccountNotification.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/AccountNotification.java index 2ecef3af..b8917dd3 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/AccountNotification.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/AccountNotification.java @@ -19,13 +19,16 @@ package org.greenbuttonalliance.espi.common.domain.customer.entity; +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import lombok.Data; -import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import lombok.ToString; import org.greenbuttonalliance.espi.common.domain.customer.enums.NotificationMethodKind; -import jakarta.persistence.*; +import java.io.Serializable; import java.time.OffsetDateTime; /** @@ -38,7 +41,7 @@ @Data @NoArgsConstructor @ToString -public class AccountNotification { +public class AccountNotification implements Serializable { /** * Method by which the customer was notified. @@ -64,11 +67,4 @@ public class AccountNotification { */ @Column(name = "customer_notification_kind", length = 256) private String customerNotificationKind; - - /** - * Customer account this notification belongs to - * Note: This should be handled at the Entity level, not in an Embeddable - */ - // @Embedded - Removed as this creates circular reference issues - // private CustomerAccountEntity customerAccount; } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java index 99f275bd..3a4cdf4e 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/CustomerAccountEntity.java @@ -42,22 +42,33 @@ */ @Entity @Table(name = "customer_accounts") -@AttributeOverrides({ - // Resolve any potential column conflicts by ensuring unique column names - @AttributeOverride(name = "upLink.rel", column = @Column(name = "customer_account_up_link_rel")), - @AttributeOverride(name = "upLink.href", column = @Column(name = "customer_account_up_link_href")), - @AttributeOverride(name = "upLink.type", column = @Column(name = "customer_account_up_link_type")), - @AttributeOverride(name = "selfLink.rel", column = @Column(name = "customer_account_self_link_rel")), - @AttributeOverride(name = "selfLink.href", column = @Column(name = "customer_account_self_link_href")), - @AttributeOverride(name = "selfLink.type", column = @Column(name = "customer_account_self_link_type")) -}) +// Resolve any potential column conflicts by ensuring unique column names +@AttributeOverride(name = "upLink.rel", column = @Column(name = "customer_account_up_link_rel")) +@AttributeOverride(name = "upLink.href", column = @Column(name = "customer_account_up_link_href")) +@AttributeOverride(name = "upLink.type", column = @Column(name = "customer_account_up_link_type")) +@AttributeOverride(name = "selfLink.rel", column = @Column(name = "customer_account_self_link_rel")) +@AttributeOverride(name = "selfLink.href", column = @Column(name = "customer_account_self_link_href")) +@AttributeOverride(name = "selfLink.type", column = @Column(name = "customer_account_self_link_type")) @Getter @Setter @NoArgsConstructor public class CustomerAccountEntity extends IdentifiedObject { // Document fields (previously inherited from Document superclass) - + // Field order matches customer.xsd Document type definition (lines 819-872) + + /** + * Type of this document. + */ + @Column(name = "document_type", length = 256) + private String type; + + /** + * Name of the author of this document. + */ + @Column(name = "author_name", length = 256) + private String authorName; + /** * Date and time that this document was created. */ @@ -76,6 +87,12 @@ public class CustomerAccountEntity extends IdentifiedObject { @Column(name = "revision_number", length = 256) private String revisionNumber; + /** + * Electronic address for the document. + */ + @Embedded + private Organisation.ElectronicAddress electronicAddress; + /** * Subject of this document, intended for this document to be found by a search engine. */ @@ -89,10 +106,10 @@ public class CustomerAccountEntity extends IdentifiedObject { private String title; /** - * Type of this document. + * Status of this document. */ - @Column(name = "document_type", length = 256) - private String type; + @Embedded + private Status docStatus; // CustomerAccount specific fields @@ -123,11 +140,26 @@ public class CustomerAccountEntity extends IdentifiedObject { private List notifications; /** - * [extension] Customer contact information used to identify individual + * [extension] Customer contact information used to identify individual * responsible for billing and payment of CustomerAccount. */ - @Column(name = "contact_name", length = 256) - private String contactInfo; + @Embedded + @AttributeOverride(name = "organisationName", column = @Column(name = "organisation_name")) + @AttributeOverride(name = "streetAddress.streetDetail", column = @Column(name = "street_detail")) + @AttributeOverride(name = "streetAddress.townDetail", column = @Column(name = "town_detail")) + @AttributeOverride(name = "streetAddress.stateOrProvince", column = @Column(name = "state_or_province")) + @AttributeOverride(name = "streetAddress.postalCode", column = @Column(name = "postal_code")) + @AttributeOverride(name = "streetAddress.country", column = @Column(name = "country")) + @AttributeOverride(name = "postalAddress.streetDetail", column = @Column(name = "postal_street_detail")) + @AttributeOverride(name = "postalAddress.townDetail", column = @Column(name = "postal_town_detail")) + @AttributeOverride(name = "postalAddress.stateOrProvince", column = @Column(name = "postal_state_or_province")) + @AttributeOverride(name = "postalAddress.postalCode", column = @Column(name = "postal_postal_code")) + @AttributeOverride(name = "postalAddress.country", column = @Column(name = "postal_country")) + @AttributeOverride(name = "electronicAddress.email1", column = @Column(name = "contact_email1")) + @AttributeOverride(name = "electronicAddress.email2", column = @Column(name = "contact_email2")) + @AttributeOverride(name = "electronicAddress.web", column = @Column(name = "contact_web")) + @AttributeOverride(name = "electronicAddress.radio", column = @Column(name = "contact_radio")) + private Organisation contactInfo; /** * [extension] Customer account identifier @@ -154,8 +186,8 @@ public class CustomerAccountEntity extends IdentifiedObject { public final boolean equals(Object o) { if (this == o) return true; if (o == null) return false; - Class oEffectiveClass = o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass(); - Class thisEffectiveClass = this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass() : this.getClass(); + Class oEffectiveClass = o instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : o.getClass(); + Class thisEffectiveClass = this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass() : this.getClass(); if (thisEffectiveClass != oEffectiveClass) return false; CustomerAccountEntity that = (CustomerAccountEntity) o; return getId() != null && Objects.equals(getId(), that.getId()); @@ -163,19 +195,22 @@ public final boolean equals(Object o) { @Override public final int hashCode() { - return this instanceof HibernateProxy ? ((HibernateProxy) this).getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); + return this instanceof HibernateProxy hibernateProxy ? hibernateProxy.getHibernateLazyInitializer().getPersistentClass().hashCode() : getClass().hashCode(); } @Override public String toString() { return getClass().getSimpleName() + "(" + "id = " + getId() + ", " + + "type = " + getType() + ", " + + "authorName = " + getAuthorName() + ", " + "createdDateTime = " + getCreatedDateTime() + ", " + "lastModifiedDateTime = " + getLastModifiedDateTime() + ", " + "revisionNumber = " + getRevisionNumber() + ", " + + "electronicAddress = " + getElectronicAddress() + ", " + "subject = " + getSubject() + ", " + "title = " + getTitle() + ", " + - "type = " + getType() + ", " + + "docStatus = " + getDocStatus() + ", " + "billingCycle = " + getBillingCycle() + ", " + "budgetBill = " + getBudgetBill() + ", " + "lastBillAmount = " + getLastBillAmount() + ", " + diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java index a93ba561..ad5472b1 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Organisation.java @@ -24,6 +24,8 @@ import jakarta.persistence.Embedded; import lombok.*; +import java.io.Serializable; + /** * Embeddable class for Organisation information. * @@ -35,7 +37,7 @@ @Setter @NoArgsConstructor @ToString -public class Organisation { +public class Organisation implements Serializable { /** * Organisation name (replaces deprecated 'name' field) @@ -70,7 +72,7 @@ public class Organisation { @Embeddable @Data @NoArgsConstructor - public static class StreetAddress { + public static class StreetAddress implements Serializable { @Column(name = "street_detail", length = 256) private String streetDetail; @@ -95,7 +97,7 @@ public static class StreetAddress { @Embeddable @Data @NoArgsConstructor - public static class ElectronicAddress { + public static class ElectronicAddress implements Serializable { @Column(name = "email1", length = 256) private String email1; diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Status.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Status.java new file mode 100644 index 00000000..a5f2538f --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/domain/customer/entity/Status.java @@ -0,0 +1,60 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.domain.customer.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.time.OffsetDateTime; + +/** + * Embeddable class for Status information. + * + * Current status information relevant to an entity. + * Per customer.xsd lines 1149-1173. + */ +@Embeddable +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Status implements Serializable { + + /** + * Status value. + */ + @Column(name = "status_value", length = 256) + private String value; + + /** + * Date and time status was last changed. + */ + @Column(name = "status_date_time") + private OffsetDateTime dateTime; + + /** + * Reason for status change. + */ + @Column(name = "status_reason", length = 512) + private String reason; +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java index 56154e95..aa539409 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDto.java @@ -19,13 +19,12 @@ package org.greenbuttonalliance.espi.common.dto.customer; -import org.greenbuttonalliance.espi.common.dto.atom.LinkDto; - import jakarta.xml.bind.annotation.*; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.greenbuttonalliance.espi.common.domain.customer.enums.NotificationMethodKind; import java.time.OffsetDateTime; import java.util.List; @@ -33,15 +32,20 @@ /** * CustomerAccount DTO class for JAXB XML marshalling/unmarshalling. * - * Represents a customer account with billing and payment information. - * Supports Atom protocol XML wrapping. + * Assignment of a group of products and services purchased by the customer through a + * customer agreement, used as a mechanism for customer billing and payment. + * It contains common information from the various types of customer agreements to + * create billings (invoices) for a customer and receive payment. + * + * Extends Document type. Field order matches customer.xsd CustomerAccount definition (lines 118-158) + * and Document base type (lines 819-872). */ @XmlRootElement(name = "CustomerAccount", namespace = "http://naesb.org/espi/customer") @XmlAccessorType(XmlAccessType.FIELD) @XmlType(name = "CustomerAccount", namespace = "http://naesb.org/espi/customer", propOrder = { - "published", "updated", "selfLink", "upLink", "relatedLinks", - "description", "accountId", "accountNumber", "budgetBill", "billingCycle", - "lastBillAmount", "transactionDate", "isPrePay", "customer", "customerAgreements" + "type", "authorName", "createdDateTime", "lastModifiedDateTime", "revisionNumber", + "electronicAddress", "subject", "title", "docStatus", + "billingCycle", "budgetBill", "lastBillAmount", "notifications", "contactInfo", "accountId" }) @Getter @Setter @@ -50,105 +54,173 @@ public class CustomerAccountDto { @XmlTransient - private Long id; - - @XmlAttribute(name = "mRID") private String uuid; - @XmlElement(name = "published") - private OffsetDateTime published; + // ========== Document fields (customer.xsd lines 819-872) ========== - @XmlElement(name = "updated") - private OffsetDateTime updated; - - @XmlElement(name = "link", namespace = "http://www.w3.org/2005/Atom") - @XmlElementWrapper(name = "links", namespace = "http://www.w3.org/2005/Atom") - private List relatedLinks; + /** + * Type of this document. + */ + @XmlElement(name = "type", namespace = "http://naesb.org/espi/customer") + private String type; - @XmlElement(name = "link", namespace = "http://www.w3.org/2005/Atom") - private LinkDto selfLink; + /** + * Name of the author of this document. + */ + @XmlElement(name = "authorName", namespace = "http://naesb.org/espi/customer") + private String authorName; - @XmlElement(name = "link", namespace = "http://www.w3.org/2005/Atom") - private LinkDto upLink; + /** + * Date and time that this document was created. + */ + @XmlElement(name = "createdDateTime", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime createdDateTime; - @XmlElement(name = "description") - private String description; + /** + * Date and time that this document was last modified. + */ + @XmlElement(name = "lastModifiedDateTime", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime lastModifiedDateTime; - @XmlElement(name = "accountId") - private String accountId; + /** + * Revision number for this document. + */ + @XmlElement(name = "revisionNumber", namespace = "http://naesb.org/espi/customer") + private String revisionNumber; - @XmlElement(name = "accountNumber") - private String accountNumber; + /** + * Electronic address for the document. + */ + @XmlElement(name = "electronicAddress", namespace = "http://naesb.org/espi/customer") + private CustomerDto.ElectronicAddressDto electronicAddress; - @XmlElement(name = "budgetBill") - private String budgetBill; + /** + * Subject of this document, intended for this document to be found by a search engine. + */ + @XmlElement(name = "subject", namespace = "http://naesb.org/espi/customer") + private String subject; - @XmlElement(name = "billingCycle") - private String billingCycle; + /** + * Title of this document. + */ + @XmlElement(name = "title", namespace = "http://naesb.org/espi/customer") + private String title; - @XmlElement(name = "lastBillAmount") - private Long lastBillAmount; + /** + * Status of this document. + */ + @XmlElement(name = "docStatus", namespace = "http://naesb.org/espi/customer") + private StatusDto docStatus; - @XmlElement(name = "transactionDate") - private OffsetDateTime transactionDate; + // ========== CustomerAccount fields (customer.xsd lines 118-158) ========== - @XmlElement(name = "isPrePay") - private Boolean isPrePay; + /** + * Cycle day on which the associated customer account will normally be billed, + * used to determine when to produce the billing. + */ + @XmlElement(name = "billingCycle", namespace = "http://naesb.org/espi/customer") + private String billingCycle; - @XmlElement(name = "Customer") - private CustomerDto customer; + /** + * Budget bill code. + */ + @XmlElement(name = "budgetBill", namespace = "http://naesb.org/espi/customer") + private String budgetBill; - @XmlElement(name = "CustomerAgreement") - @XmlElementWrapper(name = "CustomerAgreements") - private List customerAgreements; + /** + * The last amount that will be billed to the customer prior to shut off of the account. + */ + @XmlElement(name = "lastBillAmount", namespace = "http://naesb.org/espi/customer") + private Long lastBillAmount; /** - * Minimal constructor for basic account data. + * Set of customer account notifications. */ - public CustomerAccountDto(String uuid, String accountNumber) { - this(null, uuid, null, null, null, null, null, null, - null, accountNumber, null, null, null, null, null, null, null); - } + @XmlElement(name = "AccountNotification", namespace = "http://naesb.org/espi/customer") + @XmlElementWrapper(name = "notifications", namespace = "http://naesb.org/espi/customer") + private List notifications; /** - * Gets the self href for this customer account. - * - * @return self href string + * [extension] Customer contact information used to identify individual + * responsible for billing and payment of CustomerAccount. */ - public String getSelfHref() { - return selfLink != null ? selfLink.getHref() : null; - } + @XmlElement(name = "contactInfo", namespace = "http://naesb.org/espi/customer") + private CustomerDto.OrganisationDto contactInfo; /** - * Gets the up href for this customer account. - * - * @return up href string + * [extension] Customer account identifier. */ - public String getUpHref() { - return upLink != null ? upLink.getHref() : null; - } + @XmlElement(name = "accountId", namespace = "http://naesb.org/espi/customer") + private String accountId; /** - * Generates the default self href for a customer account. - * - * @return default self href + * Nested DTO for Status information. + * Matches customer.xsd Status type (lines 1149-1173). + * XML type name is "DocStatus" to avoid conflict with CustomerDto.StatusDto. */ - public String generateSelfHref() { - if (uuid != null && customer != null && customer.getUuid() != null) { - return "/espi/1_1/resource/Customer/" + customer.getUuid() + "/CustomerAccount/" + uuid; - } - return uuid != null ? "/espi/1_1/resource/CustomerAccount/" + uuid : null; + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "DocStatus", namespace = "http://naesb.org/espi/customer", propOrder = { + "value", "dateTime", "reason" + }) + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class StatusDto { + /** + * Status value. + */ + @XmlElement(name = "value", namespace = "http://naesb.org/espi/customer") + private String value; + + /** + * Date and time status was last changed. + */ + @XmlElement(name = "dateTime", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime dateTime; + + /** + * Reason for status change. + */ + @XmlElement(name = "reason", namespace = "http://naesb.org/espi/customer") + private String reason; } /** - * Generates the default up href for a customer account. - * - * @return default up href + * Nested DTO for AccountNotification information. + * [extension] Customer action notification (e.g., delinquency, move in, move out). */ - public String generateUpHref() { - if (customer != null && customer.getUuid() != null) { - return "/espi/1_1/resource/Customer/" + customer.getUuid() + "/CustomerAccount"; - } - return "/espi/1_1/resource/CustomerAccount"; + @XmlAccessorType(XmlAccessType.FIELD) + @XmlType(name = "AccountNotification", namespace = "http://naesb.org/espi/customer", propOrder = { + "methodKind", "time", "note", "customerNotificationKind" + }) + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class AccountNotificationDto { + /** + * Method by which the customer was notified. + */ + @XmlElement(name = "methodKind", namespace = "http://naesb.org/espi/customer") + private NotificationMethodKind methodKind; + + /** + * Time/date of notification. + */ + @XmlElement(name = "time", namespace = "http://naesb.org/espi/customer") + private OffsetDateTime time; + + /** + * Annotation of the reason for the notification. + */ + @XmlElement(name = "note", namespace = "http://naesb.org/espi/customer") + private String note; + + /** + * Type of customer notification (delinquency, move in, move out ...). + */ + @XmlElement(name = "customerNotificationKind", namespace = "http://naesb.org/espi/customer") + private String customerNotificationKind; } -} \ No newline at end of file +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/AccountNotificationMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/AccountNotificationMapper.java new file mode 100644 index 00000000..93f4f7a0 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/AccountNotificationMapper.java @@ -0,0 +1,48 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.mapper.customer; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.AccountNotification; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerAccountDto; +import org.greenbuttonalliance.espi.common.mapper.DateTimeMapper; +import org.mapstruct.Mapper; + +/** + * MapStruct mapper for converting between AccountNotification entity and DTO. + */ +@Mapper(componentModel = "spring", uses = {DateTimeMapper.class}) +public interface AccountNotificationMapper { + + /** + * Converts an AccountNotification entity to a DTO. + * + * @param entity the account notification entity + * @return the account notification DTO + */ + CustomerAccountDto.AccountNotificationDto toDto(AccountNotification entity); + + /** + * Converts an AccountNotification DTO to an entity. + * + * @param dto the account notification DTO + * @return the account notification entity + */ + AccountNotification toEntity(CustomerAccountDto.AccountNotificationDto dto); +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAccountMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAccountMapper.java index c4eb3336..fe6c2be4 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAccountMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/CustomerAccountMapper.java @@ -25,7 +25,6 @@ import org.greenbuttonalliance.espi.common.mapper.DateTimeMapper; import org.mapstruct.Mapper; import org.mapstruct.Mapping; -import org.mapstruct.MappingTarget; /** * MapStruct mapper for converting between CustomerAccountEntity and CustomerAccountDto. @@ -33,49 +32,80 @@ * Handles the conversion between the JPA entity used for persistence and the DTO * used for JAXB XML marshalling in the Green Button API. *

- * Maps only customer.xsd CustomerAccount fields. IdentifiedObject fields are NOT part of - * the customer.xsd CustomerAccount definition and are handled by AtomFeedDto/AtomEntryDto. + * Maps customer.xsd CustomerAccount fields including Document base fields. + * Per customer.xsd lines 118-158 (CustomerAccount) and 819-872 (Document base type). */ @Mapper(componentModel = "spring", uses = { DateTimeMapper.class, - BaseMapperUtils.class + BaseMapperUtils.class, + ElectronicAddressMapper.class, + OrganisationMapper.class, + StatusMapper.class, + AccountNotificationMapper.class }) public interface CustomerAccountMapper { /** * Converts a CustomerAccountEntity to a CustomerAccountDto. - * Maps only customer.xsd CustomerAccount fields. + * Maps all Document fields and CustomerAccount-specific fields per customer.xsd. * * @param entity the customer account entity * @return the customer account DTO */ - @Mapping(target = "id", ignore = true) // IdentifiedObject field handled by Atom layer - @Mapping(target = "accountId", source = "accountId") - @Mapping(target = "accountNumber", ignore = true) // Not in entity - @Mapping(target = "budgetBill", source = "budgetBill") + @Mapping(target = "uuid", source = "id") + // Document fields + @Mapping(target = "type", source = "type") + @Mapping(target = "authorName", source = "authorName") + @Mapping(target = "createdDateTime", source = "createdDateTime") + @Mapping(target = "lastModifiedDateTime", source = "lastModifiedDateTime") + @Mapping(target = "revisionNumber", source = "revisionNumber") + @Mapping(target = "electronicAddress", source = "electronicAddress") + @Mapping(target = "subject", source = "subject") + @Mapping(target = "title", source = "title") + @Mapping(target = "docStatus", source = "docStatus") + // CustomerAccount fields @Mapping(target = "billingCycle", source = "billingCycle") + @Mapping(target = "budgetBill", source = "budgetBill") @Mapping(target = "lastBillAmount", source = "lastBillAmount") - @Mapping(target = "transactionDate", ignore = true) // Not in entity - @Mapping(target = "isPrePay", source = "isPrePay") - @Mapping(target = "customer", ignore = true) // Relationship handled separately - @Mapping(target = "customerAgreements", ignore = true) // Relationship handled separately + @Mapping(target = "notifications", source = "notifications") + @Mapping(target = "contactInfo", source = "contactInfo") + @Mapping(target = "accountId", source = "accountId") CustomerAccountDto toDto(CustomerAccountEntity entity); /** * Converts a CustomerAccountDto to a CustomerAccountEntity. - * Maps only customer.xsd CustomerAccount fields. + * Maps all Document fields and CustomerAccount-specific fields per customer.xsd. * * @param dto the customer account DTO * @return the customer account entity */ - @Mapping(target = "id", ignore = true) // IdentifiedObject field handled by Atom layer - @Mapping(target = "accountId", source = "accountId") - @Mapping(target = "budgetBill", source = "budgetBill") + @Mapping(target = "id", source = "uuid") + // Document fields + @Mapping(target = "type", source = "type") + @Mapping(target = "authorName", source = "authorName") + @Mapping(target = "createdDateTime", source = "createdDateTime") + @Mapping(target = "lastModifiedDateTime", source = "lastModifiedDateTime") + @Mapping(target = "revisionNumber", source = "revisionNumber") + @Mapping(target = "electronicAddress", source = "electronicAddress") + @Mapping(target = "subject", source = "subject") + @Mapping(target = "title", source = "title") + @Mapping(target = "docStatus", source = "docStatus") + // CustomerAccount fields @Mapping(target = "billingCycle", source = "billingCycle") + @Mapping(target = "budgetBill", source = "budgetBill") @Mapping(target = "lastBillAmount", source = "lastBillAmount") - @Mapping(target = "isPrePay", source = "isPrePay") - @Mapping(target = "notifications", ignore = true) // Relationship handled separately - @Mapping(target = "contactInfo", ignore = true) // Relationship handled separately + @Mapping(target = "notifications", source = "notifications") + @Mapping(target = "contactInfo", source = "contactInfo") + @Mapping(target = "accountId", source = "accountId") + // Entity-only fields not in DTO + @Mapping(target = "isPrePay", ignore = true) // Not in customer.xsd CustomerAccount + @Mapping(target = "customer", ignore = true) // Relationship handled separately + // IdentifiedObject fields (inherited) - handled by Atom layer + @Mapping(target = "description", ignore = true) + @Mapping(target = "created", ignore = true) + @Mapping(target = "updated", ignore = true) + @Mapping(target = "published", ignore = true) + @Mapping(target = "selfLink", ignore = true) + @Mapping(target = "upLink", ignore = true) CustomerAccountEntity toEntity(CustomerAccountDto dto); - } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/ElectronicAddressMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/ElectronicAddressMapper.java new file mode 100644 index 00000000..8367e075 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/ElectronicAddressMapper.java @@ -0,0 +1,47 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.mapper.customer; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.Organisation; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.mapstruct.Mapper; + +/** + * MapStruct mapper for converting between ElectronicAddress entity and DTO. + */ +@Mapper(componentModel = "spring") +public interface ElectronicAddressMapper { + + /** + * Converts an ElectronicAddress entity to a DTO. + * + * @param entity the electronic address entity + * @return the electronic address DTO + */ + CustomerDto.ElectronicAddressDto toDto(Organisation.ElectronicAddress entity); + + /** + * Converts an ElectronicAddress DTO to an entity. + * + * @param dto the electronic address DTO + * @return the electronic address entity + */ + Organisation.ElectronicAddress toEntity(CustomerDto.ElectronicAddressDto dto); +} \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/OrganisationMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/OrganisationMapper.java new file mode 100644 index 00000000..05deaa72 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/OrganisationMapper.java @@ -0,0 +1,55 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.mapper.customer; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.Organisation; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +/** + * MapStruct mapper for converting between Organisation entity and DTO. + */ +@Mapper(componentModel = "spring", uses = { + StreetAddressMapper.class, + ElectronicAddressMapper.class +}) +public interface OrganisationMapper { + + /** + * Converts an Organisation entity to a DTO. + * Note: phone1 and phone2 are not included in the entity due to JPA column mapping conflicts. + * + * @param entity the organisation entity + * @return the organisation DTO + */ + @Mapping(target = "phone1", ignore = true) // Not in entity - managed separately + @Mapping(target = "phone2", ignore = true) // Not in entity - managed separately + CustomerDto.OrganisationDto toDto(Organisation entity); + + /** + * Converts an Organisation DTO to an entity. + * Note: phone1 and phone2 are not included in the entity due to JPA column mapping conflicts. + * + * @param dto the organisation DTO + * @return the organisation entity + */ + Organisation toEntity(CustomerDto.OrganisationDto dto); +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/StatusMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/StatusMapper.java new file mode 100644 index 00000000..6466d3b0 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/StatusMapper.java @@ -0,0 +1,48 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.mapper.customer; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.Status; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerAccountDto; +import org.greenbuttonalliance.espi.common.mapper.DateTimeMapper; +import org.mapstruct.Mapper; + +/** + * MapStruct mapper for converting between Status entity and DTO. + */ +@Mapper(componentModel = "spring", uses = {DateTimeMapper.class}) +public interface StatusMapper { + + /** + * Converts a Status entity to a DTO. + * + * @param entity the status entity + * @return the status DTO + */ + CustomerAccountDto.StatusDto toDto(Status entity); + + /** + * Converts a Status DTO to an entity. + * + * @param dto the status DTO + * @return the status entity + */ + Status toEntity(CustomerAccountDto.StatusDto dto); +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/StreetAddressMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/StreetAddressMapper.java new file mode 100644 index 00000000..f2e3e629 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/customer/StreetAddressMapper.java @@ -0,0 +1,47 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.mapper.customer; + +import org.greenbuttonalliance.espi.common.domain.customer.entity.Organisation; +import org.greenbuttonalliance.espi.common.dto.customer.CustomerDto; +import org.mapstruct.Mapper; + +/** + * MapStruct mapper for converting between StreetAddress entity and DTO. + */ +@Mapper(componentModel = "spring") +public interface StreetAddressMapper { + + /** + * Converts a StreetAddress entity to a DTO. + * + * @param entity the street address entity + * @return the street address DTO + */ + CustomerDto.StreetAddressDto toDto(Organisation.StreetAddress entity); + + /** + * Converts a StreetAddress DTO to an entity. + * + * @param dto the street address DTO + * @return the street address entity + */ + Organisation.StreetAddress toEntity(CustomerDto.StreetAddressDto dto); +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAccountRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAccountRepository.java index 2445ab3d..e0d7abb4 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAccountRepository.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerAccountRepository.java @@ -21,61 +21,19 @@ import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAccountEntity; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.List; -import java.util.Optional; import java.util.UUID; /** * Spring Data JPA repository for CustomerAccount entities. *

* Manages customer billing and payment account data with proper PII separation. + * Per Phase 18 guidelines, only ID-based queries on indexed fields are supported. + * All queries use the primary key (UUID) provided by JpaRepository. */ @Repository public interface CustomerAccountRepository extends JpaRepository { - - /** - * Find customer account by account ID. - */ - @Query("SELECT ca FROM CustomerAccountEntity ca WHERE ca.accountId = :accountId") - Optional findByAccountId(@Param("accountId") String accountId); - - /** - * Find customer accounts by billing cycle. - */ - @Query("SELECT ca FROM CustomerAccountEntity ca WHERE ca.billingCycle = :billingCycle") - List findByBillingCycle(@Param("billingCycle") String billingCycle); - - /** - * Find customer accounts that are pre-pay. - */ - @Query("SELECT ca FROM CustomerAccountEntity ca WHERE ca.isPrePay = true") - List findPrePayAccounts(); - - /** - * Find customer accounts with budget billing. - */ - @Query("SELECT ca FROM CustomerAccountEntity ca WHERE ca.budgetBill IS NOT NULL AND ca.budgetBill != ''") - List findBudgetBillAccounts(); - - /** - * Find customer accounts by contact info (now simplified to String). - */ - @Query("SELECT ca FROM CustomerAccountEntity ca WHERE ca.contactInfo = :contactInfo") - List findByContactInfo(@Param("contactInfo") String contactInfo); - - /** - * Find customer accounts with last bill amount greater than specified value. - */ - @Query("SELECT ca FROM CustomerAccountEntity ca WHERE ca.lastBillAmount > :amount") - List findByLastBillAmountGreaterThan(@Param("amount") Long amount); - - /** - * Find customer accounts by title (from Document base class). - */ - @Query("SELECT ca FROM CustomerAccountEntity ca WHERE UPPER(ca.title) LIKE UPPER(CONCAT('%', :title, '%'))") - List findByTitleContaining(@Param("title") String title); -} \ No newline at end of file + // All query methods inherited from JpaRepository use the primary key UUID index + // findById(UUID id), findAll(), save(), delete(), etc. +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAccountService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAccountService.java index 58b48e79..302653e2 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAccountService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/CustomerAccountService.java @@ -27,9 +27,10 @@ /** * Service interface for CustomerAccount management. - * + * * Handles business logic for customer account operations including billing, * payment tracking, and account status management. + * Per Phase 18 guidelines, only ID-based operations on indexed fields are supported. */ public interface CustomerAccountService { @@ -38,50 +39,10 @@ public interface CustomerAccountService { */ List findAll(); - /** - * Find customer account by ID. - */ - Optional findById(UUID id); - /** * Find customer account by UUID. */ - Optional findByUuid(String uuid); - - /** - * Find customer account by account ID. - */ - Optional findByAccountId(String accountId); - - /** - * Find customer accounts by billing cycle. - */ - List findByBillingCycle(String billingCycle); - - /** - * Find pre-pay accounts. - */ - List findPrePayAccounts(); - - /** - * Find accounts with budget billing. - */ - List findBudgetBillAccounts(); - - /** - * Find customer accounts by contact info organisation ID. - */ - List findByContactInfo(String contactInfo); - - /** - * Find customer accounts by last bill amount greater than specified value. - */ - List findByLastBillAmountGreaterThan(Long amount); - - /** - * Find customer accounts by title containing text. - */ - List findByTitleContaining(String title); + Optional findById(UUID id); /** * Save customer account. @@ -89,27 +50,12 @@ public interface CustomerAccountService { CustomerAccountEntity save(CustomerAccountEntity customerAccount); /** - * Delete customer account by ID. + * Check if customer account exists by UUID. */ - void deleteById(UUID id); - - /** - * Check if account exists by account ID. - */ - boolean existsByAccountId(String accountId); + boolean existsById(UUID id); /** * Count total customer accounts. */ - long countCustomerAccounts(); - - /** - * Count pre-pay accounts. - */ - long countPrePayAccounts(); - - /** - * Count accounts with budget billing. - */ - long countBudgetBillAccounts(); -} \ No newline at end of file + long count(); +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAccountServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAccountServiceImpl.java index 69401aa4..a44a547f 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAccountServiceImpl.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/customer/impl/CustomerAccountServiceImpl.java @@ -22,6 +22,7 @@ import lombok.RequiredArgsConstructor; import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerAccountEntity; import org.greenbuttonalliance.espi.common.repositories.customer.CustomerAccountRepository; +import org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService; import org.greenbuttonalliance.espi.common.service.customer.CustomerAccountService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -32,9 +33,10 @@ /** * Service implementation for CustomerAccount management. - * + * * Provides business logic for customer account operations including billing, * payment tracking, and account status management. + * Per Phase 18 guidelines, only ID-based operations on indexed fields are supported. */ @Service @Transactional @@ -42,6 +44,7 @@ public class CustomerAccountServiceImpl implements CustomerAccountService { private final CustomerAccountRepository customerAccountRepository; + private final EspiIdGeneratorService espiIdGeneratorService; @Override @Transactional(readOnly = true) @@ -55,89 +58,29 @@ public Optional findById(UUID id) { return customerAccountRepository.findById(id); } - @Override - @Transactional(readOnly = true) - public Optional findByUuid(String uuid) { - return customerAccountRepository.findById(UUID.fromString(uuid)); - } - - @Override - @Transactional(readOnly = true) - public Optional findByAccountId(String accountId) { - return customerAccountRepository.findByAccountId(accountId); - } - - @Override - @Transactional(readOnly = true) - public List findByBillingCycle(String billingCycle) { - return customerAccountRepository.findByBillingCycle(billingCycle); - } - - @Override - @Transactional(readOnly = true) - public List findPrePayAccounts() { - return customerAccountRepository.findPrePayAccounts(); - } - - @Override - @Transactional(readOnly = true) - public List findBudgetBillAccounts() { - return customerAccountRepository.findBudgetBillAccounts(); - } - - @Override - @Transactional(readOnly = true) - public List findByContactInfo(String contactInfo) { - return customerAccountRepository.findByContactInfo(contactInfo); - } - - @Override - @Transactional(readOnly = true) - public List findByLastBillAmountGreaterThan(Long amount) { - return customerAccountRepository.findByLastBillAmountGreaterThan(amount); - } - - @Override - @Transactional(readOnly = true) - public List findByTitleContaining(String title) { - return customerAccountRepository.findByTitleContaining(title); - } - @Override public CustomerAccountEntity save(CustomerAccountEntity customerAccount) { - // Generate UUID if not present + // Generate UUID v5 if not present if (customerAccount.getId() == null) { - customerAccount.setId(UUID.randomUUID()); + // Generate UUID v5 using accountId as the name component + String nameComponent = customerAccount.getAccountId() != null + ? customerAccount.getAccountId() + : "CustomerAccount-" + System.currentTimeMillis(); + UUID uuid5 = espiIdGeneratorService.generateSubscriptionId("CustomerAccount", nameComponent); + customerAccount.setId(uuid5); } return customerAccountRepository.save(customerAccount); } - @Override - public void deleteById(UUID id) { - customerAccountRepository.deleteById(id); - } - @Override @Transactional(readOnly = true) - public boolean existsByAccountId(String accountId) { - return customerAccountRepository.findByAccountId(accountId).isPresent(); + public boolean existsById(UUID id) { + return customerAccountRepository.existsById(id); } @Override @Transactional(readOnly = true) - public long countCustomerAccounts() { + public long count() { return customerAccountRepository.count(); } - - @Override - @Transactional(readOnly = true) - public long countPrePayAccounts() { - return customerAccountRepository.findPrePayAccounts().size(); - } - - @Override - @Transactional(readOnly = true) - public long countBudgetBillAccounts() { - return customerAccountRepository.findBudgetBillAccounts().size(); - } -} \ No newline at end of file +} diff --git a/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql b/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql index cf261ff3..5c194cbd 100644 --- a/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql +++ b/openespi-common/src/main/resources/db/migration/V3__Create_additiional_Base_Tables.sql @@ -244,7 +244,9 @@ CREATE TABLE customer_agreement_future_status -- Indexes for customer_agreement_future_status table CREATE INDEX idx_customer_agreement_future_status ON customer_agreement_future_status (customer_agreement_id); --- Customer Account Table (with isPrePay field from V7 migration) +-- Customer Account Table +-- Per customer.xsd CustomerAccount (lines 118-158) and Document base type (lines 819-872) +-- Phase 18: Schema compliance with all Document fields CREATE TABLE customer_accounts ( id CHAR(36) PRIMARY KEY , @@ -259,32 +261,61 @@ CREATE TABLE customer_accounts customer_account_self_link_href VARCHAR(1024), customer_account_self_link_type VARCHAR(255), - -- Document fields + -- Document fields (customer.xsd lines 819-872) - field order matches XSD + document_type VARCHAR(256), + author_name VARCHAR(256), created_date_time TIMESTAMP, last_modified_date_time TIMESTAMP, revision_number VARCHAR(256), + -- Document.electronicAddress embedded object + email1 VARCHAR(256), + email2 VARCHAR(256), + web VARCHAR(256), + radio VARCHAR(256), subject VARCHAR(256), title VARCHAR(256), - document_type VARCHAR(256), - - -- Customer account specific fields - contact_name VARCHAR(256), + -- Document.docStatus embedded object + status_value VARCHAR(256), + status_date_time TIMESTAMP, + status_reason VARCHAR(512), + + -- CustomerAccount specific fields (customer.xsd lines 118-158) + billing_cycle VARCHAR(50), + budget_bill VARCHAR(255), + last_bill_amount BIGINT, + -- notifications collection mapped to customer_account_notifications table below + -- contactInfo Organisation embedded object + -- contactInfo.streetAddress + street_detail VARCHAR(256), + town_detail VARCHAR(256), + state_or_province VARCHAR(256), + postal_code VARCHAR(256), + country VARCHAR(256), + -- contactInfo.postalAddress + postal_street_detail VARCHAR(256), + postal_town_detail VARCHAR(256), + postal_state_or_province VARCHAR(256), + postal_postal_code VARCHAR(256), + postal_country VARCHAR(256), + -- contactInfo.electronicAddress + contact_email1 VARCHAR(256), + contact_email2 VARCHAR(256), + contact_web VARCHAR(256), + contact_radio VARCHAR(256), + -- contactInfo.organisationName + organisation_name VARCHAR(256), account_id VARCHAR(256), - account_number VARCHAR(100), - account_kind VARCHAR(50), - budget_bill VARCHAR(255), - billing_cycle VARCHAR(50), - last_bill_amount BIGINT, - is_pre_pay BOOLEAN DEFAULT FALSE, + + -- Extension fields not in customer.xsd + is_pre_pay BOOLEAN DEFAULT FALSE, -- Foreign key to customer - customer_id CHAR(36), + customer_id CHAR(36), FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE ); -CREATE INDEX idx_customer_account_number ON customer_accounts (account_number); -CREATE INDEX idx_customer_account_kind ON customer_accounts (account_kind); +-- Indexes - Per Phase 18 guidelines, only ID-based indexes CREATE INDEX idx_customer_account_customer_id ON customer_accounts (customer_id); CREATE INDEX idx_customer_account_created ON customer_accounts (created); CREATE INDEX idx_customer_account_updated ON customer_accounts (updated); diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java new file mode 100644 index 00000000..3e811640 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/dto/customer/CustomerAccountDtoTest.java @@ -0,0 +1,252 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.dto.customer; + +import org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto; +import org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto; +import org.greenbuttonalliance.espi.common.service.impl.DtoExportServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * XML marshalling/unmarshalling tests for CustomerAccountDto. + * Verifies Jakarta JAXB processes JAXB annotations correctly for ESPI 4.0 customer.xsd compliance. + * Phase 18: CustomerAccount schema compliance testing. + */ +@DisplayName("CustomerAccountDto XML Marshalling Tests") +class CustomerAccountDtoTest { + + private DtoExportServiceImpl dtoExportService; + + @BeforeEach + void setUp() { + org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService espiIdGeneratorService = + new org.greenbuttonalliance.espi.common.service.EspiIdGeneratorService(); + dtoExportService = new DtoExportServiceImpl(null, null, espiIdGeneratorService); + } + + @Test + @DisplayName("Should export CustomerAccount with complete Document fields") + void shouldExportCustomerAccountWithCompleteDocumentFields() { + // Arrange + OffsetDateTime now = OffsetDateTime.of(2025, 1, 22, 10, 30, 0, 0, ZoneOffset.UTC); + + CustomerAccountDto customerAccount = createFullCustomerAccountDto(); + CustomerAtomEntryDto entry = new CustomerAtomEntryDto( + "urn:uuid:550e8400-e29b-51d4-a716-446655440000", + "Test Customer Account", + now, now, null, customerAccount + ); + + AtomFeedDto feed = new AtomFeedDto( + "urn:uuid:feed-id", "Customer Account Feed", now, now, null, + List.of(entry) + ); + + // Act + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + dtoExportService.exportAtomFeed(feed, stream); + String xml = stream.toString(StandardCharsets.UTF_8); + + // Debug output + System.out.println("========== CustomerAccount XML Output =========="); + System.out.println(xml); + System.out.println("================================================"); + + // Assert - Basic structure + assertThat(xml) + .startsWith("") + .contains(""); + + // Assert - Document fields present in correct order + assertThat(xml) + .contains("BILLING") + .contains("Billing System") + .contains("1.0") + .contains("billing@example.com") + .contains("Monthly Billing Account") + .contains("Customer Billing Account #12345") + .contains("ACTIVE"); + + // Assert - CustomerAccount fields present + assertThat(xml) + .contains("MONTHLY") + .contains("STANDARD") + .contains("15000") + .contains("") + .contains("ACME Corporation") + .contains("ACCT-12345"); + } + + @Test + @DisplayName("Should verify CustomerAccount field order matches customer.xsd") + void shouldVerifyCustomerAccountFieldOrder() { + // Arrange + OffsetDateTime now = OffsetDateTime.of(2025, 1, 22, 10, 30, 0, 0, ZoneOffset.UTC); + + CustomerAccountDto customerAccount = createFullCustomerAccountDto(); + CustomerAtomEntryDto entry = new CustomerAtomEntryDto( + "urn:uuid:550e8400-e29b-51d4-a716-446655440001", + "Test Customer Account", + now, now, null, customerAccount + ); + + AtomFeedDto feed = new AtomFeedDto( + "urn:uuid:feed-id", "Test Feed", now, now, null, + List.of(entry) + ); + + // Act + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + dtoExportService.exportAtomFeed(feed, stream); + String xml = stream.toString(StandardCharsets.UTF_8); + + // Assert - Verify Document field order per customer.xsd (lines 819-872) + int typePos = xml.indexOf(" retrieved = customerAccountRepository.findById(saved.getId()); + + // Assert + assertThat(retrieved).isPresent(); + assertThat(retrieved.get().getId()).isEqualTo(saved.getId()); + assertThat(retrieved.get().getAccountId()).isEqualTo(saved.getAccountId()); + assertThat(retrieved.get().getBillingCycle()).isEqualTo(saved.getBillingCycle()); + } + + @Test + @DisplayName("Should save customer account with customer relationship") + void shouldSaveCustomerAccountWithCustomerRelationship() { + // Arrange + CustomerAccountEntity account = createCompleteTestSetup(); + + // Act + CustomerAccountEntity saved = persistAndFlush(account); Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - assertThat(saved).isNotNull(); - assertThat(saved.getId()).isNotNull(); assertThat(retrieved).isPresent(); - assertThat(retrieved.get().getDescription()).isEqualTo("Test Customer Account for CRUD"); - assertThat(retrieved.get().getTitle()).isEqualTo("CRUD Test Account"); assertThat(retrieved.get().getCustomer()).isNotNull(); + assertThat(retrieved.get().getCustomer().getId()).isEqualTo(saved.getCustomer().getId()); } @Test @@ -127,20 +178,19 @@ void shouldUpdateCustomerAccountSuccessfully() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); CustomerAccountEntity saved = persistAndFlush(account); + UUID savedId = saved.getId(); // Act - saved.setDescription("Updated Customer Account Description"); - saved.setLastBillAmount(25000L); // $250.00 saved.setBillingCycle("QUARTERLY"); - CustomerAccountEntity updated = customerAccountRepository.save(saved); + saved.setLastBillAmount(25000L); + customerAccountRepository.save(saved); flushAndClear(); // Assert - Optional retrieved = customerAccountRepository.findById(updated.getId()); + Optional retrieved = customerAccountRepository.findById(savedId); assertThat(retrieved).isPresent(); - assertThat(retrieved.get().getDescription()).isEqualTo("Updated Customer Account Description"); - assertThat(retrieved.get().getLastBillAmount()).isEqualTo(25000L); assertThat(retrieved.get().getBillingCycle()).isEqualTo("QUARTERLY"); + assertThat(retrieved.get().getLastBillAmount()).isEqualTo(25000L); } @Test @@ -164,11 +214,9 @@ void shouldDeleteCustomerAccountSuccessfully() { @DisplayName("Should find all customer accounts") void shouldFindAllCustomerAccounts() { // Arrange + long initialCount = customerAccountRepository.count(); CustomerAccountEntity account1 = createCompleteTestSetup(); - account1.setTitle("First Account"); CustomerAccountEntity account2 = createCompleteTestSetup(); - account2.setTitle("Second Account"); - persistAndFlush(account1); persistAndFlush(account2); @@ -177,276 +225,208 @@ void shouldFindAllCustomerAccounts() { // Assert assertThat(allAccounts).hasSizeGreaterThanOrEqualTo(2); - assertThat(allAccounts) - .extracting(CustomerAccountEntity::getTitle) - .contains("First Account", "Second Account"); - } - - @Test - @DisplayName("Should count customer accounts correctly") - void shouldCountCustomerAccountsCorrectly() { - // Arrange - long initialCount = customerAccountRepository.count(); - CustomerAccountEntity account1 = createCompleteTestSetup(); - CustomerAccountEntity account2 = createCompleteTestSetup(); - - persistAndFlush(account1); - persistAndFlush(account2); - - // Act long finalCount = customerAccountRepository.count(); - - // Assert assertThat(finalCount).isEqualTo(initialCount + 2); } - } - - @Nested - @DisplayName("Custom Query Methods") - class CustomQueryMethodsTest { @Test - @DisplayName("Should find customer account by account ID") - void shouldFindCustomerAccountByAccountId() { + @DisplayName("Should check if customer account exists") + void shouldCheckIfCustomerAccountExists() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); - account.setAccountId("UNIQUE-ACCOUNT-12345"); CustomerAccountEntity saved = persistAndFlush(account); // Act - Optional found = customerAccountRepository.findByAccountId("UNIQUE-ACCOUNT-12345"); + boolean exists = customerAccountRepository.existsById(saved.getId()); + boolean notExists = customerAccountRepository.existsById(UUID.randomUUID()); // Assert - assertThat(found).isPresent(); - assertThat(found.get().getId()).isEqualTo(saved.getId()); - assertThat(found.get().getAccountId()).isEqualTo("UNIQUE-ACCOUNT-12345"); + assertThat(exists).isTrue(); + assertThat(notExists).isFalse(); } @Test - @DisplayName("Should find customer accounts by billing cycle") - void shouldFindCustomerAccountsByBillingCycle() { - // Arrange - CustomerAccountEntity account1 = createCompleteTestSetup(); - account1.setBillingCycle("MONTHLY"); - CustomerAccountEntity account2 = createCompleteTestSetup(); - account2.setBillingCycle("MONTHLY"); - CustomerAccountEntity account3 = createCompleteTestSetup(); - account3.setBillingCycle("QUARTERLY"); - - persistAndFlush(account1); - persistAndFlush(account2); - persistAndFlush(account3); - - // Act - List monthlyAccounts = customerAccountRepository.findByBillingCycle("MONTHLY"); - - // Assert - assertThat(monthlyAccounts).hasSize(2); - assertThat(monthlyAccounts).extracting(CustomerAccountEntity::getBillingCycle) - .allMatch(cycle -> cycle.equals("MONTHLY")); - } - - @Test - @DisplayName("Should find pre-pay accounts") - void shouldFindPrePayAccounts() { + @DisplayName("Should count customer accounts correctly") + void shouldCountCustomerAccountsCorrectly() { // Arrange + long initialCount = customerAccountRepository.count(); CustomerAccountEntity account1 = createCompleteTestSetup(); - account1.setIsPrePay(true); CustomerAccountEntity account2 = createCompleteTestSetup(); - account2.setIsPrePay(true); - CustomerAccountEntity account3 = createCompleteTestSetup(); - account3.setIsPrePay(false); - persistAndFlush(account1); persistAndFlush(account2); - persistAndFlush(account3); // Act - List prePayAccounts = customerAccountRepository.findPrePayAccounts(); + long finalCount = customerAccountRepository.count(); // Assert - assertThat(prePayAccounts).hasSize(2); - assertThat(prePayAccounts).extracting(CustomerAccountEntity::getIsPrePay) - .allMatch(isPrePay -> isPrePay.equals(true)); + assertThat(finalCount).isEqualTo(initialCount + 2); } + } - @Test - @DisplayName("Should find budget bill accounts") - void shouldFindBudgetBillAccounts() { - // Arrange - CustomerAccountEntity account1 = createCompleteTestSetup(); - account1.setBudgetBill("BUDGET_PLAN_A"); - CustomerAccountEntity account2 = createCompleteTestSetup(); - account2.setBudgetBill("BUDGET_PLAN_B"); - CustomerAccountEntity account3 = createCompleteTestSetup(); - account3.setBudgetBill(null); - CustomerAccountEntity account4 = createCompleteTestSetup(); - account4.setBudgetBill(""); - - persistAndFlush(account1); - persistAndFlush(account2); - persistAndFlush(account3); - persistAndFlush(account4); - - // Act - List budgetAccounts = customerAccountRepository.findBudgetBillAccounts(); - - // Assert - assertThat(budgetAccounts).hasSize(2); - assertThat(budgetAccounts).extracting(CustomerAccountEntity::getBudgetBill) - .allMatch(budget -> budget != null && !budget.isEmpty()); - } + @Nested + @DisplayName("Document Field Persistence") + class DocumentFieldPersistenceTest { @Test - @DisplayName("Should find customer accounts by contact info") - void shouldFindCustomerAccountsByContactInfo() { + @DisplayName("Should persist all Document base fields correctly") + void shouldPersistAllDocumentBaseFieldsCorrectly() { // Arrange - CustomerAccountEntity account1 = createCompleteTestSetup(); - account1.setContactInfo("John Smith"); - CustomerAccountEntity account2 = createCompleteTestSetup(); - account2.setContactInfo("John Smith"); - CustomerAccountEntity account3 = createCompleteTestSetup(); - account3.setContactInfo("Jane Doe"); - - persistAndFlush(account1); - persistAndFlush(account2); - persistAndFlush(account3); - - // Act - List johnSmithAccounts = customerAccountRepository.findByContactInfo("John Smith"); - - // Assert - assertThat(johnSmithAccounts).hasSize(2); - assertThat(johnSmithAccounts).extracting(CustomerAccountEntity::getContactInfo) - .allMatch(contact -> contact.equals("John Smith")); - } + CustomerAccountEntity account = createCompleteTestSetup(); + OffsetDateTime createdTime = OffsetDateTime.now().minusDays(5).truncatedTo(java.time.temporal.ChronoUnit.MICROS); + OffsetDateTime modifiedTime = OffsetDateTime.now().minusDays(1).truncatedTo(java.time.temporal.ChronoUnit.MICROS); - @Test - @DisplayName("Should find customer accounts by last bill amount greater than") - void shouldFindCustomerAccountsByLastBillAmountGreaterThan() { - // Arrange - CustomerAccountEntity account1 = createCompleteTestSetup(); - account1.setLastBillAmount(10000L); // $100.00 - CustomerAccountEntity account2 = createCompleteTestSetup(); - account2.setLastBillAmount(20000L); // $200.00 - CustomerAccountEntity account3 = createCompleteTestSetup(); - account3.setLastBillAmount(5000L); // $50.00 - - persistAndFlush(account1); - persistAndFlush(account2); - persistAndFlush(account3); + account.setType("PAYMENT"); + account.setAuthorName("System Administrator"); + account.setCreatedDateTime(createdTime); + account.setLastModifiedDateTime(modifiedTime); + account.setRevisionNumber("2.5"); + account.setSubject("Payment Account Management"); + account.setTitle("Payment Account #98765"); // Act - List highBillAccounts = customerAccountRepository.findByLastBillAmountGreaterThan(15000L); + CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - assertThat(highBillAccounts).hasSize(1); - assertThat(highBillAccounts.get(0).getLastBillAmount()).isEqualTo(20000L); + assertThat(retrieved).isPresent(); + CustomerAccountEntity found = retrieved.get(); + assertThat(found.getType()).isEqualTo("PAYMENT"); + assertThat(found.getAuthorName()).isEqualTo("System Administrator"); + assertThat(found.getCreatedDateTime()).isEqualTo(createdTime); + assertThat(found.getLastModifiedDateTime()).isEqualTo(modifiedTime); + assertThat(found.getRevisionNumber()).isEqualTo("2.5"); + assertThat(found.getSubject()).isEqualTo("Payment Account Management"); + assertThat(found.getTitle()).isEqualTo("Payment Account #98765"); } @Test - @DisplayName("Should find customer accounts by title containing text") - void shouldFindCustomerAccountsByTitleContaining() { + @DisplayName("Should persist Document electronicAddress embedded object") + void shouldPersistDocumentElectronicAddressEmbeddedObject() { // Arrange - CustomerAccountEntity account1 = createCompleteTestSetup(); - account1.setTitle("Residential Account Primary"); - CustomerAccountEntity account2 = createCompleteTestSetup(); - account2.setTitle("Commercial Account Secondary"); - CustomerAccountEntity account3 = createCompleteTestSetup(); - account3.setTitle("Industrial Service"); - - persistAndFlush(account1); - persistAndFlush(account2); - persistAndFlush(account3); + CustomerAccountEntity account = createCompleteTestSetup(); + Organisation.ElectronicAddress electronicAddress = new Organisation.ElectronicAddress(); + electronicAddress.setEmail1("primary@example.com"); + electronicAddress.setEmail2("secondary@example.com"); + electronicAddress.setWeb("https://www.example.com"); + electronicAddress.setRadio("FM-101.5"); + account.setElectronicAddress(electronicAddress); // Act - List accountsWithAccount = customerAccountRepository.findByTitleContaining("Account"); + CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - assertThat(accountsWithAccount).hasSize(2); - assertThat(accountsWithAccount).extracting(CustomerAccountEntity::getTitle) - .allMatch(title -> title.toLowerCase().contains("account")); + assertThat(retrieved).isPresent(); + CustomerAccountEntity found = retrieved.get(); + assertThat(found.getElectronicAddress()).isNotNull(); + assertThat(found.getElectronicAddress().getEmail1()).isEqualTo("primary@example.com"); + assertThat(found.getElectronicAddress().getEmail2()).isEqualTo("secondary@example.com"); + assertThat(found.getElectronicAddress().getWeb()).isEqualTo("https://www.example.com"); + assertThat(found.getElectronicAddress().getRadio()).isEqualTo("FM-101.5"); } @Test - @DisplayName("Should return empty results when no matches found") - void shouldReturnEmptyResultsWhenNoMatchesFound() { + @DisplayName("Should persist Document docStatus embedded object") + void shouldPersistDocumentDocStatusEmbeddedObject() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); - account.setAccountId("EXISTING-ACCOUNT"); - persistAndFlush(account); + Status docStatus = new Status(); + docStatus.setValue("SUSPENDED"); + OffsetDateTime statusTime = OffsetDateTime.now().minusDays(2).truncatedTo(java.time.temporal.ChronoUnit.MICROS); + docStatus.setDateTime(statusTime); + docStatus.setReason("Payment overdue"); + account.setDocStatus(docStatus); // Act - Optional notFound = customerAccountRepository.findByAccountId("NON-EXISTENT"); - List emptyList = customerAccountRepository.findByBillingCycle("NON-EXISTENT-CYCLE"); + CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - assertThat(notFound).isEmpty(); - assertThat(emptyList).isEmpty(); + assertThat(retrieved).isPresent(); + CustomerAccountEntity found = retrieved.get(); + assertThat(found.getDocStatus()).isNotNull(); + assertThat(found.getDocStatus().getValue()).isEqualTo("SUSPENDED"); + assertThat(found.getDocStatus().getDateTime()).isEqualTo(statusTime); + assertThat(found.getDocStatus().getReason()).isEqualTo("Payment overdue"); } } @Nested - @DisplayName("Account Management Field Testing") - class AccountManagementFieldTest { + @DisplayName("CustomerAccount Field Persistence") + class CustomerAccountFieldPersistenceTest { @Test - @DisplayName("Should persist all document fields correctly") - void shouldPersistAllDocumentFieldsCorrectly() { + @DisplayName("Should persist all CustomerAccount specific fields correctly") + void shouldPersistAllCustomerAccountSpecificFieldsCorrectly() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); - - //truncate nanos because of diff between macOS and Windoz - OffsetDateTime createdTime = OffsetDateTime.now().minusDays(1).truncatedTo(ChronoUnit.MICROS); - OffsetDateTime modifiedTime = OffsetDateTime.now().truncatedTo(ChronoUnit.MICROS); - - account.setCreatedDateTime(createdTime); - account.setLastModifiedDateTime(modifiedTime); - account.setRevisionNumber("2.1"); - account.setSubject("Billing Account Subject"); - account.setTitle("Primary Billing Account"); - account.setType("RESIDENTIAL_BILLING"); + account.setBillingCycle("QUARTERLY"); + account.setBudgetBill("PREMIUM"); + account.setLastBillAmount(35000L); + account.setAccountId("ACCT-SPECIAL-12345"); + account.setIsPrePay(true); // Act CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - Optional retrieved = customerAccountRepository.findById(saved.getId()); assertThat(retrieved).isPresent(); - CustomerAccountEntity entity = retrieved.get(); - assertThat(entity.getCreatedDateTime()).isEqualTo(createdTime); - assertThat(entity.getLastModifiedDateTime()).isEqualTo(modifiedTime); - assertThat(entity.getRevisionNumber()).isEqualTo("2.1"); - assertThat(entity.getSubject()).isEqualTo("Billing Account Subject"); - assertThat(entity.getTitle()).isEqualTo("Primary Billing Account"); - assertThat(entity.getType()).isEqualTo("RESIDENTIAL_BILLING"); + CustomerAccountEntity found = retrieved.get(); + assertThat(found.getBillingCycle()).isEqualTo("QUARTERLY"); + assertThat(found.getBudgetBill()).isEqualTo("PREMIUM"); + assertThat(found.getLastBillAmount()).isEqualTo(35000L); + assertThat(found.getAccountId()).isEqualTo("ACCT-SPECIAL-12345"); + assertThat(found.getIsPrePay()).isTrue(); } @Test - @DisplayName("Should persist all customer account specific fields correctly") - void shouldPersistAllCustomerAccountSpecificFieldsCorrectly() { + @DisplayName("Should persist contactInfo Organisation embedded object") + void shouldPersistContactInfoOrganisationEmbeddedObject() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); - account.setBillingCycle("SEMI_ANNUAL"); - account.setBudgetBill("LEVEL_PAY_PLAN"); - account.setLastBillAmount(35000L); // $350.00 - account.setContactInfo("Jane Smith - Primary Contact"); - account.setAccountId("ACCT-SPECIAL-999888"); - account.setIsPrePay(true); + + Organisation contactInfo = new Organisation(); + contactInfo.setOrganisationName("ACME Corporation"); + + Organisation.StreetAddress streetAddress = new Organisation.StreetAddress(); + streetAddress.setStreetDetail("123 Main Street"); + streetAddress.setTownDetail("Springfield"); + streetAddress.setStateOrProvince("IL"); + streetAddress.setPostalCode("62701"); + streetAddress.setCountry("USA"); + contactInfo.setStreetAddress(streetAddress); + + Organisation.ElectronicAddress electronicAddress = new Organisation.ElectronicAddress(); + electronicAddress.setEmail1("contact@acme.com"); + electronicAddress.setWeb("https://www.acme.com"); + contactInfo.setElectronicAddress(electronicAddress); + + account.setContactInfo(contactInfo); // Act CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - Optional retrieved = customerAccountRepository.findById(saved.getId()); assertThat(retrieved).isPresent(); - CustomerAccountEntity entity = retrieved.get(); - assertThat(entity.getBillingCycle()).isEqualTo("SEMI_ANNUAL"); - assertThat(entity.getBudgetBill()).isEqualTo("LEVEL_PAY_PLAN"); - assertThat(entity.getLastBillAmount()).isEqualTo(35000L); - assertThat(entity.getContactInfo()).isEqualTo("Jane Smith - Primary Contact"); - assertThat(entity.getAccountId()).isEqualTo("ACCT-SPECIAL-999888"); - assertThat(entity.getIsPrePay()).isTrue(); + CustomerAccountEntity found = retrieved.get(); + assertThat(found.getContactInfo()).isNotNull(); + assertThat(found.getContactInfo().getOrganisationName()).isEqualTo("ACME Corporation"); + assertThat(found.getContactInfo().getStreetAddress()).isNotNull(); + assertThat(found.getContactInfo().getStreetAddress().getStreetDetail()).isEqualTo("123 Main Street"); + assertThat(found.getContactInfo().getStreetAddress().getTownDetail()).isEqualTo("Springfield"); + assertThat(found.getContactInfo().getStreetAddress().getStateOrProvince()).isEqualTo("IL"); + assertThat(found.getContactInfo().getStreetAddress().getPostalCode()).isEqualTo("62701"); + assertThat(found.getContactInfo().getStreetAddress().getCountry()).isEqualTo("USA"); + assertThat(found.getContactInfo().getElectronicAddress()).isNotNull(); + assertThat(found.getContactInfo().getElectronicAddress().getEmail1()).isEqualTo("contact@acme.com"); + assertThat(found.getContactInfo().getElectronicAddress().getWeb()).isEqualTo("https://www.acme.com"); } @Test @@ -454,38 +434,25 @@ void shouldPersistAllCustomerAccountSpecificFieldsCorrectly() { void shouldHandleNullOptionalFieldsCorrectly() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); - account.setCreatedDateTime(null); - account.setLastModifiedDateTime(null); - account.setRevisionNumber(null); - account.setSubject(null); - account.setTitle(null); - account.setType(null); - account.setBillingCycle(null); account.setBudgetBill(null); account.setLastBillAmount(null); + account.setElectronicAddress(null); + account.setDocStatus(null); account.setContactInfo(null); - account.setAccountId(null); - account.setIsPrePay(null); // Act CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - Optional retrieved = customerAccountRepository.findById(saved.getId()); assertThat(retrieved).isPresent(); - CustomerAccountEntity entity = retrieved.get(); - assertThat(entity.getCreatedDateTime()).isNull(); - assertThat(entity.getLastModifiedDateTime()).isNull(); - assertThat(entity.getRevisionNumber()).isNull(); - assertThat(entity.getSubject()).isNull(); - assertThat(entity.getTitle()).isNull(); - assertThat(entity.getType()).isNull(); - assertThat(entity.getBillingCycle()).isNull(); - assertThat(entity.getBudgetBill()).isNull(); - assertThat(entity.getLastBillAmount()).isNull(); - assertThat(entity.getContactInfo()).isNull(); - assertThat(entity.getAccountId()).isNull(); - assertThat(entity.getIsPrePay()).isNull(); + CustomerAccountEntity found = retrieved.get(); + assertThat(found.getBudgetBill()).isNull(); + assertThat(found.getLastBillAmount()).isNull(); + assertThat(found.getElectronicAddress()).isNull(); + assertThat(found.getDocStatus()).isNull(); + assertThat(found.getContactInfo()).isNull(); } } @@ -497,16 +464,23 @@ class CustomerRelationshipTest { @DisplayName("Should maintain Customer relationship") void shouldMaintainCustomerRelationship() { // Arrange - CustomerAccountEntity account = createCompleteTestSetup(); + CustomerEntity customer = createValidCustomer(); + customer.setCustomerName("Test Corporation"); + CustomerEntity savedCustomer = persistAndFlush(customer); + + CustomerAccountEntity account = createValidCustomerAccount(); + account.setCustomer(savedCustomer); + CustomerAccountEntity savedAccount = persistAndFlush(account); // Act - CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(savedAccount.getId()); // Assert - Optional retrieved = customerAccountRepository.findById(saved.getId()); assertThat(retrieved).isPresent(); assertThat(retrieved.get().getCustomer()).isNotNull(); - assertThat(retrieved.get().getCustomer().getId()).isEqualTo(account.getCustomer().getId()); + assertThat(retrieved.get().getCustomer().getId()).isEqualTo(savedCustomer.getId()); + assertThat(retrieved.get().getCustomer().getCustomerName()).isEqualTo("Test Corporation"); } @Test @@ -515,14 +489,15 @@ void shouldHandleLazyLoadingOfCustomer() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); CustomerAccountEntity saved = persistAndFlush(account); - - // Act - Clear persistence context to test lazy loading flushAndClear(); + + // Act Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert assertThat(retrieved).isPresent(); - // Access the customer to trigger lazy loading + assertThat(retrieved.get().getCustomer()).isNotNull(); + // Access lazy-loaded property assertThat(retrieved.get().getCustomer().getCustomerName()).isNotNull(); } @@ -535,9 +510,10 @@ void shouldAllowNullCustomer() { // Act CustomerAccountEntity saved = persistAndFlush(account); + flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - Optional retrieved = customerAccountRepository.findById(saved.getId()); assertThat(retrieved).isPresent(); assertThat(retrieved.get().getCustomer()).isNull(); } @@ -552,20 +528,23 @@ class BaseClassTest { void shouldInheritIdentifiedObjectFunctionality() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); + account.setDescription("Test Description for Account"); + java.time.LocalDateTime publishedTime = java.time.LocalDateTime.now().minusDays(10).truncatedTo(java.time.temporal.ChronoUnit.MICROS); + account.setPublished(publishedTime); // Act - CustomerAccountEntity saved = customerAccountRepository.save(account); + CustomerAccountEntity saved = persistAndFlush(account); flushAndClear(); + Optional retrieved = customerAccountRepository.findById(saved.getId()); // Assert - Optional retrieved = customerAccountRepository.findById(saved.getId()); assertThat(retrieved).isPresent(); - - CustomerAccountEntity entity = retrieved.get(); - assertThat(entity.getId()).isNotNull(); - assertThat(entity.getCreated()).isNotNull(); - assertThat(entity.getUpdated()).isNotNull(); - assertThat(entity.getDescription()).isNotNull(); + CustomerAccountEntity found = retrieved.get(); + assertThat(found.getId()).isNotNull(); + assertThat(found.getDescription()).isEqualTo("Test Description for Account"); + assertThat(found.getPublished()).isEqualTo(publishedTime); + assertThat(found.getCreated()).isNotNull(); + assertThat(found.getUpdated()).isNotNull(); } @Test @@ -574,23 +553,18 @@ void shouldUpdateTimestampsOnModification() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); CustomerAccountEntity saved = persistAndFlush(account); - - // Wait a moment to ensure timestamp difference - try { - Thread.sleep(10); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + java.time.LocalDateTime originalUpdated = saved.getUpdated(); + UUID savedId = saved.getId(); // Act - saved.setDescription("Updated Description"); - CustomerAccountEntity updated = customerAccountRepository.save(saved); + saved.setBillingCycle("ANNUAL"); + customerAccountRepository.save(saved); flushAndClear(); // Assert - Optional retrieved = customerAccountRepository.findById(updated.getId()); + Optional retrieved = customerAccountRepository.findById(savedId); assertThat(retrieved).isPresent(); - assertThat(retrieved.get().getUpdated()).isAfter(retrieved.get().getCreated()); + assertThat(retrieved.get().getUpdated()).isAfter(originalUpdated); } @Test @@ -601,14 +575,13 @@ void shouldGenerateUniqueIdsForDifferentEntities() { CustomerAccountEntity account2 = createCompleteTestSetup(); // Act - CustomerAccountEntity saved1 = customerAccountRepository.save(account1); - CustomerAccountEntity saved2 = customerAccountRepository.save(account2); - flushAndClear(); + CustomerAccountEntity saved1 = persistAndFlush(account1); + CustomerAccountEntity saved2 = persistAndFlush(account2); // Assert - assertThat(saved1.getId()).isNotEqualTo(saved2.getId()); assertThat(saved1.getId()).isNotNull(); assertThat(saved2.getId()).isNotNull(); + assertThat(saved1.getId()).isNotEqualTo(saved2.getId()); } @Test @@ -616,22 +589,16 @@ void shouldGenerateUniqueIdsForDifferentEntities() { void shouldHandleEqualsAndHashCodeCorrectly() { // Arrange CustomerAccountEntity account1 = createCompleteTestSetup(); - CustomerAccountEntity account2 = createCompleteTestSetup(); - CustomerAccountEntity saved1 = persistAndFlush(account1); + + CustomerAccountEntity account2 = createCompleteTestSetup(); CustomerAccountEntity saved2 = persistAndFlush(account2); // Act & Assert - assertThat(saved1).isNotEqualTo(saved2); - // Note: Hibernate proxy-aware hashCode implementation returns class hashCode for different entities - // This is expected behavior for entities with different IDs - - // Same entity should be equal to itself - assertThat(saved1).isEqualTo(saved1); - assertThat(saved1.hashCode()).isEqualTo(saved1.hashCode()); - - // Different entities with different IDs should not be equal - assertThat(saved1.getId()).isNotEqualTo(saved2.getId()); + assertThat(saved1) + .isEqualTo(saved1) + .isNotEqualTo(saved2) + .hasSameHashCodeAs(saved1); } @Test @@ -639,18 +606,17 @@ void shouldHandleEqualsAndHashCodeCorrectly() { void shouldGenerateMeaningfulToStringRepresentation() { // Arrange CustomerAccountEntity account = createCompleteTestSetup(); - account.setAccountId("ACCT-12345"); - account.setTitle("Test Account"); + account.setAccountId("TEST-ACCOUNT-123"); CustomerAccountEntity saved = persistAndFlush(account); // Act String toString = saved.toString(); // Assert - assertThat(toString).contains("CustomerAccountEntity"); - assertThat(toString).contains("id = " + saved.getId()); - assertThat(toString).contains("accountId = ACCT-12345"); - assertThat(toString).contains("title = Test Account"); + assertThat(toString) + .contains("CustomerAccountEntity") + .contains("id") + .contains("accountId"); } } -} \ No newline at end of file +} diff --git a/pom.xml b/pom.xml index 679654d6..89212edf 100644 --- a/pom.xml +++ b/pom.xml @@ -93,13 +93,13 @@ - + spring-boot-only openespi-common openespi-datacustodian - openespi-authserver + openespi-thirdparty @@ -329,7 +329,6 @@ org.apache.maven.plugins maven-site-plugin - org.apache.maven.wagon