visitAttributeList = order.getEncounter().getVisit().getActiveAttributes();
-
- for (VisitAttribute attribute : visitAttributeList) {
- if (attribute.getAttributeType().getUuid().equals("caf2124f-00a9-4620-a250-efd8535afd6d")
- && attribute.getValueReference().equals("1c30ee58-82d4-4ea4-a8c1-4bf2f9dfc8cf")) {
- return true;
- }
- }
-
- return isPaying;
- }
-}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java b/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java
index 3a8fd343..030960fb 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/BillLineItemService.java
@@ -13,6 +13,7 @@
*/
package org.openmrs.module.billing.api;
+import org.openmrs.Order;
import org.openmrs.api.OpenmrsService;
import org.openmrs.module.billing.api.model.BillLineItem;
@@ -38,4 +39,13 @@ public interface BillLineItemService extends OpenmrsService {
@Nullable
BillLineItem getBillLineItemByUuid(String uuid);
+ /**
+ * Retrieves the active (non-voided) bill line item linked to the given order.
+ *
+ * @param order the order to look up
+ * @return the bill line item for that order, or null if none exists
+ */
+ @Nullable
+ BillLineItem getBillLineItemByOrder(Order order);
+
}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/BillingEventListener.java b/api/src/main/java/org/openmrs/module/billing/api/billing/BillingEventListener.java
new file mode 100644
index 00000000..3ea54330
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/billing/BillingEventListener.java
@@ -0,0 +1,46 @@
+/*
+ * 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.billing;
+
+import org.openmrs.OpenmrsObject;
+import org.openmrs.event.Event;
+import org.openmrs.event.EventListener;
+import org.openmrs.module.DaemonToken;
+
+/**
+ * Marker interface for event listeners that should be auto-subscribed when the billing module
+ * starts. Implementations declare which domain class and action they listen to. The module
+ * activator discovers all registered {@code BillingEventListener} beans, sets the daemon token, and
+ * subscribes them automatically.
+ *
+ * To add a new listener, implement this interface and register the bean in
+ * {@code moduleApplicationContext.xml} — no changes to the activator are needed.
+ */
+public interface BillingEventListener extends EventListener {
+
+ /**
+ * The OpenMRS domain class this listener subscribes to (e.g. {@code Order.class}).
+ */
+ Class extends OpenmrsObject> getSubscribedClass();
+
+ /**
+ * The event action this listener subscribes to (e.g. {@link Event.Action#CREATED}).
+ */
+ Event.Action getSubscribedAction();
+
+ /**
+ * Set the daemon token for executing operations in a daemon thread context.
+ */
+ void setDaemonToken(DaemonToken daemonToken);
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingEventListener.java b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingEventListener.java
new file mode 100644
index 00000000..a9072e45
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingEventListener.java
@@ -0,0 +1,104 @@
+/*
+ * 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.billing;
+
+import javax.jms.JMSException;
+import javax.jms.MapMessage;
+import javax.jms.Message;
+
+import java.util.List;
+
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.openmrs.OpenmrsObject;
+import org.openmrs.Order;
+import org.openmrs.api.context.Context;
+import org.openmrs.api.context.Daemon;
+import org.openmrs.event.Event;
+import org.openmrs.module.DaemonToken;
+
+/**
+ * Listens for Order CREATED events from the OpenMRS Event module and delegates billing to the
+ * appropriate {@link OrderBillingStrategy}.
+ */
+@Slf4j
+@Setter
+public class OrderBillingEventListener implements BillingEventListener {
+
+ private DaemonToken daemonToken;
+
+ @Override
+ public Class extends OpenmrsObject> getSubscribedClass() {
+ return Order.class;
+ }
+
+ @Override
+ public Event.Action getSubscribedAction() {
+ return Event.Action.CREATED;
+ }
+
+ @Override
+ public void onMessage(Message message) {
+ if (daemonToken == null) {
+ log.error("Cannot process order billing event: daemon token not set");
+ return;
+ }
+
+ Daemon.runInDaemonThread(() -> {
+ try {
+ processMessage(message);
+ }
+ catch (Exception e) {
+ log.error("Error processing order billing event", e);
+ }
+ }, daemonToken);
+ }
+
+ private void processMessage(Message message) throws JMSException {
+ MapMessage mapMessage = (MapMessage) message;
+ String uuid = mapMessage.getString("uuid");
+ String action = mapMessage.getString("action");
+
+ if (!"CREATED".equals(action)) {
+ return;
+ }
+
+ Order order = Context.getOrderService().getOrderByUuid(uuid);
+ if (order == null) {
+ log.warn("Order not found for UUID: {}", uuid);
+ return;
+ }
+
+ processOrder(order);
+ }
+
+ /**
+ * Process a single order through the billing strategy chain. Package-visible so that integration
+ * tests can invoke the billing pipeline directly without requiring a JMS broker.
+ *
+ * @param order a persisted order
+ */
+ void processOrder(Order order) {
+ List strategies = Context.getRegisteredComponents(OrderBillingStrategy.class);
+
+ for (OrderBillingStrategy strategy : strategies) {
+ if (strategy.supports(order)) {
+ strategy.generateBill(order);
+ return;
+ }
+ }
+
+ log.debug("No billing strategy found for order type: {}", order.getClass().getSimpleName());
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingStrategy.java
new file mode 100644
index 00000000..af325d15
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/billing/OrderBillingStrategy.java
@@ -0,0 +1,46 @@
+/*
+ * 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.billing;
+
+import org.openmrs.Order;
+import org.openmrs.module.billing.api.model.Bill;
+
+import java.util.Optional;
+
+/**
+ * Strategy for generating a bill from an order. Implementations are Spring beans discovered via
+ * {@code Context.getRegisteredComponents(OrderBillingStrategy.class)}. The first strategy whose
+ * {@link #supports(Order)} returns {@code true} handles the order. To override a default strategy,
+ * register a bean with the same name (e.g. "drugOrderBillingStrategy") in your module's Spring
+ * context.
+ */
+public interface OrderBillingStrategy {
+
+ /**
+ * Whether this strategy can handle the given order.
+ *
+ * @param order the order to evaluate
+ * @return true if this strategy should process the order
+ */
+ boolean supports(Order order);
+
+ /**
+ * Generate and persist a bill for the given order. Implementations should check for duplicates
+ * (idempotency) before creating a new bill.
+ *
+ * @param order a persisted order (guaranteed to exist in the database)
+ * @return the created bill, or empty if the order should not be billed
+ */
+ Optional generateBill(Order order);
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/impl/AbstractOrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/AbstractOrderBillingStrategy.java
new file mode 100644
index 00000000..e948d742
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/AbstractOrderBillingStrategy.java
@@ -0,0 +1,191 @@
+/*
+ * 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.billing.impl;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+import lombok.extern.slf4j.Slf4j;
+import org.openmrs.Order;
+import org.openmrs.Patient;
+import org.openmrs.PatientProgram;
+import org.openmrs.Provider;
+import org.openmrs.api.ProgramWorkflowService;
+import org.openmrs.api.context.Context;
+import org.openmrs.module.billing.api.BillExemptionService;
+import org.openmrs.module.billing.api.BillLineItemService;
+import org.openmrs.module.billing.api.BillService;
+import org.openmrs.module.billing.api.CashPointService;
+import org.openmrs.module.billing.api.billing.OrderBillingStrategy;
+import org.openmrs.module.billing.api.evaluator.ExemptionRuleEngine;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillExemption;
+import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.BillStatus;
+import org.openmrs.module.billing.api.model.CashPoint;
+import org.openmrs.module.billing.api.model.ExemptionType;
+
+/**
+ * Base class for billing strategies that provides shared logic for bill creation, line item
+ * voiding, and exemption checking. Subclasses implement {@link #handleNewOrder(Order)} to create
+ * line items specific to their order type.
+ */
+@Slf4j
+public abstract class AbstractOrderBillingStrategy implements OrderBillingStrategy {
+
+ @Override
+ public Optional generateBill(Order order) {
+ try {
+ switch (order.getAction()) {
+ case NEW:
+ BillLineItemService itemService = Context.getService(BillLineItemService.class);
+ BillLineItem existingLineItem = itemService.getBillLineItemByOrder(order);
+ if (existingLineItem != null) {
+ log.info("Bill line item already exists for order: {}, skipping duplicate bill creation",
+ order.getUuid());
+ return Optional.of(existingLineItem.getBill());
+ }
+ return handleNewOrder(order);
+ case REVISE:
+ return handleRevisedOrder(order);
+ case DISCONTINUE:
+ handleDiscontinuedOrder(order);
+ return Optional.empty();
+ default:
+ return Optional.empty();
+ }
+ }
+ catch (Exception e) {
+ log.error("Error processing order (action={}): {}", order.getAction(), e.getMessage(), e);
+ return Optional.empty();
+ }
+ }
+
+ protected abstract Optional handleNewOrder(Order order);
+
+ private Optional handleRevisedOrder(Order order) {
+ voidPreviousLineItem(order, "Order revised");
+ return handleNewOrder(order);
+ }
+
+ private void handleDiscontinuedOrder(Order order) {
+ voidPreviousLineItem(order, "Order discontinued");
+ }
+
+ private void voidPreviousLineItem(Order order, String reason) {
+ Order previousOrder = order.getPreviousOrder();
+ if (previousOrder == null) {
+ log.warn("No previous order found for {} order: {}", order.getAction(), order.getUuid());
+ return;
+ }
+
+ BillLineItemService lineItemService = Context.getService(BillLineItemService.class);
+ BillLineItem existingLineItem = lineItemService.getBillLineItemByOrder(previousOrder);
+ if (existingLineItem == null) {
+ log.warn("No bill line item found for previous order: {}", previousOrder.getUuid());
+ return;
+ }
+
+ existingLineItem.setVoided(true);
+ existingLineItem.setVoidReason(reason);
+ existingLineItem.setDateVoided(new Date());
+ existingLineItem.setVoidedBy(order.getCreator());
+
+ BillService billService = Context.getService(BillService.class);
+ billService.saveBill(existingLineItem.getBill());
+ }
+
+ protected Optional saveBill(Patient patient, BillLineItem lineItem, Order order) {
+ BillService billService = Context.getService(BillService.class);
+ CashPointService cashPointService = Context.getService(CashPointService.class);
+
+ List providers = new ArrayList<>(
+ Context.getProviderService().getProvidersByPerson(order.getCreator().getPerson()));
+
+ if (providers.isEmpty()) {
+ log.error("Order creator is not a provider, cannot create bill for order: {}", order.getUuid());
+ return Optional.empty();
+ }
+
+ List cashPoints = cashPointService.getAllCashPoints(false);
+ if (cashPoints.isEmpty()) {
+ log.error("No cash points configured, cannot create bill for order: {}", order.getUuid());
+ return Optional.empty();
+ }
+
+ Bill bill = new Bill();
+ bill.setPatient(patient);
+ bill.setStatus(BillStatus.PENDING);
+ bill.setCashier(providers.get(0));
+ bill.setCashPoint(cashPoints.get(0));
+ bill.addLineItem(lineItem);
+
+ Bill savedBill = billService.saveBill(bill);
+ return Optional.of(savedBill);
+ }
+
+ protected boolean checkIfOrderIsExempted(Order order, ExemptionType exemptionType) {
+ if (order == null || order.getConcept() == null) {
+ return false;
+ }
+
+ BillExemptionService exemptionService = Context.getService(BillExemptionService.class);
+ List exemptions = exemptionService.getExemptionsByConcept(order.getConcept(), exemptionType, false);
+ if (exemptions == null || exemptions.isEmpty()) {
+ return false;
+ }
+
+ ExemptionRuleEngine ruleEngine = Context.getRegisteredComponent("ruleEngine", ExemptionRuleEngine.class);
+ Map variables = buildExemptionVariables(order);
+
+ for (BillExemption exemption : exemptions) {
+ if (ruleEngine.isExemptionApplicable(exemption, variables)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private Map buildExemptionVariables(Order order) {
+ Map variables = new HashMap<>();
+ ProgramWorkflowService workflowService = Context.getProgramWorkflowService();
+
+ Patient patient = order.getPatient();
+ variables.put("patient", patient);
+ if (patient != null) {
+ variables.put("patientAge", patient.getAge());
+ }
+
+ Map orderData = new HashMap<>();
+ orderData.put("uuid", order.getUuid());
+ if (order.getConcept() != null) {
+ orderData.put("conceptId", order.getConcept().getConceptId());
+ }
+ variables.put("order", orderData);
+
+ List programs = workflowService.getPatientPrograms(patient, null, null, null, new Date(), null,
+ false);
+ List activePrograms = programs.stream().filter(PatientProgram::getActive)
+ .map(pp -> pp.getProgram().getName()).collect(Collectors.toList());
+ variables.put("activePrograms", activePrograms);
+
+ return variables;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/impl/DrugOrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/DrugOrderBillingStrategy.java
new file mode 100644
index 00000000..3dc3c34a
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/DrugOrderBillingStrategy.java
@@ -0,0 +1,99 @@
+/*
+ * 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.billing.impl;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Optional;
+
+import lombok.extern.slf4j.Slf4j;
+import org.openmrs.DrugOrder;
+import org.openmrs.Order;
+import org.openmrs.api.context.Context;
+import org.openmrs.module.billing.api.ItemPriceService;
+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.CashierItemPrice;
+import org.openmrs.module.billing.api.model.ExemptionType;
+import org.openmrs.module.stockmanagement.api.StockManagementService;
+import org.openmrs.module.stockmanagement.api.model.StockItem;
+
+/**
+ * Default billing strategy for {@link DrugOrder}s. Creates a bill line item based on the stock item
+ * linked to the ordered drug.
+ */
+@Slf4j
+public class DrugOrderBillingStrategy extends AbstractOrderBillingStrategy {
+
+ @Override
+ public boolean supports(Order order) {
+ if (!(order instanceof DrugOrder)) {
+ return false;
+ }
+ Order.Action action = order.getAction();
+ return action == Order.Action.NEW || action == Order.Action.REVISE || action == Order.Action.DISCONTINUE;
+ }
+
+ @Override
+ protected Optional handleNewOrder(Order order) {
+ DrugOrder drugOrder = (DrugOrder) order;
+
+ if (drugOrder.getDrug() == null) {
+ log.warn("DrugOrder {} has no drug set, cannot generate bill", order.getUuid());
+ return Optional.empty();
+ }
+
+ StockManagementService stockService = Context.getService(StockManagementService.class);
+ Integer drugId = drugOrder.getDrug().getDrugId();
+ List stockItems = stockService.getStockItemByDrug(drugId);
+
+ if (stockItems.isEmpty()) {
+ log.debug("No stock item found for drug ID: {}", drugId);
+ return Optional.empty();
+ }
+
+ int quantity = (int) (drugOrder.getQuantity() != null ? drugOrder.getQuantity() : 0.0);
+ boolean isExempted = checkIfOrderIsExempted(order, ExemptionType.COMMODITY);
+ BillStatus lineItemStatus = isExempted ? BillStatus.EXEMPTED : BillStatus.PENDING;
+
+ StockItem stockItem = stockItems.get(0);
+ BillLineItem lineItem = createLineItem(stockItem, quantity, lineItemStatus, order);
+
+ return saveBill(order.getPatient(), lineItem, order);
+ }
+
+ private BillLineItem createLineItem(StockItem stockItem, int quantity, BillStatus lineItemStatus, Order order) {
+ ItemPriceService priceService = Context.getService(ItemPriceService.class);
+
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setItem(stockItem);
+
+ List itemPrices = priceService.getItemPrice(stockItem);
+ if (!itemPrices.isEmpty()) {
+ lineItem.setPrice(itemPrices.get(0).getPrice());
+ } else if (stockItem.getPurchasePrice() != null) {
+ lineItem.setPrice(stockItem.getPurchasePrice());
+ } else {
+ lineItem.setPrice(BigDecimal.ZERO);
+ }
+
+ lineItem.setQuantity(quantity);
+ lineItem.setPaymentStatus(lineItemStatus);
+ lineItem.setLineItemOrder(0);
+ lineItem.setOrder(order);
+
+ return lineItem;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/billing/impl/TestOrderBillingStrategy.java b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/TestOrderBillingStrategy.java
new file mode 100644
index 00000000..8d1a2a57
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/billing/impl/TestOrderBillingStrategy.java
@@ -0,0 +1,95 @@
+/*
+ * 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.billing.impl;
+
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Optional;
+
+import lombok.extern.slf4j.Slf4j;
+import org.openmrs.Order;
+import org.openmrs.TestOrder;
+import org.openmrs.api.context.Context;
+import org.openmrs.module.billing.api.BillableServiceService;
+import org.openmrs.module.billing.api.ItemPriceService;
+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.BillableService;
+import org.openmrs.module.billing.api.model.BillableServiceStatus;
+import org.openmrs.module.billing.api.model.CashierItemPrice;
+import org.openmrs.module.billing.api.model.ExemptionType;
+import org.openmrs.module.billing.api.search.BillableServiceSearch;
+
+/**
+ * Default billing strategy for {@link TestOrder}s. Creates a bill line item based on the billable
+ * service linked to the test order's concept.
+ */
+@Slf4j
+public class TestOrderBillingStrategy extends AbstractOrderBillingStrategy {
+
+ @Override
+ public boolean supports(Order order) {
+ if (!(order instanceof TestOrder)) {
+ return false;
+ }
+ Order.Action action = order.getAction();
+ return action == Order.Action.NEW || action == Order.Action.REVISE || action == Order.Action.DISCONTINUE;
+ }
+
+ @Override
+ protected Optional handleNewOrder(Order order) {
+ TestOrder testOrder = (TestOrder) order;
+
+ BillableServiceService serviceService = Context.getService(BillableServiceService.class);
+ BillableServiceSearch searchTemplate = new BillableServiceSearch();
+ searchTemplate.setConceptUuid(testOrder.getConcept().getUuid());
+ searchTemplate.setServiceStatus(BillableServiceStatus.ENABLED);
+
+ List searchResult = serviceService.getBillableServices(searchTemplate, null);
+ if (searchResult.isEmpty()) {
+ log.debug("No billable service found for concept: {}", testOrder.getConcept().getUuid());
+ return Optional.empty();
+ }
+
+ boolean isExempted = checkIfOrderIsExempted(order, ExemptionType.SERVICE);
+ BillStatus lineItemStatus = isExempted ? BillStatus.EXEMPTED : BillStatus.PENDING;
+
+ BillableService billableService = searchResult.get(0);
+ BillLineItem lineItem = createLineItem(billableService, lineItemStatus, order);
+
+ return saveBill(order.getPatient(), lineItem, order);
+ }
+
+ private BillLineItem createLineItem(BillableService billableService, BillStatus lineItemStatus, Order order) {
+ ItemPriceService priceService = Context.getService(ItemPriceService.class);
+
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setBillableService(billableService);
+
+ List itemPrices = priceService.getServicePrice(billableService);
+ if (!itemPrices.isEmpty()) {
+ lineItem.setPrice(itemPrices.get(0).getPrice());
+ } else {
+ lineItem.setPrice(BigDecimal.ZERO);
+ }
+
+ lineItem.setQuantity(1);
+ lineItem.setPaymentStatus(lineItemStatus);
+ lineItem.setLineItemOrder(0);
+ lineItem.setOrder(order);
+
+ return lineItem;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java
index fec466fd..58407b59 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/db/BillLineItemDAO.java
@@ -1,5 +1,6 @@
package org.openmrs.module.billing.api.db;
+import org.openmrs.Order;
import org.openmrs.module.billing.api.model.BillLineItem;
import javax.annotation.Nonnull;
@@ -32,4 +33,13 @@ public interface BillLineItemDAO {
@Nullable
BillLineItem getBillLineItemByUuid(@Nonnull String uuid);
+ /**
+ * Retrieves the active (non-voided) bill line item linked to the given order.
+ *
+ * @param order the order to look up
+ * @return the bill line item for that order, or null if none exists
+ */
+ @Nullable
+ BillLineItem getBillLineItemByOrder(@Nonnull Order order);
+
}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java
index 51754ba6..ae3a81de 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/db/hibernate/HibernateBillLineItemDAO.java
@@ -2,6 +2,7 @@
import lombok.AllArgsConstructor;
import org.hibernate.SessionFactory;
+import org.openmrs.Order;
import org.openmrs.module.billing.api.db.BillLineItemDAO;
import org.openmrs.module.billing.api.model.BillLineItem;
@@ -35,4 +36,13 @@ public BillLineItem getBillLineItemByUuid(@Nonnull String uuid) {
return query.getResultStream().findFirst().orElse(null);
}
+ @Override
+ @Nullable
+ public BillLineItem getBillLineItemByOrder(@Nonnull Order order) {
+ TypedQuery query = sessionFactory.getCurrentSession()
+ .createQuery("select b from BillLineItem b where b.order = :order and b.voided = false", BillLineItem.class);
+ query.setParameter("order", order);
+ return query.getResultStream().findFirst().orElse(null);
+ }
+
}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java b/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java
index 51280316..2f871175 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/impl/BillLineItemServiceImpl.java
@@ -18,6 +18,7 @@
import lombok.Setter;
import org.apache.commons.lang3.StringUtils;
+import org.openmrs.Order;
import org.openmrs.api.impl.BaseOpenmrsService;
import org.openmrs.module.billing.api.BillLineItemService;
import org.openmrs.module.billing.api.db.BillLineItemDAO;
@@ -46,4 +47,13 @@ public BillLineItem getBillLineItemByUuid(String uuid) {
}
return billLineItemDAO.getBillLineItemByUuid(uuid);
}
+
+ @Override
+ @Transactional(readOnly = true)
+ public BillLineItem getBillLineItemByOrder(Order order) {
+ if (order == null) {
+ return null;
+ }
+ return billLineItemDAO.getBillLineItemByOrder(order);
+ }
}
diff --git a/api/src/main/resources/Bill.hbm.xml b/api/src/main/resources/Bill.hbm.xml
index dd88092c..f83b3db7 100644
--- a/api/src/main/resources/Bill.hbm.xml
+++ b/api/src/main/resources/Bill.hbm.xml
@@ -157,6 +157,7 @@
+
diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml
index 1533bb67..85f33487 100644
--- a/api/src/main/resources/moduleApplicationContext.xml
+++ b/api/src/main/resources/moduleApplicationContext.xml
@@ -260,6 +260,16 @@
+
+
+
+
+
+
+
diff --git a/api/src/test/java/org/openmrs/module/billing/api/billing/OrderBillingEventListenerTest.java b/api/src/test/java/org/openmrs/module/billing/api/billing/OrderBillingEventListenerTest.java
new file mode 100644
index 00000000..b607767c
--- /dev/null
+++ b/api/src/test/java/org/openmrs/module/billing/api/billing/OrderBillingEventListenerTest.java
@@ -0,0 +1,386 @@
+/*
+ * 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.billing;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.openmrs.Concept;
+import org.openmrs.Drug;
+import org.openmrs.DrugOrder;
+import org.openmrs.Encounter;
+import org.openmrs.Order;
+import org.openmrs.Patient;
+import org.openmrs.TestOrder;
+import org.openmrs.api.ConceptService;
+import org.openmrs.api.EncounterService;
+import org.openmrs.api.OrderService;
+import org.openmrs.api.context.Context;
+import org.openmrs.module.billing.TestConstants;
+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.test.jupiter.BaseModuleContextSensitiveTest;
+
+public class OrderBillingEventListenerTest extends BaseModuleContextSensitiveTest {
+
+ private BillService billService;
+
+ private BillLineItemService lineItemService;
+
+ private OrderService orderService;
+
+ private ConceptService conceptService;
+
+ private EncounterService encounterService;
+
+ private OrderBillingEventListener listener;
+
+ @BeforeEach
+ public void setup() {
+ billService = Context.getService(BillService.class);
+ lineItemService = Context.getService(BillLineItemService.class);
+ orderService = Context.getOrderService();
+ conceptService = Context.getConceptService();
+ encounterService = Context.getEncounterService();
+ listener = Context.getRegisteredComponent("orderBillingEventListener", OrderBillingEventListener.class);
+
+ executeDataSet(TestConstants.CORE_DATASET2);
+ executeDataSet(TestConstants.BASE_DATASET_DIR + "StockOperationType.xml");
+ executeDataSet(TestConstants.BASE_DATASET_DIR + "CashPointTest.xml");
+ executeDataSet(TestConstants.BASE_DATASET_DIR + "OrderBillingTest.xml");
+ }
+
+ @Test
+ public void shouldCreateBillWhenTestOrderIsSaved() {
+ Concept testConcept = conceptService.getConcept(5497);
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+
+ // 1. Save a test order
+ Order savedOrder = saveNewTestOrder(patient, testConcept, encounter);
+
+ // 2. Process through the billing pipeline
+ listener.processOrder(savedOrder);
+ Context.flushSession();
+
+ // 3. Verify a bill was created
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertNotNull(bills);
+ assertFalse(bills.isEmpty(), "A bill should have been created for the patient");
+
+ Bill bill = bills.get(0);
+ assertEquals(BillStatus.PENDING, bill.getStatus());
+ assertEquals(patient.getId(), bill.getPatient().getId());
+
+ // 4. Verify the line item
+ assertFalse(bill.getLineItems().isEmpty());
+ BillLineItem lineItem = bill.getLineItems().get(0);
+ assertNotNull(lineItem.getBillableService());
+ assertEquals(BillStatus.PENDING, lineItem.getPaymentStatus());
+ assertEquals(1, lineItem.getQuantity());
+ assertEquals(new BigDecimal("75.00"), lineItem.getPrice());
+ assertEquals(savedOrder.getId(), lineItem.getOrder().getId());
+ }
+
+ @Test
+ public void shouldNotCreateBillWhenNoBillableServiceMatchesConcept() {
+ Concept unmappedConcept = conceptService.getConcept(5089);
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+
+ Order savedOrder = saveNewTestOrder(patient, unmappedConcept, encounter);
+
+ listener.processOrder(savedOrder);
+ Context.flushSession();
+
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertTrue(bills == null || bills.isEmpty(),
+ "No bill should be created when no billable service matches the concept");
+ }
+
+ @Test
+ public void shouldVoidLineItemWhenOrderIsDiscontinued() {
+ Concept testConcept = conceptService.getConcept(5497);
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+
+ // 1. Save and bill the original order
+ Order originalOrder = saveNewTestOrder(patient, testConcept, encounter);
+ listener.processOrder(originalOrder);
+ Context.flushSession();
+
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertFalse(bills.isEmpty());
+ BillLineItem originalLineItem = bills.get(0).getLineItems().get(0);
+ assertFalse(originalLineItem.getVoided());
+
+ // 2. Discontinue the order
+ TestOrder discontinueOrder = new TestOrder();
+ discontinueOrder.setPatient(patient);
+ discontinueOrder.setConcept(testConcept);
+ discontinueOrder.setEncounter(encounter);
+ discontinueOrder.setOrderer(Context.getProviderService().getProvider(1));
+ discontinueOrder.setCareSetting(orderService.getCareSetting(1));
+ discontinueOrder.setOrderType(orderService.getOrderType(2));
+ discontinueOrder.setAction(Order.Action.DISCONTINUE);
+ discontinueOrder.setPreviousOrder(originalOrder);
+ discontinueOrder.setDateActivated(new Date());
+
+ Order savedDiscontinue = orderService.saveOrder(discontinueOrder, null);
+ Context.flushSession();
+
+ // 3. Process the DISCONTINUE order
+ listener.processOrder(savedDiscontinue);
+ Context.flushSession();
+ Context.clearSession();
+
+ // 4. Verify the original line item is voided
+ BillLineItem reloaded = lineItemService.getBillLineItemByUuid(originalLineItem.getUuid());
+ assertNotNull(reloaded);
+ assertTrue(reloaded.getVoided(), "Line item should be voided after order is discontinued");
+ assertEquals("Order discontinued", reloaded.getVoidReason());
+ }
+
+ @Test
+ public void shouldVoidOldAndCreateNewLineItemWhenOrderIsRevised() {
+ Concept testConcept = conceptService.getConcept(5497);
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+
+ // 1. Save and bill the original order
+ Order originalOrder = saveNewTestOrder(patient, testConcept, encounter);
+ listener.processOrder(originalOrder);
+ Context.flushSession();
+
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertFalse(bills.isEmpty());
+ String originalLineItemUuid = bills.get(0).getLineItems().get(0).getUuid();
+
+ // 2. Revise the order
+ TestOrder reviseOrder = new TestOrder();
+ reviseOrder.setPatient(patient);
+ reviseOrder.setConcept(testConcept);
+ reviseOrder.setEncounter(encounter);
+ reviseOrder.setOrderer(Context.getProviderService().getProvider(1));
+ reviseOrder.setCareSetting(orderService.getCareSetting(1));
+ reviseOrder.setOrderType(orderService.getOrderType(2));
+ reviseOrder.setAction(Order.Action.REVISE);
+ reviseOrder.setPreviousOrder(originalOrder);
+ reviseOrder.setDateActivated(new Date());
+
+ Order savedRevise = orderService.saveOrder(reviseOrder, null);
+ Context.flushSession();
+
+ // 3. Process the REVISE order
+ listener.processOrder(savedRevise);
+ Context.flushSession();
+ Context.clearSession();
+
+ // 4. Verify the original line item is voided
+ BillLineItem voidedLineItem = lineItemService.getBillLineItemByUuid(originalLineItemUuid);
+ assertNotNull(voidedLineItem);
+ assertTrue(voidedLineItem.getVoided(), "Original line item should be voided after revision");
+ assertEquals("Order revised", voidedLineItem.getVoidReason());
+
+ // 5. Verify a new bill was created with a new line item
+ List updatedBills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertTrue(updatedBills.size() >= 2, "A new bill should be created for the revised order");
+
+ Bill newBill = updatedBills.stream().filter(b -> !b.getId().equals(bills.get(0).getId())).findFirst().orElse(null);
+ assertNotNull(newBill);
+ assertFalse(newBill.getLineItems().isEmpty());
+
+ BillLineItem newLineItem = newBill.getLineItems().get(0);
+ assertFalse(newLineItem.getVoided());
+ assertEquals(savedRevise.getId(), newLineItem.getOrder().getId());
+ assertEquals(new BigDecimal("75.00"), newLineItem.getPrice());
+ }
+
+ @Test
+ public void shouldHandleDiscontinueGracefullyWhenNoOriginalBillExists() {
+ Concept testConcept = conceptService.getConcept(5497);
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+
+ // Save a NEW order but do NOT process it through billing
+ Order originalOrder = saveNewTestOrder(patient, testConcept, encounter);
+
+ TestOrder discontinueOrder = new TestOrder();
+ discontinueOrder.setPatient(patient);
+ discontinueOrder.setConcept(testConcept);
+ discontinueOrder.setEncounter(encounter);
+ discontinueOrder.setOrderer(Context.getProviderService().getProvider(1));
+ discontinueOrder.setCareSetting(orderService.getCareSetting(1));
+ discontinueOrder.setOrderType(orderService.getOrderType(2));
+ discontinueOrder.setAction(Order.Action.DISCONTINUE);
+ discontinueOrder.setPreviousOrder(originalOrder);
+ discontinueOrder.setDateActivated(new Date());
+
+ Order savedDiscontinue = orderService.saveOrder(discontinueOrder, null);
+ Context.flushSession();
+
+ // Should not throw — just log a warning
+ listener.processOrder(savedDiscontinue);
+ Context.flushSession();
+
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertTrue(bills == null || bills.isEmpty(), "No bill should exist");
+ }
+
+ @Test
+ public void strategyLookup_shouldFindRegisteredStrategies() {
+ List strategies = Context.getRegisteredComponents(OrderBillingStrategy.class);
+ assertNotNull(strategies);
+ assertFalse(strategies.isEmpty());
+
+ boolean hasDrugStrategy = false;
+ boolean hasTestStrategy = false;
+ for (OrderBillingStrategy strategy : strategies) {
+ if (strategy.getClass().getSimpleName().equals("DrugOrderBillingStrategy")) {
+ hasDrugStrategy = true;
+ }
+ if (strategy.getClass().getSimpleName().equals("TestOrderBillingStrategy")) {
+ hasTestStrategy = true;
+ }
+ }
+ assertTrue(hasDrugStrategy, "DrugOrderBillingStrategy should be registered");
+ assertTrue(hasTestStrategy, "TestOrderBillingStrategy should be registered");
+ }
+
+ @Test
+ public void shouldCreateBillWhenDrugOrderIsSaved() {
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+ Drug drug = Context.getConceptService().getDrug(2); // Triomune-30, linked to stock_item_id=100
+
+ DrugOrder drugOrder = new DrugOrder();
+ drugOrder.setPatient(patient);
+ drugOrder.setDrug(drug);
+ drugOrder.setConcept(drug.getConcept());
+ drugOrder.setEncounter(encounter);
+ drugOrder.setOrderer(Context.getProviderService().getProvider(1));
+ drugOrder.setCareSetting(orderService.getCareSetting(1));
+ drugOrder.setOrderType(orderService.getOrderType(1));
+ drugOrder.setDateActivated(new Date());
+ drugOrder.setQuantity(5.0);
+ drugOrder.setQuantityUnits(conceptService.getConcept(51));
+ drugOrder.setDose(1.0);
+ drugOrder.setDoseUnits(conceptService.getConcept(51));
+ drugOrder.setRoute(conceptService.getConcept(22));
+ drugOrder.setFrequency(orderService.getOrderFrequency(1));
+ drugOrder.setDosingType(org.openmrs.SimpleDosingInstructions.class);
+ drugOrder.setNumRefills(0);
+
+ Order savedOrder = orderService.saveOrder(drugOrder, null);
+ assertNotNull(savedOrder.getId());
+ Context.flushSession();
+
+ listener.processOrder(savedOrder);
+ Context.flushSession();
+
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertNotNull(bills);
+ assertFalse(bills.isEmpty(), "A bill should have been created for the drug order");
+
+ Bill bill = bills.get(0);
+ assertEquals(BillStatus.PENDING, bill.getStatus());
+
+ BillLineItem lineItem = bill.getLineItems().get(0);
+ assertNotNull(lineItem.getItem(), "Line item should reference a stock item");
+ assertEquals(BillStatus.PENDING, lineItem.getPaymentStatus());
+ assertEquals(5, lineItem.getQuantity());
+ assertEquals(new BigDecimal("150.00"), lineItem.getPrice());
+ assertEquals(savedOrder.getId(), lineItem.getOrder().getId());
+ }
+
+ @Test
+ public void shouldNotCreateBillForDrugOrderWhenNoStockItemExists() {
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+ Drug drug = Context.getConceptService().getDrug(3); // Aspirin — no stock item in test data
+
+ DrugOrder drugOrder = new DrugOrder();
+ drugOrder.setPatient(patient);
+ drugOrder.setDrug(drug);
+ drugOrder.setConcept(drug.getConcept());
+ drugOrder.setEncounter(encounter);
+ drugOrder.setOrderer(Context.getProviderService().getProvider(1));
+ drugOrder.setCareSetting(orderService.getCareSetting(2));
+ drugOrder.setOrderType(orderService.getOrderType(1));
+ drugOrder.setDateActivated(new Date());
+ drugOrder.setQuantity(2.0);
+ drugOrder.setQuantityUnits(conceptService.getConcept(51));
+ drugOrder.setDose(1.0);
+ drugOrder.setDoseUnits(conceptService.getConcept(51));
+ drugOrder.setRoute(conceptService.getConcept(22));
+ drugOrder.setFrequency(orderService.getOrderFrequency(1));
+ drugOrder.setDosingType(org.openmrs.SimpleDosingInstructions.class);
+ drugOrder.setNumRefills(0);
+
+ Order savedOrder = orderService.saveOrder(drugOrder, null);
+ Context.flushSession();
+
+ listener.processOrder(savedOrder);
+ Context.flushSession();
+
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertTrue(bills == null || bills.isEmpty(), "No bill should be created when no stock item matches the drug");
+ }
+
+ @Test
+ public void shouldNotCreateDuplicateBillForSameOrder() {
+ Concept testConcept = conceptService.getConcept(5497);
+ Encounter encounter = encounterService.getEncounter(3);
+ Patient patient = encounter.getPatient();
+
+ Order savedOrder = saveNewTestOrder(patient, testConcept, encounter);
+
+ // Process the same order twice
+ listener.processOrder(savedOrder);
+ Context.flushSession();
+ listener.processOrder(savedOrder);
+ Context.flushSession();
+
+ List bills = billService.getBillsByPatientUuid(patient.getUuid(), null);
+ assertNotNull(bills);
+ assertEquals(1, bills.size(), "Only one bill should exist — second call should be idempotent");
+ }
+
+ private Order saveNewTestOrder(Patient patient, Concept concept, Encounter encounter) {
+ TestOrder testOrder = new TestOrder();
+ testOrder.setPatient(patient);
+ testOrder.setConcept(concept);
+ testOrder.setEncounter(encounter);
+ testOrder.setOrderer(Context.getProviderService().getProvider(1));
+ testOrder.setCareSetting(orderService.getCareSetting(1));
+ testOrder.setOrderType(orderService.getOrderType(2));
+ testOrder.setDateActivated(new Date());
+
+ Order savedOrder = orderService.saveOrder(testOrder, null);
+ assertNotNull(savedOrder.getId());
+ Context.flushSession();
+ return savedOrder;
+ }
+}
diff --git a/api/src/test/resources/org/openmrs/module/billing/api/include/OrderBillingTest.xml b/api/src/test/resources/org/openmrs/module/billing/api/include/OrderBillingTest.xml
new file mode 100644
index 00000000..c945a666
--- /dev/null
+++ b/api/src/test/resources/org/openmrs/module/billing/api/include/OrderBillingTest.xml
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/fhir/pom.xml b/fhir/pom.xml
index fe23d85d..74289688 100644
--- a/fhir/pom.xml
+++ b/fhir/pom.xml
@@ -73,6 +73,10 @@
stockmanagement-api
jar