diff --git a/api/src/main/java/org/openmrs/module/billing/api/BillService.java b/api/src/main/java/org/openmrs/module/billing/api/BillService.java
index bf51980d..9a7d4ab8 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/BillService.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/BillService.java
@@ -4,6 +4,7 @@
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.BillStatus;
import org.openmrs.module.billing.api.search.BillSearch;
import org.openmrs.module.billing.api.util.PrivilegeConstants;
@@ -144,4 +145,64 @@ public interface BillService extends OpenmrsService {
@Authorized(PrivilegeConstants.VIEW_BILLS)
boolean isBillEditable(Bill bill);
+ /**
+ * Reads the bill's status directly from the database, bypassing Hibernate's session cache. Intended
+ * for status-transition validation where an in-memory entity may have a pending target status and
+ * the caller needs the untouched persisted value.
+ *
+ * @param billId the database ID of the bill
+ * @return the persisted status, or null if no bill with that ID exists
+ */
+ @Authorized(PrivilegeConstants.VIEW_BILLS)
+ BillStatus getPersistedBillStatus(Integer billId);
+
+ /**
+ * Requests a refund for a paid bill.
+ *
+ * The bill must have status {@code PAID}. On success, the bill's status transitions to
+ * {@code REFUND_REQUESTED} and the refund request metadata (reason, requestedBy, date) is recorded.
+ *
+ *
+ * @param bill the bill to request a refund for
+ * @param refundReason the reason for requesting the refund (required, cannot be blank)
+ * @return the updated bill with status {@code REFUND_REQUESTED}
+ * @throws org.openmrs.api.APIAuthenticationException if the user lacks MANAGE_BILLS privilege
+ * @throws IllegalArgumentException if the bill is null, not in PAID status, or refundReason is
+ * blank
+ */
+ @Authorized(PrivilegeConstants.MANAGE_BILLS)
+ Bill requestRefund(Bill bill, String refundReason);
+
+ /**
+ * Approves a pending refund request.
+ *
+ * The bill must have status {@code REFUND_REQUESTED}. On success, the bill's status transitions to
+ * {@code REFUNDED} and the approval metadata (approvedBy, date) is recorded.
+ *
+ *
+ * @param bill the bill whose refund request is being approved
+ * @return the updated bill with status {@code REFUNDED}
+ * @throws org.openmrs.api.APIAuthenticationException if the user lacks REFUND_MONEY privilege
+ * @throws IllegalArgumentException if the bill is null or not in REFUND_REQUESTED status
+ */
+ @Authorized(PrivilegeConstants.REFUND_MONEY)
+ Bill approveRefund(Bill bill);
+
+ /**
+ * Rejects a pending refund request.
+ *
+ * The bill must have status {@code REFUND_REQUESTED}. On success, the bill's status transitions to
+ * {@code REFUND_DENIED} and the rejection metadata (reason, rejectedBy, date) is recorded.
+ *
+ *
+ * @param bill the bill whose refund request is being rejected
+ * @param denialReason the reason for denying the refund (required, cannot be blank)
+ * @return the updated bill with status {@code REFUND_DENIED}
+ * @throws org.openmrs.api.APIAuthenticationException if the user lacks REFUND_MONEY privilege
+ * @throws IllegalArgumentException if the bill is null, not in REFUND_REQUESTED status, or
+ * denialReason is blank
+ */
+ @Authorized(PrivilegeConstants.REFUND_MONEY)
+ Bill rejectRefund(Bill bill, String denialReason);
+
}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/BillDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/BillDAO.java
index a5d08e98..3892204e 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/db/BillDAO.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/db/BillDAO.java
@@ -2,6 +2,7 @@
import org.openmrs.module.billing.api.base.PagingInfo;
import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillStatus;
import org.openmrs.module.billing.api.search.BillSearch;
import javax.annotation.Nonnull;
@@ -99,4 +100,15 @@ public interface BillDAO {
*/
void purgeBill(@Nonnull Bill bill);
+ /**
+ * Reads the bill's status directly from the database, bypassing Hibernate's session cache. This
+ * returns the persisted (pre-mutation) value even when the managed entity in the current session
+ * has been modified, so callers can compare the DB truth against in-memory changes (e.g., for
+ * status-transition validation).
+ *
+ * @param billId the database ID of the bill (must not be null)
+ * @return the persisted status, or null if no bill with that ID exists
+ */
+ BillStatus getPersistedBillStatus(@Nonnull Integer billId);
+
}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillDAO.java
index 1a9bca4e..74d61b3a 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillDAO.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillDAO.java
@@ -12,6 +12,7 @@
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.BillStatus;
import org.openmrs.module.billing.api.search.BillSearch;
import javax.annotation.Nonnull;
@@ -133,6 +134,20 @@ public void purgeBill(@Nonnull Bill bill) {
sessionFactory.getCurrentSession().remove(bill);
}
+ /**
+ * {@inheritDoc}
+ */
+ @Override
+ public BillStatus getPersistedBillStatus(@Nonnull Integer billId) {
+ List> results = sessionFactory.getCurrentSession()
+ .createNativeQuery("SELECT status FROM cashier_bill WHERE bill_id = :billId").setParameter("billId", billId)
+ .getResultList();
+ if (results.isEmpty() || results.get(0) == null) {
+ return null;
+ }
+ return BillStatus.valueOf(results.get(0).toString());
+ }
+
private List buildBillSearchPredicate(CriteriaBuilder cb, Root root, BillSearch billSearch) {
List predicates = new ArrayList<>();
diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/ImmutableBillInterceptor.java b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/ImmutableBillInterceptor.java
index 8360cbff..46ffd021 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/ImmutableBillInterceptor.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/ImmutableBillInterceptor.java
@@ -29,7 +29,8 @@ public class ImmutableBillInterceptor extends ImmutableEntityInterceptor {
private static final String[] MUTABLE_PROPERTY_NAMES = new String[] { "changedBy", "dateChanged", "voided", "dateVoided",
"voidedBy", "voidReason", "payment", "billAdjusted", "adjustmentReason", "adjustedBy", "receiptPrinted",
- "status", "receiptNumber" };
+ "status", "receiptNumber", "refundReason", "refundRequestedBy", "dateRefundRequested", "refundApprovedBy",
+ "dateRefundApproved", "refundDenialReason", "refundRejectedBy", "dateRefundRejected" };
@Override
protected Class> getSupportedType() {
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..ece0f0d7 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
@@ -21,12 +21,14 @@
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.BillStatus;
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.Date;
import java.util.List;
/**
@@ -175,4 +177,54 @@ public boolean isBillEditable(Bill bill) {
return true;
}
+ @Override
+ @Transactional(readOnly = true)
+ public BillStatus getPersistedBillStatus(Integer billId) {
+ if (billId == null) {
+ return null;
+ }
+ return billDAO.getPersistedBillStatus(billId);
+ }
+
+ @Override
+ public Bill requestRefund(Bill bill, String refundReason) {
+ if (bill == null) {
+ throw new IllegalArgumentException("The bill must be defined.");
+ }
+ if (StringUtils.isBlank(refundReason)) {
+ throw new IllegalArgumentException("refundReason cannot be null or empty");
+ }
+ bill.setRefundReason(refundReason);
+ bill.setRefundRequestedBy(Context.getAuthenticatedUser());
+ bill.setDateRefundRequested(new Date());
+ bill.setStatus(BillStatus.REFUND_REQUESTED);
+ return Context.getService(BillService.class).saveBill(bill);
+ }
+
+ @Override
+ public Bill approveRefund(Bill bill) {
+ if (bill == null) {
+ throw new IllegalArgumentException("The bill must be defined.");
+ }
+ bill.setRefundApprovedBy(Context.getAuthenticatedUser());
+ bill.setDateRefundApproved(new Date());
+ bill.setStatus(BillStatus.REFUNDED);
+ return Context.getService(BillService.class).saveBill(bill);
+ }
+
+ @Override
+ public Bill rejectRefund(Bill bill, String denialReason) {
+ if (bill == null) {
+ throw new IllegalArgumentException("The bill must be defined.");
+ }
+ if (StringUtils.isBlank(denialReason)) {
+ throw new IllegalArgumentException("denialReason cannot be null or empty");
+ }
+ bill.setRefundDenialReason(denialReason);
+ bill.setRefundRejectedBy(Context.getAuthenticatedUser());
+ bill.setDateRefundRejected(new Date());
+ bill.setStatus(BillStatus.REFUND_DENIED);
+ return Context.getService(BillService.class).saveBill(bill);
+ }
+
}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/model/Bill.java b/api/src/main/java/org/openmrs/module/billing/api/model/Bill.java
index bf65b11b..aec1ea19 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/model/Bill.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/model/Bill.java
@@ -16,6 +16,7 @@
import java.math.BigDecimal;
import java.security.AccessControlException;
import java.util.ArrayList;
+import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -25,6 +26,7 @@
import org.openmrs.BaseOpenmrsData;
import org.openmrs.Patient;
import org.openmrs.Provider;
+import org.openmrs.User;
import org.openmrs.api.context.Context;
import org.openmrs.module.billing.api.util.PrivilegeConstants;
import org.openmrs.module.stockmanagement.api.model.StockItem;
@@ -63,6 +65,22 @@ public class Bill extends BaseOpenmrsData {
private String adjustmentReason;
+ private String refundReason;
+
+ private User refundRequestedBy;
+
+ private Date dateRefundRequested;
+
+ private User refundApprovedBy;
+
+ private Date dateRefundApproved;
+
+ private String refundDenialReason;
+
+ private User refundRejectedBy;
+
+ private Date dateRefundRejected;
+
public BigDecimal getTotal() {
BigDecimal total = BigDecimal.ZERO;
@@ -169,6 +187,9 @@ public void addPayment(Payment payment) {
}
public void synchronizeBillStatus() {
+ if (this.status == BillStatus.REFUND_REQUESTED || this.status == BillStatus.REFUNDED) {
+ return;
+ }
if (!this.getPayments().isEmpty() && getTotalPayments().compareTo(BigDecimal.ZERO) > 0) {
boolean billFullySettled = getTotalPayments().compareTo(getTotal()) >= 0;
if (billFullySettled) {
diff --git a/api/src/main/java/org/openmrs/module/billing/api/model/BillStatus.java b/api/src/main/java/org/openmrs/module/billing/api/model/BillStatus.java
index 29e17d2e..ff6698cd 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/model/BillStatus.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/model/BillStatus.java
@@ -23,7 +23,10 @@ public enum BillStatus {
PAID(),
CANCELLED(),
ADJUSTED(),
- EXEMPTED();
+ EXEMPTED(),
+ REFUND_REQUESTED(),
+ REFUNDED(),
+ REFUND_DENIED();
BillStatus() {
}
diff --git a/api/src/main/java/org/openmrs/module/billing/validator/BillValidator.java b/api/src/main/java/org/openmrs/module/billing/validator/BillValidator.java
index 67947550..19ae5edd 100644
--- a/api/src/main/java/org/openmrs/module/billing/validator/BillValidator.java
+++ b/api/src/main/java/org/openmrs/module/billing/validator/BillValidator.java
@@ -11,8 +11,10 @@
import org.openmrs.annotation.Handler;
import org.openmrs.api.context.Context;
import org.openmrs.module.billing.api.BillLineItemService;
+import org.openmrs.module.billing.api.BillService;
import org.openmrs.module.billing.api.model.Bill;
import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.BillStatus;
import org.openmrs.module.billing.api.model.Payment;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@@ -38,6 +40,7 @@ public void validate(@Nonnull Object target, @Nonnull Errors errors) {
validateNewPaymentsHaveCashier(bill, errors);
validateLineItemsNotModified(bill, errors);
+ validateRefundFields(bill, errors);
}
}
@@ -84,6 +87,40 @@ private void validateLineItemsNotModified(Bill bill, Errors errors) {
}
}
+ /**
+ * Validates refund-related fields and that the persisted status is a valid predecessor for the
+ * requested refund-workflow target. Reads the persisted status via
+ * {@link BillService#getPersistedBillStatus(Integer)} (bypasses the Hibernate session cache)
+ * because {@code BillResource.setBillStatus} and the refund service methods mutate the in-memory
+ * status to the target before validation runs — so the managed entity's status is not usable as the
+ * source of truth here.
+ */
+ private void validateRefundFields(Bill bill, Errors errors) {
+ if (bill.getId() == null || bill.getStatus() == null) {
+ return;
+ }
+
+ BillStatus requiredSource;
+ if (bill.getStatus() == BillStatus.REFUND_REQUESTED) {
+ requiredSource = BillStatus.PAID;
+ } else if (bill.getStatus() == BillStatus.REFUNDED || bill.getStatus() == BillStatus.REFUND_DENIED) {
+ requiredSource = BillStatus.REFUND_REQUESTED;
+ } else {
+ return;
+ }
+ BillStatus persisted = Context.getService(BillService.class).getPersistedBillStatus(bill.getId());
+ if (persisted != requiredSource) {
+ errors.reject("billing.error.invalidBillStatusTransition");
+ }
+
+ if (bill.getStatus() == BillStatus.REFUND_REQUESTED && StringUtils.isBlank(bill.getRefundReason())) {
+ errors.reject("billing.error.refundReasonRequired");
+ }
+ if (bill.getStatus() == BillStatus.REFUND_DENIED && StringUtils.isBlank(bill.getRefundDenialReason())) {
+ errors.reject("billing.error.denialReasonRequired");
+ }
+ }
+
/**
* Validates that any new (unsaved) non-voided payment has a cashier. Existing persisted payments
* (id != null) are exempt to allow legacy data.
diff --git a/api/src/main/resources/Bill.hbm.xml b/api/src/main/resources/Bill.hbm.xml
index 2a3ea05e..d856a0db 100644
--- a/api/src/main/resources/Bill.hbm.xml
+++ b/api/src/main/resources/Bill.hbm.xml
@@ -139,6 +139,22 @@
+
+
+
+
+
+
+
+
diff --git a/api/src/main/resources/messages.properties b/api/src/main/resources/messages.properties
index 4db22f78..be5bbea6 100644
--- a/api/src/main/resources/messages.properties
+++ b/api/src/main/resources/messages.properties
@@ -181,6 +181,9 @@ openhmis.cashier.payment.error.amountType=Amount needs to be a number
openhmis.cashier.payment.error.amountRequired=Amount is required.
openhmis.cashier.payment.confirm.paymentProcess=Are you sure you want to process a %s payment of %s?
billing.error.paymentCashierRequired=Each payment must have an associated cashier.
+billing.error.refundReasonRequired=A reason is required when requesting a refund.
+billing.error.denialReasonRequired=A reason is required when denying a refund.
+billing.error.invalidBillStatusTransition=The bill status transition is not allowed.
#setting page
openhmis.cashier.setting.header=Cashier Settings
openhmis.cashier.setting.adjustmentReason.field.header=Require Adjustment Reason
diff --git a/api/src/test/java/org/openmrs/module/billing/api/model/BillTest.java b/api/src/test/java/org/openmrs/module/billing/api/model/BillTest.java
index 17a1240a..82b74c4a 100644
--- a/api/src/test/java/org/openmrs/module/billing/api/model/BillTest.java
+++ b/api/src/test/java/org/openmrs/module/billing/api/model/BillTest.java
@@ -218,6 +218,75 @@ public void synchronizeBillStatus_shouldUpdateAllNonVoidedLineItemsToPaidWhenBil
assertNull(voidedLineItem.getPaymentStatus());
}
+ @Test
+ public void synchronizeBillStatus_shouldNotOverwriteRefundRequestedStatus() {
+ Bill bill = new Bill();
+ bill.setLineItems(new ArrayList<>());
+ bill.setPayments(new HashSet<>());
+ bill.setStatus(BillStatus.REFUND_REQUESTED);
+
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setPrice(BigDecimal.valueOf(100));
+ lineItem.setQuantity(1);
+ lineItem.setVoided(false);
+ bill.getLineItems().add(lineItem);
+
+ Payment payment = new Payment();
+ payment.setAmountTendered(BigDecimal.valueOf(100));
+ payment.setVoided(false);
+ bill.getPayments().add(payment);
+
+ bill.synchronizeBillStatus();
+
+ assertEquals(BillStatus.REFUND_REQUESTED, bill.getStatus());
+ }
+
+ @Test
+ public void synchronizeBillStatus_shouldNotOverwriteRefundedStatus() {
+ Bill bill = new Bill();
+ bill.setLineItems(new ArrayList<>());
+ bill.setPayments(new HashSet<>());
+ bill.setStatus(BillStatus.REFUNDED);
+
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setPrice(BigDecimal.valueOf(100));
+ lineItem.setQuantity(1);
+ lineItem.setVoided(false);
+ bill.getLineItems().add(lineItem);
+
+ Payment payment = new Payment();
+ payment.setAmountTendered(BigDecimal.valueOf(100));
+ payment.setVoided(false);
+ bill.getPayments().add(payment);
+
+ bill.synchronizeBillStatus();
+
+ assertEquals(BillStatus.REFUNDED, bill.getStatus());
+ }
+
+ @Test
+ public void synchronizeBillStatus_shouldSynchronizeRefundDeniedBillToPaid() {
+ Bill bill = new Bill();
+ bill.setLineItems(new ArrayList<>());
+ bill.setPayments(new HashSet<>());
+ bill.setStatus(BillStatus.REFUND_DENIED);
+
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setPrice(BigDecimal.valueOf(100));
+ lineItem.setQuantity(1);
+ lineItem.setVoided(false);
+ bill.getLineItems().add(lineItem);
+
+ Payment payment = new Payment();
+ payment.setAmountTendered(BigDecimal.valueOf(100));
+ payment.setVoided(false);
+ bill.getPayments().add(payment);
+
+ bill.synchronizeBillStatus();
+
+ assertEquals(BillStatus.PAID, bill.getStatus());
+ }
+
@Test
public void setLineItems_shouldAllowSettingLineItemsOnNewBill() {
Bill bill = new Bill();
diff --git a/api/src/test/java/org/openmrs/module/billing/impl/BillServiceImplTest.java b/api/src/test/java/org/openmrs/module/billing/impl/BillServiceImplTest.java
index 4f2602f9..43b0937f 100644
--- a/api/src/test/java/org/openmrs/module/billing/impl/BillServiceImplTest.java
+++ b/api/src/test/java/org/openmrs/module/billing/impl/BillServiceImplTest.java
@@ -509,4 +509,154 @@ public void saveBill_shouldGenerateReceiptNumberWhenNotProvided() {
assertNotNull(savedBill.getReceiptNumber());
assertFalse(savedBill.getReceiptNumber().isEmpty());
}
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#requestRefund(Bill, String)
+ */
+ @Test
+ public void requestRefund_shouldThrowIllegalArgumentExceptionIfBillIsNull() {
+ assertThrows(IllegalArgumentException.class, () -> billService.requestRefund(null, "reason"));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#requestRefund(Bill, String)
+ */
+ @Test
+ public void requestRefund_shouldThrowIllegalArgumentExceptionIfReasonIsBlank() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ assertEquals(BillStatus.PAID, paidBill.getStatus());
+
+ assertThrows(IllegalArgumentException.class, () -> billService.requestRefund(billService.getBill(1), ""));
+ Context.clearSession();
+ assertThrows(IllegalArgumentException.class, () -> billService.requestRefund(billService.getBill(1), null));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#requestRefund(Bill, String)
+ */
+ @Test
+ public void requestRefund_shouldThrowValidationExceptionIfBillIsNotPaid() {
+ Bill pendingBill = billService.getBill(2);
+ assertNotNull(pendingBill);
+ assertEquals(BillStatus.PENDING, pendingBill.getStatus());
+
+ assertThrows(ValidationException.class, () -> billService.requestRefund(pendingBill, "Equipment failure"));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#requestRefund(Bill, String)
+ */
+ @Test
+ public void requestRefund_shouldTransitionPaidBillToRefundRequested() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ assertEquals(BillStatus.PAID, paidBill.getStatus());
+
+ Bill result = billService.requestRefund(paidBill, "Equipment failure");
+
+ assertNotNull(result);
+ assertEquals(BillStatus.REFUND_REQUESTED, result.getStatus());
+ assertEquals("Equipment failure", result.getRefundReason());
+ assertNotNull(result.getRefundRequestedBy());
+ assertNotNull(result.getDateRefundRequested());
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#approveRefund(Bill)
+ */
+ @Test
+ public void approveRefund_shouldThrowIllegalArgumentExceptionIfBillIsNull() {
+ assertThrows(IllegalArgumentException.class, () -> billService.approveRefund(null));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#approveRefund(Bill)
+ */
+ @Test
+ public void approveRefund_shouldThrowValidationExceptionIfBillIsNotRefundRequested() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ assertEquals(BillStatus.PAID, paidBill.getStatus());
+
+ assertThrows(ValidationException.class, () -> billService.approveRefund(paidBill));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#approveRefund(Bill)
+ */
+ @Test
+ public void approveRefund_shouldTransitionRefundRequestedBillToRefunded() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+
+ Bill requestedBill = billService.requestRefund(paidBill, "Equipment failure");
+ assertEquals(BillStatus.REFUND_REQUESTED, requestedBill.getStatus());
+ // Flush so the validator's native-SQL read of the persisted status sees REFUND_REQUESTED.
+ Context.flushSession();
+
+ Bill result = billService.approveRefund(requestedBill);
+
+ assertNotNull(result);
+ assertEquals(BillStatus.REFUNDED, result.getStatus());
+ assertEquals("Equipment failure", result.getRefundReason());
+ assertNotNull(result.getRefundApprovedBy());
+ assertNotNull(result.getDateRefundApproved());
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#rejectRefund(Bill, String)
+ */
+ @Test
+ public void rejectRefund_shouldThrowIllegalArgumentExceptionIfBillIsNull() {
+ assertThrows(IllegalArgumentException.class, () -> billService.rejectRefund(null, "reason"));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#rejectRefund(Bill, String)
+ */
+ @Test
+ public void rejectRefund_shouldThrowIllegalArgumentExceptionIfDenialReasonIsBlank() {
+ Integer billId = billService.requestRefund(billService.getBill(1), "Equipment failure").getId();
+ Context.flushSession();
+ Context.clearSession();
+
+ assertThrows(IllegalArgumentException.class, () -> billService.rejectRefund(billService.getBill(billId), ""));
+ Context.clearSession();
+ assertThrows(IllegalArgumentException.class, () -> billService.rejectRefund(billService.getBill(billId), null));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#rejectRefund(Bill, String)
+ */
+ @Test
+ public void rejectRefund_shouldThrowValidationExceptionIfBillIsNotRefundRequested() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ assertEquals(BillStatus.PAID, paidBill.getStatus());
+
+ assertThrows(ValidationException.class, () -> billService.rejectRefund(paidBill, "Not eligible"));
+ }
+
+ /**
+ * @see org.openmrs.module.billing.api.impl.BillServiceImpl#rejectRefund(Bill, String)
+ */
+ @Test
+ public void rejectRefund_shouldTransitionRefundRequestedBillToRefundDenied() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+
+ Bill requestedBill = billService.requestRefund(paidBill, "Equipment failure");
+ assertEquals(BillStatus.REFUND_REQUESTED, requestedBill.getStatus());
+ Context.flushSession();
+
+ Bill result = billService.rejectRefund(requestedBill, "Service was already provided");
+
+ assertNotNull(result);
+ assertEquals(BillStatus.REFUND_DENIED, result.getStatus());
+ assertEquals("Equipment failure", result.getRefundReason());
+ assertEquals("Service was already provided", result.getRefundDenialReason());
+ assertNotNull(result.getRefundRejectedBy());
+ assertNotNull(result.getDateRefundRejected());
+ }
}
diff --git a/api/src/test/java/org/openmrs/module/billing/validator/BillValidatorTest.java b/api/src/test/java/org/openmrs/module/billing/validator/BillValidatorTest.java
index cdb3aaa6..5e33e51d 100644
--- a/api/src/test/java/org/openmrs/module/billing/validator/BillValidatorTest.java
+++ b/api/src/test/java/org/openmrs/module/billing/validator/BillValidatorTest.java
@@ -141,4 +141,108 @@ public void validate_shouldTolerateVoidedNewPaymentWithNoCashier() {
assertFalse(errors.hasErrors());
}
+ @Test
+ public void validate_shouldRejectRefundRequestedBillWithNoRefundReason() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ paidBill.setStatus(BillStatus.REFUND_REQUESTED);
+ // refundReason intentionally NOT set
+
+ Errors errors = new BindException(paidBill, "bill");
+ billValidator.validate(paidBill, errors);
+
+ assertTrue(errors.hasErrors());
+ }
+
+ @Test
+ public void validate_shouldNotRejectRefundRequestedBillWithRefundReason() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ paidBill.setStatus(BillStatus.REFUND_REQUESTED);
+ paidBill.setRefundReason("Equipment failure");
+
+ Errors errors = new BindException(paidBill, "bill");
+ billValidator.validate(paidBill, errors);
+
+ assertFalse(errors.hasErrors());
+ }
+
+ @Test
+ public void validate_shouldRejectRefundDeniedBillWithNoDenialReason() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ paidBill.setStatus(BillStatus.REFUND_DENIED);
+ // denialReason intentionally NOT set
+
+ Errors errors = new BindException(paidBill, "bill");
+ billValidator.validate(paidBill, errors);
+
+ assertTrue(errors.hasErrors());
+ }
+
+ @Test
+ public void validate_shouldNotRejectRefundDeniedBillWithDenialReason() {
+ // REFUND_DENIED requires the persisted status to be REFUND_REQUESTED, so transition the bill
+ // through requestRefund first.
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ billService.requestRefund(paidBill, "Equipment failure");
+ Context.flushSession();
+ Bill requestedBill = billService.getBill(1);
+ requestedBill.setStatus(BillStatus.REFUND_DENIED);
+ requestedBill.setRefundDenialReason("Service was already provided");
+
+ Errors errors = new BindException(requestedBill, "bill");
+ billValidator.validate(requestedBill, errors);
+
+ assertFalse(errors.hasErrors());
+ }
+
+ @Test
+ public void validate_shouldRejectRefundRequestedTransitionFromNonPaidBill() {
+ Bill pendingBill = billService.getBill(2);
+ assertNotNull(pendingBill);
+ assertEquals(BillStatus.PENDING, pendingBill.getStatus());
+ pendingBill.setStatus(BillStatus.REFUND_REQUESTED);
+ pendingBill.setRefundReason("Attempt from PENDING");
+
+ Errors errors = new BindException(pendingBill, "bill");
+ billValidator.validate(pendingBill, errors);
+
+ assertTrue(errors.hasErrors());
+ }
+
+ @Test
+ public void validate_shouldRejectSecondRefundRequestOnAlreadyRefundRequestedBill() {
+ // Core audit-field-clobber guard: a second request on an already-REFUND_REQUESTED bill must
+ // be rejected so audit fields (refundRequestedBy / dateRefundRequested) aren't overwritten.
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ billService.requestRefund(paidBill, "First reason");
+ // Flush so the DB reflects REFUND_REQUESTED — simulates a second HTTP request arriving after
+ // the prior transaction committed.
+ Context.flushSession();
+ Bill requestedBill = billService.getBill(1);
+ assertEquals(BillStatus.REFUND_REQUESTED, requestedBill.getStatus());
+ // Caller is trying to re-submit a refund request; in-memory status is still REFUND_REQUESTED.
+ requestedBill.setRefundReason("Second reason");
+
+ Errors errors = new BindException(requestedBill, "bill");
+ billValidator.validate(requestedBill, errors);
+
+ assertTrue(errors.hasErrors());
+ }
+
+ @Test
+ public void validate_shouldRejectApproveRefundOnNonRequestedBill() {
+ Bill paidBill = billService.getBill(1);
+ assertNotNull(paidBill);
+ paidBill.setStatus(BillStatus.REFUNDED);
+
+ Errors errors = new BindException(paidBill, "bill");
+ billValidator.validate(paidBill, errors);
+
+ assertTrue(errors.hasErrors());
+ }
+
}
diff --git a/omod/src/main/java/org/openmrs/module/billing/web/rest/resource/BillResource.java b/omod/src/main/java/org/openmrs/module/billing/web/rest/resource/BillResource.java
index 7f57ec5a..3c7165cf 100644
--- a/omod/src/main/java/org/openmrs/module/billing/web/rest/resource/BillResource.java
+++ b/omod/src/main/java/org/openmrs/module/billing/web/rest/resource/BillResource.java
@@ -75,6 +75,14 @@ public DelegatingResourceDescription getRepresentationDescription(Representation
description.addProperty("receiptNumber");
description.addProperty("status");
description.addProperty("adjustmentReason");
+ description.addProperty("refundReason");
+ description.addProperty("refundRequestedBy", Representation.REF);
+ description.addProperty("dateRefundRequested");
+ description.addProperty("refundApprovedBy", Representation.REF);
+ description.addProperty("dateRefundApproved");
+ description.addProperty("refundDenialReason");
+ description.addProperty("refundRejectedBy", Representation.REF);
+ description.addProperty("dateRefundRejected");
description.addProperty("uuid");
return description;
}
@@ -131,6 +139,11 @@ public void setBillStatus(Bill instance, BillStatus status) {
instance.setStatus(status);
} else if (instance.getStatus() == BillStatus.PENDING && status == BillStatus.POSTED) {
instance.setStatus(status);
+ } else if (instance.getStatus() == BillStatus.PAID && status == BillStatus.REFUND_REQUESTED) {
+ instance.setStatus(status);
+ } else if (instance.getStatus() == BillStatus.REFUND_REQUESTED
+ && (status == BillStatus.REFUNDED || status == BillStatus.REFUND_DENIED)) {
+ instance.setStatus(status);
}
if (status == BillStatus.POSTED) {
RoundingUtil.handleRoundingLineItem(instance);
@@ -148,6 +161,18 @@ public void setAdjustReason(Bill instance, String adjustReason) {
public Bill save(Bill bill) {
//TODO: Test all the ways that this could fail
+ BillService service = Context.getService(BillService.class);
+
+ if (bill.getStatus() == BillStatus.REFUND_REQUESTED) {
+ return service.requestRefund(bill, bill.getRefundReason());
+ }
+ if (bill.getStatus() == BillStatus.REFUNDED) {
+ return service.approveRefund(bill);
+ }
+ if (bill.getStatus() == BillStatus.REFUND_DENIED) {
+ return service.rejectRefund(bill, bill.getRefundDenialReason());
+ }
+
if (bill.getId() == null) {
if (bill.getCashier() == null) {
Provider cashier = getCurrentCashier();
@@ -171,7 +196,7 @@ public Bill save(Bill bill) {
}
}
- return Context.getService(BillService.class).saveBill(bill);
+ return service.saveBill(bill);
}
@Override
diff --git a/omod/src/main/resources/liquibase.xml b/omod/src/main/resources/liquibase.xml
index 43ae8ee8..0414eb70 100644
--- a/omod/src/main/resources/liquibase.xml
+++ b/omod/src/main/resources/liquibase.xml
@@ -1109,4 +1109,46 @@
referencedTableName="provider" referencedColumnNames="provider_id"
onDelete="SET NULL" onUpdate="CASCADE"/>
+
+
+ Widen status columns and add refund audit fields to cashier_bill
+
+ ALTER TABLE cashier_bill MODIFY COLUMN status varchar(50)
+ ALTER TABLE cashier_bill_line_item MODIFY COLUMN payment_status varchar(50)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+