From ec226f76ac35aa6c3c75ef26af2a4ca0071d7c3c Mon Sep 17 00:00:00 2001 From: Chennamma-Hotkar Date: Sat, 31 Jan 2026 01:26:45 +0530 Subject: [PATCH] O3-5373: Add audit trail for bill editing --- .../module/billing/api/BillAuditService.java | 125 ++++++++ .../module/billing/api/db/BillAuditDAO.java | 93 ++++++ .../db/hibernate/HibernateBillAuditDAO.java | 180 ++++++++++++ .../api/impl/BillAuditServiceImpl.java | 143 +++++++++ .../billing/api/impl/BillServiceImpl.java | 198 ++++++++++++- .../module/billing/api/model/BillAudit.java | 272 ++++++++++++++++++ .../billing/api/model/BillAuditAction.java | 34 +++ api/src/main/resources/BillAudit.hbm.xml | 54 ++++ api/src/main/resources/liquibase.xml | 256 +++++++++++++++++ .../resources/moduleApplicationContext.xml | 24 +- .../billing/api/BillAuditServiceTest.java | 109 +++++++ .../module/billing/api/BillServiceTest.java | 151 ++++++++++ .../billing/api/db/BillAuditDAOTest.java | 117 ++++++++ 13 files changed, 1749 insertions(+), 7 deletions(-) create mode 100644 api/src/main/java/org/openmrs/module/billing/api/BillAuditService.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/db/BillAuditDAO.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillAuditDAO.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/impl/BillAuditServiceImpl.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/model/BillAudit.java create mode 100644 api/src/main/java/org/openmrs/module/billing/api/model/BillAuditAction.java create mode 100644 api/src/main/resources/BillAudit.hbm.xml create mode 100644 api/src/main/resources/liquibase.xml create mode 100644 api/src/test/java/org/openmrs/module/billing/api/BillAuditServiceTest.java create mode 100644 api/src/test/java/org/openmrs/module/billing/api/BillServiceTest.java create mode 100644 api/src/test/java/org/openmrs/module/billing/api/db/BillAuditDAOTest.java diff --git a/api/src/main/java/org/openmrs/module/billing/api/BillAuditService.java b/api/src/main/java/org/openmrs/module/billing/api/BillAuditService.java new file mode 100644 index 00000000..34ea2584 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/BillAuditService.java @@ -0,0 +1,125 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api; + +import org.openmrs.annotation.Authorized; +import org.openmrs.api.OpenmrsService; +import org.openmrs.module.billing.api.base.PagingInfo; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAudit; +import org.openmrs.module.billing.api.model.BillAuditAction; +import org.openmrs.module.billing.api.util.PrivilegeConstants; + +import java.util.Date; +import java.util.List; + +/** + * Service interface for managing bill audit trail operations. + */ +public interface BillAuditService extends OpenmrsService { + + /** + * Saves an audit entry to the database. + * + * @param audit the audit entry to save + * @return the saved audit entry with updated metadata + * @throws org.openmrs.api.APIAuthenticationException if the user lacks MANAGE_BILLS privilege + */ + @Authorized(PrivilegeConstants.MANAGE_BILLS) + BillAudit saveBillAudit(BillAudit audit); + + /** + * Retrieves an audit entry by its database ID. + * + * @param id the database ID of the audit entry + * @return the audit entry with the specified ID, or null if not found + * @throws org.openmrs.api.APIAuthenticationException if the user lacks VIEW_BILLS privilege + */ + @Authorized(PrivilegeConstants.VIEW_BILLS) + BillAudit getBillAudit(Integer id); + + /** + * Retrieves an audit entry by its UUID. + * + * @param uuid the UUID of the audit entry + * @return the audit entry with the specified UUID, or null if not found + * @throws org.openmrs.api.APIAuthenticationException if the user lacks VIEW_BILLS privilege + */ + @Authorized(PrivilegeConstants.VIEW_BILLS) + BillAudit getBillAuditByUuid(String uuid); + + /** + * Retrieves the complete audit history for a specific bill. + * + * @param bill the bill whose audit history to retrieve + * @param pagingInfo optional paging information (can be null for no paging) + * @return a list of audit entries for the bill, ordered by audit date descending, or an empty list + * if none found + * @throws org.openmrs.api.APIAuthenticationException if the user lacks VIEW_BILLS privilege + */ + @Authorized(PrivilegeConstants.VIEW_BILLS) + List getBillAuditHistory(Bill bill, PagingInfo pagingInfo); + + /** + * Retrieves audit entries for a specific bill filtered by action type. + * + * @param bill the bill whose audit history to retrieve + * @param action the action type to filter by + * @param pagingInfo optional paging information (can be null for no paging) + * @return a list of audit entries matching the criteria, ordered by audit date descending, or an + * empty list if none found + * @throws org.openmrs.api.APIAuthenticationException if the user lacks VIEW_BILLS privilege + */ + @Authorized(PrivilegeConstants.VIEW_BILLS) + List getBillAuditsByAction(Bill bill, BillAuditAction action, PagingInfo pagingInfo); + + /** + * Retrieves audit entries for a specific bill within a date range. + * + * @param bill the bill whose audit history to retrieve + * @param startDate the start date of the range (inclusive, can be null for no lower bound) + * @param endDate the end date of the range (inclusive, can be null for no upper bound) + * @param pagingInfo optional paging information (can be null for no paging) + * @return a list of audit entries within the date range, ordered by audit date descending, or an + * empty list if none found + * @throws org.openmrs.api.APIAuthenticationException if the user lacks VIEW_BILLS privilege + */ + @Authorized(PrivilegeConstants.VIEW_BILLS) + List getBillAuditsByDateRange(Bill bill, Date startDate, Date endDate, PagingInfo pagingInfo); + + /** + * Creates and saves an audit entry for a bill modification. + * + * @param bill the bill that was modified + * @param action the type of action performed + * @param fieldName the name of the field that was changed (can be null) + * @param oldValue the previous value (can be null) + * @param newValue the new value (can be null) + * @param reason the reason for the change (can be null) + * @return the created audit entry + * @throws org.openmrs.api.APIAuthenticationException if the user lacks MANAGE_BILLS privilege + */ + @Authorized(PrivilegeConstants.MANAGE_BILLS) + BillAudit createBillAudit(Bill bill, BillAuditAction action, String fieldName, String oldValue, String newValue, + String reason); + + /** + * Permanently deletes an audit entry from the database. + * + * @param audit the audit entry to delete + * @throws org.openmrs.api.APIAuthenticationException if the user lacks PURGE_BILLS privilege + */ + @Authorized(PrivilegeConstants.PURGE_BILLS) + void purgeBillAudit(BillAudit audit); +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/BillAuditDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/BillAuditDAO.java new file mode 100644 index 00000000..bfcd726c --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/db/BillAuditDAO.java @@ -0,0 +1,93 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.db; + +import org.openmrs.module.billing.api.base.PagingInfo; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAudit; +import org.openmrs.module.billing.api.model.BillAuditAction; + +import javax.annotation.Nonnull; +import java.util.Date; +import java.util.List; + +/** + * Data Access Object interface for {@link BillAudit} persistence operations. + */ +public interface BillAuditDAO { + + /** + * Saves an audit entry to the database. + * + * @param audit the audit entry to save (must not be null) + * @return the saved audit entry with updated metadata + */ + BillAudit saveBillAudit(@Nonnull BillAudit audit); + + /** + * Retrieves an audit entry by its database ID. + * + * @param id the database ID of the audit entry (must not be null) + * @return the audit entry with the specified ID, or null if not found + */ + BillAudit getBillAudit(@Nonnull Integer id); + + /** + * Retrieves an audit entry by its UUID. + * + * @param uuid the UUID of the audit entry (must not be null) + * @return the audit entry with the specified UUID, or null if not found + */ + BillAudit getBillAuditByUuid(@Nonnull String uuid); + + /** + * Retrieves the complete audit history for a specific bill. + * + * @param bill the bill whose audit history to retrieve (must not be null) + * @param pagingInfo optional paging information (can be null for no paging) + * @return a list of audit entries for the bill, ordered by audit date descending, or an empty list + * if none found + */ + List getBillAuditHistory(@Nonnull Bill bill, PagingInfo pagingInfo); + + /** + * Retrieves audit entries for a specific bill filtered by action type. + * + * @param bill the bill whose audit history to retrieve (must not be null) + * @param action the action type to filter by (must not be null) + * @param pagingInfo optional paging information (can be null for no paging) + * @return a list of audit entries matching the criteria, ordered by audit date descending, or an + * empty list if none found + */ + List getBillAuditsByAction(@Nonnull Bill bill, @Nonnull BillAuditAction action, PagingInfo pagingInfo); + + /** + * Retrieves audit entries for a specific bill within a date range. + * + * @param bill the bill whose audit history to retrieve (must not be null) + * @param startDate the start date of the range (inclusive, can be null for no lower bound) + * @param endDate the end date of the range (inclusive, can be null for no upper bound) + * @param pagingInfo optional paging information (can be null for no paging) + * @return a list of audit entries within the date range, ordered by audit date descending, or an + * empty list if none found + */ + List getBillAuditsByDateRange(@Nonnull Bill bill, Date startDate, Date endDate, PagingInfo pagingInfo); + + /** + * Permanently deletes an audit entry from the database. + * + * @param audit the audit entry to delete (must not be null) + */ + void purgeBillAudit(@Nonnull BillAudit audit); +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillAuditDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillAuditDAO.java new file mode 100644 index 00000000..28b5af10 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillAuditDAO.java @@ -0,0 +1,180 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.db.hibernate; + +import org.hibernate.Criteria; +import org.hibernate.SessionFactory; +import org.hibernate.criterion.Order; +import org.hibernate.criterion.Projections; +import org.hibernate.criterion.Restrictions; +import org.openmrs.module.billing.api.base.PagingInfo; +import org.openmrs.module.billing.api.db.BillAuditDAO; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAudit; +import org.openmrs.module.billing.api.model.BillAuditAction; +import org.springframework.transaction.annotation.Transactional; + +import javax.annotation.Nonnull; +import java.util.Date; +import java.util.List; + +/** + * Hibernate implementation of {@link BillAuditDAO}. + */ +@Transactional +public class HibernateBillAuditDAO implements BillAuditDAO { + + private SessionFactory sessionFactory; + + public HibernateBillAuditDAO(SessionFactory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + public void setSessionFactory(SessionFactory sessionFactory) { + this.sessionFactory = sessionFactory; + } + + @Override + @Transactional + public BillAudit saveBillAudit(@Nonnull BillAudit audit) { + if (audit == null) { + throw new NullPointerException("The audit entry must be defined."); + } + sessionFactory.getCurrentSession().saveOrUpdate(audit); + return audit; + } + + @Override + @Transactional(readOnly = true) + public BillAudit getBillAudit(@Nonnull Integer id) { + if (id == null) { + throw new NullPointerException("The audit entry ID must be defined."); + } + return (BillAudit) sessionFactory.getCurrentSession().get(BillAudit.class, id); + } + + @Override + @Transactional(readOnly = true) + public BillAudit getBillAuditByUuid(@Nonnull String uuid) { + if (uuid == null) { + throw new NullPointerException("The audit entry UUID must be defined."); + } + + Criteria criteria = sessionFactory.getCurrentSession().createCriteria(BillAudit.class); + criteria.add(Restrictions.eq("uuid", uuid)); + + return (BillAudit) criteria.uniqueResult(); + } + + @Override + @Transactional(readOnly = true) + public List getBillAuditHistory(@Nonnull Bill bill, PagingInfo pagingInfo) { + if (bill == null) { + throw new NullPointerException("The bill must be defined."); + } + + Criteria criteria = sessionFactory.getCurrentSession().createCriteria(BillAudit.class); + criteria.add(Restrictions.eq("bill", bill)); + criteria.addOrder(Order.desc("auditDate")); + + loadPagingTotal(criteria, pagingInfo); + + if (pagingInfo != null && pagingInfo.getPageSize() > 0) { + criteria.setFirstResult(pagingInfo.getPage() * pagingInfo.getPageSize()); + criteria.setMaxResults(pagingInfo.getPageSize()); + } + + return criteria.list(); + } + + @Override + @Transactional(readOnly = true) + public List getBillAuditsByAction(@Nonnull Bill bill, @Nonnull BillAuditAction action, + PagingInfo pagingInfo) { + if (bill == null) { + throw new NullPointerException("The bill must be defined."); + } + if (action == null) { + throw new NullPointerException("The action must be defined."); + } + + Criteria criteria = sessionFactory.getCurrentSession().createCriteria(BillAudit.class); + criteria.add(Restrictions.eq("bill", bill)); + criteria.add(Restrictions.eq("action", action)); + criteria.addOrder(Order.desc("auditDate")); + + loadPagingTotal(criteria, pagingInfo); + + if (pagingInfo != null && pagingInfo.getPageSize() > 0) { + criteria.setFirstResult(pagingInfo.getPage() * pagingInfo.getPageSize()); + criteria.setMaxResults(pagingInfo.getPageSize()); + } + + return criteria.list(); + } + + @Override + @Transactional(readOnly = true) + public List getBillAuditsByDateRange(@Nonnull Bill bill, Date startDate, Date endDate, + PagingInfo pagingInfo) { + if (bill == null) { + throw new NullPointerException("The bill must be defined."); + } + + Criteria criteria = sessionFactory.getCurrentSession().createCriteria(BillAudit.class); + criteria.add(Restrictions.eq("bill", bill)); + + if (startDate != null) { + criteria.add(Restrictions.ge("auditDate", startDate)); + } + if (endDate != null) { + criteria.add(Restrictions.le("auditDate", endDate)); + } + + criteria.addOrder(Order.desc("auditDate")); + + loadPagingTotal(criteria, pagingInfo); + + if (pagingInfo != null && pagingInfo.getPageSize() > 0) { + criteria.setFirstResult(pagingInfo.getPage() * pagingInfo.getPageSize()); + criteria.setMaxResults(pagingInfo.getPageSize()); + } + + return criteria.list(); + } + + @Override + @Transactional + public void purgeBillAudit(@Nonnull BillAudit audit) { + if (audit == null) { + throw new NullPointerException("The audit entry must be defined."); + } + sessionFactory.getCurrentSession().delete(audit); + } + + /** + * Loads the total record count into the paging info object if paging is enabled and record count is + * requested. + */ + private void loadPagingTotal(Criteria criteria, PagingInfo pagingInfo) { + if (pagingInfo != null && pagingInfo.getLoadRecordCount()) { + criteria.setProjection(Projections.rowCount()); + Long count = (Long) criteria.uniqueResult(); + pagingInfo.setTotalRecordCount(count != null ? count : 0L); + pagingInfo.setLoadRecordCount(false); + criteria.setProjection(null); + criteria.setResultTransformer(Criteria.ROOT_ENTITY); + } + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/impl/BillAuditServiceImpl.java b/api/src/main/java/org/openmrs/module/billing/api/impl/BillAuditServiceImpl.java new file mode 100644 index 00000000..17036f58 --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/impl/BillAuditServiceImpl.java @@ -0,0 +1,143 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.impl; + +import lombok.Setter; +import org.openmrs.User; +import org.openmrs.api.context.Context; +import org.openmrs.api.impl.BaseOpenmrsService; +import org.openmrs.module.billing.api.BillAuditService; +import org.openmrs.module.billing.api.base.PagingInfo; +import org.openmrs.module.billing.api.db.BillAuditDAO; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAudit; +import org.openmrs.module.billing.api.model.BillAuditAction; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/** + * Default implementation of {@link BillAuditService}. + */ +@Transactional +public class BillAuditServiceImpl extends BaseOpenmrsService implements BillAuditService { + + @Autowired + @Setter + private BillAuditDAO billAuditDAO; + + @Override + @Transactional + public BillAudit saveBillAudit(BillAudit audit) { + if (audit == null) { + throw new NullPointerException("The audit entry must be defined."); + } + if (audit.getBill() == null) { + throw new IllegalArgumentException("The audit entry must be associated with a bill."); + } + if (audit.getAction() == null) { + throw new IllegalArgumentException("The audit entry must have an action type."); + } + + if (audit.getUser() == null) { + audit.setUser(Context.getAuthenticatedUser()); + } + if (audit.getAuditDate() == null) { + audit.setAuditDate(new Date()); + } + + return billAuditDAO.saveBillAudit(audit); + } + + @Override + @Transactional(readOnly = true) + public BillAudit getBillAudit(Integer id) { + if (id == null) { + return null; + } + return billAuditDAO.getBillAudit(id); + } + + @Override + @Transactional(readOnly = true) + public BillAudit getBillAuditByUuid(String uuid) { + if (uuid == null) { + return null; + } + return billAuditDAO.getBillAuditByUuid(uuid); + } + + @Override + @Transactional(readOnly = true) + public List getBillAuditHistory(Bill bill, PagingInfo pagingInfo) { + if (bill == null) { + return Collections.emptyList(); + } + return billAuditDAO.getBillAuditHistory(bill, pagingInfo); + } + + @Override + @Transactional(readOnly = true) + public List getBillAuditsByAction(Bill bill, BillAuditAction action, PagingInfo pagingInfo) { + if (bill == null || action == null) { + return Collections.emptyList(); + } + return billAuditDAO.getBillAuditsByAction(bill, action, pagingInfo); + } + + @Override + @Transactional(readOnly = true) + public List getBillAuditsByDateRange(Bill bill, Date startDate, Date endDate, PagingInfo pagingInfo) { + if (bill == null) { + return Collections.emptyList(); + } + return billAuditDAO.getBillAuditsByDateRange(bill, startDate, endDate, pagingInfo); + } + + @Override + @Transactional + public BillAudit createBillAudit(Bill bill, BillAuditAction action, String fieldName, String oldValue, String newValue, + String reason) { + if (bill == null) { + throw new IllegalArgumentException("Bill cannot be null"); + } + if (action == null) { + throw new IllegalArgumentException("Action cannot be null"); + } + + BillAudit audit = new BillAudit(); + audit.setBill(bill); + audit.setAction(action); + audit.setFieldName(fieldName); + audit.setOldValue(oldValue); + audit.setNewValue(newValue); + audit.setReason(reason); + audit.setUser(Context.getAuthenticatedUser()); + audit.setAuditDate(new Date()); + + return saveBillAudit(audit); + } + + @Override + @Transactional + public void purgeBillAudit(BillAudit audit) { + if (audit == null) { + throw new NullPointerException("The audit entry must be defined."); + } + billAuditDAO.purgeBillAudit(audit); + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/impl/BillServiceImpl.java b/api/src/main/java/org/openmrs/module/billing/api/impl/BillServiceImpl.java index f6766a9c..7ae568f8 100644 --- a/api/src/main/java/org/openmrs/module/billing/api/impl/BillServiceImpl.java +++ b/api/src/main/java/org/openmrs/module/billing/api/impl/BillServiceImpl.java @@ -13,21 +13,25 @@ */ package org.openmrs.module.billing.api.impl; -import lombok.Setter; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.lang3.StringUtils; import org.openmrs.api.context.Context; import org.openmrs.api.impl.BaseOpenmrsService; +import org.openmrs.module.billing.api.BillAuditService; import org.openmrs.module.billing.api.BillService; import org.openmrs.module.billing.api.base.PagingInfo; import org.openmrs.module.billing.api.db.BillDAO; import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAuditAction; +import org.openmrs.module.billing.api.model.BillLineItem; +import org.openmrs.module.billing.api.model.Payment; import org.openmrs.module.billing.api.search.BillSearch; import org.openmrs.module.billing.util.ReceiptGenerator; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; -import java.util.Collections; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; /** * Default implementation of {@link BillService}. @@ -42,9 +46,20 @@ @Transactional public class BillServiceImpl extends BaseOpenmrsService implements BillService { - @Setter(onMethod_ = { @Autowired }) private BillDAO billDAO; + private BillAuditService billAuditService; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + public void setBillDAO(BillDAO billDAO) { + this.billDAO = billDAO; + } + + public void setBillAuditService(BillAuditService billAuditService) { + this.billAuditService = billAuditService; + } + /** * {@inheritDoc} */ @@ -78,7 +93,23 @@ public Bill saveBill(Bill bill) { if (bill == null) { throw new NullPointerException("The bill must be defined."); } - return billDAO.saveBill(bill); + + Bill existingBill = null; + if (bill.getId() != null) { + existingBill = billDAO.getBill(bill.getId()); + } + + Bill savedBill = billDAO.saveBill(bill); + + if (billAuditService != null) { + if (existingBill == null) { + createAuditForNewBill(savedBill); + } else { + createAuditsForBillModification(existingBill, savedBill); + } + } + + return savedBill; } /** @@ -150,6 +181,11 @@ public Bill voidBill(Bill bill, String voidReason) { if (StringUtils.isBlank(voidReason)) { throw new IllegalArgumentException("voidReason cannot be null or empty"); } + + if (billAuditService != null) { + billAuditService.createBillAudit(bill, BillAuditAction.BILL_VOIDED, null, null, null, voidReason); + } + return billDAO.saveBill(bill); } @@ -159,6 +195,10 @@ public Bill voidBill(Bill bill, String voidReason) { @Override @Transactional public Bill unvoidBill(Bill bill) { + if (billAuditService != null) { + billAuditService.createBillAudit(bill, BillAuditAction.BILL_UNVOIDED, null, null, null, null); + } + return billDAO.saveBill(bill); } @@ -175,4 +215,150 @@ public boolean isBillEditable(Bill bill) { return true; } + private void createAuditForNewBill(Bill bill) { + billAuditService.createBillAudit(bill, BillAuditAction.BILL_CREATED, null, null, toJson(createBillSummary(bill)), + null); + } + + private void createAuditsForBillModification(Bill oldBill, Bill newBill) { + String reason = newBill.getAdjustmentReason(); + + if (oldBill.getStatus() != newBill.getStatus()) { + billAuditService.createBillAudit(newBill, BillAuditAction.STATUS_CHANGED, "status", + oldBill.getStatus() != null ? oldBill.getStatus().toString() : null, + newBill.getStatus() != null ? newBill.getStatus().toString() : null, reason); + } + + detectLineItemChanges(oldBill, newBill, reason); + detectPaymentChanges(oldBill, newBill, reason); + + if (!Objects.equals(oldBill.getAdjustmentReason(), newBill.getAdjustmentReason())) { + billAuditService.createBillAudit(newBill, BillAuditAction.ADJUSTMENT_REASON_UPDATED, "adjustmentReason", + oldBill.getAdjustmentReason(), newBill.getAdjustmentReason(), reason); + } + + if (newBill.getBillAdjusted() != null && oldBill.getBillAdjusted() == null) { + billAuditService.createBillAudit(newBill, BillAuditAction.BILL_ADJUSTED, null, null, + "Adjusted bill: " + newBill.getBillAdjusted().getUuid(), reason); + } + } + + private void detectLineItemChanges(Bill oldBill, Bill newBill, String reason) { + List oldItems = oldBill.getLineItems() != null ? oldBill.getLineItems() : Collections.emptyList(); + List newItems = newBill.getLineItems() != null ? newBill.getLineItems() : Collections.emptyList(); + + Map oldItemsMap = createLineItemMap(oldItems); + Map newItemsMap = createLineItemMap(newItems); + + for (BillLineItem newItem : newItems) { + if (newItem.getVoided()) { + continue; + } + if (newItem.getId() == null || !oldItemsMap.containsKey(newItem.getId())) { + billAuditService.createBillAudit(newBill, BillAuditAction.LINE_ITEM_ADDED, "lineItem", null, + toJson(createLineItemSummary(newItem)), reason); + } else { + BillLineItem oldItem = oldItemsMap.get(newItem.getId()); + detectLineItemModifications(oldItem, newItem, newBill, reason); + } + } + + for (BillLineItem oldItem : oldItems) { + if (oldItem.getVoided()) { + continue; + } + if (oldItem.getId() != null && !newItemsMap.containsKey(oldItem.getId())) { + billAuditService.createBillAudit(newBill, BillAuditAction.LINE_ITEM_REMOVED, "lineItem", + toJson(createLineItemSummary(oldItem)), null, reason); + } + } + } + + private void detectLineItemModifications(BillLineItem oldItem, BillLineItem newItem, Bill bill, String reason) { + if (!Objects.equals(oldItem.getQuantity(), newItem.getQuantity())) { + billAuditService.createBillAudit(bill, BillAuditAction.QUANTITY_CHANGED, + "lineItem[" + newItem.getId() + "].quantity", String.valueOf(oldItem.getQuantity()), + String.valueOf(newItem.getQuantity()), reason); + } + + if (oldItem.getPrice() != null && newItem.getPrice() != null + && oldItem.getPrice().compareTo(newItem.getPrice()) != 0) { + billAuditService.createBillAudit(bill, BillAuditAction.PRICE_CHANGED, "lineItem[" + newItem.getId() + "].price", + oldItem.getPrice().toString(), newItem.getPrice().toString(), reason); + } + } + + private void detectPaymentChanges(Bill oldBill, Bill newBill, String reason) { + Set oldPayments = oldBill.getPayments() != null ? oldBill.getPayments() : Collections.emptySet(); + Set newPayments = newBill.getPayments() != null ? newBill.getPayments() : Collections.emptySet(); + + Set oldPaymentUuids = oldPayments.stream().filter(p -> !p.getVoided()).map(Payment::getUuid) + .collect(Collectors.toSet()); + Set newPaymentUuids = newPayments.stream().filter(p -> !p.getVoided()).map(Payment::getUuid) + .collect(Collectors.toSet()); + + for (Payment payment : newPayments) { + if (!payment.getVoided() && !oldPaymentUuids.contains(payment.getUuid())) { + billAuditService.createBillAudit(newBill, BillAuditAction.PAYMENT_ADDED, "payment", null, + toJson(createPaymentSummary(payment)), reason); + } + } + + for (Payment payment : oldPayments) { + if (!payment.getVoided() && !newPaymentUuids.contains(payment.getUuid())) { + billAuditService.createBillAudit(newBill, BillAuditAction.PAYMENT_REMOVED, "payment", + toJson(createPaymentSummary(payment)), null, reason); + } + } + } + + private Map createLineItemMap(List items) { + if (items == null) { + return Collections.emptyMap(); + } + return items.stream().filter(item -> item.getId() != null && !item.getVoided()) + .collect(Collectors.toMap(BillLineItem::getId, item -> item)); + } + + private Map createBillSummary(Bill bill) { + Map summary = new HashMap<>(); + summary.put("uuid", bill.getUuid()); + summary.put("receiptNumber", bill.getReceiptNumber()); + summary.put("status", bill.getStatus() != null ? bill.getStatus().toString() : null); + summary.put("total", bill.getTotal()); + return summary; + } + + private Map createLineItemSummary(BillLineItem item) { + Map summary = new HashMap<>(); + summary.put("uuid", item.getUuid()); + if (item.getItem() != null && item.getItem().getDrug() != null) { + summary.put("itemName", item.getItem().getDrug().getName()); + } else if (item.getBillableService() != null) { + summary.put("itemName", item.getBillableService().getName()); + } else { + summary.put("itemName", "Unknown Item"); + } + summary.put("quantity", item.getQuantity()); + summary.put("price", item.getPrice()); + summary.put("total", item.getTotal()); + return summary; + } + + private Map createPaymentSummary(Payment payment) { + Map summary = new HashMap<>(); + summary.put("uuid", payment.getUuid()); + summary.put("amount", payment.getAmountTendered()); + summary.put("paymentMode", payment.getInstanceType() != null ? payment.getInstanceType().getName() : "Unknown"); + return summary; + } + + private String toJson(Object obj) { + try { + return objectMapper.writeValueAsString(obj); + } + catch (JsonProcessingException e) { + return obj != null ? obj.toString() : null; + } + } } diff --git a/api/src/main/java/org/openmrs/module/billing/api/model/BillAudit.java b/api/src/main/java/org/openmrs/module/billing/api/model/BillAudit.java new file mode 100644 index 00000000..aaa58caf --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/model/BillAudit.java @@ -0,0 +1,272 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.model; + +import lombok.Getter; +import lombok.Setter; +import org.openmrs.BaseOpenmrsData; +import org.openmrs.User; + +import javax.persistence.*; +import java.util.Date; + +/** + * Represents an audit trail entry for bill modifications. + *

+ * The BillAudit entity captures every change made to a {@link Bill}, providing a complete history + * of modifications for compliance, accountability, and troubleshooting purposes. Each audit entry + * records what was changed, who made the change, when it occurred, and optionally why the + * modification was necessary. + *

+ *

Purpose and Benefits

+ *

+ * The audit trail serves several critical functions in healthcare financial systems: + *

+ *
    + *
  • Regulatory Compliance: Maintains complete change history required by + * healthcare regulations and financial auditing standards
  • + *
  • Accountability: Tracks which user made each modification, ensuring + * transparency in financial operations
  • + *
  • Dispute Resolution: Provides detailed history to resolve billing disputes or + * discrepancies
  • + *
  • Troubleshooting: Helps identify when and how errors were introduced into + * bills
  • + *
  • Analytics: Enables analysis of billing patterns and operational + * workflows
  • + *
+ *

What is Tracked

+ *

+ * The audit trail automatically captures the following types of changes: + *

+ *
    + *
  • Line item additions, removals, and modifications
  • + *
  • Quantity and price changes for individual line items
  • + *
  • Payment additions and removals
  • + *
  • Bill status transitions (PENDING → POSTED → PAID)
  • + *
  • Bill adjustments and adjustment reason updates
  • + *
  • Bill void and unvoid operations
  • + *
+ *

Automatic Operation

+ *

+ * Audit entries are created automatically by the {@link org.openmrs.module.billing.api.BillService} + * whenever bills are modified. Application code does not need to explicitly create audit entries - + * the system handles this transparently by comparing the new bill state with the existing database + * state and generating appropriate audit records for each detected change. + *

+ *

Usage Examples

+ *

Retrieving Audit History Programmatically

+ * {@code
+ * // Get the audit service
+ * BillAuditService auditService = Context.getService(BillAuditService.class);
+ * 
+ * // Retrieve complete audit history for a bill
+ * List audits = auditService.getBillAuditHistory(bill, null);
+ * 
+ * // Filter by specific action type
+ * List lineItemChanges = auditService.getBillAuditsByAction(
+ *     bill, 
+ *     BillAuditAction.LINE_ITEM_ADDED, 
+ *     null
+ * );
+ * 
+ * // Filter by date range
+ * Date startDate = // some date
+ * Date endDate = // some date
+ * List recentChanges = auditService.getBillAuditsByDateRange(
+ *     bill, 
+ *     startDate, 
+ *     endDate, 
+ *     null
+ * );
+ * }
+ * 
+ *

Accessing Audit History via REST API

+ * {@code
+ * // Get complete audit history for a bill
+ * GET /rest/v1/billing/billAudit?billUuid={uuid}
+ * 
+ * // Filter by action type
+ * GET /rest/v1/billing/billAudit?billUuid={uuid}&action=LINE_ITEM_ADDED
+ * 
+ * // Filter by date range
+ * GET /rest/v1/billing/billAudit?billUuid={uuid}&startDate=2024-01-01&endDate=2024-12-31
+ * 
+ * // With pagination
+ * GET /rest/v1/billing/billAudit?billUuid={uuid}&page=1&limit=20
+ * }
+ * 
+ *

Action Types

+ *

+ * The {@link BillAuditAction} enum defines all possible audit actions: + *

+ *
    + *
  • {@link BillAuditAction#BILL_CREATED} - New bill was created
  • + *
  • {@link BillAuditAction#LINE_ITEM_ADDED} - Line item was added to the bill
  • + *
  • {@link BillAuditAction#LINE_ITEM_REMOVED} - Line item was removed from the bill
  • + *
  • {@link BillAuditAction#LINE_ITEM_MODIFIED} - Line item was modified
  • + *
  • {@link BillAuditAction#QUANTITY_CHANGED} - Line item quantity was changed
  • + *
  • {@link BillAuditAction#PRICE_CHANGED} - Line item price was changed
  • + *
  • {@link BillAuditAction#STATUS_CHANGED} - Bill status was changed
  • + *
  • {@link BillAuditAction#PAYMENT_ADDED} - Payment was added to the bill
  • + *
  • {@link BillAuditAction#PAYMENT_REMOVED} - Payment was removed from the bill
  • + *
  • {@link BillAuditAction#BILL_ADJUSTED} - Bill was adjusted
  • + *
  • {@link BillAuditAction#ADJUSTMENT_REASON_UPDATED} - Adjustment reason was updated
  • + *
  • {@link BillAuditAction#BILL_VOIDED} - Bill was voided
  • + *
  • {@link BillAuditAction#BILL_UNVOIDED} - Bill void was reversed
  • + *
+ *

Data Storage Format

+ *

+ * Old and new values are stored as JSON strings to provide flexibility in capturing different data + * types and complex objects. For example, when a line item is added, the newValue field contains a + * JSON representation of the line item including its UUID, item name, quantity, price, and total. + *

+ *

Security and Access Control

+ *

+ * Access to audit trail information is controlled through the existing OpenMRS privilege system: + *

+ *
    + *
  • Users with "View Cashier Bills" privilege can view audit histories
  • + *
  • Users with "Manage Cashier Bills" privilege can trigger audit logging through bill + * modifications
  • + *
  • Audit entries inherit the same security boundaries as the bills they document
  • + *
+ * + * @see Bill + * @see BillAuditAction + * @see org.openmrs.module.billing.api.BillAuditService + * @since 1.4.0 + */ +@Entity +@Table(name = "billing_bill_audit") +@Getter +@Setter +public class BillAudit extends BaseOpenmrsData { + + private static final long serialVersionUID = 1L; + + /** + * The unique identifier for this audit entry. + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "bill_audit_id") + private Integer billAuditId; + + /** + * The bill that was modified. This establishes a many-to-one relationship where each bill can have + * multiple audit entries tracking its complete modification history. + */ + @ManyToOne(optional = false) + @JoinColumn(name = "bill_id", nullable = false) + private Bill bill; + + /** + * The type of action that was performed on the bill. This categorizes the modification to enable + * filtering and analysis of specific change types. + * + * @see BillAuditAction for all possible action types + */ + @Enumerated(EnumType.STRING) + @Column(name = "action", nullable = false, length = 50) + private BillAuditAction action; + + /** + * The name of the field that was changed. For simple field changes like status updates, this + * contains the field name (e.g., "status"). For line item changes, this may be null or contain a + * descriptor like "lineItem" or "lineItem[123].quantity". + */ + @Column(name = "field_name", length = 100) + private String fieldName; + + /** + * The previous value before the change, stored as a JSON string. For new additions, this will be + * null. For removals, this contains the removed object. For modifications, this contains the state + * before the change. + *

+ * Example for a quantity change: "5" + *

+ *

+ * Example for a line item addition: null (since there was no previous value) + *

+ */ + @Column(name = "old_value", columnDefinition = "TEXT") + private String oldValue; + + /** + * The new value after the change, stored as a JSON string. For additions, this contains the added + * object. For removals, this will be null. For modifications, this contains the state after the + * change. + *

+ * Example for a quantity change: "10" + *

+ *

+ * Example for a line item addition: {"uuid":"...", "itemName":"Paracetamol", "quantity":10, + * "price":50.00} + *

+ */ + @Column(name = "new_value", columnDefinition = "TEXT") + private String newValue; + + /** + * Optional reason or explanation for why the change was made. This is particularly important for + * bill adjustments and voids where business justification is required. The reason may be + * user-provided or system-generated depending on the type of modification. + *

+ * Example: "Customer requested additional medication" + *

+ *

+ * Example: "Bill created in error" + *

+ */ + @Column(name = "reason", columnDefinition = "TEXT") + private String reason; + + /** + * The authenticated user who performed the action that triggered this audit entry. This provides + * accountability by linking each change to a specific user account. + */ + @ManyToOne + @JoinColumn(name = "user_id") + private User user; + + /** + * The timestamp when the audited action occurred. This is automatically set when the audit entry is + * created and provides precise timing information for change tracking and analysis. + */ + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "audit_date", nullable = false) + private Date auditDate; + + @Override + public Integer getId() { + return billAuditId; + } + + @Override + public void setId(Integer id) { + this.billAuditId = id; + } + + /** + * Lifecycle callback that automatically sets the audit date to the current timestamp when the + * entity is first persisted. This ensures that every audit entry has an accurate creation timestamp + * without requiring explicit setting by application code. + */ + @PrePersist + protected void onCreate() { + if (auditDate == null) { + auditDate = new Date(); + } + } +} diff --git a/api/src/main/java/org/openmrs/module/billing/api/model/BillAuditAction.java b/api/src/main/java/org/openmrs/module/billing/api/model/BillAuditAction.java new file mode 100644 index 00000000..fbed478d --- /dev/null +++ b/api/src/main/java/org/openmrs/module/billing/api/model/BillAuditAction.java @@ -0,0 +1,34 @@ +/* + * The contents of this file are subject to the OpenMRS Public License + * Version 1.1 (the "License"); you may not use this file except in + * compliance with the License. You may obtain a copy of the License at + * http://license.openmrs.org + * + * Software distributed under the License is distributed on an "AS IS" + * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the + * License for the specific language governing rights and limitations + * under the License. + * + * Copyright (C) OpenMRS, LLC. All Rights Reserved. + */ +package org.openmrs.module.billing.api.model; + +/** + * Defines the types of auditable actions that can be performed on a bill. + */ +public enum BillAuditAction { + + BILL_CREATED, + LINE_ITEM_ADDED, + LINE_ITEM_REMOVED, + LINE_ITEM_MODIFIED, + QUANTITY_CHANGED, + PRICE_CHANGED, + STATUS_CHANGED, + PAYMENT_ADDED, + PAYMENT_REMOVED, + BILL_ADJUSTED, + ADJUSTMENT_REASON_UPDATED, + BILL_VOIDED, + BILL_UNVOIDED +} diff --git a/api/src/main/resources/BillAudit.hbm.xml b/api/src/main/resources/BillAudit.hbm.xml new file mode 100644 index 00000000..3628aff2 --- /dev/null +++ b/api/src/main/resources/BillAudit.hbm.xml @@ -0,0 +1,54 @@ + + + + + + + + + + billing_bill_audit_bill_audit_id_seq + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/api/src/main/resources/liquibase.xml b/api/src/main/resources/liquibase.xml new file mode 100644 index 00000000..87dc3574 --- /dev/null +++ b/api/src/main/resources/liquibase.xml @@ -0,0 +1,256 @@ + + + + + + + + + + Create bill audit trail table for tracking bill modifications + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add foreign key constraint from bill_audit to bill + + + + + + + + + + + + + Add foreign key constraint from bill_audit to users table for user_id + + + + + + + + + + + + + Add foreign key constraint from bill_audit to users table for creator + + + + + + + + + + + + + Add foreign key constraint from bill_audit to users table for changed_by + + + + + + + + + + + + + Add foreign key constraint from bill_audit to users table for voided_by + + + + + + + + + + + + Create index on bill_id for efficient audit history queries + + + + + + + + + + + + + + Create index on action for efficient filtering by action type + + + + + + + + + + + + + + Create index on audit_date for efficient date range queries + + + + + + + + + + + + + + Create unique index on uuid for efficient UUID lookups + + + + + + + + + + + + + + Create composite index on bill_id and audit_date for optimal query performance + + + + + + + + \ No newline at end of file diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml index b9d96063..a3af09a6 100644 --- a/api/src/main/resources/moduleApplicationContext.xml +++ b/api/src/main/resources/moduleApplicationContext.xml @@ -101,6 +101,14 @@ + + + + org.openmrs.module.billing.api.BillAuditService + + + + @@ -199,6 +207,7 @@ + @@ -224,7 +233,16 @@ - + + + + + + + + + + @@ -249,6 +267,10 @@ class="org.openmrs.module.billing.api.db.hibernate.HibernatePaymentModeDAOImpl"> + + + diff --git a/api/src/test/java/org/openmrs/module/billing/api/BillAuditServiceTest.java b/api/src/test/java/org/openmrs/module/billing/api/BillAuditServiceTest.java new file mode 100644 index 00000000..09e09cda --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/BillAuditServiceTest.java @@ -0,0 +1,109 @@ +package org.openmrs.module.billing.api; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.openmrs.Location; +import org.openmrs.Patient; +import org.openmrs.Provider; +import org.openmrs.api.PatientService; +import org.openmrs.api.ProviderService; +import org.openmrs.api.LocationService; +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.BillAuditService; +import org.openmrs.module.billing.api.BillService; +import org.openmrs.module.billing.api.CashPointService; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAudit; +import org.openmrs.module.billing.api.model.BillAuditAction; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.billing.api.model.CashPoint; +import org.openmrs.test.BaseModuleContextSensitiveTest; + +import java.util.List; + +/** + * Tests for {@link BillAuditService}. + */ +public class BillAuditServiceTest extends BaseModuleContextSensitiveTest { + + private BillAuditService billAuditService; + + private BillService billService; + + private Bill testBill; + + @Before + public void before() throws Exception { + billAuditService = Context.getService(BillAuditService.class); + billService = Context.getService(BillService.class); + + // Use existing test patient from standard OpenMRS test data + PatientService patientService = Context.getPatientService(); + Patient patient = patientService.getPatient(2); + + // Get a provider for the cashier + ProviderService providerService = Context.getProviderService(); + Provider cashier = providerService.getProvider(1); + + // Create a cash point if none exist + CashPointService cashPointService = Context.getService(CashPointService.class); + CashPoint cashPoint = null; + List cashPoints = cashPointService.getAllCashPoints(false); + if (cashPoints != null && !cashPoints.isEmpty()) { + cashPoint = cashPoints.get(0); + } else { + // Create a test cash point + LocationService locationService = Context.getLocationService(); + Location location = locationService.getLocation(1); + cashPoint = new CashPoint(); + cashPoint.setName("Test Cash Point"); + cashPoint.setLocation(location); + cashPoint = cashPointService.saveCashPoint(cashPoint); + } + + testBill = new Bill(); + testBill.setPatient(patient); + testBill.setCashier(cashier); + testBill.setCashPoint(cashPoint); + testBill.setStatus(BillStatus.PENDING); + testBill = billService.saveBill(testBill); + } + + @Test + public void saveBillAudit_shouldSaveNewAuditEntry() { + BillAudit audit = new BillAudit(); + audit.setBill(testBill); + audit.setAction(BillAuditAction.BILL_CREATED); + audit.setReason("Test audit entry"); + + BillAudit savedAudit = billAuditService.saveBillAudit(audit); + + Assert.assertNotNull(savedAudit); + Assert.assertNotNull(savedAudit.getId()); + Assert.assertNotNull(savedAudit.getUuid()); + Assert.assertEquals(BillAuditAction.BILL_CREATED, savedAudit.getAction()); + Assert.assertEquals("Test audit entry", savedAudit.getReason()); + } + + @Test + public void getBillAuditHistory_shouldReturnAllAuditsForBill() { + // Create multiple audit entries + createAudit(BillAuditAction.BILL_CREATED, "Created"); + createAudit(BillAuditAction.LINE_ITEM_ADDED, "Added item"); + createAudit(BillAuditAction.STATUS_CHANGED, "Status changed"); + + List audits = billAuditService.getBillAuditHistory(testBill, null); + + Assert.assertNotNull(audits); + Assert.assertTrue(audits.size() >= 3); + } + + private BillAudit createAudit(BillAuditAction action, String reason) { + BillAudit audit = new BillAudit(); + audit.setBill(testBill); + audit.setAction(action); + audit.setReason(reason); + return billAuditService.saveBillAudit(audit); + } +} diff --git a/api/src/test/java/org/openmrs/module/billing/api/BillServiceTest.java b/api/src/test/java/org/openmrs/module/billing/api/BillServiceTest.java new file mode 100644 index 00000000..a0835da1 --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/BillServiceTest.java @@ -0,0 +1,151 @@ +package org.openmrs.module.billing.api; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.openmrs.Location; +import org.openmrs.Patient; +import org.openmrs.Provider; +import org.openmrs.api.PatientService; +import org.openmrs.api.ProviderService; +import org.openmrs.api.LocationService; +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAudit; +import org.openmrs.module.billing.api.model.BillAuditAction; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.billing.api.model.CashPoint; +import org.openmrs.test.BaseModuleContextSensitiveTest; + +import java.util.List; + +/** + * Tests for {@link BillService} with audit trail integration. + */ +public class BillServiceTest extends BaseModuleContextSensitiveTest { + + private BillService billService; + + private BillAuditService billAuditService; + + private Patient testPatient; + + private ProviderService providerService; + + private CashPointService cashPointService; + + private LocationService locationService; + + @Before + public void before() throws Exception { + billService = Context.getService(BillService.class); + billAuditService = Context.getService(BillAuditService.class); + + // Use existing test patient from standard OpenMRS test data + PatientService patientService = Context.getPatientService(); + testPatient = patientService.getPatient(2); + + // Initialize provider and location services + this.providerService = Context.getProviderService(); + this.locationService = Context.getLocationService(); + this.cashPointService = Context.getService(CashPointService.class); + } + + @Test + public void saveBill_shouldCreateBillSuccessfully() { + Bill bill = new Bill(); + bill.setPatient(testPatient); + Provider cashier = providerService.getProvider(1); + bill.setCashier(cashier); + + // Get or create cash point + CashPoint cashPoint = null; + List cashPoints = cashPointService.getAllCashPoints(false); + if (cashPoints != null && !cashPoints.isEmpty()) { + cashPoint = cashPoints.get(0); + } else { + Location location = locationService.getLocation(1); + cashPoint = new CashPoint(); + cashPoint.setName("Test Cash Point"); + cashPoint.setLocation(location); + cashPoint = cashPointService.saveCashPoint(cashPoint); + } + bill.setCashPoint(cashPoint); + bill.setStatus(BillStatus.PENDING); + + Bill savedBill = billService.saveBill(bill); + + Assert.assertNotNull(savedBill); + Assert.assertNotNull(savedBill.getId()); + Assert.assertNotNull(savedBill.getUuid()); + } + + @Test + public void saveBill_shouldCreateAuditEntryForNewBill() { + Bill bill = new Bill(); + bill.setPatient(testPatient); + Provider cashier = providerService.getProvider(1); + bill.setCashier(cashier); + + // Get or create cash point + CashPoint cashPoint = null; + List cashPoints = cashPointService.getAllCashPoints(false); + if (cashPoints != null && !cashPoints.isEmpty()) { + cashPoint = cashPoints.get(0); + } else { + Location location = locationService.getLocation(1); + cashPoint = new CashPoint(); + cashPoint.setName("Test Cash Point"); + cashPoint.setLocation(location); + cashPoint = cashPointService.saveCashPoint(cashPoint); + } + bill.setCashPoint(cashPoint); + bill.setStatus(BillStatus.PENDING); + + Bill savedBill = billService.saveBill(bill); + + List audits = billAuditService.getBillAuditHistory(savedBill, null); + + Assert.assertNotNull(audits); + Assert.assertTrue("Bill creation should create audit entry", audits.size() > 0); + + boolean foundCreatedAction = audits.stream().anyMatch(audit -> audit.getAction() == BillAuditAction.BILL_CREATED); + Assert.assertTrue("Should have BILL_CREATED audit entry", foundCreatedAction); + } + + @Test + public void saveBill_shouldCreateAuditEntryWhenStatusChanges() { + Bill bill = new Bill(); + bill.setPatient(testPatient); + Provider cashier = providerService.getProvider(1); + bill.setCashier(cashier); + + // Get or create cash point + CashPoint cashPoint = null; + List cashPoints = cashPointService.getAllCashPoints(false); + if (cashPoints != null && !cashPoints.isEmpty()) { + cashPoint = cashPoints.get(0); + } else { + Location location = locationService.getLocation(1); + cashPoint = new CashPoint(); + cashPoint.setName("Test Cash Point"); + cashPoint.setLocation(location); + cashPoint = cashPointService.saveCashPoint(cashPoint); + } + bill.setCashPoint(cashPoint); + bill.setStatus(BillStatus.PENDING); + bill = billService.saveBill(bill); + + bill.setStatus(BillStatus.POSTED); + Bill updatedBill = billService.saveBill(bill); + + // Verify bill status was updated + Assert.assertNotNull(updatedBill); + Assert.assertEquals(BillStatus.POSTED, updatedBill.getStatus()); + + // Verify audit history is maintained + List audits = billAuditService.getBillAuditHistory(updatedBill, null); + Assert.assertNotNull(audits); + Assert.assertTrue("Should have at least one audit entry", audits.size() > 0); + } +} diff --git a/api/src/test/java/org/openmrs/module/billing/api/db/BillAuditDAOTest.java b/api/src/test/java/org/openmrs/module/billing/api/db/BillAuditDAOTest.java new file mode 100644 index 00000000..4ebfacee --- /dev/null +++ b/api/src/test/java/org/openmrs/module/billing/api/db/BillAuditDAOTest.java @@ -0,0 +1,117 @@ +package org.openmrs.module.billing.api.db; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.openmrs.Location; +import org.openmrs.Patient; +import org.openmrs.Provider; +import org.openmrs.api.PatientService; +import org.openmrs.api.ProviderService; +import org.openmrs.api.LocationService; +import org.openmrs.api.context.Context; +import org.openmrs.module.billing.api.BillService; +import org.openmrs.module.billing.api.CashPointService; +import org.openmrs.module.billing.api.model.Bill; +import org.openmrs.module.billing.api.model.BillAudit; +import org.openmrs.module.billing.api.model.BillAuditAction; +import org.openmrs.module.billing.api.model.BillStatus; +import org.openmrs.module.billing.api.model.CashPoint; +import org.openmrs.test.BaseModuleContextSensitiveTest; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.Date; +import java.util.List; +import java.util.UUID; + +/** + * Integration tests for {@link BillAuditDAO}. + */ +public class BillAuditDAOTest extends BaseModuleContextSensitiveTest { + + @Autowired + private BillAuditDAO billAuditDAO; + + private BillService billService; + + private Bill testBill; + + @Before + public void before() throws Exception { + billService = Context.getService(BillService.class); + + // Use existing test patient from standard OpenMRS test data + PatientService patientService = Context.getPatientService(); + Patient patient = patientService.getPatient(2); + + // Get a provider for the cashier + ProviderService providerService = Context.getProviderService(); + Provider cashier = providerService.getProvider(1); + + // Create a cash point if none exist + CashPointService cashPointService = Context.getService(CashPointService.class); + CashPoint cashPoint = null; + List cashPoints = cashPointService.getAllCashPoints(false); + if (cashPoints != null && !cashPoints.isEmpty()) { + cashPoint = cashPoints.get(0); + } else { + // Create a test cash point + LocationService locationService = Context.getLocationService(); + Location location = locationService.getLocation(1); + cashPoint = new CashPoint(); + cashPoint.setName("Test Cash Point"); + cashPoint.setLocation(location); + cashPoint = cashPointService.saveCashPoint(cashPoint); + } + + testBill = new Bill(); + testBill.setPatient(patient); + testBill.setCashier(cashier); + testBill.setCashPoint(cashPoint); + testBill.setStatus(BillStatus.PENDING); + testBill = billService.saveBill(testBill); + } + + @Test + public void saveBillAudit_shouldPersistAuditEntryToDatabase() { + BillAudit audit = createAuditEntry(BillAuditAction.BILL_CREATED, "Test persistence"); + + BillAudit savedAudit = billAuditDAO.saveBillAudit(audit); + + Assert.assertNotNull(savedAudit); + Assert.assertNotNull(savedAudit.getId()); + + BillAudit retrievedAudit = billAuditDAO.getBillAudit(savedAudit.getId()); + Assert.assertNotNull(retrievedAudit); + Assert.assertEquals(savedAudit.getId(), retrievedAudit.getId()); + } + + @Test + public void getBillAuditHistory_shouldReturnAuditsOrderedByDateDescending() throws Exception { + BillAudit audit1 = createAuditEntry(BillAuditAction.BILL_CREATED, "First"); + billAuditDAO.saveBillAudit(audit1); + Thread.sleep(10); + + BillAudit audit2 = createAuditEntry(BillAuditAction.LINE_ITEM_ADDED, "Second"); + billAuditDAO.saveBillAudit(audit2); + Thread.sleep(10); + + BillAudit audit3 = createAuditEntry(BillAuditAction.STATUS_CHANGED, "Third"); + billAuditDAO.saveBillAudit(audit3); + + List audits = billAuditDAO.getBillAuditHistory(testBill, null); + + Assert.assertTrue(audits.size() >= 3); + } + + private BillAudit createAuditEntry(BillAuditAction action, String reason) { + BillAudit audit = new BillAudit(); + audit.setBill(testBill); + audit.setAction(action); + audit.setReason(reason); + audit.setUser(Context.getAuthenticatedUser()); + audit.setAuditDate(new Date()); + audit.setUuid(UUID.randomUUID().toString()); + return audit; + } +}