@openmrs/esm-billing-app 1.0.2-pre.1069 → 1.0.2-pre.1075

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/routes.json CHANGED
@@ -1 +1 @@
1
- {"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"webservices.rest":">=2.24.0","fhir2":">=1.2"},"pages":[{"component":"billableServicesHome","route":"billable-services"}],"extensions":[{"component":"billingDashboardLink","name":"billing-dashboard-link","slot":"homepage-dashboard-slot","meta":{"name":"billing","title":"billing","slot":"billing-dashboard-slot"}},{"component":"root","name":"billing-dashboard-root","slot":"billing-dashboard-slot"},{"name":"billing-patient-summary","component":"billingPatientSummary","slot":"patient-chart-billing-dashboard-slot","order":10,"meta":{"columnSpan":4}},{"name":"billing-summary-dashboard-link","component":"billingSummaryDashboardLink","slot":"patient-chart-dashboard-slot","order":11,"meta":{"columns":1,"columnSpan":1,"slot":"patient-chart-billing-dashboard-slot","path":"Billing history"}},{"name":"billable-services-app-menu-item","component":"billableServicesAppMenuItem","slot":"app-menu-item-slot","meta":{"name":"Billable Services"}},{"name":"billing-checkin-form","slot":"extra-visit-attribute-slot","component":"billingCheckInForm"},{"slot":"system-admin-page-card-link-slot","component":"billableServicesCardLink","name":"billable-services-admin-card-link"},{"name":"patient-banner-billing-tags","component":"visitAttributeTags","slot":"patient-banner-tags-slot","order":2},{"name":"billable-services-left-panel-link","component":"billableServicesLeftPanelLink","slot":"billable-services-left-panel-slot","order":0},{"name":"bill-waiver-left-panel-link","component":"billWaiverLeftPanelLink","slot":"billable-services-left-panel-slot","order":1},{"name":"billing-settings-left-panel-menu","component":"billingSettingsLeftPanelMenu","slot":"billable-services-left-panel-slot","order":2}],"modals":[{"name":"add-cash-point-modal","component":"addCashPointModal"},{"name":"payment-mode-form-modal","component":"paymentModeFormModal"},{"name":"delete-payment-mode-modal","component":"deletePaymentModeModal"},{"name":"edit-bill-item-modal","component":"editBillLineItemModal"},{"name":"edit-bill-line-item-modal","component":"editBillLineItemModal"},{"name":"require-billing-modal","component":"requirePaymentModal"}],"workspaces":[{"name":"billing-form-workspace","title":"billingForm","component":"billingFormWorkspace","type":"form"},{"name":"billable-service-form","title":"billableServiceForm","component":"billableServiceFormWorkspace","type":"form","width":"wider"}],"version":"1.0.2-pre.1069"}
1
+ {"$schema":"https://json.openmrs.org/routes.schema.json","backendDependencies":{"webservices.rest":">=2.24.0","fhir2":">=1.2"},"pages":[{"component":"billableServicesHome","route":"billable-services"}],"extensions":[{"component":"billingDashboardLink","name":"billing-dashboard-link","slot":"homepage-dashboard-slot","meta":{"name":"billing","title":"billing","slot":"billing-dashboard-slot"}},{"component":"root","name":"billing-dashboard-root","slot":"billing-dashboard-slot"},{"name":"billing-patient-summary","component":"billingPatientSummary","slot":"patient-chart-billing-dashboard-slot","order":10,"meta":{"columnSpan":4}},{"name":"billing-summary-dashboard-link","component":"billingSummaryDashboardLink","slot":"patient-chart-dashboard-slot","order":11,"meta":{"columns":1,"columnSpan":1,"slot":"patient-chart-billing-dashboard-slot","path":"Billing history"}},{"name":"billable-services-app-menu-item","component":"billableServicesAppMenuItem","slot":"app-menu-item-slot","meta":{"name":"Billable Services"}},{"name":"billing-checkin-form","slot":"extra-visit-attribute-slot","component":"billingCheckInForm"},{"slot":"system-admin-page-card-link-slot","component":"billableServicesCardLink","name":"billable-services-admin-card-link"},{"name":"patient-banner-billing-tags","component":"visitAttributeTags","slot":"patient-banner-tags-slot","order":2},{"name":"billable-services-left-panel-link","component":"billableServicesLeftPanelLink","slot":"billable-services-left-panel-slot","order":0},{"name":"bill-waiver-left-panel-link","component":"billWaiverLeftPanelLink","slot":"billable-services-left-panel-slot","order":1},{"name":"billing-settings-left-panel-menu","component":"billingSettingsLeftPanelMenu","slot":"billable-services-left-panel-slot","order":2}],"modals":[{"name":"add-cash-point-modal","component":"addCashPointModal"},{"name":"payment-mode-form-modal","component":"paymentModeFormModal"},{"name":"delete-payment-mode-modal","component":"deletePaymentModeModal"},{"name":"edit-bill-item-modal","component":"editBillLineItemModal"},{"name":"edit-bill-line-item-modal","component":"editBillLineItemModal"},{"name":"require-billing-modal","component":"requirePaymentModal"}],"workspaces":[{"name":"billing-form-workspace","title":"billingForm","component":"billingFormWorkspace","type":"form"},{"name":"billable-service-form","title":"billableServiceForm","component":"billableServiceFormWorkspace","type":"form","width":"wider"}],"version":"1.0.2-pre.1075"}
@@ -0,0 +1,84 @@
1
+ import { type Page, expect } from '@playwright/test';
2
+
3
+ export class BillingDashboardPage {
4
+ constructor(readonly page: Page) {}
5
+
6
+ readonly billsTable = () => this.page.getByRole('table').first();
7
+ readonly filterDropdown = () => this.page.getByRole('combobox', { name: /filter by/i });
8
+ readonly searchInput = () => this.page.getByPlaceholder(/filter table/i);
9
+ readonly billListHeading = () => this.page.getByRole('heading', { name: /bill list/i });
10
+
11
+ async goto() {
12
+ await this.page.goto('home/billing');
13
+ }
14
+
15
+ async waitForBillsTableToLoad() {
16
+ await this.billsTable().waitFor({ state: 'visible' });
17
+ try {
18
+ await this.page.waitForResponse(
19
+ (response) => response.url().includes('billing/bill') && response.status() === 200,
20
+ { timeout: 10000 },
21
+ );
22
+ } catch {
23
+ // If bills already loaded or request times out, continue
24
+ }
25
+ }
26
+
27
+ async selectFilter(filterText: 'All bills' | 'Pending bills' | 'Paid bills') {
28
+ await this.filterDropdown().click();
29
+ await this.page.getByRole('option', { name: filterText }).click();
30
+ }
31
+
32
+ async searchBills(searchTerm: string) {
33
+ await this.searchInput().fill(searchTerm);
34
+ }
35
+
36
+ async getBillsCount() {
37
+ const rows = await this.billsTable().locator('tbody tr').all();
38
+ return rows.length;
39
+ }
40
+
41
+ async getBillRowByPatientName(patientName: string) {
42
+ return this.billsTable().locator('tbody tr').filter({ hasText: patientName });
43
+ }
44
+
45
+ async clickPatientNameLink(patientName: string) {
46
+ const patientLink = this.billsTable().getByRole('link', { name: new RegExp(patientName, 'i') });
47
+ await patientLink.click();
48
+ }
49
+
50
+ async verifyBillInTable(patientName: string, shouldBeVisible: boolean = true) {
51
+ const billRow = await this.getBillRowByPatientName(patientName);
52
+ if (shouldBeVisible) {
53
+ await expect(billRow).toBeVisible();
54
+ } else {
55
+ await expect(billRow).toBeHidden();
56
+ }
57
+ }
58
+
59
+ async getBillsInTable() {
60
+ const rows = await this.billsTable().locator('tbody tr').all();
61
+ const bills = [];
62
+
63
+ const headers = await this.billsTable().locator('thead th').allTextContents();
64
+ const visitTimeIndex = headers.findIndex((h) => h.includes('Visit time') || h.includes('visitTime'));
65
+ const identifierIndex = headers.findIndex((h) => h.includes('Identifier') || h.includes('identifier'));
66
+ const nameIndex = headers.findIndex((h) => h.includes('Name') || h.includes('name'));
67
+ const billedItemsIndex = headers.findIndex((h) => h.includes('Billed Items') || h.includes('billedItems'));
68
+
69
+ for (const row of rows) {
70
+ const cells = await row.locator('td').allTextContents();
71
+ bills.push({
72
+ visitTime: cells[visitTimeIndex],
73
+ identifier: cells[identifierIndex],
74
+ patientName: cells[nameIndex],
75
+ billedItems: cells[billedItemsIndex],
76
+ });
77
+ }
78
+ return bills;
79
+ }
80
+
81
+ async verifyNoMatchingBillsMessage() {
82
+ await expect(this.page.getByText(/no matching bills to display/i)).toBeVisible();
83
+ }
84
+ }
@@ -1,4 +1,5 @@
1
- import { type Page } from '@playwright/test';
1
+ import { expect, type Page } from '@playwright/test';
2
+ import { extractNumericValue } from '../commands';
2
3
 
3
4
  export class BillingFormPage {
4
5
  constructor(readonly page: Page) {}
@@ -14,7 +15,6 @@ export class BillingFormPage {
14
15
 
15
16
  async openBillingForm(patientUuid: string) {
16
17
  await this.page.goto(`patient/${patientUuid}/chart`);
17
- // The billing form opens as a workspace
18
18
  await this.page.getByRole('button', { name: /add bill/i }).click();
19
19
  }
20
20
 
@@ -24,6 +24,34 @@ export class BillingFormPage {
24
24
  await this.page.getByRole('option', { name: new RegExp(serviceName, 'i') }).click();
25
25
  }
26
26
 
27
+ async clearBillableServiceCombobox() {
28
+ const combobox = this.billableServicesCombobox();
29
+ // Carbon ComboBox has a clear button (X icon) - try to find it
30
+ // The button is typically inside the combobox container
31
+ const comboboxContainer = combobox.locator('..');
32
+ const clearButton = comboboxContainer
33
+ .getByRole('button', { name: /clear|close/i })
34
+ .or(comboboxContainer.locator('button[aria-label*="clear" i]'));
35
+
36
+ const isClearButtonVisible = await clearButton.isVisible().catch(() => false);
37
+
38
+ if (isClearButtonVisible) {
39
+ await clearButton.click();
40
+ await expect
41
+ .poll(async () => {
42
+ const value = await combobox.inputValue();
43
+ return value === '';
44
+ })
45
+ .toBe(true);
46
+ } else {
47
+ // Fallback: clear the input field directly by selecting all and deleting
48
+ await combobox.click();
49
+ await combobox.selectText();
50
+ await combobox.press('Backspace');
51
+ await combobox.press('Escape');
52
+ }
53
+ }
54
+
27
55
  async updateQuantity(itemUuid: string, quantity: number) {
28
56
  const input = this.quantityInput(itemUuid);
29
57
  await input.clear();
@@ -66,13 +94,21 @@ export class BillingFormPage {
66
94
  }
67
95
 
68
96
  async selectPaymentMethodIfVisible(paymentMethodName: string = 'Cash') {
69
- // Find any visible payment method dropdown by its placeholder text
70
97
  const dropdown = this.page.getByPlaceholder('Select payment method').first();
71
98
  const isVisible = await dropdown.isVisible().catch(() => false);
72
99
 
73
100
  if (isVisible) {
74
101
  await dropdown.click();
75
- await this.page.getByRole('option', { name: new RegExp(paymentMethodName, 'i') }).click();
102
+ await this.page
103
+ .getByRole('option', { name: new RegExp(paymentMethodName, 'i') })
104
+ .first()
105
+ .click();
76
106
  }
77
107
  }
108
+
109
+ async verifyGrandTotal(expectedTotal: number) {
110
+ const grandTotal = await this.getGrandTotal();
111
+ const grandTotalValue = extractNumericValue(grandTotal || '');
112
+ expect(grandTotalValue).toBeCloseTo(expectedTotal, 2);
113
+ }
78
114
  }
@@ -1,3 +1,4 @@
1
+ export * from './billing-dashboard-page';
1
2
  export * from './billing-form-page';
2
3
  export * from './invoice-page';
3
4
  export * from './payment-page';
@@ -8,6 +8,7 @@ export class InvoicePage {
8
8
  readonly amountTenderedLabel = () => this.page.getByText(/amount tendered/i);
9
9
  readonly amountDueLabel = () => this.page.getByText(/amount due/i);
10
10
  readonly invoiceStatusLabel = () => this.page.getByText(/invoice status/i);
11
+ readonly dateAndTimeLabel = () => this.page.getByText(/date and time/i);
11
12
  readonly printBillButton = () => this.page.getByRole('button', { name: /print bill/i });
12
13
  readonly printReceiptButton = () => this.page.getByRole('button', { name: /print receipt/i });
13
14
  readonly invoiceTable = () => this.page.getByRole('table').first();
@@ -64,16 +65,23 @@ export class InvoicePage {
64
65
  return value?.trim();
65
66
  }
66
67
 
68
+ async getDateAndTime() {
69
+ const parent = this.dateAndTimeLabel().locator('..');
70
+ const value = await parent.locator('[class*="value"]').textContent();
71
+ return value?.trim();
72
+ }
73
+
67
74
  async getLineItems() {
68
75
  const rows = await this.invoiceTable().locator('tbody tr').all();
69
76
  const items = [];
70
77
 
71
- // Get header indices dynamically
72
78
  const headers = await this.invoiceTable().locator('thead th').allTextContents();
73
79
  const itemIndex = headers.findIndex((h) => h.includes('Bill item'));
74
80
  const quantityIndex = headers.findIndex((h) => h.includes('Quantity'));
75
81
  const priceIndex = headers.findIndex((h) => h.includes('Price'));
76
82
  const totalIndex = headers.findIndex((h) => h.includes('Total'));
83
+ // Look for "Status" column (case-insensitive, handles "Status" or "Service status")
84
+ const statusIndex = headers.findIndex((h) => h.toLowerCase().includes('status'));
77
85
 
78
86
  for (const row of rows) {
79
87
  const cells = await row.locator('td').allTextContents();
@@ -82,6 +90,7 @@ export class InvoicePage {
82
90
  quantity: cells[quantityIndex],
83
91
  price: cells[priceIndex],
84
92
  total: cells[totalIndex],
93
+ status: statusIndex >= 0 && cells[statusIndex] ? cells[statusIndex].trim() : undefined,
85
94
  });
86
95
  }
87
96
  return items;
@@ -1,4 +1,4 @@
1
- import { type Page } from '@playwright/test';
1
+ import { expect, type Page } from '@playwright/test';
2
2
 
3
3
  export class PaymentPage {
4
4
  constructor(readonly page: Page) {}
@@ -10,6 +10,7 @@ export class PaymentPage {
10
10
  readonly processPaymentButton = () => this.page.getByRole('button', { name: /process payment/i });
11
11
  readonly paymentHistorySection = () =>
12
12
  this.page.getByRole('table').filter({ has: this.page.getByText('Date of payment') });
13
+ readonly addPaymentMethodButton = () => this.page.getByRole('button', { name: /add payment method/i });
13
14
  readonly removePaymentButton = () => this.page.getByRole('button', { name: /remove/i });
14
15
 
15
16
  async waitForPaymentForm() {
@@ -20,23 +21,46 @@ export class PaymentPage {
20
21
  }
21
22
 
22
23
  async addPayment(paymentMethod: string, amount: number, referenceCode?: string) {
23
- // waitForPaymentForm() should be called before this to ensure the form is ready
24
- // Select payment method
25
24
  await this.paymentMethodCombobox().click();
26
25
  await this.page.getByRole('option', { name: new RegExp(paymentMethod, 'i') }).click();
27
26
 
28
- // Enter amount
29
27
  await this.amountInput().fill(amount.toString());
30
28
 
31
- // Enter reference code if provided
32
29
  if (referenceCode) {
33
30
  await this.referenceCodeInput().fill(referenceCode);
34
31
  }
35
32
  }
36
33
 
37
34
  async addMultiplePayments(payments: Array<{ method: string; amount: number; referenceCode?: string }>) {
38
- for (const payment of payments) {
39
- await this.addPayment(payment.method, payment.amount, payment.referenceCode);
35
+ const addButton = this.addPaymentMethodButton();
36
+ const isAddButtonVisible = await addButton.isVisible().catch(() => false);
37
+ const existingRows = await this.page.locator('[class*="paymentMethodContainer"]').count();
38
+
39
+ if (isAddButtonVisible && payments.length > existingRows) {
40
+ const rowsToAdd = payments.length - existingRows;
41
+ for (let i = 0; i < rowsToAdd; i++) {
42
+ await addButton.click();
43
+ const expectedRowCount = existingRows + i + 1;
44
+ await expect(this.page.locator('[class*="paymentMethodContainer"]')).toHaveCount(expectedRowCount);
45
+ }
46
+ }
47
+
48
+ const paymentRows = await this.page.locator('[class*="paymentMethodContainer"]').all();
49
+
50
+ for (let i = 0; i < payments.length && i < paymentRows.length; i++) {
51
+ const row = paymentRows[i];
52
+
53
+ const methodDropdown = row.getByRole('combobox', { name: /payment method/i });
54
+ await methodDropdown.click();
55
+ await this.page.getByRole('option', { name: new RegExp(payments[i].method, 'i') }).click();
56
+
57
+ const amountInput = row.getByLabel(/amount/i);
58
+ await amountInput.fill(payments[i].amount.toString());
59
+
60
+ if (payments[i].referenceCode) {
61
+ const refInput = row.getByLabel(/reference number/i);
62
+ await refInput.fill(payments[i].referenceCode);
63
+ }
40
64
  }
41
65
  }
42
66
 
@@ -44,6 +68,10 @@ export class PaymentPage {
44
68
  await this.processPaymentButton().click();
45
69
  }
46
70
 
71
+ async isProcessPaymentButtonEnabled() {
72
+ return await this.processPaymentButton().isEnabled();
73
+ }
74
+
47
75
  async removePayment(index = 0) {
48
76
  const removeButtons = await this.removePaymentButton().all();
49
77
  await removeButtons[index].click();
@@ -0,0 +1,203 @@
1
+ import { expect } from '@playwright/test';
2
+ import { test } from '../core/test';
3
+ import { deleteBill, extractNumericValue, ensureServiceHasPrices, waitForSuccessNotification } from '../commands';
4
+ import { BillingDashboardPage, BillingFormPage, InvoicePage, PaymentPage } from '../pages';
5
+
6
+ test.describe('Billing Dashboard workflow', () => {
7
+ // Run tests serially to avoid race conditions when setting up service prices
8
+ test.describe.configure({ mode: 'serial' });
9
+
10
+ let testServiceName: string;
11
+ let expectedServicePrice: number;
12
+ const billsToCleanup = new Set<string>();
13
+
14
+ test.beforeAll(async ({ api }) => {
15
+ const serviceUuid = process.env.E2E_TEST_SERVICE_UUID;
16
+ if (!serviceUuid) {
17
+ throw new Error('E2E_TEST_SERVICE_UUID must be configured in .env file');
18
+ }
19
+
20
+ // Ensure the test service has prices configured (required for billing tests)
21
+ // If prices already exist, this will skip; otherwise it adds a default Cash price of 30.00
22
+ const service = await ensureServiceHasPrices(api, serviceUuid, 30.0);
23
+
24
+ testServiceName = service.name;
25
+
26
+ const cashPrice = service.servicePrices.find((sp) => sp.name === 'Cash');
27
+ if (!cashPrice) {
28
+ throw new Error('Cash price not found for test service');
29
+ }
30
+ expectedServicePrice = parseFloat(cashPrice.price);
31
+ });
32
+
33
+ test.afterEach(async ({ api }) => {
34
+ // Cleanup: delete all bills created during tests
35
+ for (const billUuid of billsToCleanup) {
36
+ try {
37
+ await deleteBill(api, billUuid);
38
+ } catch (error) {
39
+ // Log but don't fail test if cleanup fails
40
+ console.error(`Failed to delete bill ${billUuid}:`, error);
41
+ }
42
+ }
43
+ billsToCleanup.clear();
44
+ });
45
+
46
+ test('Process payment from dashboard and verify bill moves from pending to paid bills', async ({
47
+ page,
48
+ api,
49
+ patient,
50
+ }) => {
51
+ const billingDashboardPage = new BillingDashboardPage(page);
52
+ const billingFormPage = new BillingFormPage(page);
53
+ const invoicePage = new InvoicePage(page);
54
+ const paymentPage = new PaymentPage(page);
55
+ const patientUuid = patient.uuid;
56
+ const patientName = patient.person.display;
57
+ let billUuid: string;
58
+
59
+ await test.step('Given I have created and saved a bill', async () => {
60
+ await page.goto(`patient/${patientUuid}/chart/Billing history`);
61
+
62
+ const createBillButton = page.getByRole('button', { name: /launch bill form|add bill/i });
63
+ await createBillButton.click();
64
+
65
+ await billingFormPage.searchAndSelectBillableService(testServiceName);
66
+ await billingFormPage.selectPaymentMethodIfVisible();
67
+ await billingFormPage.saveBill();
68
+ await waitForSuccessNotification(page, 'Bill processed successfully');
69
+
70
+ const billsResponse = await api.get(`billing/bill?patient=${patientUuid}&v=full`);
71
+ const billsData = await billsResponse.json();
72
+ billUuid = billsData.results[0].uuid;
73
+ billsToCleanup.add(billUuid);
74
+ });
75
+
76
+ await test.step('When I navigate to the Billing dashboard', async () => {
77
+ await billingDashboardPage.goto();
78
+ await billingDashboardPage.waitForBillsTableToLoad();
79
+ });
80
+
81
+ await test.step('Then the Bill List should display pending bills', async () => {
82
+ await expect(billingDashboardPage.filterDropdown()).toBeVisible();
83
+
84
+ await billingDashboardPage.verifyBillInTable(patientName, true);
85
+ });
86
+
87
+ await test.step('When I click on a patient name in the Bill List', async () => {
88
+ await billingDashboardPage.clickPatientNameLink(patientName);
89
+ await invoicePage.waitForInvoiceToLoad();
90
+ });
91
+
92
+ await test.step("Then the patient's bill details page should open", async () => {
93
+ const totalAmount = await invoicePage.getTotalAmount();
94
+ expect(totalAmount).toBeTruthy();
95
+ const totalValue = extractNumericValue(totalAmount);
96
+ expect(totalValue).toBeCloseTo(expectedServicePrice, 2);
97
+
98
+ const amountTendered = await invoicePage.getAmountTendered();
99
+ expect(amountTendered).toBeTruthy();
100
+ const tenderedValue = extractNumericValue(amountTendered);
101
+ expect(tenderedValue).toBe(0);
102
+
103
+ const receiptNumber = await invoicePage.getInvoiceNumber();
104
+ expect(receiptNumber).toBeTruthy();
105
+ await expect(invoicePage.invoiceNumberLabel()).toBeVisible();
106
+
107
+ const dateAndTime = await invoicePage.getDateAndTime();
108
+ expect(dateAndTime).toBeTruthy();
109
+
110
+ const invoiceStatus = await invoicePage.getInvoiceStatus();
111
+ expect(invoiceStatus).toBe('PENDING');
112
+ });
113
+
114
+ await test.step('And the Payments section should be displayed', async () => {
115
+ await paymentPage.waitForPaymentForm();
116
+
117
+ await expect(paymentPage.paymentMethodCombobox()).toBeVisible();
118
+ await expect(paymentPage.amountInput()).toBeVisible();
119
+ await expect(paymentPage.referenceCodeInput()).toBeVisible();
120
+ });
121
+
122
+ await test.step('When I select a payment method and enter an amount matching the bill total', async () => {
123
+ const amountDue = await invoicePage.getAmountDue();
124
+ const amountDueValue = extractNumericValue(amountDue);
125
+
126
+ await paymentPage.addPayment('Cash', amountDueValue);
127
+ await expect(paymentPage.amountInput()).toHaveValue(amountDueValue.toString());
128
+
129
+ const isEnabled = await paymentPage.isProcessPaymentButtonEnabled();
130
+ expect(isEnabled).toBe(true);
131
+ });
132
+
133
+ await test.step('When I click "Process Payment"', async () => {
134
+ await paymentPage.processPayment();
135
+ await waitForSuccessNotification(page, 'Payment processed successfully');
136
+ });
137
+
138
+ await test.step('Then the invoice status should update to PAID', async () => {
139
+ await page.reload();
140
+ await invoicePage.waitForInvoiceToLoad();
141
+
142
+ const updatedStatus = await invoicePage.getInvoiceStatus();
143
+ expect(updatedStatus).toBe('PAID');
144
+
145
+ const billResponse = await api.get(`billing/bill/${billUuid}`);
146
+ const billData = await billResponse.json();
147
+ expect(billData.status).toBe('PAID');
148
+ });
149
+
150
+ await test.step('And the Amount Tendered should update to reflect the payment', async () => {
151
+ const totalAmount = await invoicePage.getTotalAmount();
152
+ const updatedAmountTendered = await invoicePage.getAmountTendered();
153
+ expect(updatedAmountTendered).toEqual(totalAmount);
154
+
155
+ const updatedAmountDue = await invoicePage.getAmountDue();
156
+ expect(extractNumericValue(updatedAmountDue)).toBe(0);
157
+ });
158
+
159
+ await test.step('And the Service status in the Line items table should change to PAID', async () => {
160
+ const lineItems = await invoicePage.getLineItems();
161
+ expect(lineItems.length).toBeGreaterThan(0);
162
+ lineItems.forEach((lineItem) => {
163
+ expect(lineItem.status).toBe('PAID');
164
+ });
165
+
166
+ // Also verify backend line items have paymentStatus set to PAID
167
+ const billResponse = await api.get(`billing/bill/${billUuid}`);
168
+ const billData = await billResponse.json();
169
+ billData.lineItems.forEach((lineItem: { paymentStatus: string }) => {
170
+ expect(lineItem.paymentStatus).toBe('PAID');
171
+ });
172
+ });
173
+
174
+ await test.step('And the Payments section should display a payment record', async () => {
175
+ const paymentHistory = await paymentPage.getPaymentHistory();
176
+ expect(paymentHistory.length).toBeGreaterThan(0);
177
+
178
+ const payment = paymentHistory[0];
179
+ expect(payment.date).toBeTruthy();
180
+ expect(payment.billAmount).toBeTruthy();
181
+ expect(payment.amountTendered).toBeTruthy();
182
+ expect(payment.method).toContain('Cash');
183
+ });
184
+
185
+ await test.step('When I navigate back to the Billing dashboard landing page', async () => {
186
+ await billingDashboardPage.goto();
187
+ await billingDashboardPage.waitForBillsTableToLoad();
188
+ });
189
+
190
+ await test.step('Then the processed bill should no longer appear in the pending bills list', async () => {
191
+ await expect(billingDashboardPage.filterDropdown()).toBeVisible();
192
+
193
+ await billingDashboardPage.verifyBillInTable(patientName, false);
194
+ });
195
+
196
+ await test.step('And the bill should appear under Paid bills', async () => {
197
+ await billingDashboardPage.selectFilter('Paid bills');
198
+ await billingDashboardPage.waitForBillsTableToLoad();
199
+
200
+ await billingDashboardPage.verifyBillInTable(patientName, true);
201
+ });
202
+ });
203
+ });
@@ -3,16 +3,15 @@ import { test } from '../core/test';
3
3
  import { deleteBill, extractNumericValue, ensureServiceHasPrices, waitForSuccessNotification } from '../commands';
4
4
  import { BillingFormPage, InvoicePage, PaymentPage } from '../pages';
5
5
 
6
- test.describe('Billing and payment operations', () => {
6
+ test.describe('Billing: Patient Chart workflow', () => {
7
7
  // Run tests serially to avoid race conditions when setting up service prices
8
8
  test.describe.configure({ mode: 'serial' });
9
9
 
10
- let billUuid: string;
11
10
  let testServiceName: string;
12
11
  let expectedServicePrice: number;
12
+ const billsToCleanup = new Set<string>();
13
13
 
14
14
  test.beforeAll(async ({ api }) => {
15
- // Get the configured test service details
16
15
  const serviceUuid = process.env.E2E_TEST_SERVICE_UUID;
17
16
  if (!serviceUuid) {
18
17
  throw new Error('E2E_TEST_SERVICE_UUID must be configured in .env file');
@@ -24,7 +23,6 @@ test.describe('Billing and payment operations', () => {
24
23
 
25
24
  testServiceName = service.name;
26
25
 
27
- // Extract the Cash price for use in assertions
28
26
  const cashPrice = service.servicePrices.find((sp) => sp.name === 'Cash');
29
27
  if (!cashPrice) {
30
28
  throw new Error('Cash price not found for test service');
@@ -33,9 +31,16 @@ test.describe('Billing and payment operations', () => {
33
31
  });
34
32
 
35
33
  test.afterEach(async ({ api }) => {
36
- if (billUuid) {
37
- await deleteBill(api, billUuid);
34
+ // Cleanup: delete all bills created during tests
35
+ for (const billUuid of billsToCleanup) {
36
+ try {
37
+ await deleteBill(api, billUuid);
38
+ } catch (error) {
39
+ // Log but don't fail test if cleanup fails
40
+ console.error(`Failed to delete bill ${billUuid}:`, error);
41
+ }
38
42
  }
43
+ billsToCleanup.clear();
39
44
  });
40
45
 
41
46
  test('Create bill, verify receipt number, and process full payment', async ({ page, api, patient }) => {
@@ -43,6 +48,7 @@ test.describe('Billing and payment operations', () => {
43
48
  const invoicePage = new InvoicePage(page);
44
49
  const paymentPage = new PaymentPage(page);
45
50
  const patientUuid = patient.uuid;
51
+ let billUuid: string;
46
52
 
47
53
  await test.step('When I navigate to the Billing history page', async () => {
48
54
  await page.goto(`patient/${patientUuid}/chart/Billing history`);
@@ -81,6 +87,7 @@ test.describe('Billing and payment operations', () => {
81
87
 
82
88
  const bill = billsData.results[0];
83
89
  billUuid = bill.uuid;
90
+ billsToCleanup.add(billUuid);
84
91
  });
85
92
 
86
93
  await test.step('When I navigate to the invoice page', async () => {
@@ -89,21 +96,17 @@ test.describe('Billing and payment operations', () => {
89
96
  });
90
97
 
91
98
  await test.step('Then I should see the invoice details with correct initial state', async () => {
92
- // Verify status is PENDING
93
99
  const invoiceStatus = await invoicePage.getInvoiceStatus();
94
100
  expect(invoiceStatus).toBe('PENDING');
95
101
 
96
- // Verify amounts are correct for new bill
97
102
  const totalAmount = await invoicePage.getTotalAmount();
98
103
  const amountDue = await invoicePage.getAmountDue();
99
104
  expect(totalAmount).toBeTruthy();
100
105
  expect(amountDue).toEqual(totalAmount);
101
106
 
102
- // Verify bill total matches the expected service price
103
107
  const totalValue = extractNumericValue(totalAmount);
104
108
  expect(totalValue).toBeCloseTo(expectedServicePrice, 2);
105
109
 
106
- // Verify invoice number is displayed
107
110
  const receiptNumber = await invoicePage.getInvoiceNumber();
108
111
  expect(receiptNumber).toBeTruthy();
109
112
  await expect(invoicePage.invoiceNumberLabel()).toBeVisible();
@@ -123,11 +126,9 @@ test.describe('Billing and payment operations', () => {
123
126
  await page.reload();
124
127
  await invoicePage.waitForInvoiceToLoad();
125
128
 
126
- // Verify UI shows PAID status
127
129
  const updatedStatus = await invoicePage.getInvoiceStatus();
128
130
  expect(updatedStatus).toBe('PAID');
129
131
 
130
- // Verify backend also updated to PAID
131
132
  const billResponse = await api.get(`billing/bill/${billUuid}`);
132
133
  const billData = await billResponse.json();
133
134
  expect(billData.status).toBe('PAID');
@@ -151,6 +152,7 @@ test.describe('Billing and payment operations', () => {
151
152
  const billingFormPage = new BillingFormPage(page);
152
153
  const invoicePage = new InvoicePage(page);
153
154
  const patientUuid = patient.uuid;
155
+ let billUuid: string;
154
156
 
155
157
  await test.step('When I navigate to the patient billing history', async () => {
156
158
  await page.goto(`patient/${patientUuid}/chart/Billing history`);
@@ -166,7 +168,6 @@ test.describe('Billing and payment operations', () => {
166
168
  await expect(page.getByText(testServiceName, { exact: false })).toBeVisible();
167
169
  await billingFormPage.selectPaymentMethodIfVisible();
168
170
 
169
- // Update quantity to 2
170
171
  const quantityInput = page.locator('input[type="number"]').first();
171
172
  await expect(quantityInput).toHaveValue('1');
172
173
 
@@ -183,6 +184,7 @@ test.describe('Billing and payment operations', () => {
183
184
  const billsResponse = await api.get(`billing/bill?patient=${patientUuid}&v=full`);
184
185
  const billsData = await billsResponse.json();
185
186
  billUuid = billsData.results[0].uuid;
187
+ billsToCleanup.add(billUuid);
186
188
 
187
189
  await invoicePage.goto(patientUuid, billUuid);
188
190
  await invoicePage.waitForInvoiceToLoad();
@@ -191,7 +193,6 @@ test.describe('Billing and payment operations', () => {
191
193
  expect(lineItems.length).toBe(1);
192
194
  expect(lineItems[0].quantity).toBe('2');
193
195
 
194
- // Verify total amount is quantity * unit price
195
196
  const totalAmount = await invoicePage.getTotalAmount();
196
197
  const totalValue = extractNumericValue(totalAmount);
197
198
  expect(totalValue).toBeCloseTo(expectedServicePrice * 2, 2);
@@ -226,7 +227,6 @@ test.describe('Billing and payment operations', () => {
226
227
 
227
228
  await test.step('When I discard the bill', async () => {
228
229
  await billingFormPage.discardBill();
229
- // Wait for the form to close by checking the discard button is hidden
230
230
  await expect(billingFormPage.discardButton()).toBeHidden();
231
231
  });
232
232
 
@@ -234,7 +234,6 @@ test.describe('Billing and payment operations', () => {
234
234
  const billsResponse = await api.get(`billing/bill?patient=${patientUuid}&v=full`);
235
235
  expect(billsResponse.ok()).toBeTruthy();
236
236
  const billsData = await billsResponse.json();
237
- // Bill count should remain the same (no new bill created)
238
237
  expect(billsData.results.length).toBe(initialBillCount);
239
238
  });
240
239
  });
@@ -259,7 +258,6 @@ test.describe('Billing and payment operations', () => {
259
258
  });
260
259
 
261
260
  await test.step('Then I should see one line item', async () => {
262
- // Wait for the item card to be visible (indicates item is rendered)
263
261
  await expect(billingFormPage.selectedItemCards().first()).toBeVisible();
264
262
  const itemCount = await billingFormPage.getLineItemsCount();
265
263
  expect(itemCount).toBe(1);
@@ -269,7 +267,6 @@ test.describe('Billing and payment operations', () => {
269
267
  await test.step('When I remove the line item', async () => {
270
268
  await billingFormPage.removeItem(0);
271
269
 
272
- // Wait for the item count to decrease to 0
273
270
  await expect.poll(async () => await billingFormPage.getLineItemsCount()).toBe(0);
274
271
  });
275
272
 
@@ -288,6 +285,7 @@ test.describe('Billing and payment operations', () => {
288
285
  const invoicePage = new InvoicePage(page);
289
286
  const paymentPage = new PaymentPage(page);
290
287
  const patientUuid = patient.uuid;
288
+ let billUuid: string;
291
289
  let partialAmount: number;
292
290
 
293
291
  await test.step('Given I have created and saved a bill', async () => {
@@ -304,6 +302,7 @@ test.describe('Billing and payment operations', () => {
304
302
  const billsResponse = await api.get(`billing/bill?patient=${patientUuid}&v=full`);
305
303
  const billsData = await billsResponse.json();
306
304
  billUuid = billsData.results[0].uuid;
305
+ billsToCleanup.add(billUuid);
307
306
  });
308
307
 
309
308
  await test.step('When I navigate to the invoice page', async () => {
@@ -322,13 +321,11 @@ test.describe('Billing and payment operations', () => {
322
321
 
323
322
  await paymentPage.addPayment('Cash', partialAmount);
324
323
 
325
- // Verify amount entered correctly
326
324
  await expect(paymentPage.amountInput()).toHaveValue(partialAmount.toString());
327
325
 
328
326
  await paymentPage.processPayment();
329
327
  await waitForSuccessNotification(page, 'Payment processed successfully');
330
328
 
331
- // Verify payment was actually recorded
332
329
  await expect
333
330
  .poll(async () => {
334
331
  const history = await paymentPage.getPaymentHistory();
@@ -356,7 +353,6 @@ test.describe('Billing and payment operations', () => {
356
353
  const amountDue = await invoicePage.getAmountDue();
357
354
  const amountDueValue = extractNumericValue(amountDue);
358
355
 
359
- // Verify amount due = total - tendered
360
356
  expect(amountDueValue).toBeCloseTo(totalValue - partialAmount, 2);
361
357
  });
362
358
 
@@ -375,11 +371,9 @@ test.describe('Billing and payment operations', () => {
375
371
  await page.reload();
376
372
  await invoicePage.waitForInvoiceToLoad();
377
373
 
378
- // Verify UI shows PAID status
379
374
  const status = await invoicePage.getInvoiceStatus();
380
375
  expect(status).toBe('PAID');
381
376
 
382
- // Verify backend also updated to PAID
383
377
  const billResponse = await api.get(`billing/bill/${billUuid}`);
384
378
  const billData = await billResponse.json();
385
379
  expect(billData.status).toBe('PAID');
@@ -390,7 +384,6 @@ test.describe('Billing and payment operations', () => {
390
384
  const amountDueValue = extractNumericValue(amountDue);
391
385
  expect(amountDueValue).toBe(0);
392
386
 
393
- // Verify total amount equals tendered amount
394
387
  const totalAmount = await invoicePage.getTotalAmount();
395
388
  const totalValue = extractNumericValue(totalAmount);
396
389
  const tenderedAmount = await invoicePage.getAmountTendered();
@@ -398,4 +391,348 @@ test.describe('Billing and payment operations', () => {
398
391
  expect(tenderedValue).toBeCloseTo(totalValue, 2);
399
392
  });
400
393
  });
394
+
395
+ test('Create bill with increased service quantity and verify totals', async ({ page, api, patient }) => {
396
+ const billingFormPage = new BillingFormPage(page);
397
+ const invoicePage = new InvoicePage(page);
398
+ const paymentPage = new PaymentPage(page);
399
+ const patientUuid = patient.uuid;
400
+ let billUuid: string;
401
+
402
+ // Note: Selecting the same service multiple times increments quantity (doesn't create multiple line items)
403
+ // This test verifies quantity increment and total calculation
404
+ const quantity = 3;
405
+ const expectedTotal = expectedServicePrice * quantity;
406
+
407
+ await test.step('When I navigate to the Billing history page', async () => {
408
+ await page.goto(`patient/${patientUuid}/chart/Billing history`);
409
+ });
410
+
411
+ await test.step('And I launch the Create Bill form', async () => {
412
+ const createBillButton = page.getByRole('button', { name: /launch bill form|add bill/i });
413
+ await createBillButton.click();
414
+ });
415
+
416
+ await test.step('And I add the same service multiple times to increment quantity', async () => {
417
+ await billingFormPage.searchAndSelectBillableService(testServiceName);
418
+ await billingFormPage.selectPaymentMethodIfVisible();
419
+ await expect(page.getByText(testServiceName, { exact: false })).toBeVisible();
420
+
421
+ const quantityInput = page.locator('input[type="number"]').first();
422
+ await expect(quantityInput).toHaveValue('1');
423
+
424
+ for (let i = 1; i < quantity; i++) {
425
+ await billingFormPage.clearBillableServiceCombobox();
426
+ await billingFormPage.searchAndSelectBillableService(testServiceName);
427
+ await expect
428
+ .poll(
429
+ async () => {
430
+ const value = await quantityInput.inputValue();
431
+ return value;
432
+ },
433
+ { timeout: 5000 },
434
+ )
435
+ .toBe((i + 1).toString());
436
+ }
437
+
438
+ await expect(quantityInput).toHaveValue(quantity.toString());
439
+ });
440
+
441
+ await test.step('Then the grand total should equal price times quantity', async () => {
442
+ await billingFormPage.verifyGrandTotal(expectedTotal);
443
+ });
444
+
445
+ await test.step('When I save the bill', async () => {
446
+ await billingFormPage.saveBill();
447
+ await waitForSuccessNotification(page, 'Bill processed successfully');
448
+ });
449
+
450
+ await test.step('Then the bill should be created with correct line item', async () => {
451
+ const billsResponse = await api.get(`billing/bill?patient=${patientUuid}&v=full`);
452
+ expect(billsResponse.ok()).toBeTruthy();
453
+ const billsData = await billsResponse.json();
454
+ expect(billsData.results.length).toBeGreaterThan(0);
455
+
456
+ const bill = billsData.results[0];
457
+ billUuid = bill.uuid;
458
+ billsToCleanup.add(billUuid);
459
+
460
+ expect(bill.lineItems.length).toBe(1);
461
+ const lineItem = bill.lineItems[0];
462
+ expect(lineItem.billableService).toBeTruthy();
463
+ expect(lineItem.quantity).toBe(quantity);
464
+ expect(lineItem.price).toBeCloseTo(expectedServicePrice, 2);
465
+
466
+ const backendTotal = bill.lineItems.reduce(
467
+ (sum: number, item: { price: number; quantity: number }) => sum + item.price * item.quantity,
468
+ 0,
469
+ );
470
+ expect(backendTotal).toBeCloseTo(expectedTotal, 2);
471
+ });
472
+
473
+ await test.step('When I navigate to the invoice page', async () => {
474
+ await invoicePage.goto(patientUuid, billUuid);
475
+ await invoicePage.waitForInvoiceToLoad();
476
+ });
477
+
478
+ await test.step('Then the invoice should display the line item correctly', async () => {
479
+ const lineItems = await invoicePage.getLineItems();
480
+ expect(lineItems.length).toBe(1);
481
+ expect(lineItems[0].quantity).toBe(quantity.toString());
482
+ });
483
+
484
+ await test.step('And the total amount should match the calculated total', async () => {
485
+ const totalAmount = await invoicePage.getTotalAmount();
486
+ const totalValue = extractNumericValue(totalAmount);
487
+ expect(totalValue).toBeCloseTo(expectedTotal, 2);
488
+
489
+ const billResponse = await api.get(`billing/bill/${billUuid}?v=full`);
490
+ const billData = await billResponse.json();
491
+ const backendTotal = billData.lineItems.reduce(
492
+ (sum: number, item: { price: number; quantity: number }) => sum + item.price * item.quantity,
493
+ 0,
494
+ );
495
+ expect(backendTotal).toBeCloseTo(totalValue, 2);
496
+ });
497
+
498
+ await test.step('When I process payment for the full amount', async () => {
499
+ await paymentPage.waitForPaymentForm();
500
+ const amountDue = await invoicePage.getAmountDue();
501
+ const amountDueValue = extractNumericValue(amountDue);
502
+
503
+ await paymentPage.addPayment('Cash', amountDueValue);
504
+ await paymentPage.processPayment();
505
+ await waitForSuccessNotification(page, 'Payment processed successfully');
506
+ });
507
+
508
+ await test.step('Then the line item should be marked as PAID', async () => {
509
+ await page.reload();
510
+ await invoicePage.waitForInvoiceToLoad();
511
+
512
+ const lineItems = await invoicePage.getLineItems();
513
+ lineItems.forEach((lineItem) => {
514
+ expect(lineItem.status).toBe('PAID');
515
+ });
516
+
517
+ // Verify backend state
518
+ const billResponse = await api.get(`billing/bill/${billUuid}?v=full`);
519
+ const billData = await billResponse.json();
520
+ billData.lineItems.forEach((lineItem: { paymentStatus: string }) => {
521
+ expect(lineItem.paymentStatus).toBe('PAID');
522
+ });
523
+ });
524
+ });
525
+
526
+ test('Process payment with multiple payment methods', async ({ page, api, patient }) => {
527
+ const billingFormPage = new BillingFormPage(page);
528
+ const invoicePage = new InvoicePage(page);
529
+ const paymentPage = new PaymentPage(page);
530
+ const patientUuid = patient.uuid;
531
+ let billUuid: string;
532
+
533
+ await test.step('Given I have created a bill', async () => {
534
+ await page.goto(`patient/${patientUuid}/chart/Billing history`);
535
+
536
+ const createBillButton = page.getByRole('button', { name: /launch bill form|add bill/i });
537
+ await createBillButton.click();
538
+
539
+ await billingFormPage.searchAndSelectBillableService(testServiceName);
540
+ await billingFormPage.selectPaymentMethodIfVisible();
541
+ await billingFormPage.saveBill();
542
+ await waitForSuccessNotification(page, 'Bill processed successfully');
543
+
544
+ const billsResponse = await api.get(`billing/bill?patient=${patientUuid}&v=full`);
545
+ const billsData = await billsResponse.json();
546
+ billUuid = billsData.results[0].uuid;
547
+ billsToCleanup.add(billUuid);
548
+ });
549
+
550
+ await test.step('When I navigate to the invoice page', async () => {
551
+ await invoicePage.goto(patientUuid, billUuid);
552
+ await invoicePage.waitForInvoiceToLoad();
553
+ });
554
+
555
+ await test.step('And I add multiple payment methods', async () => {
556
+ await paymentPage.waitForPaymentForm();
557
+
558
+ const amountDue = await invoicePage.getAmountDue();
559
+ const amountDueValue = extractNumericValue(amountDue);
560
+
561
+ // Split payment: 60% Cash, 40% Cash (using same method for simplicity)
562
+ // In real scenario, would use different payment methods like Mobile Money
563
+ const cashAmount1 = Math.round(amountDueValue * 0.6 * 100) / 100;
564
+ const cashAmount2 = amountDueValue - cashAmount1;
565
+
566
+ await paymentPage.addMultiplePayments([
567
+ { method: 'Cash', amount: cashAmount1 },
568
+ { method: 'Cash', amount: cashAmount2 },
569
+ ]);
570
+ });
571
+
572
+ await test.step('When I process the payment', async () => {
573
+ await paymentPage.processPayment();
574
+ await waitForSuccessNotification(page, 'Payment processed successfully');
575
+ });
576
+
577
+ await test.step('Then the bill should be marked as PAID', async () => {
578
+ await page.reload();
579
+ await invoicePage.waitForInvoiceToLoad();
580
+
581
+ const status = await invoicePage.getInvoiceStatus();
582
+ expect(status).toBe('PAID');
583
+
584
+ const billResponse = await api.get(`billing/bill/${billUuid}`);
585
+ const billData = await billResponse.json();
586
+ expect(billData.status).toBe('PAID');
587
+ });
588
+
589
+ await test.step('And the payment history should show multiple payments', async () => {
590
+ const paymentHistory = await paymentPage.getPaymentHistory();
591
+ expect(paymentHistory.length).toBeGreaterThanOrEqual(2);
592
+ });
593
+
594
+ await test.step('And the backend should store payment methods correctly', async () => {
595
+ const billResponse = await api.get(`billing/bill/${billUuid}?v=full`);
596
+ const billData = await billResponse.json();
597
+ const payments = billData.payments;
598
+
599
+ expect(payments.length).toBeGreaterThanOrEqual(2);
600
+
601
+ // Verify each payment has instanceType (payment method)
602
+ payments.forEach((payment: { instanceType: { name: string }; amountTendered: number }) => {
603
+ expect(payment.instanceType).toBeTruthy();
604
+ expect(payment.instanceType.name).toBeTruthy();
605
+ expect(payment.amountTendered).toBeGreaterThan(0);
606
+ });
607
+ });
608
+
609
+ await test.step('And the amount tendered should equal the total amount', async () => {
610
+ const totalAmount = await invoicePage.getTotalAmount();
611
+ const tenderedAmount = await invoicePage.getAmountTendered();
612
+
613
+ const totalValue = extractNumericValue(totalAmount);
614
+ const tenderedValue = extractNumericValue(tenderedAmount);
615
+
616
+ expect(tenderedValue).toBeCloseTo(totalValue, 2);
617
+
618
+ // Verify backend
619
+ const billResponse = await api.get(`billing/bill/${billUuid}`);
620
+ const billData = await billResponse.json();
621
+ const backendTendered = billData.payments.reduce(
622
+ (sum: number, p: { amountTendered: number }) => sum + p.amountTendered,
623
+ 0,
624
+ );
625
+ expect(backendTendered).toBeCloseTo(totalValue, 2);
626
+ });
627
+ });
628
+
629
+ test('Create bill with quantity increase and process split payment', async ({ page, api, patient }) => {
630
+ const billingFormPage = new BillingFormPage(page);
631
+ const invoicePage = new InvoicePage(page);
632
+ const paymentPage = new PaymentPage(page);
633
+ const patientUuid = patient.uuid;
634
+ let billUuid: string;
635
+
636
+ // Note: Selecting same service twice increments quantity to 2
637
+ const quantity = 2;
638
+ const expectedTotal = expectedServicePrice * quantity;
639
+
640
+ await test.step('Given I create a bill with a service quantity of 2', async () => {
641
+ await page.goto(`patient/${patientUuid}/chart/Billing history`);
642
+
643
+ const createBillButton = page.getByRole('button', { name: /launch bill form|add bill/i });
644
+ await createBillButton.click();
645
+
646
+ await billingFormPage.searchAndSelectBillableService(testServiceName);
647
+ await billingFormPage.selectPaymentMethodIfVisible();
648
+ await expect(page.getByText(testServiceName, { exact: false })).toBeVisible();
649
+
650
+ await billingFormPage.clearBillableServiceCombobox();
651
+ await billingFormPage.searchAndSelectBillableService(testServiceName);
652
+
653
+ const quantityInput = page.locator('input[type="number"]').first();
654
+ await expect
655
+ .poll(
656
+ async () => {
657
+ const value = await quantityInput.inputValue();
658
+ return value;
659
+ },
660
+ { timeout: 5000 },
661
+ )
662
+ .toBe('2');
663
+
664
+ await billingFormPage.verifyGrandTotal(expectedTotal);
665
+ await billingFormPage.saveBill();
666
+ await waitForSuccessNotification(page, 'Bill processed successfully');
667
+
668
+ const billsResponse = await api.get(`billing/bill?patient=${patientUuid}&v=full`);
669
+ const billsData = await billsResponse.json();
670
+ billUuid = billsData.results[0].uuid;
671
+ billsToCleanup.add(billUuid);
672
+
673
+ const bill = billsData.results[0];
674
+ expect(bill.lineItems.length).toBe(1);
675
+ expect(bill.lineItems[0].quantity).toBe(quantity);
676
+ });
677
+
678
+ await test.step('When I navigate to the invoice page', async () => {
679
+ await invoicePage.goto(patientUuid, billUuid);
680
+ await invoicePage.waitForInvoiceToLoad();
681
+ });
682
+
683
+ await test.step('And I process payment with multiple payment methods', async () => {
684
+ await paymentPage.waitForPaymentForm();
685
+
686
+ const amountDue = await invoicePage.getAmountDue();
687
+ const amountDueValue = extractNumericValue(amountDue);
688
+
689
+ // Split payment: 50% + 50%
690
+ const payment1 = Math.round(amountDueValue * 0.5 * 100) / 100;
691
+ const payment2 = amountDueValue - payment1;
692
+
693
+ await paymentPage.addMultiplePayments([
694
+ { method: 'Cash', amount: payment1 },
695
+ { method: 'Cash', amount: payment2 },
696
+ ]);
697
+
698
+ await paymentPage.processPayment();
699
+ await waitForSuccessNotification(page, 'Payment processed successfully');
700
+ });
701
+
702
+ await test.step('Then the bill should be marked as PAID', async () => {
703
+ await page.reload();
704
+ await invoicePage.waitForInvoiceToLoad();
705
+
706
+ const status = await invoicePage.getInvoiceStatus();
707
+ expect(status).toBe('PAID');
708
+
709
+ const billResponse = await api.get(`billing/bill/${billUuid}`);
710
+ const billData = await billResponse.json();
711
+ expect(billData.status).toBe('PAID');
712
+ });
713
+
714
+ await test.step('And the line item should be marked as PAID', async () => {
715
+ const lineItems = await invoicePage.getLineItems();
716
+ lineItems.forEach((lineItem) => {
717
+ expect(lineItem.status).toBe('PAID');
718
+ });
719
+ });
720
+
721
+ await test.step('And the payment history should show multiple payments', async () => {
722
+ const paymentHistory = await paymentPage.getPaymentHistory();
723
+ expect(paymentHistory.length).toBeGreaterThanOrEqual(2);
724
+
725
+ // Verify backend payments
726
+ const billResponse = await api.get(`billing/bill/${billUuid}?v=full`);
727
+ const billData = await billResponse.json();
728
+ const payments = billData.payments;
729
+
730
+ expect(payments.length).toBeGreaterThanOrEqual(2);
731
+ payments.forEach((payment: { instanceType: { name: string }; amountTendered: number }) => {
732
+ expect(payment.instanceType).toBeTruthy();
733
+ expect(payment.instanceType.name).toBeTruthy();
734
+ expect(payment.amountTendered).toBeGreaterThan(0);
735
+ });
736
+ });
737
+ });
401
738
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-billing-app",
3
- "version": "1.0.2-pre.1069",
3
+ "version": "1.0.2-pre.1075",
4
4
  "description": "O3 frontend module for handling billing concerns in healthcare settings",
5
5
  "browser": "dist/openmrs-esm-billing-app.js",
6
6
  "main": "src/index.ts",
@@ -18,8 +18,8 @@ const config: PlaywrightTestConfig = {
18
18
  baseURL: `${process.env.E2E_BASE_URL}/spa/`,
19
19
  locale: 'en-US',
20
20
  storageState: 'e2e/storageState.json',
21
- video: 'retain-on-failure',
22
21
  trace: 'retain-on-failure',
22
+ video: 'retain-on-failure',
23
23
  },
24
24
  projects: [
25
25
  {