@openmrs/esm-billing-app 1.0.2-pre.112 → 1.0.2-pre.1124

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.
Files changed (241) hide show
  1. package/.eslintrc +16 -2
  2. package/README.md +54 -9
  3. package/__mocks__/bills.mock.ts +12 -0
  4. package/__mocks__/react-i18next.js +6 -5
  5. package/dist/1119.js +1 -1
  6. package/dist/1146.js +1 -2
  7. package/dist/1146.js.map +1 -1
  8. package/dist/1197.js +1 -1
  9. package/dist/1537.js +1 -0
  10. package/dist/1537.js.map +1 -0
  11. package/dist/1856.js +1 -0
  12. package/dist/1856.js.map +1 -0
  13. package/dist/2146.js +1 -1
  14. package/dist/2177.js +1 -1
  15. package/dist/2177.js.map +1 -1
  16. package/dist/2524.js +1 -0
  17. package/dist/2524.js.map +1 -0
  18. package/dist/2690.js +1 -1
  19. package/dist/3099.js +1 -1
  20. package/dist/3584.js +1 -1
  21. package/dist/3717.js +2 -0
  22. package/dist/3717.js.map +1 -0
  23. package/dist/4055.js +1 -1
  24. package/dist/4132.js +1 -1
  25. package/dist/4300.js +1 -1
  26. package/dist/4335.js +1 -1
  27. package/dist/439.js +1 -0
  28. package/dist/4618.js +1 -1
  29. package/dist/4652.js +1 -1
  30. package/dist/4724.js +1 -0
  31. package/dist/4724.js.map +1 -0
  32. package/dist/4739.js +1 -1
  33. package/dist/4739.js.map +1 -1
  34. package/dist/4944.js +1 -1
  35. package/dist/5173.js +1 -1
  36. package/dist/5241.js +1 -1
  37. package/dist/5442.js +1 -1
  38. package/dist/5661.js +1 -1
  39. package/dist/6022.js +1 -1
  40. package/dist/6468.js +1 -1
  41. package/dist/6540.js +1 -1
  42. package/dist/6540.js.map +1 -1
  43. package/dist/6589.js +1 -0
  44. package/dist/6679.js +1 -1
  45. package/dist/6840.js +1 -1
  46. package/dist/6859.js +1 -1
  47. package/dist/7097.js +1 -1
  48. package/dist/7159.js +1 -1
  49. package/dist/723.js +1 -1
  50. package/dist/7255.js +1 -1
  51. package/dist/7255.js.map +1 -1
  52. package/dist/7617.js +1 -1
  53. package/dist/795.js +1 -1
  54. package/dist/8163.js +1 -1
  55. package/dist/8349.js +1 -1
  56. package/dist/8371.js +1 -0
  57. package/dist/8572.js +1 -0
  58. package/dist/8572.js.map +1 -0
  59. package/dist/8618.js +1 -1
  60. package/dist/8708.js +2 -0
  61. package/dist/{6557.js.LICENSE.txt → 8708.js.LICENSE.txt} +22 -0
  62. package/dist/8708.js.map +1 -0
  63. package/dist/890.js +1 -1
  64. package/dist/9214.js +1 -1
  65. package/dist/9538.js +1 -1
  66. package/dist/9569.js +1 -1
  67. package/dist/961.js +1 -1
  68. package/dist/961.js.map +1 -1
  69. package/dist/986.js +1 -1
  70. package/dist/9879.js +1 -1
  71. package/dist/9895.js +1 -1
  72. package/dist/9900.js +1 -1
  73. package/dist/9913.js +1 -1
  74. package/dist/main.js +1 -1
  75. package/dist/main.js.map +1 -1
  76. package/dist/openmrs-esm-billing-app.js +1 -1
  77. package/dist/openmrs-esm-billing-app.js.buildmanifest.json +321 -249
  78. package/dist/openmrs-esm-billing-app.js.map +1 -1
  79. package/dist/routes.json +1 -1
  80. package/e2e/README.md +19 -18
  81. package/e2e/commands/billing-operations.ts +96 -0
  82. package/e2e/commands/index.ts +3 -0
  83. package/e2e/commands/patient-operations.ts +82 -0
  84. package/e2e/commands/types.ts +73 -0
  85. package/e2e/core/test.ts +12 -1
  86. package/e2e/fixtures/api.ts +2 -3
  87. package/e2e/pages/billing-dashboard-page.ts +84 -0
  88. package/e2e/pages/billing-form-page.ts +114 -0
  89. package/e2e/pages/index.ts +4 -1
  90. package/e2e/pages/invoice-page.ts +113 -0
  91. package/e2e/pages/payment-page.ts +104 -0
  92. package/e2e/specs/billing-dashboard.spec.ts +203 -0
  93. package/e2e/specs/billing-patient-chart.spec.ts +738 -0
  94. package/e2e/support/github/Dockerfile +1 -1
  95. package/e2e/support/github/run-e2e-docker-env.sh +29 -32
  96. package/example.env +15 -1
  97. package/package.json +19 -16
  98. package/playwright.config.ts +4 -2
  99. package/src/bill-history/bill-history.component.tsx +15 -17
  100. package/src/bill-history/bill-history.scss +4 -94
  101. package/src/bill-history/bill-history.test.tsx +37 -77
  102. package/src/bill-item-actions/bill-item-actions.scss +21 -5
  103. package/src/bill-item-actions/edit-bill-item.modal.tsx +227 -0
  104. package/src/bill-item-actions/edit-bill-item.test.tsx +233 -40
  105. package/src/billable-services/bill-waiver/bill-selection.component.tsx +5 -5
  106. package/src/billable-services/bill-waiver/bill-waiver-form.component.tsx +37 -39
  107. package/src/billable-services/bill-waiver/patient-bills.component.tsx +7 -7
  108. package/src/billable-services/bill-waiver/utils.ts +13 -3
  109. package/src/billable-services/{create-edit/add-billable-service.scss → billable-service-form/billable-service-form.scss} +32 -64
  110. package/src/billable-services/billable-service-form/billable-service-form.test.tsx +1099 -0
  111. package/src/billable-services/billable-service-form/billable-service-form.workspace.tsx +516 -0
  112. package/src/billable-services/billable-service.resource.ts +71 -27
  113. package/src/billable-services/billable-services-home.component.tsx +13 -42
  114. package/src/billable-services/billable-services-left-panel-link.component.tsx +50 -0
  115. package/src/billable-services/billable-services-left-panel-menu.component.tsx +48 -0
  116. package/src/billable-services/billable-services-menu-item/item.component.tsx +5 -4
  117. package/src/billable-services/billable-services.component.tsx +156 -152
  118. package/src/billable-services/billable-services.scss +29 -0
  119. package/src/billable-services/billable-services.test.tsx +6 -49
  120. package/src/billable-services/cash-point/add-cash-point.modal.tsx +170 -0
  121. package/src/billable-services/cash-point/cash-point-configuration.component.tsx +19 -193
  122. package/src/billable-services/cash-point/cash-point-configuration.scss +1 -5
  123. package/src/billable-services/dashboard/dashboard.component.tsx +0 -2
  124. package/src/billable-services/payment-modes/delete-payment-mode.modal.tsx +77 -0
  125. package/src/billable-services/payment-modes/payment-mode-form.modal.tsx +131 -0
  126. package/src/billable-services/payment-modes/payment-modes-config.component.tsx +139 -0
  127. package/src/billable-services/{payyment-modes → payment-modes}/payment-modes-config.scss +5 -4
  128. package/src/billable-services-admin-card-link.component.test.tsx +2 -2
  129. package/src/billable-services-admin-card-link.component.tsx +1 -1
  130. package/src/billing-dashboard/billing-dashboard.scss +1 -1
  131. package/src/billing-form/billing-checkin-form.component.tsx +29 -29
  132. package/src/billing-form/billing-checkin-form.scss +0 -7
  133. package/src/billing-form/billing-checkin-form.test.tsx +100 -27
  134. package/src/billing-form/billing-form.component.tsx +226 -284
  135. package/src/billing-form/billing-form.scss +143 -0
  136. package/src/billing-form/visit-attributes/visit-attributes-form.component.tsx +47 -41
  137. package/src/billing-form/visit-attributes/visit-attributes-form.scss +16 -3
  138. package/src/billing.resource.test.ts +503 -0
  139. package/src/billing.resource.ts +68 -76
  140. package/src/bills-table/bills-table.component.tsx +17 -24
  141. package/src/bills-table/bills-table.test.tsx +98 -54
  142. package/src/config-schema.ts +84 -31
  143. package/src/dashboard.meta.ts +5 -3
  144. package/src/helpers/functions.ts +5 -4
  145. package/src/index.ts +75 -13
  146. package/src/invoice/invoice-table.component.tsx +36 -70
  147. package/src/invoice/invoice-table.scss +8 -5
  148. package/src/invoice/invoice-table.test.tsx +273 -62
  149. package/src/invoice/invoice.component.tsx +44 -38
  150. package/src/invoice/invoice.scss +11 -4
  151. package/src/invoice/invoice.test.tsx +324 -120
  152. package/src/invoice/payments/invoice-breakdown/invoice-breakdown.scss +9 -9
  153. package/src/invoice/payments/payment-form/payment-form.component.tsx +45 -36
  154. package/src/invoice/payments/payment-form/payment-form.scss +5 -6
  155. package/src/invoice/payments/payment-form/payment-form.test.tsx +217 -67
  156. package/src/invoice/payments/payment-history/payment-history.component.tsx +6 -4
  157. package/src/invoice/payments/payment-history/payment-history.test.tsx +9 -14
  158. package/src/invoice/payments/payments.component.tsx +56 -68
  159. package/src/invoice/payments/payments.scss +4 -3
  160. package/src/invoice/payments/payments.test.tsx +282 -0
  161. package/src/invoice/payments/utils.ts +15 -27
  162. package/src/invoice/printable-invoice/print-receipt.component.tsx +3 -3
  163. package/src/invoice/printable-invoice/print-receipt.test.tsx +14 -25
  164. package/src/invoice/printable-invoice/printable-footer.component.tsx +2 -2
  165. package/src/invoice/printable-invoice/printable-footer.test.tsx +4 -13
  166. package/src/invoice/printable-invoice/printable-invoice-header.component.tsx +20 -11
  167. package/src/invoice/printable-invoice/printable-invoice-header.test.tsx +95 -16
  168. package/src/invoice/printable-invoice/printable-invoice.component.tsx +21 -35
  169. package/src/left-panel-link.component.tsx +9 -8
  170. package/src/left-panel-link.test.tsx +1 -4
  171. package/src/metrics-cards/metrics-cards.component.tsx +16 -6
  172. package/src/metrics-cards/metrics-cards.scss +4 -0
  173. package/src/metrics-cards/metrics-cards.test.tsx +18 -5
  174. package/src/modal/require-payment-modal.test.tsx +27 -22
  175. package/src/modal/{require-payment-modal.component.tsx → require-payment.modal.tsx} +18 -19
  176. package/src/routes.json +44 -20
  177. package/src/types/index.ts +87 -24
  178. package/translations/am.json +140 -80
  179. package/translations/ar.json +141 -81
  180. package/translations/ar_SY.json +141 -81
  181. package/translations/bn.json +143 -83
  182. package/translations/cs.json +230 -0
  183. package/translations/de.json +141 -81
  184. package/translations/en.json +142 -81
  185. package/translations/en_US.json +141 -81
  186. package/translations/es.json +140 -80
  187. package/translations/es_MX.json +141 -81
  188. package/translations/fr.json +224 -164
  189. package/translations/he.json +140 -80
  190. package/translations/hi.json +141 -81
  191. package/translations/hi_IN.json +141 -81
  192. package/translations/id.json +141 -81
  193. package/translations/it.json +166 -106
  194. package/translations/ka.json +141 -81
  195. package/translations/km.json +140 -80
  196. package/translations/ku.json +141 -81
  197. package/translations/ky.json +141 -81
  198. package/translations/lg.json +141 -81
  199. package/translations/ne.json +141 -81
  200. package/translations/pl.json +141 -81
  201. package/translations/pt.json +141 -81
  202. package/translations/pt_BR.json +141 -81
  203. package/translations/qu.json +141 -81
  204. package/translations/ro_RO.json +225 -165
  205. package/translations/ru_RU.json +141 -81
  206. package/translations/si.json +141 -81
  207. package/translations/sq.json +230 -0
  208. package/translations/sw.json +141 -81
  209. package/translations/sw_KE.json +141 -81
  210. package/translations/tr.json +141 -81
  211. package/translations/tr_TR.json +141 -81
  212. package/translations/uk.json +141 -81
  213. package/translations/uz.json +141 -81
  214. package/translations/uz@Latn.json +141 -81
  215. package/translations/uz_UZ.json +141 -81
  216. package/translations/vi.json +141 -81
  217. package/translations/zh.json +141 -81
  218. package/translations/zh_CN.json +171 -111
  219. package/translations/zh_TW.json +230 -0
  220. package/dist/1146.js.LICENSE.txt +0 -21
  221. package/dist/2352.js +0 -1
  222. package/dist/2352.js.map +0 -1
  223. package/dist/246.js +0 -1
  224. package/dist/246.js.map +0 -1
  225. package/dist/4689.js +0 -2
  226. package/dist/4689.js.map +0 -1
  227. package/dist/6557.js +0 -2
  228. package/dist/6557.js.map +0 -1
  229. package/dist/8638.js +0 -1
  230. package/dist/8638.js.map +0 -1
  231. package/dist/9968.js +0 -1
  232. package/dist/9968.js.map +0 -1
  233. package/e2e/pages/home-page.ts +0 -9
  234. package/e2e/specs/sample-test.spec.ts +0 -11
  235. package/src/bill-item-actions/edit-bill-item.component.tsx +0 -221
  236. package/src/billable-services/create-edit/add-billable-service.component.tsx +0 -401
  237. package/src/billable-services/create-edit/add-billable-service.test.tsx +0 -154
  238. package/src/billable-services/dashboard/service-metrics.component.tsx +0 -41
  239. package/src/billable-services/payyment-modes/payment-modes-config.component.tsx +0 -280
  240. package/src/invoice/payments/payments.component.test.tsx +0 -121
  241. /package/dist/{4689.js.LICENSE.txt → 3717.js.LICENSE.txt} +0 -0
@@ -0,0 +1,114 @@
1
+ import { expect, type Page } from '@playwright/test';
2
+ import { extractNumericValue } from '../commands';
3
+
4
+ export class BillingFormPage {
5
+ constructor(readonly page: Page) {}
6
+
7
+ readonly billableServicesCombobox = () => this.page.getByRole('combobox', { name: /search items and services/i });
8
+ readonly quantityInput = (itemUuid: string) => this.page.locator(`#quantity-${itemUuid}`);
9
+ readonly paymentMethodCombobox = (itemUuid: string) => this.page.locator(`#payment-method-${itemUuid}`);
10
+ readonly selectedItemCards = () => this.page.locator('[class*="itemCard"]');
11
+ readonly removeItemButton = () => this.page.getByRole('button', { name: /remove/i });
12
+ readonly saveButton = () => this.page.getByRole('button', { name: /save and close/i });
13
+ readonly discardButton = () => this.page.getByRole('button', { name: /discard/i });
14
+ readonly grandTotalLabel = () => this.page.getByText(/grand total/i);
15
+
16
+ async openBillingForm(patientUuid: string) {
17
+ await this.page.goto(`patient/${patientUuid}/chart`);
18
+ await this.page.getByRole('button', { name: /add bill/i }).click();
19
+ }
20
+
21
+ async searchAndSelectBillableService(serviceName: string) {
22
+ await this.billableServicesCombobox().click();
23
+ await this.billableServicesCombobox().fill(serviceName);
24
+ await this.page.getByRole('option', { name: new RegExp(serviceName, 'i') }).click();
25
+ }
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
+
55
+ async updateQuantity(itemUuid: string, quantity: number) {
56
+ const input = this.quantityInput(itemUuid);
57
+ await input.clear();
58
+ await input.fill(quantity.toString());
59
+ }
60
+
61
+ async selectPaymentMethod(itemUuid: string, paymentMethod: string) {
62
+ const combobox = this.paymentMethodCombobox(itemUuid);
63
+ await combobox.click();
64
+ await this.page.getByRole('option', { name: new RegExp(paymentMethod, 'i') }).click();
65
+ }
66
+
67
+ async removeItem(index = 0) {
68
+ const removeButtons = await this.removeItemButton().all();
69
+ if (removeButtons[index]) {
70
+ await removeButtons[index].click();
71
+ }
72
+ }
73
+
74
+ async getLineItemsCount() {
75
+ const items = await this.selectedItemCards().all();
76
+ return items.length;
77
+ }
78
+
79
+ async getGrandTotal() {
80
+ const totalText = await this.grandTotalLabel().textContent();
81
+ return totalText;
82
+ }
83
+
84
+ async saveBill() {
85
+ await this.saveButton().click();
86
+ }
87
+
88
+ async discardBill() {
89
+ await this.discardButton().click();
90
+ }
91
+
92
+ async verifyItemAdded(itemName: string) {
93
+ return this.page.getByText(itemName).isVisible();
94
+ }
95
+
96
+ async selectPaymentMethodIfVisible(paymentMethodName: string = 'Cash') {
97
+ const dropdown = this.page.getByPlaceholder('Select payment method').first();
98
+ const isVisible = await dropdown.isVisible().catch(() => false);
99
+
100
+ if (isVisible) {
101
+ await dropdown.click();
102
+ await this.page
103
+ .getByRole('option', { name: new RegExp(paymentMethodName, 'i') })
104
+ .first()
105
+ .click();
106
+ }
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
+ }
114
+ }
@@ -1 +1,4 @@
1
- export * from './home-page';
1
+ export * from './billing-dashboard-page';
2
+ export * from './billing-form-page';
3
+ export * from './invoice-page';
4
+ export * from './payment-page';
@@ -0,0 +1,113 @@
1
+ import { type Page } from '@playwright/test';
2
+
3
+ export class InvoicePage {
4
+ constructor(readonly page: Page) {}
5
+
6
+ readonly invoiceNumberLabel = () => this.page.getByRole('heading', { name: /invoice #/i });
7
+ readonly totalAmountLabel = () => this.page.getByText(/total amount/i).first();
8
+ readonly amountTenderedLabel = () => this.page.getByText(/amount tendered/i);
9
+ readonly amountDueLabel = () => this.page.getByText(/amount due/i);
10
+ readonly invoiceStatusLabel = () => this.page.getByText(/invoice status/i);
11
+ readonly dateAndTimeLabel = () => this.page.getByText(/date and time/i);
12
+ readonly printBillButton = () => this.page.getByRole('button', { name: /print bill/i });
13
+ readonly printReceiptButton = () => this.page.getByRole('button', { name: /print receipt/i });
14
+ readonly invoiceTable = () => this.page.getByRole('table').first();
15
+ readonly discardButton = () => this.page.getByRole('button', { name: /discard/i });
16
+
17
+ async goto(patientUuid: string, billUuid: string) {
18
+ await this.page.goto(`home/billing/patient/${patientUuid}/${billUuid}`);
19
+ }
20
+
21
+ async waitForInvoiceToLoad() {
22
+ await this.invoiceTable().waitFor({ state: 'visible' });
23
+
24
+ // Wait for billableServices API to complete before allowing payment processing
25
+ // This is a workaround for a production race condition where payment processing
26
+ // requires billableServices data to map service names back to UUIDs
27
+ try {
28
+ await this.page.waitForResponse(
29
+ (response) => response.url().includes('billableService') && response.status() === 200,
30
+ { timeout: 5000 },
31
+ );
32
+ } catch {
33
+ // If billableServices already loaded or request times out, continue
34
+ // The data might be cached from a previous page
35
+ }
36
+ }
37
+
38
+ async getInvoiceNumber() {
39
+ await this.invoiceNumberLabel().waitFor({ state: 'visible' });
40
+ const parent = this.invoiceNumberLabel().locator('..');
41
+ const valueElement = parent.locator('[class*="value"]');
42
+ await valueElement.waitFor({ state: 'visible' });
43
+ const value = await valueElement.textContent();
44
+ return value?.trim();
45
+ }
46
+
47
+ async getTotalAmount() {
48
+ const parent = this.totalAmountLabel().locator('..');
49
+ const value = await parent.locator('[class*="value"]').textContent();
50
+ return value?.trim();
51
+ }
52
+
53
+ async getAmountTendered() {
54
+ const parent = this.amountTenderedLabel().locator('..');
55
+ const value = await parent.locator('[class*="value"]').textContent();
56
+ return value?.trim();
57
+ }
58
+
59
+ async getAmountDue() {
60
+ const parent = this.amountDueLabel().locator('..');
61
+ const value = await parent.locator('[class*="value"]').textContent();
62
+ return value?.trim();
63
+ }
64
+
65
+ async getInvoiceStatus() {
66
+ const parent = this.invoiceStatusLabel().locator('..');
67
+ const value = await parent.locator('[class*="value"]').textContent();
68
+ return value?.trim();
69
+ }
70
+
71
+ async getDateAndTime() {
72
+ const parent = this.dateAndTimeLabel().locator('..');
73
+ const value = await parent.locator('[class*="value"]').textContent();
74
+ return value?.trim();
75
+ }
76
+
77
+ async getLineItems() {
78
+ const rows = await this.invoiceTable().locator('tbody tr').all();
79
+ const items = [];
80
+
81
+ const headers = await this.invoiceTable().locator('thead th').allTextContents();
82
+ const itemIndex = headers.findIndex((h) => h.includes('Bill item'));
83
+ const quantityIndex = headers.findIndex((h) => h.includes('Quantity'));
84
+ const priceIndex = headers.findIndex((h) => h.includes('Price'));
85
+ const totalIndex = headers.findIndex((h) => h.includes('Total'));
86
+ // Look for "Status" column (case-insensitive, handles "Status" or "Service status")
87
+ const statusIndex = headers.findIndex((h) => h.toLowerCase().includes('status'));
88
+
89
+ for (const row of rows) {
90
+ const cells = await row.locator('td').allTextContents();
91
+ items.push({
92
+ item: cells[itemIndex],
93
+ quantity: cells[quantityIndex],
94
+ price: cells[priceIndex],
95
+ total: cells[totalIndex],
96
+ status: statusIndex >= 0 && cells[statusIndex] ? cells[statusIndex].trim() : undefined,
97
+ });
98
+ }
99
+ return items;
100
+ }
101
+
102
+ async clickPrintBill() {
103
+ await this.printBillButton().click();
104
+ }
105
+
106
+ async clickPrintReceipt() {
107
+ await this.printReceiptButton().click();
108
+ }
109
+
110
+ async navigateToBillingDashboard() {
111
+ await this.discardButton().click();
112
+ }
113
+ }
@@ -0,0 +1,104 @@
1
+ import { expect, type Page } from '@playwright/test';
2
+
3
+ export class PaymentPage {
4
+ constructor(readonly page: Page) {}
5
+
6
+ readonly addPaymentButton = () => this.page.getByRole('button', { name: /add payment/i });
7
+ readonly paymentMethodCombobox = () => this.page.getByRole('combobox', { name: /payment method/i }).first();
8
+ readonly amountInput = () => this.page.getByLabel(/amount/i).first();
9
+ readonly referenceCodeInput = () => this.page.getByLabel(/reference number/i).first();
10
+ readonly processPaymentButton = () => this.page.getByRole('button', { name: /process payment/i });
11
+ readonly paymentHistorySection = () =>
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 });
14
+ readonly removePaymentButton = () => this.page.getByRole('button', { name: /remove/i });
15
+
16
+ async waitForPaymentForm() {
17
+ // For single line item bills, wait for the auto-created payment row to be interactive
18
+ // The payment method combobox only appears after payment modes have loaded
19
+ // (while loading, the form shows a skeleton instead)
20
+ await this.paymentMethodCombobox().waitFor({ state: 'visible', timeout: 30000 });
21
+ }
22
+
23
+ async addPayment(paymentMethod: string, amount: number, referenceCode?: string) {
24
+ await this.paymentMethodCombobox().click();
25
+ await this.page.getByRole('option', { name: new RegExp(paymentMethod, 'i') }).click();
26
+
27
+ await this.amountInput().fill(amount.toString());
28
+
29
+ if (referenceCode) {
30
+ await this.referenceCodeInput().fill(referenceCode);
31
+ }
32
+ }
33
+
34
+ async addMultiplePayments(payments: Array<{ method: string; amount: number; referenceCode?: string }>) {
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
+ }
64
+ }
65
+ }
66
+
67
+ async processPayment() {
68
+ await this.processPaymentButton().click();
69
+ }
70
+
71
+ async isProcessPaymentButtonEnabled() {
72
+ return await this.processPaymentButton().isEnabled();
73
+ }
74
+
75
+ async removePayment(index = 0) {
76
+ const removeButtons = await this.removePaymentButton().all();
77
+ await removeButtons[index].click();
78
+ }
79
+
80
+ async getPaymentHistory() {
81
+ const rows = await this.paymentHistorySection().locator('tbody tr').all();
82
+ const payments = [];
83
+ for (const row of rows) {
84
+ const cells = await row.locator('td').allTextContents();
85
+ payments.push({
86
+ date: cells[0],
87
+ billAmount: cells[1],
88
+ amountTendered: cells[2],
89
+ method: cells[3],
90
+ });
91
+ }
92
+ return payments;
93
+ }
94
+
95
+ async verifyPaymentProcessed(amount: string) {
96
+ const history = await this.getPaymentHistory();
97
+ return history.some((payment) => payment.amountTendered.includes(amount));
98
+ }
99
+
100
+ async getAmountDue() {
101
+ const amountDueText = await this.page.getByText(/amount due/i).textContent();
102
+ return amountDueText;
103
+ }
104
+ }
@@ -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
+ });