Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions api/src/main/java/org/openmrs/module/billing/api/BillService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
*
* @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.
* <p>
* 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.
* </p>
*
* @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);

}
12 changes: 12 additions & 0 deletions api/src/main/java/org/openmrs/module/billing/api/db/BillDAO.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Predicate> buildBillSearchPredicate(CriteriaBuilder cb, Root<Bill> root, BillSearch billSearch) {
List<Predicate> predicates = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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) {
Comment thread
UjjawalPrabhat marked this conversation as resolved.
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);
}

}
21 changes: 21 additions & 0 deletions api/src/main/java/org/openmrs/module/billing/api/model/Bill.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ public enum BillStatus {
PAID(),
CANCELLED(),
ADJUSTED(),
EXEMPTED();
EXEMPTED(),
REFUND_REQUESTED(),
REFUNDED(),
REFUND_DENIED();

BillStatus() {
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -38,6 +40,7 @@ public void validate(@Nonnull Object target, @Nonnull Errors errors) {

validateNewPaymentsHaveCashier(bill, errors);
validateLineItemsNotModified(bill, errors);
validateRefundFields(bill, errors);
}
}

Expand Down Expand Up @@ -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) {
Comment thread
UjjawalPrabhat marked this conversation as resolved.
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.
Expand Down
16 changes: 16 additions & 0 deletions api/src/main/resources/Bill.hbm.xml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@
<property name="uuid" type="java.lang.String" column="uuid" length="38" unique="true"/>
<property name="adjustmentReason" type="java.lang.String" column="adjustment_reason" length="500"
not-null="false"/>
<property name="refundReason" type="java.lang.String" column="refund_reason" length="500"
not-null="false"/>
<many-to-one name="refundRequestedBy" class="org.openmrs.User" column="refund_requested_by"
not-null="false"/>
<property name="dateRefundRequested" type="java.util.Date" column="date_refund_requested" length="19"
not-null="false"/>
<many-to-one name="refundApprovedBy" class="org.openmrs.User" column="refund_approved_by"
not-null="false"/>
<property name="dateRefundApproved" type="java.util.Date" column="date_refund_approved" length="19"
not-null="false"/>
<property name="refundDenialReason" type="java.lang.String" column="refund_denial_reason" length="500"
not-null="false"/>
<many-to-one name="refundRejectedBy" class="org.openmrs.User" column="refund_rejected_by"
not-null="false"/>
<property name="dateRefundRejected" type="java.util.Date" column="date_refund_rejected" length="19"
not-null="false"/>
</class>

<class name="org.openmrs.module.billing.api.model.BillLineItem" table="cashier_bill_line_item">
Expand Down
3 changes: 3 additions & 0 deletions api/src/main/resources/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading