@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 +1 -1
- package/e2e/pages/billing-dashboard-page.ts +84 -0
- package/e2e/pages/billing-form-page.ts +40 -4
- package/e2e/pages/index.ts +1 -0
- package/e2e/pages/invoice-page.ts +10 -1
- package/e2e/pages/payment-page.ts +35 -7
- package/e2e/specs/billing-dashboard.spec.ts +203 -0
- package/e2e/specs/{billing-and-payment-operations.spec.ts → billing-patient-chart.spec.ts} +361 -24
- package/package.json +1 -1
- package/playwright.config.ts +1 -1
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.
|
|
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
|
|
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
|
}
|
package/e2e/pages/index.ts
CHANGED
|
@@ -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
|
-
|
|
39
|
-
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
package/playwright.config.ts
CHANGED
|
@@ -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
|
{
|