/*
* Copyright (c) 2005-2011 Grameen Foundation USA
* All rights reserved.
*
* 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.
*
* See also http://www.apache.org/licenses/LICENSE-2.0.html for an
* explanation of the license and how it is applied.
*/
package org.mifos.config;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Source;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamSource;
import javax.xml.validation.Schema;
import javax.xml.validation.SchemaFactory;
import javax.xml.validation.Validator;
import org.hibernate.Query;
import org.hibernate.Session;
import org.mifos.accounts.financial.business.COABO;
import org.mifos.accounts.financial.business.GLCategoryType;
import org.mifos.application.NamedQueryConstants;
import org.mifos.config.exceptions.ConfigurationException;
import org.mifos.core.MifosResourceUtil;
import org.mifos.framework.util.ConfigurationLocator;
import org.mifos.framework.util.helpers.FilePaths;
import org.springframework.core.io.ClassPathResource;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
/**
* Encapsulates chart of accounts configuration.
* <p>
* Use {@link #load(String)} to get an instance of this class.
*/
public class ChartOfAccountsConfig {
// XML element names
private static final String ASSETS_CATEGORY = "GLAssetsAccount";
private static final String LIABILITIES_CATEGORY = "GLLiabilitiesAccount";
private static final String INCOME_CATEGORY = "GLIncomeAccount";
private static final String EXPENDITURE_CATEGORY = "GLExpenditureAccount";
// XML attribute names
private static final String GLCODE_ATTR = "code";
protected static final String ACCOUNT_NAME_ATTR = "name";
private ChartOfAccountsConfig() {
}
private Document coaDocument;
/**
* Factory method which loads the Chart of Accounts configuration from the
* given filename. Given XML filename will be validated against
* {@link FilePaths#CHART_OF_ACCOUNTS_SCHEMA}.
*
* @param chartOfAccountsXml
* a relative path to the Chart of Accounts configuration file.
*/
public static ChartOfAccountsConfig load(String chartOfAccountsXml) throws ConfigurationException {
ChartOfAccountsConfig instance = null;
Document document = null;
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder parser = dbf.newDocumentBuilder();
if (FilePaths.CHART_OF_ACCOUNTS_DEFAULT.equals(chartOfAccountsXml)) { // default chart of accounts
document = parser.parse(MifosResourceUtil.getClassPathResourceAsStream(chartOfAccountsXml));
}
else { // custom chart of accounts
document = parser.parse(MifosResourceUtil.getFile(chartOfAccountsXml));
}
// create a SchemaFactory capable of understanding XML schemas
SchemaFactory factory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
// load an XML schema
ClassPathResource schemaFileResource = new ClassPathResource(FilePaths.CHART_OF_ACCOUNTS_SCHEMA);
Source schemaFile = null;
if (schemaFileResource.exists()) {
InputStream in = ChartOfAccountsConfig.class.getClassLoader().getResourceAsStream(FilePaths.CHART_OF_ACCOUNTS_SCHEMA);
schemaFile = new StreamSource(in);
} else {
schemaFile = new StreamSource(MifosResourceUtil.getFile(FilePaths.CHART_OF_ACCOUNTS_SCHEMA));
}
Schema schema = factory.newSchema(schemaFile);
// create a Validator instance and validate document
Validator validator = schema.newValidator();
validator.validate(new DOMSource(document));
} catch (IOException e) {
throw new ConfigurationException(e);
} catch (SAXException e) {
throw new ConfigurationException(e);
} catch (ParserConfigurationException e) {
throw new ConfigurationException(e);
}
instance = new ChartOfAccountsConfig();
instance.coaDocument = document;
return instance;
}
/**
* Get Chart of Accounts URI. Returns custom config file if present, returns
* default otherwise.
* <p>
* Allows for easy overriding/customization of the chart of accounts by
* placing a file called <code>mifosChartOfAccounts.custom.xml</code>
* anywhere in the application server classpath.
*
* @param session Session
* @return relative path to Chart of Accounts config file that the
* {@link MifosResourceUtil} can use to derive the actual on-disk
* location.
*/
public static String getCoaUri(Session session) throws IOException {
final boolean customCoaExists = new ConfigurationLocator().getResource(FilePaths.CHART_OF_ACCOUNTS_CUSTOM).exists();
if (customCoaExists) {
return FilePaths.CHART_OF_ACCOUNTS_CUSTOM;
}
// if data exists in the database, the only way to add GL accounts is
// to create a custom chart of accounts XML file and place it on the
// classpath
if (isLoaded(session) && !customCoaExists) {
return null;
}
return FilePaths.CHART_OF_ACCOUNTS_DEFAULT;
}
/**
* The only time you <em>can't</em> load the chart of accounts is when the database
* has existing chart of accounts data, but a custom chart of accounts
* configuration file is not found on the classpath.
*/
public static boolean canLoadCoa(Session session) throws IOException {
return null != getCoaUri(session);
}
/**
* Convenience method to get all configured general ledger accounts. Result
* set is guaranteed to be ordered since parent accounts will probably need
* to be dealt with before children.
*/
public Set<GLAccount> getGLAccounts() {
// LinkedHashSet satisfies ordering guarantee, above
Set<GLAccount> glAccounts = new LinkedHashSet<GLAccount>();
for (GLCategoryType category : GLCategoryType.values()) {
glAccounts.addAll(traverse(getCategory(category), null));
}
return glAccounts;
}
/**
* Get top-level general ledger account (aka category) from the chart of
* accounts configuration.
*/
protected Node getCategory(GLCategoryType category) {
// these could also be placed in a HashTable, but with only four
// choices, this seemed easier to maintain. Consider initializing a
// table in the #load() method if this becomes unwieldy.
if (category == GLCategoryType.ASSET) {
return coaDocument.getElementsByTagName(ASSETS_CATEGORY).item(0);
} else if (category == GLCategoryType.LIABILITY) {
return coaDocument.getElementsByTagName(LIABILITIES_CATEGORY).item(0);
} else if (category == GLCategoryType.INCOME) {
return coaDocument.getElementsByTagName(INCOME_CATEGORY).item(0);
} else if (category == GLCategoryType.EXPENDITURE) {
return coaDocument.getElementsByTagName(EXPENDITURE_CATEGORY).item(0);
}
throw new RuntimeException("invalid category type: " + category);
}
private static GLCategoryType getTopLevelType(Node node) {
assert Node.ELEMENT_NODE == node.getNodeType();
String elementName = node.getNodeName();
// these could also be placed in a HashTable, but with only four
// choices, this seemed easier to maintain. Consider initializing a
// table in the #load() method if this becomes unwieldy.
if (ASSETS_CATEGORY.equals(elementName)) {
return GLCategoryType.ASSET;
} else if (LIABILITIES_CATEGORY.equals(elementName)) {
return GLCategoryType.LIABILITY;
} else if (INCOME_CATEGORY.equals(elementName)) {
return GLCategoryType.INCOME;
} else if (EXPENDITURE_CATEGORY.equals(elementName)) {
return GLCategoryType.EXPENDITURE;
}
return null;
}
/**
* Recursively traverses given {@link Node} tree.
* <p>
* Does not check for null glCode or name attributes. This is enforced
* during validation by the schema.
* <p>
* Result set is guaranteed to be ordered since parent accounts will
* probably need to be dealt with before children.
*
* @param node
* Usually a top-level account (aka category). This means
* GLAssetsAccount, GLLiabilitiesAccount, etc. Also accepts any
* lower-level GLAccount <code>Node</code>s (and this can be
* useful in testing). Must not be null.
* @param parentGlCode
* General ledger code of parent account. May be null, as is the
* case for top-level accounts (aka categories).
*/
protected static Set<GLAccount> traverse(Node node, String parentGlCode) {
assert null != node;
// LinkedHashSet satisfies ordering guarantee, above
Set<GLAccount> glAccounts = new LinkedHashSet<GLAccount>();
GLAccount glAccount = new GLAccount();
glAccount.glCode = node.getAttributes().getNamedItem(GLCODE_ATTR).getNodeValue();
glAccount.name = node.getAttributes().getNamedItem(ACCOUNT_NAME_ATTR).getNodeValue();
glAccount.parentGlCode = parentGlCode;
glAccount.categoryType = getTopLevelType(node);
glAccounts.add(glAccount);
NodeList children = node.getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
// necessary since children may be Node.TEXT_NODE type
if (Node.ELEMENT_NODE != child.getNodeType()) {
continue;
}
if (!glAccounts.addAll(traverse(child, glAccount.glCode))) {
// A duplicate exists. The exception will help us avoid any
// serious errors later on.
throw new RuntimeException("duplicate account definition. " + GLCODE_ATTR + "=" + glAccount.glCode
+ " " + ACCOUNT_NAME_ATTR + "=" + glAccount.name);
}
}
return glAccounts;
}
/**
* @return true if the chart of accounts has been loaded from the on-disk
* configuration file into the database.
*/
public static boolean isLoaded(Session session) {
// A more comprehensive check would also make sure no rows exist in
// the following tables: coahierarchy, coa_idmapper, gl_code
Query query = session.getNamedQuery(NamedQueryConstants.GET_ALL_COA);
List<COABO> coaBoList = query.list();
return !coaBoList.isEmpty();
}
}