diff --git a/fineract-mix/dependencies.gradle b/fineract-mix/dependencies.gradle index 698ffa7e6fd..d705a0227a5 100644 --- a/fineract-mix/dependencies.gradle +++ b/fineract-mix/dependencies.gradle @@ -47,6 +47,8 @@ dependencies { 'org.mapstruct:mapstruct', 'io.github.resilience4j:resilience4j-spring-boot3', + + 'jakarta.xml.bind:jakarta.xml.bind-api' ) compileOnly 'com.github.spotbugs:spotbugs-annotations' compileOnly 'org.projectlombok:lombok' diff --git a/fineract-mix/src/main/java/org/apache/fineract/mix/api/MixReportApiResource.java b/fineract-mix/src/main/java/org/apache/fineract/mix/api/MixReportApiResource.java index 89d48356606..c28ec08d848 100644 --- a/fineract-mix/src/main/java/org/apache/fineract/mix/api/MixReportApiResource.java +++ b/fineract-mix/src/main/java/org/apache/fineract/mix/api/MixReportApiResource.java @@ -28,6 +28,7 @@ import java.sql.Date; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.annotation.AlternativeOperationId; +import org.apache.fineract.mix.data.MixReportXBRLDocument; import org.apache.fineract.mix.service.MixReportXBRLBuilder; import org.apache.fineract.mix.service.MixReportXBRLResultService; import org.springframework.stereotype.Component; @@ -46,12 +47,11 @@ public class MixReportApiResource { @Produces({ MediaType.APPLICATION_XML }) @Operation(summary = "Retrieve Mix XBRL report", operationId = "retrieveMixReport") @AlternativeOperationId("retrieveXBRLReport") - public String retrieveXBRLReport(@QueryParam("startDate") final Date startDate, @QueryParam("endDate") final Date endDate, - @QueryParam("currency") final String currency) { + public MixReportXBRLDocument retrieveXBRLReport(@QueryParam("startDate") final Date startDate, + @QueryParam("endDate") final Date endDate, @QueryParam("currency") final String currency) { final var data = xbrlResultService.getXBRLResult(startDate, endDate, currency); - // TODO: make this type safe? - return this.xbrlBuilder.build(data); + return this.xbrlBuilder.buildDocumentGraph(data); } } diff --git a/fineract-mix/src/main/java/org/apache/fineract/mix/data/MixReportXBRLDocument.java b/fineract-mix/src/main/java/org/apache/fineract/mix/data/MixReportXBRLDocument.java new file mode 100644 index 00000000000..8df5fae965a --- /dev/null +++ b/fineract-mix/src/main/java/org/apache/fineract/mix/data/MixReportXBRLDocument.java @@ -0,0 +1,191 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.apache.fineract.mix.data; + +import jakarta.xml.bind.JAXBElement; +import jakarta.xml.bind.annotation.XmlAccessType; +import jakarta.xml.bind.annotation.XmlAccessorType; +import jakarta.xml.bind.annotation.XmlAnyElement; +import jakarta.xml.bind.annotation.XmlAttribute; +import jakarta.xml.bind.annotation.XmlElement; +import jakarta.xml.bind.annotation.XmlRootElement; +import jakarta.xml.bind.annotation.XmlType; +import jakarta.xml.bind.annotation.XmlValue; +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@XmlRootElement(name = "xbrl") +@XmlType(propOrder = { "schemaRef", "taxonomyElements", "contexts", "units" }) +public class MixReportXBRLDocument implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private List> taxonomyElements = new ArrayList<>(); + private List contexts = new ArrayList<>(); + private List units = new ArrayList<>(); + + @XmlElement(name = "schemaRef") + public SchemaRefDTO getSchemaRef() { + return new SchemaRefDTO(); + } + + @XmlAnyElement(lax = true) + public List> getTaxonomyElements() { + return this.taxonomyElements; + } + + @XmlElement(name = "context") + public List getContexts() { + return this.contexts; + } + + @XmlElement(name = "unit") + public List getUnits() { + return this.units; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @XmlAccessorType(XmlAccessType.FIELD) + public static class TaxonomyValue implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @XmlAttribute(name = "contextRef") + private String contextRef; + + @XmlAttribute(name = "unitRef") + private String unitRef; + + @XmlAttribute(name = "decimals") + private String decimals; + + @XmlValue + private BigDecimal value; + } + + public static class SchemaRefDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + public String getNamespaceUri() { + return "http://www.themix.org/sites/default/files/Taxonomy2010/dct/dc-all_2010-08-31.xsd"; + } + } + + @Getter + @Setter + @NoArgsConstructor + public static class XBRLContextDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String id; + private EntityDTO entity = new EntityDTO(); + private PeriodDTO period; + private ScenarioDTO scenario; + } + + @Getter + @Setter + @NoArgsConstructor + public static class EntityDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private IdentifierDTO identifier = new IdentifierDTO(); + } + + @Getter + @Setter + @NoArgsConstructor + public static class IdentifierDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String scheme = "http://www.themix.org"; + private String value = "000000"; + } + + @Getter + @Setter + @NoArgsConstructor + public static class PeriodDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String instant; + private String startDate; + private String endDate; + } + + @Getter + @Setter + @NoArgsConstructor + public static class ScenarioDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private ExplicitMemberDTO explicitMember; + } + + @Getter + @Setter + @NoArgsConstructor + public static class ExplicitMemberDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String dimension; + private String value; + } + + @Getter + @Setter + @NoArgsConstructor + public static class XBRLUnitDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + private String id; + private String measure; + } +} diff --git a/fineract-mix/src/main/java/org/apache/fineract/mix/service/MixReportXBRLBuilder.java b/fineract-mix/src/main/java/org/apache/fineract/mix/service/MixReportXBRLBuilder.java index 1d97f3210aa..85dd81f592b 100644 --- a/fineract-mix/src/main/java/org/apache/fineract/mix/service/MixReportXBRLBuilder.java +++ b/fineract-mix/src/main/java/org/apache/fineract/mix/service/MixReportXBRLBuilder.java @@ -19,22 +19,24 @@ package org.apache.fineract.mix.service; import com.google.common.base.Splitter; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.Marshaller; +import java.io.StringWriter; import java.math.BigDecimal; import java.sql.Date; import java.text.SimpleDateFormat; import java.util.HashMap; import java.util.List; import java.util.Map; +import javax.xml.namespace.QName; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.mix.data.MixReportXBRLContextData; import org.apache.fineract.mix.data.MixReportXBRLData; +import org.apache.fineract.mix.data.MixReportXBRLDocument; import org.apache.fineract.mix.data.MixReportXBRLNamespaceData; import org.apache.fineract.mix.data.MixTaxonomyData; import org.apache.fineract.mix.exception.MixReportXBRLMappingInvalidException; -import org.dom4j.Document; -import org.dom4j.DocumentHelper; -import org.dom4j.Element; import org.springframework.stereotype.Component; @Slf4j @@ -42,7 +44,6 @@ @Component public class MixReportXBRLBuilder { - // NOTE: see https://www.xbrl.org/taxonomyrecognition/mx_2009-06-19_summary-page.htm private static final String SCHEME_URL = "http://www.themix.org"; private static final String IDENTIFIER = "000000"; private static final String UNITID_PURE = "Unit1"; @@ -50,63 +51,124 @@ public class MixReportXBRLBuilder { private final MixReportXBRLNamespaceReadService readNamespaceService; - // TODO: we should do this with JAXB public String build(final MixReportXBRLData xbrlData) { return this.build(xbrlData.getResultMap(), xbrlData.getStartDate(), xbrlData.getEndDate(), xbrlData.getCurrency()); } public String build(final Map map, final Date startDate, final Date endDate, final String currency) { + MixReportXBRLData xbrlData = new MixReportXBRLData(map, startDate, endDate, currency); + MixReportXBRLDocument doc = buildDocumentGraph(xbrlData); + try { + JAXBContext context = JAXBContext.newInstance(MixReportXBRLDocument.class, MixReportXBRLDocument.TaxonomyValue.class); + Marshaller marshaller = context.createMarshaller(); + marshaller.setProperty(Marshaller.JAXB_FORMATTED_OUTPUT, Boolean.TRUE); + StringWriter writer = new StringWriter(); + marshaller.marshal(doc, writer); + return writer.toString(); + } catch (Exception e) { + log.error("Error marshalling XBRL document", e); + throw new RuntimeException(e); + } + } + + public MixReportXBRLDocument buildDocumentGraph(final MixReportXBRLData xbrlData) { + MixReportXBRLDocument document = new MixReportXBRLDocument(); + Integer instantScenarioCounter = 0; Integer durationScenarioCounter = 0; Map contextMap = new HashMap<>(); - final Document doc = DocumentHelper.createDocument(); - Element root = doc.addElement("xbrl"); - root.addElement("schemaRef").addNamespace("link", - "http://www.themix.org/sites/default/files/Taxonomy2010/dct/dc-all_2010-08-31.xsd"); - - for (final Map.Entry entry : map.entrySet()) { + // 1. Build the dynamic taxonomy elements using JAXBElement + for (final Map.Entry entry : xbrlData.getResultMap().entrySet()) { final MixTaxonomyData taxonomy = entry.getKey(); final BigDecimal value = entry.getValue(); - addTaxonomy(root, taxonomy, value, startDate, endDate, instantScenarioCounter, durationScenarioCounter, contextMap); - } + if (xbrlData.getStartDate() == null || xbrlData.getEndDate() == null) { + throw new MixReportXBRLMappingInvalidException("start date and end date should not be null"); + } - addContexts(root, startDate, endDate, contextMap); - addCurrencyUnit(root, currency); - addNumberUnit(root); + final String prefix = taxonomy.getNamespace(); + String localName = taxonomy.getName(); + String nsUrl = ""; + if (prefix != null && !prefix.isEmpty()) { + final MixReportXBRLNamespaceData ns = this.readNamespaceService.retrieveNamespaceByPrefix(prefix); + if (ns != null) { + nsUrl = ns.getUrl(); + } + } - doc.setXMLEncoding("UTF-8"); + MixReportXBRLContextData context = getContextForTaxonomy(taxonomy); - return doc.asXML(); - } + if (!contextMap.containsKey(context)) { + final SimpleDateFormat timeFormat = new SimpleDateFormat("MM_dd_yyyy"); + final String startDateStr = timeFormat.format(xbrlData.getStartDate()); + final String endDateStr = timeFormat.format(xbrlData.getEndDate()); + instantScenarioCounter += 1; + durationScenarioCounter += 1; + final String contextRefID = context.getPeriodType() == 0 ? "As_Of_" + endDateStr + instantScenarioCounter + : "Duration_" + startDateStr + "_To_" + endDateStr + durationScenarioCounter; + + contextMap.put(context, contextRefID); + } + + MixReportXBRLDocument.TaxonomyValue taxValue = new MixReportXBRLDocument.TaxonomyValue(contextMap.get(context), + getUnitRef(taxonomy), getNumberOfDecimalPlaces(value).toString(), value); - private Element addTaxonomy(final Element rootElement, final MixTaxonomyData taxonomy, final BigDecimal value, final Date startDate, - final Date endDate, Integer instantScenarioCounter, Integer durationScenarioCounter, - final Map contextMap) { + QName qname = new QName(nsUrl, localName, prefix != null ? prefix : ""); + jakarta.xml.bind.JAXBElement jaxbElement = new jakarta.xml.bind.JAXBElement<>(qname, + MixReportXBRLDocument.TaxonomyValue.class, taxValue); - // throw an error is start / endate is null - if (startDate == null || endDate == null) { - throw new MixReportXBRLMappingInvalidException("start date and end date should not be null"); + document.getTaxonomyElements().add(jaxbElement); } - final String prefix = taxonomy.getNamespace(); - String qname = taxonomy.getName(); - if (prefix != null && !prefix.isEmpty()) { - final MixReportXBRLNamespaceData ns = this.readNamespaceService.retrieveNamespaceByPrefix(prefix); - if (ns != null) { + // 2. Map Contexts into the structural DTO layout + final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); - rootElement.addNamespace(prefix, ns.getUrl()); + for (final Map.Entry entry : contextMap.entrySet()) { + final MixReportXBRLContextData context = entry.getKey(); + MixReportXBRLDocument.XBRLContextDTO contextDTO = new MixReportXBRLDocument.XBRLContextDTO(); + contextDTO.setId(entry.getValue()); + + MixReportXBRLDocument.PeriodDTO periodDTO = new MixReportXBRLDocument.PeriodDTO(); + if (context.getPeriodType() == 0) { + periodDTO.setInstant(format.format(xbrlData.getEndDate())); + } else { + periodDTO.setStartDate(format.format(xbrlData.getStartDate())); + periodDTO.setEndDate(format.format(xbrlData.getEndDate())); + } + contextDTO.setPeriod(periodDTO); + + final String dimension = context.getDimension(); + final String dimType = context.getDimensionType(); + if (dimType != null && dimension != null) { + MixReportXBRLDocument.ScenarioDTO scenarioDTO = new MixReportXBRLDocument.ScenarioDTO(); + MixReportXBRLDocument.ExplicitMemberDTO memberDTO = new MixReportXBRLDocument.ExplicitMemberDTO(); + memberDTO.setDimension(dimType); + memberDTO.setValue(dimension); + scenarioDTO.setExplicitMember(memberDTO); + contextDTO.setScenario(scenarioDTO); } - qname = prefix + ":" + taxonomy.getName(); + document.getContexts().add(contextDTO); } - final Element xmlElement = rootElement.addElement(qname); - final String dimension = taxonomy.getDimension(); - final SimpleDateFormat timeFormat = new SimpleDateFormat("MM_dd_yyyy"); + // 3. Map Units into structured DTO layout + MixReportXBRLDocument.XBRLUnitDTO pureUnit = new MixReportXBRLDocument.XBRLUnitDTO(); + pureUnit.setId(UNITID_PURE); + pureUnit.setMeasure("xbrli:pure"); + document.getUnits().add(pureUnit); + MixReportXBRLDocument.XBRLUnitDTO curUnit = new MixReportXBRLDocument.XBRLUnitDTO(); + curUnit.setId(UNITID_CUR); + curUnit.setMeasure("iso4217:" + xbrlData.getCurrency()); + document.getUnits().add(curUnit); + + return document; + } + + private MixReportXBRLContextData getContextForTaxonomy(final MixTaxonomyData taxonomy) { MixReportXBRLContextData context = null; + final String dimension = taxonomy.getDimension(); if (dimension != null) { final List dims = Splitter.on(':').splitToList(dimension); @@ -122,84 +184,13 @@ private Element addTaxonomy(final Element rootElement, final MixTaxonomyData tax taxonomy.getType().equals(MixTaxonomyData.BALANCE_SHEET) || taxonomy.getType().equals(MixTaxonomyData.PORTFOLIO) ? 0 : 1); } - - if (!contextMap.containsKey(context)) { - - final String startDateStr = timeFormat.format(startDate); - final String endDateStr = timeFormat.format(endDate); - instantScenarioCounter += 1; - durationScenarioCounter += 1; - final String contextRefID = context.getPeriodType() == 0 ? "As_Of_" + endDateStr + instantScenarioCounter - : "Duration_" + startDateStr + "_To_" + endDateStr + durationScenarioCounter; - - contextMap.put(context, contextRefID); - } - - xmlElement.addAttribute("contextRef", contextMap.get(context)); - xmlElement.addAttribute("unitRef", getUnitRef(taxonomy)); - xmlElement.addAttribute("decimals", getNumberOfDecimalPlaces(value).toString()); - - // add the child - xmlElement.addText(value.toPlainString()); - - return xmlElement; + return context; } private String getUnitRef(final MixTaxonomyData tx) { return tx.isPortfolio() ? UNITID_PURE : UNITID_CUR; } - /** - * Adds the generic number unit - */ - private void addNumberUnit(final Element root) { - final Element numerUnit = root.addElement("unit"); - numerUnit.addAttribute("id", UNITID_PURE); - final Element measure = numerUnit.addElement("measure"); - measure.addText("xbrli:pure"); - - } - - /** - * Adds the currency unit to the document - * - * @param currencyCode - */ - private void addCurrencyUnit(final Element root, final String currencyCode) { - final Element currencyUnitElement = root.addElement("unit"); - currencyUnitElement.addAttribute("id", UNITID_CUR); - final Element measure = currencyUnitElement.addElement("measure"); - measure.addText("iso4217:" + currencyCode); - - } - - private void addContexts(final Element root, final Date startDate, final Date endDate, - final Map contextMap) { - final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); - for (final Map.Entry entry : contextMap.entrySet()) { - final MixReportXBRLContextData context = entry.getKey(); - final Element contextElement = root.addElement("context"); - contextElement.addAttribute("id", entry.getValue()); - contextElement.addElement("entity").addElement("identifier").addAttribute("scheme", SCHEME_URL).addText(IDENTIFIER); - - final Element periodElement = contextElement.addElement("period"); - - if (context.getPeriodType() == 0) { - periodElement.addElement("instant").addText(format.format(endDate)); - } else { - periodElement.addElement("startDate").addText(format.format(startDate)); - periodElement.addElement("endDate").addText(format.format(endDate)); - } - - final String dimension = context.getDimension(); - final String dimType = context.getDimensionType(); - if (dimType != null && dimension != null) { - contextElement.addElement("scenario").addElement("explicitMember").addAttribute("dimension", dimType).addText(dimension); - } - } - - } - private Integer getNumberOfDecimalPlaces(final BigDecimal bigDecimal) { final String string = bigDecimal.stripTrailingZeros().toPlainString(); final int index = string.indexOf("."); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/XBRLIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/XBRLIntegrationTest.java index 61d023f638e..5e649f01b2c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/XBRLIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/XBRLIntegrationTest.java @@ -64,4 +64,21 @@ private void verifyTaxonomyList(final ArrayList taxonomyList) { assertEquals("AdministrativeExpense", taxonomyList.get(0).get("name"), "Checking for the 1st taxonomy"); } + @Test + public void shouldSerializeMixXBRLReportToXMLNatively() throws Exception { + LOG.info("--------------------VERIFYING NATIVE JAXB XML SERIALIZATION CONTRACT--------------------------"); + + this.xbrlHelper = new XBRLIntegrationTestHelper(this.requestSpec, this.responseSpec); + + final String xmlResponse = this.xbrlHelper.getMixReport("2005-01-01", "2005-12-31", "USD"); + + LOG.info("GENERATED NATIVE XBRL XML REPORT CONTENT:\n{}", xmlResponse); + + org.junit.jupiter.api.Assertions.assertNotNull(xmlResponse, "The serialized XML output should not be null"); + + org.junit.jupiter.api.Assertions.assertTrue(xmlResponse.toLowerCase().contains("xbrl"), + "Serialized XML must contain the xbrl root context schema identifier"); + + LOG.info("--------------------XML NATIVE SERIALIZATION VERIFIED SUCCESSFULLY--------------------------"); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/xbrl/XBRLIntegrationTestHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/xbrl/XBRLIntegrationTestHelper.java index dcc305d0b91..c1cc36af4e7 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/xbrl/XBRLIntegrationTestHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/xbrl/XBRLIntegrationTestHelper.java @@ -61,4 +61,11 @@ public HashMap getTaxonomyMapping() { return response; } + public String getMixReport(final String startDate, final String endDate, final String currency) { + final String url = "/fineract-provider/api/v1/mixreport?startDate=" + startDate + "&endDate=" + endDate + "¤cy=" + currency + + "&" + Utils.TENANT_IDENTIFIER; + final String response = Utils.performServerGet(this.requestSpec, this.responseSpec, url, null); + return response; + } + }