diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index f84440e6..0ffdf883 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -14,6 +14,12 @@ concurrency:
jobs:
build:
uses: openmrs/openmrs-contrib-gha-workflows/.github/workflows/build-backend-module.yml@main
+ # Java 8 is excluded because the querystore-api dependency is compiled with Java 11
+ # bytecode (class version 55). A Java 8 deployment cannot load it at runtime, so the
+ # Java 8 matrix cell would only ever surface a compatibility error that doesn't apply
+ # to any deployment that can actually use this slice.
+ with:
+ java_versions: '[11, 17, 21]'
permissions:
contents: read
id-token: write
diff --git a/api/pom.xml b/api/pom.xml
index 15d5f5de..3a7b64da 100644
--- a/api/pom.xml
+++ b/api/pom.xml
@@ -53,6 +53,11 @@
event-api
+
+ org.openmrs.module
+ querystore-api
+
+
org.openmrs.module
uiframework-api
diff --git a/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java b/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java
index 9f769570..e468ee6e 100644
--- a/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java
+++ b/api/src/main/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImpl.java
@@ -9,15 +9,22 @@
*/
package org.openmrs.module.billing.api.impl;
+import java.util.Date;
import java.util.List;
import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.openmrs.api.context.Context;
import org.openmrs.module.billing.api.BillDiscountService;
+import org.openmrs.module.billing.api.BillService;
import org.openmrs.module.billing.api.db.BillDiscountDAO;
+import org.openmrs.module.billing.api.model.Bill;
import org.openmrs.module.billing.api.model.BillDiscount;
import org.openmrs.module.billing.api.model.DiscountStatus;
+import org.openmrs.module.billing.api.util.PrivilegeConstants;
import org.springframework.transaction.annotation.Transactional;
+@Slf4j
@RequiredArgsConstructor
public class BillDiscountServiceImpl implements BillDiscountService {
@@ -56,7 +63,9 @@ public List getDiscountsByBillId(Integer billId) {
@Override
@Transactional
public BillDiscount saveBillDiscount(BillDiscount billDiscount) {
- return billDiscountDAO.saveBillDiscount(billDiscount);
+ BillDiscount saved = billDiscountDAO.saveBillDiscount(billDiscount);
+ touchParentBill(saved);
+ return saved;
}
@Override
@@ -64,4 +73,32 @@ public BillDiscount saveBillDiscount(BillDiscount billDiscount) {
public DiscountStatus getStatusById(Integer id) {
return billDiscountDAO.getStatusById(id);
}
+
+ // Bill.getAmountAfterDiscount() is derived; a discount mutation changes the bill's effective
+ // value without touching any bill column, so the parent row stays clean. Re-save to advance
+ // dateChanged — the querystore BillIndexingAdvice fires on the resulting BillService.saveBill.
+ private void touchParentBill(BillDiscount discount) {
+ Integer billId = discount.getBill() == null ? null : discount.getBill().getId();
+ if (billId == null) {
+ log.error("Saved discount {} has no associated bill; skipping parent bill touch", discount.getUuid());
+ return;
+ }
+ try {
+ Context.addProxyPrivilege(PrivilegeConstants.MANAGE_BILLS);
+ Bill freshBill = Context.getService(BillService.class).getBill(billId);
+ if (freshBill == null) {
+ // Bill was concurrently voided/purged between this discount's save and the reload —
+ // recoverable race, not a hard failure. warn rather than error so ops dashboards
+ // don't page on routine concurrent edits.
+ log.warn("Discount {} references bill {} which could not be loaded; parent bill not touched",
+ discount.getUuid(), billId);
+ return;
+ }
+ freshBill.setDateChanged(new Date());
+ Context.getService(BillService.class).saveBill(freshBill);
+ }
+ finally {
+ Context.removeProxyPrivilege(PrivilegeConstants.MANAGE_BILLS);
+ }
+ }
}
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 05c4655d..f21c086e 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
@@ -10,16 +10,21 @@
package org.openmrs.module.billing.api.impl;
import java.util.Collections;
+import java.util.Date;
import java.util.List;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.openmrs.Order;
+import org.openmrs.api.context.Context;
import org.openmrs.api.impl.BaseOpenmrsService;
import org.openmrs.module.billing.api.BillLineItemService;
+import org.openmrs.module.billing.api.BillService;
import org.openmrs.module.billing.api.db.BillLineItemDAO;
+import org.openmrs.module.billing.api.model.Bill;
import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.util.PrivilegeConstants;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@@ -62,5 +67,32 @@ public void voidBillLineItem(BillLineItem lineItem, String voidReason) {
throw new IllegalArgumentException("voidReason cannot be null or empty");
}
billLineItemDAO.saveBillLineItem(lineItem);
+ touchParentBill(lineItem);
+ }
+
+ // Bill.getTotal() / getAmountAfterDiscount() / getTotalPayments() all skip voided line items,
+ // so voiding a line item changes the bill's effective value without touching any bill column.
+ // Re-save the parent so dateChanged advances and the querystore BillIndexingAdvice fires on
+ // the resulting BillService.saveBill.
+ private void touchParentBill(BillLineItem lineItem) {
+ Integer billId = lineItem.getBill() == null ? null : lineItem.getBill().getId();
+ if (billId == null) {
+ log.error("Voided line item {} has no associated bill; skipping parent bill touch", lineItem.getUuid());
+ return;
+ }
+ try {
+ Context.addProxyPrivilege(PrivilegeConstants.MANAGE_BILLS);
+ Bill freshBill = Context.getService(BillService.class).getBill(billId);
+ if (freshBill == null) {
+ log.warn("Line item {} references bill {} which could not be loaded; parent bill not touched",
+ lineItem.getUuid(), billId);
+ return;
+ }
+ freshBill.setDateChanged(new Date());
+ Context.getService(BillService.class).saveBill(freshBill);
+ }
+ finally {
+ Context.removeProxyPrivilege(PrivilegeConstants.MANAGE_BILLS);
+ }
}
}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountIndexingAdvice.java
new file mode 100644
index 00000000..6b0ec14a
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountIndexingAdvice.java
@@ -0,0 +1,50 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.openmrs.api.context.Context;
+import org.openmrs.module.billing.api.model.BillDiscount;
+import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice;
+
+public class BillDiscountIndexingAdvice extends AbstractIndexingAdvice {
+
+ // BillDiscountService exposes only saveBillDiscount — approve / reject / void all happen by
+ // mutating the entity and calling saveBillDiscount, routed through AbstractIndexingAdvice's
+ // per-node voided policy (voided rows go to delete on the resave path). No purge method
+ // exists, so PURGE_METHODS is empty rather than aspirationally listing a name AOP can never
+ // match — see IndexingAdviceConfigTest.
+ static final Set TRIGGER_METHODS = Collections.singleton("saveBillDiscount");
+
+ static final Set PURGE_METHODS = Collections.emptySet();
+
+ @Override
+ protected Class getSupportedType() {
+ return BillDiscount.class;
+ }
+
+ @Override
+ protected BillDiscountRecordSerializer serializer() {
+ return Context.getRegisteredComponent("billing.querystore.serializer.bill_discount",
+ BillDiscountRecordSerializer.class);
+ }
+
+ @Override
+ protected Set triggerMethods() {
+ return TRIGGER_METHODS;
+ }
+
+ @Override
+ protected Set purgeMethods() {
+ return PURGE_METHODS;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java
new file mode 100644
index 00000000..ea21e209
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializer.java
@@ -0,0 +1,103 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+import org.openmrs.Patient;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillDiscount;
+import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.DiscountStatus;
+import org.openmrs.module.billing.api.model.DiscountType;
+import org.openmrs.module.querystore.model.QueryDocument;
+import org.openmrs.module.querystore.serialization.AbstractRecordSerializer;
+import org.openmrs.module.querystore.util.DateFormatUtil;
+
+public class BillDiscountRecordSerializer extends AbstractRecordSerializer {
+
+ @Override
+ public String getResourceType() {
+ return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_DISCOUNT;
+ }
+
+ @Override
+ public Class getSupportedType() {
+ return BillDiscount.class;
+ }
+
+ @Override
+ protected String getPatientUuid(BillDiscount discount) {
+ Bill bill = discount.getBill();
+ if (bill == null) {
+ return null;
+ }
+ Patient patient = bill.getPatient();
+ return patient != null ? patient.getUuid() : null;
+ }
+
+ @Override
+ protected String getResourceUuid(BillDiscount discount) {
+ return discount.getUuid();
+ }
+
+ @Override
+ protected LocalDate getDate(BillDiscount discount) {
+ return DateFormatUtil.toLocalDate(discount.getDateCreated());
+ }
+
+ @Override
+ protected void populate(BillDiscount discount, QueryDocument doc) {
+ Bill bill = discount.getBill();
+ if (bill == null || bill.getPatient() == null) {
+ return;
+ }
+ // Defensive: if discountType or discountValue is null the validator should have rejected
+ // the row, but the indexing advice swallows RuntimeException per-entity and would silently
+ // drop the discount from the approval queue. Skip the document rather than NPE inside
+ // getDiscountAmount().
+ if (discount.getDiscountType() == null || discount.getDiscountValue() == null) {
+ return;
+ }
+
+ DiscountStatus status = discount.getStatus();
+ DiscountType type = discount.getDiscountType();
+ BigDecimal value = discount.getDiscountValue();
+ BigDecimal amount = discount.getDiscountAmount();
+ String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid();
+
+ doc.setText(String.format("Discount on bill %s. Status: %s. Type: %s. Value: %s. Amount: %s. Reason: %s.",
+ receiptOrUuid, status != null ? status.name() : "UNKNOWN", type.name(), value.toPlainString(),
+ amount.toPlainString(), discount.getJustification() != null ? discount.getJustification() : ""));
+
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_UUID, bill.getUuid());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_STATUS, status != null ? status.name() : null);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_TYPE, type.name());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_VALUE, value);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_AMOUNT, amount);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_JUSTIFICATION, discount.getJustification());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, discount.getVoided());
+
+ BillLineItem lineItem = discount.getLineItem();
+ if (lineItem != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID, lineItem.getUuid());
+ }
+ if (discount.getInitiator() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_INITIATOR_UUID, discount.getInitiator().getUuid());
+ }
+ if (discount.getApprover() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_APPROVER_UUID, discount.getApprover().getUuid());
+ }
+
+ BillingAuditFields.populate(doc, discount);
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountResourceTypeProvider.java
new file mode 100644
index 00000000..9fc9d05f
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillDiscountResourceTypeProvider.java
@@ -0,0 +1,38 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import org.openmrs.module.querystore.bootstrap.TypeBootstrapper;
+import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer;
+import org.openmrs.module.querystore.spi.ResourceTypeProvider;
+
+public class BillDiscountResourceTypeProvider implements ResourceTypeProvider {
+
+ private final BillDiscountRecordSerializer serializer;
+
+ public BillDiscountResourceTypeProvider(BillDiscountRecordSerializer serializer) {
+ this.serializer = serializer;
+ }
+
+ @Override
+ public String getResourceType() {
+ return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_DISCOUNT;
+ }
+
+ @Override
+ public ClinicalRecordSerializer> getSerializer() {
+ return serializer;
+ }
+
+ @Override
+ public TypeBootstrapper> getBootstrapper() {
+ return null;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillIndexingAdvice.java
new file mode 100644
index 00000000..4dd0bc55
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillIndexingAdvice.java
@@ -0,0 +1,47 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.openmrs.api.context.Context;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice;
+
+public class BillIndexingAdvice extends AbstractIndexingAdvice {
+
+ static final Set TRIGGER_METHODS = new HashSet<>(
+ Arrays.asList("saveBill", "voidBill", "unvoidBill", "purgeBill"));
+
+ static final Set PURGE_METHODS = Collections.singleton("purgeBill");
+
+ @Override
+ protected Class getSupportedType() {
+ return Bill.class;
+ }
+
+ @Override
+ protected BillRecordSerializer serializer() {
+ return Context.getRegisteredComponent("billing.querystore.serializer.bill", BillRecordSerializer.class);
+ }
+
+ @Override
+ protected Set triggerMethods() {
+ return TRIGGER_METHODS;
+ }
+
+ @Override
+ protected Set purgeMethods() {
+ return PURGE_METHODS;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java
new file mode 100644
index 00000000..bfbaba2c
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRecordSerializer.java
@@ -0,0 +1,264 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import org.openmrs.Order;
+import org.openmrs.Patient;
+import org.openmrs.Visit;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillDiscount;
+import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.BillLineItemStatus;
+import org.openmrs.module.billing.api.model.BillStatus;
+import org.openmrs.module.billing.api.model.Payment;
+import org.openmrs.module.billing.api.model.PaymentMode;
+import org.openmrs.module.querystore.model.QueryDocument;
+import org.openmrs.module.querystore.serialization.AbstractRecordSerializer;
+import org.openmrs.module.querystore.util.DateFormatUtil;
+
+public class BillRecordSerializer extends AbstractRecordSerializer {
+
+ @Override
+ public String getResourceType() {
+ return BillingQueryStoreConstants.RESOURCE_TYPE_BILL;
+ }
+
+ @Override
+ public Class getSupportedType() {
+ return Bill.class;
+ }
+
+ @Override
+ protected String getPatientUuid(Bill bill) {
+ Patient patient = bill.getPatient();
+ return patient != null ? patient.getUuid() : null;
+ }
+
+ @Override
+ protected String getResourceUuid(Bill bill) {
+ return bill.getUuid();
+ }
+
+ @Override
+ protected LocalDate getDate(Bill bill) {
+ return DateFormatUtil.toLocalDate(bill.getDateCreated());
+ }
+
+ @Override
+ protected void populate(Bill bill, QueryDocument doc) {
+ if (bill.getPatient() == null) {
+ return;
+ }
+
+ BillStatus status = bill.getStatus();
+ BigDecimal total = bill.getTotal();
+ BigDecimal amountAfterDiscount = bill.getAmountAfterDiscount();
+ BigDecimal totalPaid = bill.getTotalPayments();
+ // Balance must be derived from amountAfterDiscount, not total — a bill whose only discount
+ // brings the effective amount to ≤ totalPayments has a zero/negative balance, and using
+ // gross total here would over-state what's still owed.
+ BigDecimal balance = amountAfterDiscount.subtract(totalPaid);
+
+ // Multi-valued metadata is stored as a List per the querystore module convention
+ // (see VisitRecordSerializer's FIELD_ENCOUNTER_UUIDS, AllergyRecordSerializer's FIELD_REACTIONS).
+ // Storing a comma-joined string would force consumers into substring matching, breaking
+ // exact-match queries like "bills containing item X".
+ List itemNames = collectLineItemNames(bill);
+
+ String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid();
+ String itemsClause = itemNames.isEmpty() ? "" : " Items: " + String.join(", ", itemNames) + ".";
+ doc.setText(String.format("Bill %s. Status: %s. Total: %s. Paid: %s. Balance: %s.%s", receiptOrUuid,
+ status != null ? status.name() : "UNKNOWN", total.toPlainString(), totalPaid.toPlainString(),
+ balance.toPlainString(), itemsClause));
+
+ if (!itemNames.isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES, itemNames);
+ }
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_STATUS, status != null ? status.name() : null);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_TOTAL, total);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_AMOUNT_AFTER_DISCOUNT, amountAfterDiscount);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_TOTAL_PAID, totalPaid);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_BALANCE, balance);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, bill.getVoided());
+
+ if (bill.getCashier() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_CASHIER_UUID, bill.getCashier().getUuid());
+ if (bill.getCashier().getName() != null && !bill.getCashier().getName().trim().isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_CASHIER_NAME, bill.getCashier().getName());
+ }
+ }
+ if (bill.getCashPoint() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID, bill.getCashPoint().getUuid());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME, bill.getCashPoint().getName());
+ }
+ Visit visit = bill.getVisit();
+ if (visit != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_VISIT_UUID, visit.getUuid());
+ }
+
+ Map paymentTotalsByMode = collectPaymentTotalsByMode(bill);
+ if (!paymentTotalsByMode.isEmpty()) {
+ // payment_modes and payment_mode_amounts are emitted in parallel TreeMap iteration
+ // order (alphabetic) so consumers can zip the two arrays at query time.
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_PAYMENT_MODES, new ArrayList<>(paymentTotalsByMode.keySet()));
+ List amounts = new ArrayList<>(paymentTotalsByMode.size());
+ for (BigDecimal amount : paymentTotalsByMode.values()) {
+ amounts.add(amount.toPlainString());
+ }
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_PAYMENT_MODE_AMOUNTS, amounts);
+ }
+ List discountStatuses = collectDiscountStatuses(bill);
+ if (!discountStatuses.isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES, discountStatuses);
+ }
+ List lineItemStatuses = collectLineItemStatuses(bill);
+ if (!lineItemStatuses.isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_LINE_ITEM_STATUSES, lineItemStatuses);
+ }
+ List orderUuids = collectOrderUuids(bill);
+ if (!orderUuids.isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_ORDER_UUIDS, orderUuids);
+ }
+ if (bill.getBillAdjusted() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_ADJUSTED_UUID, bill.getBillAdjusted().getUuid());
+ }
+ List adjustedByUuids = collectAdjustedByUuids(bill);
+ if (!adjustedByUuids.isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS, adjustedByUuids);
+ }
+ if (bill.getAdjustmentReason() != null && !bill.getAdjustmentReason().trim().isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON, bill.getAdjustmentReason());
+ }
+ // Boolean.TRUE.equals normalizes null → false. Persisted bills always have a value
+ // (Bill.hbm.xml: not-null, defaults to false), so this only matters for hand-built bills
+ // in tests or for in-flight bills constructed via the builder paths. Always-emit pattern
+ // lets consumers write "paid bills not yet printed" as a single term filter, no
+ // exists-clause.
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED, Boolean.TRUE.equals(bill.getReceiptPrinted()));
+
+ BillingAuditFields.populate(doc, bill);
+ }
+
+ private List collectLineItemNames(Bill bill) {
+ List names = new ArrayList<>();
+ if (bill.getLineItems() == null) {
+ return names;
+ }
+ for (BillLineItem lineItem : bill.getLineItems()) {
+ if (lineItem == null || lineItem.getVoided()) {
+ continue;
+ }
+ String name = BillingDisplayNames.lineItemDisplayName(lineItem);
+ if (name != null) {
+ names.add(name);
+ }
+ }
+ return names;
+ }
+
+ // Distinct, sorted by mode name, with each mode's non-voided amount summed. Bill.payments is
+ // a HashSet (non-deterministic iteration); TreeMap keeps the (modes, amounts) parallel pair
+ // in a stable alphabetic order so the resulting bill document is bytewise-identical across
+ // reindexes of the same logical state. A bill with two Cash payments emits one entry whose
+ // amount is their sum; whitespace-only mode names are skipped (they would otherwise show up
+ // as " " between Cash and Mobile Money on every ops dashboard).
+ private Map collectPaymentTotalsByMode(Bill bill) {
+ Map totals = new TreeMap<>();
+ if (bill.getPayments() == null) {
+ return totals;
+ }
+ for (Payment payment : bill.getPayments()) {
+ if (payment == null || payment.getVoided()) {
+ continue;
+ }
+ PaymentMode mode = payment.getInstanceType();
+ if (mode == null || mode.getName() == null || mode.getName().trim().isEmpty()) {
+ continue;
+ }
+ BigDecimal amount = payment.getAmountTendered() != null ? payment.getAmountTendered() : BigDecimal.ZERO;
+ totals.merge(mode.getName(), amount, BigDecimal::add);
+ }
+ return totals;
+ }
+
+ private List collectDiscountStatuses(Bill bill) {
+ Set statuses = new TreeSet<>();
+ if (bill.getDiscounts() == null) {
+ return new ArrayList<>(statuses);
+ }
+ for (BillDiscount discount : bill.getDiscounts()) {
+ if (discount == null || discount.getVoided() || discount.getStatus() == null) {
+ continue;
+ }
+ statuses.add(discount.getStatus().name());
+ }
+ return new ArrayList<>(statuses);
+ }
+
+ private List collectLineItemStatuses(Bill bill) {
+ Set statuses = new TreeSet<>();
+ if (bill.getLineItems() == null) {
+ return new ArrayList<>(statuses);
+ }
+ for (BillLineItem lineItem : bill.getLineItems()) {
+ if (lineItem == null || lineItem.getVoided()) {
+ continue;
+ }
+ BillLineItemStatus status = lineItem.getStatus();
+ if (status != null) {
+ statuses.add(status.name());
+ }
+ }
+ return new ArrayList<>(statuses);
+ }
+
+ private List collectOrderUuids(Bill bill) {
+ Set uuids = new TreeSet<>();
+ if (bill.getLineItems() == null) {
+ return new ArrayList<>(uuids);
+ }
+ for (BillLineItem lineItem : bill.getLineItems()) {
+ if (lineItem == null || lineItem.getVoided()) {
+ continue;
+ }
+ Order order = lineItem.getOrder();
+ if (order != null && order.getUuid() != null) {
+ uuids.add(order.getUuid());
+ }
+ }
+ return new ArrayList<>(uuids);
+ }
+
+ private List collectAdjustedByUuids(Bill bill) {
+ // Sorted for the same reason payment_modes / discount_statuses are: Bill.adjustedBy is a
+ // HashSet, so iteration order is non-deterministic. Without the sort, the same logical
+ // state would emit different document bytes across reindexes.
+ Set uuids = new TreeSet<>();
+ if (bill.getAdjustedBy() == null) {
+ return new ArrayList<>(uuids);
+ }
+ for (Bill adjuster : bill.getAdjustedBy()) {
+ if (adjuster != null && adjuster.getUuid() != null) {
+ uuids.add(adjuster.getUuid());
+ }
+ }
+ return new ArrayList<>(uuids);
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java
new file mode 100644
index 00000000..c90a47f3
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundIndexingAdvice.java
@@ -0,0 +1,49 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.util.Collections;
+import java.util.Set;
+
+import org.openmrs.api.context.Context;
+import org.openmrs.module.billing.api.model.BillRefund;
+import org.openmrs.module.querystore.bridge.AbstractIndexingAdvice;
+
+public class BillRefundIndexingAdvice extends AbstractIndexingAdvice {
+
+ // BillRefundService exposes only saveBillRefund — void/unvoid happen by setting the voided
+ // flag on the entity and calling saveBillRefund (AbstractIndexingAdvice's per-node voided
+ // policy routes voided records to delete on the resave path). There is no purgeBillRefund
+ // method, so PURGE_METHODS is empty rather than aspirationally listing a name AOP can never
+ // match — if hard-delete becomes a real path it should land on a service method first.
+ static final Set TRIGGER_METHODS = Collections.singleton("saveBillRefund");
+
+ static final Set PURGE_METHODS = Collections.emptySet();
+
+ @Override
+ protected Class getSupportedType() {
+ return BillRefund.class;
+ }
+
+ @Override
+ protected BillRefundRecordSerializer serializer() {
+ return Context.getRegisteredComponent("billing.querystore.serializer.bill_refund", BillRefundRecordSerializer.class);
+ }
+
+ @Override
+ protected Set triggerMethods() {
+ return TRIGGER_METHODS;
+ }
+
+ @Override
+ protected Set purgeMethods() {
+ return PURGE_METHODS;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java
new file mode 100644
index 00000000..381e81da
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializer.java
@@ -0,0 +1,114 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.time.LocalDate;
+import java.util.Collections;
+
+import org.openmrs.Patient;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.BillRefund;
+import org.openmrs.module.billing.api.model.RefundStatus;
+import org.openmrs.module.querystore.model.QueryDocument;
+import org.openmrs.module.querystore.serialization.AbstractRecordSerializer;
+import org.openmrs.module.querystore.util.DateFormatUtil;
+
+public class BillRefundRecordSerializer extends AbstractRecordSerializer {
+
+ @Override
+ public String getResourceType() {
+ return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_REFUND;
+ }
+
+ @Override
+ public Class getSupportedType() {
+ return BillRefund.class;
+ }
+
+ @Override
+ protected String getPatientUuid(BillRefund refund) {
+ Bill bill = refund.getBill();
+ if (bill == null) {
+ return null;
+ }
+ Patient patient = bill.getPatient();
+ return patient != null ? patient.getUuid() : null;
+ }
+
+ @Override
+ protected String getResourceUuid(BillRefund refund) {
+ return refund.getUuid();
+ }
+
+ @Override
+ protected LocalDate getDate(BillRefund refund) {
+ return DateFormatUtil.toLocalDate(refund.getDateCreated());
+ }
+
+ @Override
+ protected void populate(BillRefund refund, QueryDocument doc) {
+ Bill bill = refund.getBill();
+ if (bill == null || bill.getPatient() == null) {
+ return;
+ }
+ // Defensive: AbstractIndexingAdvice swallows RuntimeException per-entity, so an NPE here
+ // would silently drop the refund from the index with only a warn-level log. A partially
+ // constructed refund (validator gap, recovered transient) should be skipped, not crash.
+ if (refund.getRefundAmount() == null) {
+ return;
+ }
+
+ RefundStatus status = refund.getStatus();
+ String receiptOrUuid = bill.getReceiptNumber() != null ? bill.getReceiptNumber() : bill.getUuid();
+ // A refund is an audit record of a past line item. Even if the line item has since been
+ // voided on the bill, the refund must still carry the item's name so the audit trail
+ // reads coherently — the parent bill's indexed names omit voided items, but the refund's
+ // own indexed name preserves them.
+ String itemName = BillingDisplayNames.lineItemDisplayName(refund.getLineItem());
+ // Singular "Item:" (vs. the bill's plural "Items:") is intentional — a refund row is
+ // always line-scoped, so consumers parsing the text blob can rely on at most one item.
+ String itemClause = itemName != null ? " Item: " + itemName + "." : "";
+ doc.setText(String.format("Refund of %s for bill %s. Status: %s. Reason: %s.%s",
+ refund.getRefundAmount().toPlainString(), receiptOrUuid, status != null ? status.name() : "UNKNOWN",
+ refund.getReason() != null ? refund.getReason() : "", itemClause));
+
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_UUID, bill.getUuid());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER, bill.getReceiptNumber());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_REFUND_AMOUNT, refund.getRefundAmount());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_STATUS, status != null ? status.name() : null);
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_REASON, refund.getReason());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED, refund.getVoided());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_APPROVED, refund.getDateApproved());
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_COMPLETED, refund.getDateCompleted());
+
+ BillLineItem lineItem = refund.getLineItem();
+ if (lineItem != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID, lineItem.getUuid());
+ }
+ if (itemName != null) {
+ // Stored as a singleton list to match the Bill serializer's shape — consumers branch
+ // on resource type but share the field key, so a String here against a List there
+ // would surface as ClassCastException downstream.
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES, Collections.singletonList(itemName));
+ }
+ if (refund.getInitiator() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_INITIATOR_UUID, refund.getInitiator().getUuid());
+ }
+ if (refund.getApprover() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_APPROVER_UUID, refund.getApprover().getUuid());
+ }
+ if (refund.getCompleter() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_COMPLETER_UUID, refund.getCompleter().getUuid());
+ }
+
+ BillingAuditFields.populate(doc, refund);
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundResourceTypeProvider.java
new file mode 100644
index 00000000..6eb9299a
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillRefundResourceTypeProvider.java
@@ -0,0 +1,38 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import org.openmrs.module.querystore.bootstrap.TypeBootstrapper;
+import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer;
+import org.openmrs.module.querystore.spi.ResourceTypeProvider;
+
+public class BillRefundResourceTypeProvider implements ResourceTypeProvider {
+
+ private final BillRefundRecordSerializer serializer;
+
+ public BillRefundResourceTypeProvider(BillRefundRecordSerializer serializer) {
+ this.serializer = serializer;
+ }
+
+ @Override
+ public String getResourceType() {
+ return BillingQueryStoreConstants.RESOURCE_TYPE_BILL_REFUND;
+ }
+
+ @Override
+ public ClinicalRecordSerializer> getSerializer() {
+ return serializer;
+ }
+
+ @Override
+ public TypeBootstrapper> getBootstrapper() {
+ return null;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillResourceTypeProvider.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillResourceTypeProvider.java
new file mode 100644
index 00000000..05a4292f
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillResourceTypeProvider.java
@@ -0,0 +1,41 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import org.openmrs.module.querystore.bootstrap.TypeBootstrapper;
+import org.openmrs.module.querystore.serialization.ClinicalRecordSerializer;
+import org.openmrs.module.querystore.spi.ResourceTypeProvider;
+
+public class BillResourceTypeProvider implements ResourceTypeProvider {
+
+ private final BillRecordSerializer serializer;
+
+ public BillResourceTypeProvider(BillRecordSerializer serializer) {
+ this.serializer = serializer;
+ }
+
+ @Override
+ public String getResourceType() {
+ return BillingQueryStoreConstants.RESOURCE_TYPE_BILL;
+ }
+
+ @Override
+ public ClinicalRecordSerializer> getSerializer() {
+ return serializer;
+ }
+
+ @Override
+ public TypeBootstrapper> getBootstrapper() {
+ // No historical-record bootstrap for this v1; the AOP advice on BillService projects
+ // ongoing mutations, and any pre-existing bills will not appear in the index until a
+ // bootstrap path lands (separate slice).
+ return null;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java
new file mode 100644
index 00000000..83106ddf
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingAuditFields.java
@@ -0,0 +1,48 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import org.openmrs.BaseOpenmrsData;
+import org.openmrs.module.querystore.model.QueryDocument;
+
+// OpenMRS BaseOpenmrsData audit columns shared by Bill / BillRefund / BillDiscount.
+// Centralised because all three resource types want the same six audit fields (creator, changedBy,
+// dateChanged, voidedBy, dateVoided, voidReason) plus the time-precise createdAt; emitting them
+// inline would invite drift — a future refactor adds the field to one serializer and forgets the
+// others, and audit queries silently miss the new resource type.
+final class BillingAuditFields {
+
+ private BillingAuditFields() {
+ }
+
+ static void populate(QueryDocument doc, BaseOpenmrsData entity) {
+ if (entity.getDateCreated() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_CREATED_AT, entity.getDateCreated());
+ }
+ if (entity.getDateChanged() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_CHANGED, entity.getDateChanged());
+ }
+ if (entity.getDateVoided() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_DATE_VOIDED, entity.getDateVoided());
+ }
+ if (entity.getCreator() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_CREATOR_UUID, entity.getCreator().getUuid());
+ }
+ if (entity.getChangedBy() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_CHANGED_BY_UUID, entity.getChangedBy().getUuid());
+ }
+ if (entity.getVoidedBy() != null) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_VOIDED_BY_UUID, entity.getVoidedBy().getUuid());
+ }
+ if (entity.getVoidReason() != null && !entity.getVoidReason().trim().isEmpty()) {
+ doc.putMetadata(BillingQueryStoreConstants.FIELD_VOID_REASON, entity.getVoidReason());
+ }
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingDisplayNames.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingDisplayNames.java
new file mode 100644
index 00000000..a823bf9e
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingDisplayNames.java
@@ -0,0 +1,40 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.BillableService;
+import org.openmrs.module.stockmanagement.api.model.StockItem;
+
+// Display-name lookup scoped to the querystore SPI — shared only by BillRecordSerializer and
+// BillRefundRecordSerializer so their indexed item names stay consistent. Intentionally NOT the
+// source for ReceiptGenerator (which prefers Drug.name on printed receipts) or the FHIR
+// translator (which keys off Concept presence). Pulling this helper out of the querystore
+// package and "consolidating" the three consumers would silently change user-visible receipts.
+final class BillingDisplayNames {
+
+ private BillingDisplayNames() {
+ }
+
+ static String lineItemDisplayName(BillLineItem lineItem) {
+ if (lineItem == null) {
+ return null;
+ }
+ BillableService service = lineItem.getBillableService();
+ if (service != null && service.getName() != null && !service.getName().isEmpty()) {
+ return service.getName();
+ }
+ StockItem item = lineItem.getItem();
+ if (item != null && item.getCommonName() != null && !item.getCommonName().isEmpty()) {
+ return item.getCommonName();
+ }
+ return null;
+ }
+}
diff --git a/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java
new file mode 100644
index 00000000..30d9b185
--- /dev/null
+++ b/api/src/main/java/org/openmrs/module/billing/api/querystore/BillingQueryStoreConstants.java
@@ -0,0 +1,145 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+// Resource-type names and document field names that appear in BOTH the serializer (QueryDocument
+// metadata writes) AND any future consumer (querystore queries, dashboards, integrations). A typo
+// on either side of that boundary silently breaks indexing of the renamed field — the schema is
+// self-healing, so a misspelled key just creates a parallel column nobody queries against.
+// Per the ADR, field names are part of the public contract; a rename is a re-index event.
+final class BillingQueryStoreConstants {
+
+ static final String RESOURCE_TYPE_BILL = "billing_bill";
+
+ static final String RESOURCE_TYPE_BILL_REFUND = "billing_bill_refund";
+
+ static final String RESOURCE_TYPE_BILL_DISCOUNT = "billing_bill_discount";
+
+ static final String FIELD_RECEIPT_NUMBER = "receipt_number";
+
+ static final String FIELD_BILL_UUID = "bill_uuid";
+
+ static final String FIELD_STATUS = "status";
+
+ static final String FIELD_VOIDED = "voided";
+
+ static final String FIELD_TOTAL = "total";
+
+ static final String FIELD_AMOUNT_AFTER_DISCOUNT = "amount_after_discount";
+
+ static final String FIELD_TOTAL_PAID = "total_paid";
+
+ static final String FIELD_BALANCE = "balance";
+
+ static final String FIELD_CASHIER_UUID = "cashier_uuid";
+
+ static final String FIELD_CASH_POINT_UUID = "cash_point_uuid";
+
+ static final String FIELD_CASH_POINT_NAME = "cash_point_name";
+
+ static final String FIELD_VISIT_UUID = "visit_uuid";
+
+ static final String FIELD_REFUND_AMOUNT = "refund_amount";
+
+ static final String FIELD_REASON = "reason";
+
+ static final String FIELD_DATE_APPROVED = "date_approved";
+
+ static final String FIELD_DATE_COMPLETED = "date_completed";
+
+ static final String FIELD_BILL_LINE_ITEM_UUID = "bill_line_item_uuid";
+
+ // Shape contract: List on BOTH bill and refund docs. The bill emits one entry per
+ // non-voided line item; the refund emits a singleton (refunds are line-scoped). The shared
+ // key + uniform list shape lets a single "find every record referencing item X" query span
+ // both resource types without type-branching downstream.
+ static final String FIELD_LINE_ITEM_NAMES = "line_item_names";
+
+ static final String FIELD_INITIATOR_UUID = "initiator_uuid";
+
+ static final String FIELD_APPROVER_UUID = "approver_uuid";
+
+ static final String FIELD_COMPLETER_UUID = "completer_uuid";
+
+ // Distinct non-voided PaymentMode.name values across the bill's payments. Lets ops queries
+ // like "settlements by tender type" succeed without scanning every payment row.
+ static final String FIELD_PAYMENT_MODES = "payment_modes";
+
+ // Distinct non-voided DiscountStatus values on the bill, sorted alphabetically (NOT workflow
+ // order — APPROVED comes before PENDING). The aggregate exists for presence queries ("which
+ // bills have a pending discount?") — consumers must not treat the list as a timeline; the
+ // BillDiscount resource type carries the per-discount detail when ordering matters.
+ static final String FIELD_DISCOUNT_STATUSES = "discount_statuses";
+
+ // UUID of the bill this one adjusts (if any). Together with FIELD_ADJUSTED_BY_UUIDS, lets a
+ // query trace the adjustment chain in either direction.
+ static final String FIELD_BILL_ADJUSTED_UUID = "bill_adjusted_uuid";
+
+ static final String FIELD_ADJUSTED_BY_UUIDS = "adjusted_by_uuids";
+
+ static final String FIELD_ADJUSTMENT_REASON = "adjustment_reason";
+
+ static final String FIELD_RECEIPT_PRINTED = "receipt_printed";
+
+ // BillDiscount fields. The BillDiscount document is keyed to its parent bill's patient so the
+ // "approval queue" question — "show me all pending discounts requiring my review" — is a
+ // patient-scoped search per the querystore SPI contract.
+ static final String FIELD_DISCOUNT_TYPE = "discount_type";
+
+ // Raw input — a percentage (e.g., 15 for "15% off") when discount_type=PERCENTAGE, a money
+ // amount when discount_type=FIXED_AMOUNT. Always paired with discount_type to be meaningful.
+ static final String FIELD_DISCOUNT_VALUE = "discount_value";
+
+ // Computed money figure — the actual currency amount removed from the bill, derived from
+ // (value, type, current base). Use this for "find discounts > $50" queries; use discount_value
+ // for "find 15% discounts" queries.
+ static final String FIELD_DISCOUNT_AMOUNT = "discount_amount";
+
+ static final String FIELD_JUSTIFICATION = "justification";
+
+ // Distinct sorted non-voided BillLineItemStatus values across the bill's line items. Lets
+ // workflow queries like "find bills with a REFUND_REQUESTED line item" succeed without
+ // scanning every line item row.
+ static final String FIELD_LINE_ITEM_STATUSES = "line_item_statuses";
+
+ // Distinct sorted Order.uuid values across the bill's non-voided line items. Lets clinical-to-
+ // billing trace queries like "find the bill for this lab order" succeed.
+ static final String FIELD_ORDER_UUIDS = "order_uuids";
+
+ // Provider.getName() of the cashier — denormalized alongside cashier_uuid so admin queries
+ // like "find bills cashier-handled by Mary" don't require an extra Provider lookup.
+ static final String FIELD_CASHIER_NAME = "cashier_name";
+
+ // Parallel to FIELD_PAYMENT_MODES (same TreeMap iteration order). Each entry is the total
+ // non-voided amount tendered for the mode at the same index. Lets "total Cash collected this
+ // week" succeed by zipping the two arrays at query time.
+ static final String FIELD_PAYMENT_MODE_AMOUNTS = "payment_mode_amounts";
+
+ // OpenMRS BaseOpenmrsData audit fields. `voided` and the (LocalDate) `date` are already on
+ // the document; these add the time-precise create/change timestamps, the change/void actors,
+ // and the void reason so audit queries like "bills modified in the last hour" or "bills
+ // voided by Alice" can succeed.
+ static final String FIELD_CREATED_AT = "created_at";
+
+ static final String FIELD_DATE_CHANGED = "date_changed";
+
+ static final String FIELD_DATE_VOIDED = "date_voided";
+
+ static final String FIELD_CREATOR_UUID = "creator_uuid";
+
+ static final String FIELD_CHANGED_BY_UUID = "changed_by_uuid";
+
+ static final String FIELD_VOIDED_BY_UUID = "voided_by_uuid";
+
+ static final String FIELD_VOID_REASON = "void_reason";
+
+ private BillingQueryStoreConstants() {
+ }
+}
diff --git a/api/src/main/resources/moduleApplicationContext.xml b/api/src/main/resources/moduleApplicationContext.xml
index f309b94d..a43d62cf 100644
--- a/api/src/main/resources/moduleApplicationContext.xml
+++ b/api/src/main/resources/moduleApplicationContext.xml
@@ -337,6 +337,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java b/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java
index a6e3fd90..67c85026 100644
--- a/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java
+++ b/api/src/test/java/org/openmrs/module/billing/api/BillLineItemServiceTest.java
@@ -17,6 +17,8 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.List;
@@ -84,6 +86,29 @@ public void voidBillLineItem_shouldVoidLineItem() {
assertNotNull(reloaded.getVoidedBy());
}
+ @Test
+ public void voidBillLineItem_shouldAdvanceParentBillDateChanged() {
+ // Bill.getTotal() / getAmountAfterDiscount() skip voided line items, so voiding one
+ // changes the bill's effective value without touching any bill column. The parent bill
+ // must be re-saved so dateChanged advances and consumers see the change.
+ // Truncate to second precision: the DB TIMESTAMP column drops sub-second precision.
+ Date beforeVoid = Date.from(Instant.now().truncatedTo(ChronoUnit.SECONDS));
+
+ Bill bill = createBillWithLineItem();
+ BillLineItem lineItem = bill.getLineItems().get(0);
+ lineItemService.voidBillLineItem(lineItem, "Test parent-bill touch");
+ Context.flushSession();
+ Context.clearSession();
+
+ Bill parentBill = billService.getBill(bill.getId());
+ assertNotNull(parentBill);
+ Date afterDateChanged = parentBill.getDateChanged();
+ assertNotNull(afterDateChanged, "Parent Bill.dateChanged must be set after a line-item void");
+ assertTrue(afterDateChanged.compareTo(beforeVoid) >= 0,
+ "Parent Bill.dateChanged must be at-or-after the void timestamp (was " + afterDateChanged + ", expected >= "
+ + beforeVoid + ")");
+ }
+
@Test
public void voidBillLineItem_shouldThrowWhenReasonIsBlank() {
Bill bill = createBillWithLineItem();
diff --git a/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java b/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java
index c68adad9..63cedaeb 100644
--- a/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java
+++ b/api/src/test/java/org/openmrs/module/billing/api/impl/BillDiscountServiceImplTest.java
@@ -25,6 +25,9 @@
import org.openmrs.test.jupiter.BaseModuleContextSensitiveTest;
import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -333,6 +336,31 @@ public void saveBillDiscount_shouldAllowReSavingExistingDiscountWithoutFalsePosi
assertEquals("Updated justification", saved.getJustification());
}
+ @Test
+ public void saveBillDiscount_shouldAdvanceParentBillDateChanged() {
+ // amount_after_discount is derived from the discounts collection — a discount mutation
+ // changes the bill's effective value without touching any bill column. The parent bill
+ // must be re-saved so dateChanged advances and consumers see the change.
+ // Truncate to second precision: the DB TIMESTAMP column drops sub-second precision, so a
+ // ms-precision baseline captured in the same second as the save would fail comparison
+ // against the truncated reloaded value.
+ Date beforeSave = Date.from(Instant.now().truncatedTo(ChronoUnit.SECONDS));
+
+ BillDiscount discount = buildDiscount(POSTED_BILL_UUID, DiscountType.FIXED_AMOUNT, new BigDecimal("50.00"),
+ new BigDecimal("50.00"), "Test parent-bill touch");
+ service.saveBillDiscount(discount);
+ Context.flushSession();
+ Context.clearSession();
+
+ Bill parentBill = billService.getBillByUuid(POSTED_BILL_UUID);
+ assertNotNull(parentBill);
+ Date afterDateChanged = parentBill.getDateChanged();
+ assertNotNull(afterDateChanged, "Parent Bill.dateChanged must be set after a discount save");
+ assertTrue(afterDateChanged.compareTo(beforeSave) >= 0,
+ "Parent Bill.dateChanged must be at-or-after the discount save timestamp (was " + afterDateChanged
+ + ", expected >= " + beforeSave + ")");
+ }
+
@Test
public void getDiscountsByBillId_shouldReturnFullAuditHistory() {
Bill bill = billService.getBillByUuid(BILL_WITH_ACTIVE_DISCOUNT_UUID);
diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializerTest.java
new file mode 100644
index 00000000..07c04550
--- /dev/null
+++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillDiscountRecordSerializerTest.java
@@ -0,0 +1,193 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+import org.junit.jupiter.api.Test;
+import org.openmrs.Patient;
+import org.openmrs.User;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillDiscount;
+import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.DiscountStatus;
+import org.openmrs.module.billing.api.model.DiscountType;
+import org.openmrs.module.querystore.model.QueryDocument;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class BillDiscountRecordSerializerTest {
+
+ private static final String DISCOUNT_UUID = "discount-uuid-1";
+
+ private static final String BILL_UUID = "bill-uuid-1";
+
+ private static final String PATIENT_UUID = "patient-uuid-1";
+
+ private final BillDiscountRecordSerializer serializer = new BillDiscountRecordSerializer();
+
+ @Test
+ public void serialize_shouldSetCoreFieldsFromDiscount() {
+ BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("25.00"), DiscountStatus.PENDING);
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNotNull(doc);
+ assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_BILL_DISCOUNT, doc.getResourceType());
+ assertEquals(DISCOUNT_UUID, doc.getResourceUuid());
+ assertEquals(PATIENT_UUID, doc.getPatientUuid(),
+ "patientUuid must come from the parent bill so the approval-queue query stays patient-scoped");
+ assertNotNull(doc.getDate());
+ }
+
+ @Test
+ public void serialize_shouldEmitDiscountSpecificMetadata() {
+ BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("25.00"), DiscountStatus.APPROVED);
+ discount.setJustification("Charity-eligible patient");
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNotNull(doc);
+ assertEquals(BILL_UUID, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_UUID));
+ assertEquals("APPROVED", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_STATUS));
+ assertEquals("FIXED_AMOUNT", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_TYPE));
+ assertEquals(new BigDecimal("25.00"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_VALUE));
+ assertEquals(new BigDecimal("25.00"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_AMOUNT));
+ assertEquals("Charity-eligible patient", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_JUSTIFICATION));
+ }
+
+ @Test
+ public void serialize_shouldIncludeLineItemUuidWhenLineScoped() {
+ BillDiscount discount = newDiscount(DiscountType.PERCENTAGE, new BigDecimal("10"), DiscountStatus.APPROVED);
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setUuid("line-item-uuid-1");
+ lineItem.setPrice(new BigDecimal("50.00"));
+ lineItem.setQuantity(2);
+ lineItem.setVoided(false);
+ discount.setLineItem(lineItem);
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNotNull(doc);
+ assertEquals("line-item-uuid-1", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID));
+ }
+
+ @Test
+ public void serialize_shouldIncludeInitiatorAndApprover() {
+ BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.APPROVED);
+ discount.setInitiator(userWithUuid("initiator-uuid"));
+ discount.setApprover(userWithUuid("approver-uuid"));
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNotNull(doc);
+ assertEquals("initiator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_INITIATOR_UUID));
+ assertEquals("approver-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_APPROVER_UUID));
+ }
+
+ @Test
+ public void serialize_shouldOmitApproverWhenStillPending() {
+ // PENDING discounts haven't been approved yet — the approver_uuid field must be absent
+ // (not an empty string) so a "find unassigned approvals" query can use a missing-field
+ // filter rather than equality against an empty value.
+ BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.PENDING);
+ discount.setInitiator(userWithUuid("initiator-uuid"));
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_APPROVER_UUID));
+ }
+
+ @Test
+ public void serialize_shouldEmitTextBlobIncludingJustification() {
+ BillDiscount discount = newDiscount(DiscountType.PERCENTAGE, new BigDecimal("15"), DiscountStatus.APPROVED);
+ discount.setJustification("Charity-eligible patient");
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNotNull(doc);
+ assertTrue(doc.getText().contains("Discount on bill R-100."), doc.getText());
+ assertTrue(doc.getText().contains("Status: APPROVED."), doc.getText());
+ assertTrue(doc.getText().contains("Type: PERCENTAGE."), doc.getText());
+ assertTrue(doc.getText().contains("Reason: Charity-eligible patient."), doc.getText());
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenBillAbsent() {
+ BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.PENDING);
+ discount.setBill(null);
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNull(doc);
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenPatientAbsent() {
+ BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, new BigDecimal("10"), DiscountStatus.PENDING);
+ discount.getBill().setPatient(null);
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNull(doc);
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenDiscountTypeAbsent() {
+ // Discount type drives getDiscountAmount(); a partially constructed row (validator gap)
+ // would NPE inside that derivation. The advice swallows the NPE per-entity and would
+ // silently drop the row from the approval queue — better to skip with a null doc here.
+ BillDiscount discount = newDiscount(null, new BigDecimal("10"), DiscountStatus.PENDING);
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNull(doc);
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenDiscountValueAbsent() {
+ BillDiscount discount = newDiscount(DiscountType.FIXED_AMOUNT, null, DiscountStatus.PENDING);
+
+ QueryDocument doc = serializer.serialize(discount);
+
+ assertNull(doc);
+ }
+
+ private BillDiscount newDiscount(DiscountType type, BigDecimal value, DiscountStatus status) {
+ Bill bill = new Bill();
+ bill.setUuid(BILL_UUID);
+ bill.setReceiptNumber("R-100");
+ Patient patient = new Patient();
+ patient.setUuid(PATIENT_UUID);
+ bill.setPatient(patient);
+
+ BillDiscount discount = new BillDiscount();
+ discount.setUuid(DISCOUNT_UUID);
+ discount.setBill(bill);
+ discount.setDiscountType(type);
+ discount.setDiscountValue(value);
+ discount.setStatus(status);
+ discount.setVoided(false);
+ discount.setDateCreated(new Date());
+ return discount;
+ }
+
+ private User userWithUuid(String uuid) {
+ User user = new User();
+ user.setUuid(uuid);
+ return user;
+ }
+}
diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java
new file mode 100644
index 00000000..9a3fc375
--- /dev/null
+++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRecordSerializerTest.java
@@ -0,0 +1,799 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+import org.openmrs.Patient;
+import org.openmrs.Provider;
+import org.openmrs.Visit;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillDiscount;
+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.CashPoint;
+import org.openmrs.module.billing.api.model.DiscountStatus;
+import org.openmrs.module.billing.api.model.DiscountType;
+import org.openmrs.Order;
+import org.openmrs.User;
+import org.openmrs.module.billing.api.model.BillLineItemStatus;
+import org.openmrs.module.billing.api.model.Payment;
+import org.openmrs.module.billing.api.model.PaymentMode;
+import org.openmrs.module.querystore.model.QueryDocument;
+import org.openmrs.module.stockmanagement.api.model.StockItem;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class BillRecordSerializerTest {
+
+ private static final String BILL_UUID = "bill-uuid-1";
+
+ private static final String PATIENT_UUID = "patient-uuid-1";
+
+ private final BillRecordSerializer serializer = new BillRecordSerializer();
+
+ @Test
+ public void serialize_shouldSetCoreFieldsFromBill() {
+ Bill bill = postedBillWithLineItem("R-001", new BigDecimal("100"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_BILL, doc.getResourceType());
+ assertEquals(BILL_UUID, doc.getResourceUuid());
+ assertEquals(PATIENT_UUID, doc.getPatientUuid());
+ assertNotNull(doc.getDate(), "date must be set so the document is queryable by clinical date");
+ }
+
+ @Test
+ public void serialize_shouldDeriveBalanceFromAmountAfterDiscount() {
+ // total=100, approved discount=30, paid=50 → amount_after_discount=70, balance=20.
+ // If balance were derived from gross total instead, it would report 50 — overstating
+ // what's still owed and breaking dashboards built on the balance field.
+ Bill bill = postedBillWithLineItem("R-002", new BigDecimal("100"), 1);
+ addApprovedDiscount(bill, new BigDecimal("30"));
+ addPayment(bill, new BigDecimal("50"));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(new BigDecimal("100"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_TOTAL));
+ assertEquals(new BigDecimal("70"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_AMOUNT_AFTER_DISCOUNT));
+ assertEquals(new BigDecimal("50"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_TOTAL_PAID));
+ assertEquals(new BigDecimal("20"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BALANCE));
+ }
+
+ @Test
+ public void serialize_shouldClampBalanceWhenAmountAfterDiscountIsZero() {
+ // Approved discount exceeds total. getAmountAfterDiscount() clamps to zero, so balance =
+ // 0 - payments. A negative balance means the bill is over-credited; consumers can detect
+ // drift by checking balance < 0.
+ Bill bill = postedBillWithLineItem("R-003", new BigDecimal("100"), 1);
+ addApprovedDiscount(bill, new BigDecimal("150"));
+ addPayment(bill, new BigDecimal("0"));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(BigDecimal.ZERO, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_AMOUNT_AFTER_DISCOUNT));
+ }
+
+ @Test
+ public void serialize_shouldIncludeCashierCashPointAndVisitWhenPresent() {
+ Bill bill = postedBillWithLineItem("R-004", new BigDecimal("50"), 1);
+ Provider cashier = new Provider();
+ cashier.setUuid("cashier-uuid");
+ bill.setCashier(cashier);
+ CashPoint cashPoint = new CashPoint();
+ cashPoint.setUuid("cashpoint-uuid");
+ cashPoint.setName("Main Counter");
+ bill.setCashPoint(cashPoint);
+ Visit visit = new Visit();
+ visit.setUuid("visit-uuid");
+ bill.setVisit(visit);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals("cashier-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_UUID));
+ assertEquals("cashpoint-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID));
+ assertEquals("Main Counter", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME));
+ assertEquals("visit-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VISIT_UUID));
+ }
+
+ @Test
+ public void serialize_shouldOmitOptionalReferencesWhenAbsent() {
+ Bill bill = postedBillWithLineItem("R-005", new BigDecimal("50"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASHIER_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASH_POINT_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASH_POINT_NAME));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_VISIT_UUID));
+ }
+
+ @Test
+ public void serialize_shouldFallBackToUuidInTextWhenReceiptNumberAbsent() {
+ Bill bill = postedBillWithLineItem(null, new BigDecimal("50"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ // receipt-number metadata is null; the searchable text falls back to the bill UUID so
+ // the document still has a stable label for indexing.
+ assertNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_NUMBER));
+ assertTrue(doc.getText().contains(BILL_UUID),
+ "text must include the bill UUID when receipt number is absent: " + doc.getText());
+ }
+
+ @Test
+ public void serialize_shouldIncludeBillableServiceNameInTextAndLineItemNamesMetadata() {
+ // "Find every bill that includes service X" is the core query this slice enables; if the
+ // name does not make it into either the text blob or the structured list, that query has
+ // no signal to match against.
+ Bill bill = postedBillWithLineItem("R-100", new BigDecimal("50"), 1);
+ setBillableService(bill.getLineItems().get(0), "Consultation");
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(java.util.Collections.singletonList("Consultation"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ assertTrue(doc.getText().contains("Items: Consultation."),
+ "text must surface the line item name for full-text search: " + doc.getText());
+ }
+
+ @Test
+ public void serialize_shouldFallBackToStockItemCommonNameWhenBillableServiceAbsent() {
+ Bill bill = postedBillWithLineItem("R-101", new BigDecimal("50"), 1);
+ setStockItem(bill.getLineItems().get(0), "Paracetamol 500mg");
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(java.util.Collections.singletonList("Paracetamol 500mg"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ }
+
+ @Test
+ public void serialize_shouldPreferBillableServiceNameOverStockItemCommonName() {
+ // Both populated → service wins. A line item with both fields set is rare but possible;
+ // pinning the precedence here prevents future refactors from silently flipping it.
+ Bill bill = postedBillWithLineItem("R-102", new BigDecimal("50"), 1);
+ setBillableService(bill.getLineItems().get(0), "Consultation");
+ setStockItem(bill.getLineItems().get(0), "Paracetamol 500mg");
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(java.util.Collections.singletonList("Consultation"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ }
+
+ @Test
+ public void serialize_shouldSkipVoidedLineItemsFromLineItemNames() {
+ // Voided line items are excluded from total/balance computations, so they must also be
+ // excluded from the indexed names — otherwise a voided item leaves a phantom hit in the
+ // index that contradicts the bill's effective state.
+ Bill bill = postedBillWithLineItem("R-103", new BigDecimal("50"), 1);
+ setBillableService(bill.getLineItems().get(0), "ActiveService");
+ BillLineItem voided = newLineItem(new BigDecimal("10"), 1);
+ voided.setVoided(true);
+ setBillableService(voided, "VoidedService");
+ bill.getLineItems().add(voided);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(java.util.Collections.singletonList("ActiveService"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ assertFalse(doc.getText().contains("VoidedService"),
+ "voided line item names must not appear in the indexed text: " + doc.getText());
+ }
+
+ @Test
+ public void serialize_shouldOmitLineItemNamesMetadataWhenAllLineItemsVoided() {
+ Bill bill = postedBillWithLineItem("R-104", new BigDecimal("50"), 1);
+ bill.getLineItems().get(0).setVoided(true);
+ setBillableService(bill.getLineItems().get(0), "VoidedOnly");
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES),
+ "metadata key must be absent (not empty list) when no names are eligible");
+ assertFalse(doc.getText().contains("Items:"));
+ }
+
+ @Test
+ public void serialize_shouldJoinMultipleLineItemNamesInOrder() {
+ Bill bill = postedBillWithLineItem("R-105", new BigDecimal("10"), 1);
+ setBillableService(bill.getLineItems().get(0), "First");
+ BillLineItem second = newLineItem(new BigDecimal("20"), 1);
+ setBillableService(second, "Second");
+ bill.getLineItems().add(second);
+ BillLineItem third = newLineItem(new BigDecimal("30"), 1);
+ setStockItem(third, "Third");
+ bill.getLineItems().add(third);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ List expected = Arrays.asList("First", "Second", "Third");
+ assertEquals(expected, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ assertTrue(doc.getText().contains("Items: First, Second, Third."),
+ "text must preserve insertion order and join with ', ': " + doc.getText());
+ }
+
+ @Test
+ public void serialize_shouldSkipLineItemWithNeitherServiceNorStockItem() {
+ // Rounding rows (e.g., RoundingUtil.findRoundingLineItem) have neither field set — they
+ // should not pollute the search text with a blank/null entry.
+ Bill bill = postedBillWithLineItem("R-106", new BigDecimal("50"), 1);
+ setBillableService(bill.getLineItems().get(0), "RealItem");
+ BillLineItem bareRounding = newLineItem(new BigDecimal("0.01"), 1);
+ bill.getLineItems().add(bareRounding);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(java.util.Collections.singletonList("RealItem"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ }
+
+ @Test
+ public void serialize_shouldHandleNullLineItemsCollection() {
+ // Bills loaded through certain paths can have an unset lineItems collection; collectLineItemNames
+ // must not NPE in that case. The indexing advice swallows RuntimeException per-entity, so an NPE
+ // here would silently drop the bill from the index with only a warn-level log.
+ Bill bill = postedBillWithLineItem("R-107", new BigDecimal("50"), 1);
+ bill.setLineItems(null);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ }
+
+ @Test
+ public void serialize_shouldEmitPaymentModeAmountsInParallelToModes() {
+ // payment_modes and payment_mode_amounts must be parallel — payment_modes[i] paid amount
+ // payment_mode_amounts[i]. Two payments of the same mode are summed into one entry.
+ Bill bill = postedBillWithLineItem("R-300", new BigDecimal("100"), 1);
+ addPayment(bill, new BigDecimal("30.00"), paymentMode("Mobile Money"));
+ addPayment(bill, new BigDecimal("40.00"), paymentMode("Cash"));
+ addPayment(bill, new BigDecimal("20.00"), paymentMode("Mobile Money"));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("Cash", "Mobile Money"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES));
+ assertEquals(Arrays.asList("40.00", "50.00"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODE_AMOUNTS));
+ }
+
+ @Test
+ public void serialize_shouldOmitPaymentModeAmountsWhenNoPayments() {
+ Bill bill = postedBillWithLineItem("R-301", new BigDecimal("100"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_PAYMENT_MODE_AMOUNTS));
+ }
+
+ @Test
+ public void serialize_shouldEmitDistinctSortedLineItemStatuses() {
+ // "Find bills with a REFUND_REQUESTED line item" requires the workflow status surfaced;
+ // the BillStatus field is bill-level only. Voided line items are excluded so a once-
+ // requested-then-voided line doesn't leave a phantom REFUND_REQUESTED in the index.
+ Bill bill = postedBillWithLineItem("R-302", new BigDecimal("50"), 1);
+ bill.getLineItems().get(0).setStatus(BillLineItemStatus.PAID);
+ BillLineItem second = newLineItem(new BigDecimal("20"), 1);
+ second.setStatus(BillLineItemStatus.REFUND_REQUESTED);
+ bill.getLineItems().add(second);
+ BillLineItem voided = newLineItem(new BigDecimal("10"), 1);
+ voided.setStatus(BillLineItemStatus.REFUNDED);
+ voided.setVoided(true);
+ bill.getLineItems().add(voided);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("PAID", "REFUND_REQUESTED"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_STATUSES));
+ }
+
+ @Test
+ public void serialize_shouldOmitLineItemStatusesWhenAllNullOrVoided() {
+ Bill bill = postedBillWithLineItem("R-303", new BigDecimal("50"), 1);
+ // line item has no status set (null)
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_STATUSES));
+ }
+
+ @Test
+ public void serialize_shouldEmitDistinctSortedOrderUuids() {
+ // Clinical-to-billing trace: "find the bill for this lab order" matches against
+ // order_uuids. Voided line items contribute nothing — the index reflects the bill's
+ // current effective ordered work, not its history.
+ Bill bill = postedBillWithLineItem("R-304", new BigDecimal("50"), 1);
+ Order orderA = new Order();
+ orderA.setUuid("z-order");
+ Order orderB = new Order();
+ orderB.setUuid("a-order");
+ bill.getLineItems().get(0).setOrder(orderA);
+ BillLineItem second = newLineItem(new BigDecimal("20"), 1);
+ second.setOrder(orderB);
+ bill.getLineItems().add(second);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("a-order", "z-order"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ORDER_UUIDS));
+ }
+
+ @Test
+ public void serialize_shouldOmitOrderUuidsWhenNoLineItemHasOrder() {
+ Bill bill = postedBillWithLineItem("R-305", new BigDecimal("50"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ORDER_UUIDS));
+ }
+
+ @Test
+ public void serialize_shouldEmitCashierNameAlongsideUuid() {
+ // "Find bills cashier-handled by Mary" — denormalized name avoids a Provider lookup.
+ // Provider.getName() derives from the linked Person's PersonName when there's no metadata
+ // name; we build a Person+PersonName to match the production data shape.
+ Bill bill = postedBillWithLineItem("R-306", new BigDecimal("50"), 1);
+ bill.setCashier(providerWithPersonName("cashier-uuid", "Mary"));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals("cashier-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_UUID));
+ assertEquals("Mary", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_NAME));
+ }
+
+ @Test
+ public void serialize_shouldOmitCashierNameWhenProviderHasNoName() {
+ // Production providers usually have a Person; system providers (REST-created with no
+ // Person and no metadata name) would otherwise emit an empty/whitespace cashier_name —
+ // the guard must skip the field entirely so a "find bills with a named cashier"
+ // exists-filter doesn't match these system rows.
+ Bill bill = postedBillWithLineItem("R-306b", new BigDecimal("50"), 1);
+ Provider cashier = new Provider();
+ cashier.setUuid("cashier-no-name");
+ bill.setCashier(cashier);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals("cashier-no-name", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CASHIER_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CASHIER_NAME));
+ }
+
+ @Test
+ public void serialize_shouldOmitAuditFieldsWhenAbsent() {
+ // The audit-fields helper must skip-when-null so a "voided by" exists-query doesn't
+ // false-positive on rows where voidedBy was never set. dateCreated is always present on
+ // a saved bill, but the others (changedBy, voidedBy, dateChanged, dateVoided, voidReason)
+ // are populated only when the bill is touched.
+ Bill bill = postedBillWithLineItem("R-307b", new BigDecimal("50"), 1);
+ bill.setCreator(null);
+ bill.setDateChanged(null);
+ bill.setChangedBy(null);
+ bill.setDateVoided(null);
+ bill.setVoidedBy(null);
+ bill.setVoidReason(null);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CREATOR_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_DATE_CHANGED));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_CHANGED_BY_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_DATE_VOIDED));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_VOIDED_BY_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_VOID_REASON));
+ }
+
+ @Test
+ public void serialize_shouldEmitAuditFieldsWhenPresent() {
+ // Audit context (creator, changedBy, voidedBy, voidReason, dateChanged, dateVoided,
+ // createdAt) is what makes "who voided this bill and when" queries possible. dateCreated
+ // is already on the parent QueryDocument as LocalDate; created_at is the Date timestamp
+ // for time-of-day filtering.
+ Bill bill = postedBillWithLineItem("R-307", new BigDecimal("50"), 1);
+ bill.setCreator(userWithUuid("creator-uuid"));
+ bill.setChangedBy(userWithUuid("changer-uuid"));
+ bill.setVoidedBy(userWithUuid("voider-uuid"));
+ bill.setVoidReason("erroneously created");
+ Date changed = new Date();
+ bill.setDateChanged(changed);
+ Date voided = new Date();
+ bill.setDateVoided(voided);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals("creator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATOR_UUID));
+ assertEquals("changer-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CHANGED_BY_UUID));
+ assertEquals("voider-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VOIDED_BY_UUID));
+ assertEquals("erroneously created", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_VOID_REASON));
+ assertEquals(changed, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_CHANGED));
+ assertEquals(voided, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_VOIDED));
+ assertNotNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_CREATED_AT));
+ }
+
+ @Test
+ public void serialize_shouldEmitDistinctSortedPaymentModes() {
+ // "Settlements by tender type" is an ops/finance query. The list must be distinct (two
+ // Mobile Money payments don't double-count) and exact-match-queryable (List, not
+ // comma-joined). Sorted because the underlying HashSet has no stable iteration
+ // order — sorting gives consumers bytewise-identical docs across reindexes.
+ Bill bill = postedBillWithLineItem("R-200", new BigDecimal("100"), 1);
+ addPayment(bill, new BigDecimal("30"), paymentMode("Mobile Money"));
+ addPayment(bill, new BigDecimal("40"), paymentMode("Cash"));
+ addPayment(bill, new BigDecimal("30"), paymentMode("Mobile Money"));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("Cash", "Mobile Money"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES));
+ }
+
+ @Test
+ public void serialize_shouldSkipPaymentsWithMissingMode() {
+ // A payment row can lack a useful mode (legacy data, half-constructed, blank name). Without
+ // the guards the serializer would NPE or emit a blank/whitespace entry, polluting the text
+ // blob and the metadata list — a tender mode literally named " " would otherwise show up
+ // between Cash and Mobile Money on every ops dashboard.
+ Bill bill = postedBillWithLineItem("R-212", new BigDecimal("100"), 1);
+ Payment noMode = new Payment();
+ noMode.setAmount(new BigDecimal("10"));
+ noMode.setAmountTendered(new BigDecimal("10"));
+ noMode.setVoided(false);
+ bill.setPayments(new HashSet<>());
+ bill.getPayments().add(noMode);
+ PaymentMode blank = new PaymentMode();
+ blank.setName("");
+ addPayment(bill, new BigDecimal("5"), blank);
+ PaymentMode whitespace = new PaymentMode();
+ whitespace.setName(" ");
+ addPayment(bill, new BigDecimal("5"), whitespace);
+ addPayment(bill, new BigDecimal("20"), paymentMode("Cash"));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("Cash"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES));
+ }
+
+ @Test
+ public void serialize_shouldExcludeVoidedPaymentsFromPaymentModes() {
+ Bill bill = postedBillWithLineItem("R-201", new BigDecimal("100"), 1);
+ addPayment(bill, new BigDecimal("30"), paymentMode("Cash"));
+ Payment voided = new Payment();
+ voided.setAmount(new BigDecimal("20"));
+ voided.setAmountTendered(new BigDecimal("20"));
+ voided.setVoided(true);
+ voided.setInstanceType(paymentMode("Mobile Money"));
+ bill.getPayments().add(voided);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("Cash"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_PAYMENT_MODES));
+ }
+
+ @Test
+ public void serialize_shouldOmitPaymentModesMetadataWhenNoNonVoidedPayments() {
+ Bill bill = postedBillWithLineItem("R-202", new BigDecimal("100"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_PAYMENT_MODES));
+ }
+
+ @Test
+ public void serialize_shouldEmitDistinctDiscountStatuses() {
+ // "Which bills have a pending discount?" is the workflow-queue question this enables.
+ // Voided discounts must not pollute the field — a rejected then re-issued discount
+ // shouldn't show its old rejected status.
+ Bill bill = postedBillWithLineItem("R-203", new BigDecimal("100"), 1);
+ addDiscount(bill, DiscountStatus.PENDING, false);
+ addDiscount(bill, DiscountStatus.APPROVED, false);
+ addDiscount(bill, DiscountStatus.PENDING, false);
+ addDiscount(bill, DiscountStatus.REJECTED, true);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ @SuppressWarnings("unchecked")
+ List statuses = (List) doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES);
+ assertNotNull(statuses);
+ assertEquals(2, statuses.size());
+ assertTrue(statuses.contains("PENDING"));
+ assertTrue(statuses.contains("APPROVED"));
+ assertFalse(statuses.contains("REJECTED"));
+ }
+
+ @Test
+ public void serialize_shouldSkipDiscountsWithNullStatus() {
+ // A half-constructed discount may reach the serializer with status still null. Indexing
+ // the literal string "null" or NPE'ing inside the loop would silently drop the bill from
+ // the index via the indexing advice's per-entity exception swallow.
+ Bill bill = postedBillWithLineItem("R-220", new BigDecimal("100"), 1);
+ addDiscount(bill, null, false);
+ addDiscount(bill, DiscountStatus.PENDING, false);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("PENDING"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES));
+ }
+
+ @Test
+ public void serialize_shouldOmitDiscountStatusesMetadataWhenNoNonVoidedDiscounts() {
+ Bill bill = postedBillWithLineItem("R-204", new BigDecimal("100"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_DISCOUNT_STATUSES));
+ }
+
+ @Test
+ public void serialize_shouldIncludeBillAdjustedUuidWhenBillAdjustsAnother() {
+ Bill original = new Bill();
+ original.setUuid("original-bill-uuid");
+ Bill bill = postedBillWithLineItem("R-205", new BigDecimal("50"), 1);
+ bill.setBillAdjusted(original);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals("original-bill-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_ADJUSTED_UUID));
+ }
+
+ @Test
+ public void serialize_shouldIncludeAdjustedByUuidsWhenBillHasBeenAdjusted() {
+ Bill bill = postedBillWithLineItem("R-206", new BigDecimal("50"), 1);
+ Bill adjusterA = new Bill();
+ adjusterA.setUuid("adjuster-a");
+ Bill adjusterB = new Bill();
+ adjusterB.setUuid("adjuster-b");
+ bill.setAdjustedBy(new HashSet<>(Arrays.asList(adjusterA, adjusterB)));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ @SuppressWarnings("unchecked")
+ List uuids = (List) doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS);
+ assertNotNull(uuids);
+ assertEquals(2, uuids.size());
+ assertTrue(uuids.contains("adjuster-a"));
+ assertTrue(uuids.contains("adjuster-b"));
+ }
+
+ @Test
+ public void serialize_shouldOmitAdjustmentFieldsWhenAbsent() {
+ Bill bill = postedBillWithLineItem("R-207", new BigDecimal("50"), 1);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_BILL_ADJUSTED_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON));
+ }
+
+ @Test
+ public void serialize_shouldIncludeAdjustmentReasonWhenSet() {
+ Bill bill = postedBillWithLineItem("R-208", new BigDecimal("50"), 1);
+ bill.setAdjustmentReason("Patient was overcharged for consult");
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals("Patient was overcharged for consult",
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON));
+ }
+
+ @Test
+ public void serialize_shouldOmitAdjustmentReasonWhenEmpty() {
+ // An empty-string reason is semantically equivalent to "no reason given" — emitting it
+ // would pollute "find bills with an adjustment reason" exists-queries with empty matches.
+ Bill bill = postedBillWithLineItem("R-208a", new BigDecimal("50"), 1);
+ bill.setAdjustmentReason("");
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_ADJUSTMENT_REASON));
+ }
+
+ @Test
+ public void serialize_shouldEmitAdjustedByUuidsSorted() {
+ // Bill.adjustedBy is a HashSet — without sorting, the same logical state would emit
+ // different document bytes across reindexes, breaking snapshot/cache stability.
+ Bill bill = postedBillWithLineItem("R-208b", new BigDecimal("50"), 1);
+ Bill adjusterC = new Bill();
+ adjusterC.setUuid("z-uuid");
+ Bill adjusterA = new Bill();
+ adjusterA.setUuid("a-uuid");
+ Bill adjusterB = new Bill();
+ adjusterB.setUuid("m-uuid");
+ bill.setAdjustedBy(new HashSet<>(Arrays.asList(adjusterC, adjusterA, adjusterB)));
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNotNull(doc);
+ assertEquals(Arrays.asList("a-uuid", "m-uuid", "z-uuid"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_ADJUSTED_BY_UUIDS));
+ }
+
+ @Test
+ public void serialize_shouldAlwaysEmitReceiptPrintedAsBoolean() {
+ // Always present so consumers can build "paid bills not yet printed" queries with a single
+ // term filter, no exists-clause. Null on the entity is normalized to false — a half-saved
+ // bill that never set the flag is treated as not-printed, which is the safer default.
+ Bill notPrinted = postedBillWithLineItem("R-209", new BigDecimal("50"), 1);
+ notPrinted.setReceiptPrinted(false);
+ Bill printed = postedBillWithLineItem("R-210", new BigDecimal("50"), 1);
+ printed.setReceiptPrinted(true);
+ Bill unset = postedBillWithLineItem("R-211", new BigDecimal("50"), 1);
+ unset.setReceiptPrinted(null);
+
+ assertEquals(Boolean.FALSE,
+ serializer.serialize(notPrinted).getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED));
+ assertEquals(Boolean.TRUE,
+ serializer.serialize(printed).getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED));
+ assertEquals(Boolean.FALSE,
+ serializer.serialize(unset).getMetadata().get(BillingQueryStoreConstants.FIELD_RECEIPT_PRINTED));
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenPatientAbsent() {
+ // Without a patient, the serializer cannot produce a document keyed to a patient. Returning
+ // null short-circuits indexing for this record (per the AbstractRecordSerializer contract:
+ // empty text → null document).
+ Bill bill = postedBillWithLineItem("R-006", new BigDecimal("50"), 1);
+ bill.setPatient(null);
+
+ QueryDocument doc = serializer.serialize(bill);
+
+ assertNull(doc);
+ }
+
+ private Bill postedBillWithLineItem(String receiptNumber, BigDecimal price, int quantity) {
+ Bill bill = new Bill();
+ bill.setUuid(BILL_UUID);
+ bill.setReceiptNumber(receiptNumber);
+ Patient patient = new Patient();
+ patient.setUuid(PATIENT_UUID);
+ bill.setPatient(patient);
+ bill.setStatus(BillStatus.POSTED);
+ bill.setDateCreated(new Date());
+ bill.setLineItems(new ArrayList<>());
+ bill.getLineItems().add(newLineItem(price, quantity));
+ return bill;
+ }
+
+ private static BillLineItem newLineItem(BigDecimal price, int quantity) {
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setPrice(price);
+ lineItem.setQuantity(quantity);
+ lineItem.setVoided(false);
+ return lineItem;
+ }
+
+ private static void setBillableService(BillLineItem lineItem, String name) {
+ BillableService service = new BillableService();
+ service.setName(name);
+ lineItem.setBillableService(service);
+ }
+
+ private static void setStockItem(BillLineItem lineItem, String commonName) {
+ StockItem item = new StockItem();
+ item.setCommonName(commonName);
+ lineItem.setItem(item);
+ }
+
+ private void addApprovedDiscount(Bill bill, BigDecimal amount) {
+ if (bill.getDiscounts() == null) {
+ bill.setDiscounts(new HashSet<>());
+ }
+ // getDiscountAmount() derives from discountValue + discountType, not a stored column —
+ // FIXED_AMOUNT means amount == value.
+ BillDiscount discount = new BillDiscount();
+ discount.setDiscountType(DiscountType.FIXED_AMOUNT);
+ discount.setDiscountValue(amount);
+ discount.setStatus(DiscountStatus.APPROVED);
+ discount.setVoided(false);
+ bill.getDiscounts().add(discount);
+ }
+
+ private void addPayment(Bill bill, BigDecimal amount) {
+ addPayment(bill, amount, null);
+ }
+
+ private void addPayment(Bill bill, BigDecimal amount, PaymentMode mode) {
+ if (bill.getPayments() == null) {
+ bill.setPayments(new HashSet<>());
+ }
+ Payment payment = new Payment();
+ payment.setAmount(amount);
+ payment.setAmountTendered(amount);
+ payment.setVoided(false);
+ payment.setInstanceType(mode);
+ bill.getPayments().add(payment);
+ }
+
+ private static PaymentMode paymentMode(String name) {
+ PaymentMode mode = new PaymentMode();
+ mode.setName(name);
+ return mode;
+ }
+
+ private static User userWithUuid(String uuid) {
+ User user = new User();
+ user.setUuid(uuid);
+ return user;
+ }
+
+ private static Provider providerWithPersonName(String uuid, String name) {
+ Provider provider = new Provider();
+ provider.setUuid(uuid);
+ org.openmrs.Person person = new org.openmrs.Person();
+ org.openmrs.PersonName personName = new org.openmrs.PersonName();
+ personName.setGivenName(name);
+ personName.setFamilyName("");
+ person.addName(personName);
+ provider.setPerson(person);
+ return provider;
+ }
+
+ private void addDiscount(Bill bill, DiscountStatus status, boolean voided) {
+ if (bill.getDiscounts() == null) {
+ bill.setDiscounts(new HashSet<>());
+ }
+ BillDiscount discount = new BillDiscount();
+ discount.setDiscountType(DiscountType.FIXED_AMOUNT);
+ discount.setDiscountValue(new BigDecimal("5"));
+ discount.setStatus(status);
+ discount.setVoided(voided);
+ bill.getDiscounts().add(discount);
+ }
+}
diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java
new file mode 100644
index 00000000..bda94a98
--- /dev/null
+++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/BillRefundRecordSerializerTest.java
@@ -0,0 +1,256 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.math.BigDecimal;
+import java.util.Collections;
+import java.util.Date;
+
+import org.junit.jupiter.api.Test;
+import org.openmrs.Patient;
+import org.openmrs.User;
+import org.openmrs.module.billing.api.model.Bill;
+import org.openmrs.module.billing.api.model.BillLineItem;
+import org.openmrs.module.billing.api.model.BillRefund;
+import org.openmrs.module.billing.api.model.BillableService;
+import org.openmrs.module.billing.api.model.RefundStatus;
+import org.openmrs.module.querystore.model.QueryDocument;
+import org.openmrs.module.stockmanagement.api.model.StockItem;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class BillRefundRecordSerializerTest {
+
+ private static final String REFUND_UUID = "refund-uuid-1";
+
+ private static final String BILL_UUID = "bill-uuid-1";
+
+ private static final String PATIENT_UUID = "patient-uuid-1";
+
+ private final BillRefundRecordSerializer serializer = new BillRefundRecordSerializer();
+
+ @Test
+ public void serialize_shouldSetCoreFieldsFromRefund() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "Patient error");
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertEquals(BillingQueryStoreConstants.RESOURCE_TYPE_BILL_REFUND, doc.getResourceType());
+ assertEquals(REFUND_UUID, doc.getResourceUuid());
+ assertEquals(PATIENT_UUID, doc.getPatientUuid());
+ assertNotNull(doc.getDate());
+ }
+
+ @Test
+ public void serialize_shouldSetMetadataFields() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.APPROVED, "Patient error");
+ refund.setDateApproved(new Date());
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertEquals(BILL_UUID, doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_UUID));
+ assertEquals(new BigDecimal("50.00"), doc.getMetadata().get(BillingQueryStoreConstants.FIELD_REFUND_AMOUNT));
+ assertEquals("APPROVED", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_STATUS));
+ assertEquals("Patient error", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_REASON));
+ assertNotNull(doc.getMetadata().get(BillingQueryStoreConstants.FIELD_DATE_APPROVED));
+ }
+
+ @Test
+ public void serialize_shouldIncludeLineItemUuidWhenLineScoped() {
+ BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.REQUESTED, "Line correction");
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setUuid("line-item-uuid-1");
+ refund.setLineItem(lineItem);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertEquals("line-item-uuid-1", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID));
+ }
+
+ @Test
+ public void serialize_shouldIncludeInitiatorApproverCompleterWhenPresent() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.COMPLETED, "Patient error");
+ User initiator = userWithUuid("initiator-uuid");
+ User approver = userWithUuid("approver-uuid");
+ User completer = userWithUuid("completer-uuid");
+ refund.setInitiator(initiator);
+ refund.setApprover(approver);
+ refund.setCompleter(completer);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertEquals("initiator-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_INITIATOR_UUID));
+ assertEquals("approver-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_APPROVER_UUID));
+ assertEquals("completer-uuid", doc.getMetadata().get(BillingQueryStoreConstants.FIELD_COMPLETER_UUID));
+ }
+
+ @Test
+ public void serialize_shouldIncludeLineItemBillableServiceNameInTextAndMetadata() {
+ // Mirrors the bill serializer: a singleton list under the shared FIELD_LINE_ITEM_NAMES key
+ // so downstream consumers can read the field uniformly across resource types.
+ BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.REQUESTED, "Line correction");
+ BillLineItem lineItem = newLineItemWithBillableService("Consultation");
+ refund.setLineItem(lineItem);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertEquals(Collections.singletonList("Consultation"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ assertTrue(doc.getText().contains("Item: Consultation."),
+ "refund text must surface the refunded line item name: " + doc.getText());
+ }
+
+ @Test
+ public void serialize_shouldFallBackToStockItemCommonNameForRefundLineItem() {
+ BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.REQUESTED, "any");
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setVoided(false);
+ StockItem item = new StockItem();
+ item.setCommonName("Paracetamol 500mg");
+ lineItem.setItem(item);
+ refund.setLineItem(lineItem);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertEquals(Collections.singletonList("Paracetamol 500mg"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ }
+
+ @Test
+ public void serialize_shouldOmitLineItemNamesMetadataWhenLineItemAbsent() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any");
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ assertFalse(doc.getText().contains("Item:"));
+ }
+
+ @Test
+ public void serialize_shouldOmitLineItemNamesMetadataWhenLineItemHasNeitherServiceNorStockItem() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any");
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setUuid("line-item-uuid-x");
+ refund.setLineItem(lineItem);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ }
+
+ @Test
+ public void serialize_shouldStillIncludeRefundLineItemNameWhenLineItemIsVoided() {
+ // Refunds are an audit record of past activity. The parent bill's indexed names omit voided
+ // line items, but the refund's own indexed name preserves them so the audit trail reads
+ // coherently — querying for refunds of "ServiceA" must still hit the refund even if the
+ // underlying line item has since been voided.
+ BillRefund refund = newRefund(new BigDecimal("25.00"), RefundStatus.COMPLETED, "any");
+ BillLineItem lineItem = newLineItemWithBillableService("OnceBilledService");
+ lineItem.setVoided(true);
+ refund.setLineItem(lineItem);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertEquals(Collections.singletonList("OnceBilledService"),
+ doc.getMetadata().get(BillingQueryStoreConstants.FIELD_LINE_ITEM_NAMES));
+ }
+
+ @Test
+ public void serialize_shouldOmitOptionalReferencesWhenAbsent() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "Patient error");
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNotNull(doc);
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_BILL_LINE_ITEM_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_INITIATOR_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_APPROVER_UUID));
+ assertFalse(doc.getMetadata().containsKey(BillingQueryStoreConstants.FIELD_COMPLETER_UUID));
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenRefundAmountAbsent() {
+ // Defensive null-guard: a partially constructed refund (validator gap, recovered transient)
+ // must be skipped, not crash. Without the guard, the advice's per-entity RuntimeException
+ // swallow would drop this refund from the index with only a warn log.
+ BillRefund refund = newRefund(null, RefundStatus.REQUESTED, "any");
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNull(doc);
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenBillAbsent() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any");
+ refund.setBill(null);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNull(doc);
+ }
+
+ @Test
+ public void serialize_shouldReturnNullWhenPatientAbsent() {
+ BillRefund refund = newRefund(new BigDecimal("50.00"), RefundStatus.REQUESTED, "any");
+ refund.getBill().setPatient(null);
+
+ QueryDocument doc = serializer.serialize(refund);
+
+ assertNull(doc);
+ }
+
+ private BillRefund newRefund(BigDecimal amount, RefundStatus status, String reason) {
+ Bill bill = new Bill();
+ bill.setUuid(BILL_UUID);
+ bill.setReceiptNumber("R-100");
+ Patient patient = new Patient();
+ patient.setUuid(PATIENT_UUID);
+ bill.setPatient(patient);
+
+ BillRefund refund = new BillRefund();
+ refund.setUuid(REFUND_UUID);
+ refund.setBill(bill);
+ refund.setRefundAmount(amount);
+ refund.setStatus(status);
+ refund.setReason(reason);
+ refund.setVoided(false);
+ refund.setDateCreated(new Date());
+ return refund;
+ }
+
+ private User userWithUuid(String uuid) {
+ User user = new User();
+ user.setUuid(uuid);
+ return user;
+ }
+
+ private static BillLineItem newLineItemWithBillableService(String name) {
+ BillLineItem lineItem = new BillLineItem();
+ lineItem.setVoided(false);
+ BillableService service = new BillableService();
+ service.setName(name);
+ lineItem.setBillableService(service);
+ return lineItem;
+ }
+}
diff --git a/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java
new file mode 100644
index 00000000..41054a65
--- /dev/null
+++ b/api/src/test/java/org/openmrs/module/billing/api/querystore/IndexingAdviceConfigTest.java
@@ -0,0 +1,85 @@
+/*
+ * This Source Code Form is subject to the terms of the Mozilla Public License,
+ * v. 2.0. If a copy of the MPL was not distributed with this file, You can
+ * obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
+ * the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
+ *
+ * Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
+ * graphic logo is a trademark of OpenMRS Inc.
+ */
+package org.openmrs.module.billing.api.querystore;
+
+import java.lang.reflect.Method;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+import org.openmrs.module.billing.api.BillDiscountService;
+import org.openmrs.module.billing.api.BillRefundService;
+import org.openmrs.module.billing.api.BillService;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+// AbstractIndexingAdvice matches advised invocations by method NAME against the configured
+// trigger/purge sets. A typo in one of those names produces no compile error, no startup error,
+// and no runtime exception — the advice simply never fires on that name, the document is never
+// indexed or deleted, and the failure surfaces only as "stale rows in the read store" weeks
+// later. These tests catch the typo class at unit-test time by reflecting against the target
+// service interface declared in omod/config.xml's .
+public class IndexingAdviceConfigTest {
+
+ @Test
+ public void billIndexingAdvice_triggerMethodsShouldAllExistOnBillService() {
+ assertAllMethodsExist(BillIndexingAdvice.TRIGGER_METHODS, BillService.class, "BillIndexingAdvice");
+ }
+
+ @Test
+ public void billIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() {
+ assertSubset(BillIndexingAdvice.PURGE_METHODS, BillIndexingAdvice.TRIGGER_METHODS, "BillIndexingAdvice");
+ }
+
+ @Test
+ public void billRefundIndexingAdvice_triggerMethodsShouldAllExistOnBillRefundService() {
+ assertAllMethodsExist(BillRefundIndexingAdvice.TRIGGER_METHODS, BillRefundService.class, "BillRefundIndexingAdvice");
+ }
+
+ @Test
+ public void billRefundIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() {
+ assertSubset(BillRefundIndexingAdvice.PURGE_METHODS, BillRefundIndexingAdvice.TRIGGER_METHODS,
+ "BillRefundIndexingAdvice");
+ }
+
+ @Test
+ public void billDiscountIndexingAdvice_triggerMethodsShouldAllExistOnBillDiscountService() {
+ assertAllMethodsExist(BillDiscountIndexingAdvice.TRIGGER_METHODS, BillDiscountService.class,
+ "BillDiscountIndexingAdvice");
+ }
+
+ @Test
+ public void billDiscountIndexingAdvice_purgeMethodsShouldBeSubsetOfTriggerMethods() {
+ assertSubset(BillDiscountIndexingAdvice.PURGE_METHODS, BillDiscountIndexingAdvice.TRIGGER_METHODS,
+ "BillDiscountIndexingAdvice");
+ }
+
+ private static void assertAllMethodsExist(Set methodNames, Class> iface, String adviceName) {
+ for (String name : methodNames) {
+ assertTrue(hasMethod(iface, name), adviceName + " references method '" + name + "' but no such method exists on "
+ + iface.getSimpleName() + " — AOP would silently never fire on this name");
+ }
+ }
+
+ private static void assertSubset(Set subset, Set superset, String adviceName) {
+ assertTrue(superset.containsAll(subset),
+ adviceName + ": PURGE_METHODS must be a subset of TRIGGER_METHODS per the AbstractIndexingAdvice "
+ + "contract — a name in purge but not in trigger never fires the advice, so the purge path "
+ + "is unreachable");
+ }
+
+ private static boolean hasMethod(Class> iface, String name) {
+ for (Method m : iface.getMethods()) {
+ if (m.getName().equals(name)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/fhir/pom.xml b/fhir/pom.xml
index 6d033a34..802ed376 100644
--- a/fhir/pom.xml
+++ b/fhir/pom.xml
@@ -44,6 +44,17 @@
billing-api
${project.parent.version}
+
+
+ org.openmrs.module
+ querystore-api
+ test
+
org.openmrs.module
fhir2-api
diff --git a/omod/src/main/resources/config.xml b/omod/src/main/resources/config.xml
index e40c0adb..5c80563c 100644
--- a/omod/src/main/resources/config.xml
+++ b/omod/src/main/resources/config.xml
@@ -40,8 +40,24 @@
org.openmrs.module.stockmanagement
org.openmrs.module.fhir2
org.openmrs.event
+ org.openmrs.module.querystore
+
+ org.openmrs.module.billing.api.BillService
+ org.openmrs.module.billing.api.querystore.BillIndexingAdvice
+
+
+
+ org.openmrs.module.billing.api.BillRefundService
+ org.openmrs.module.billing.api.querystore.BillRefundIndexingAdvice
+
+
+
+ org.openmrs.module.billing.api.BillDiscountService
+ org.openmrs.module.billing.api.querystore.BillDiscountIndexingAdvice
+
+
org.openmrs.module.billing.BillingModuleActivator
diff --git a/pom.xml b/pom.xml
index 8d0c85f4..e7ef88cb 100644
--- a/pom.xml
+++ b/pom.xml
@@ -50,12 +50,16 @@
2.7.8
- 8
- 8
+
+ 11
+ 11
8.0.2
1.4.0
2.4.0
4.0.0
+ 1.0.0-SNAPSHOT
1.18.38
1.17.6
@@ -154,6 +158,13 @@
provided
+
+ org.openmrs.module
+ querystore-api
+ ${querystoreVersion}
+ provided
+
+
org.reflections
reflections
@@ -391,6 +402,9 @@
openmrs-repo
OpenMRS Public
https://mavenrepo.openmrs.org/public
+
+ true
+