Some checks failed
E2E Tests / test (push) Has been cancelled
Copies the Playwright end-to-end suite out of the PassDashboard app repo so it can be run and deployed independently. Nothing was removed from the app repo - this is a copy. Carried over verbatim: tests/ (10 spec files, 4 helper modules, README) plus playwright.config.ts and .env.test.example. The suite drives the deployed app over HTTP and imports nothing from src/, which is what makes it standalone. New for this repo: - package.json with only the deps the suite actually uses (@playwright/test, allure-playwright, xlsx, typescript, @types/node) and the e2e scripts. - tsconfig.json covering tests/ - the app repo's tsconfig.app.json only included src/, so these files were never typechecked before. - .gitea/workflows/e2e-tests.yml for Gitea Actions. The app repo derived ENV from github.ref_name; that doesn't apply here since this repo has no per-environment branches, so the target is an explicit workflow_dispatch input and scheduled/push runs default to develop. Reports are uploaded as artifacts rather than deleted. - .gitignore and README covering local setup and the required CI secrets. The Jest unit tests under src/__tests__/ were deliberately left in the app repo: they import application source directly (AuthContext, ProtectedRoute, cryptoUtils, api) and cannot run without it. Verified: npx tsc --noEmit is clean and playwright collects all 43 tests across all 10 spec files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
546 lines
25 KiB
TypeScript
546 lines
25 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
import { login } from './helpers/auth';
|
|
import { epic, feature, severity, description, tag } from './helpers/allure';
|
|
import { navigateToTransactions, navigateToDiscounts, navigateToSettings } from './helpers/navigation';
|
|
|
|
test.describe('Discounts & Fees Tests', () => {
|
|
test.setTimeout(300000);
|
|
|
|
test('should calculate discount and verify against transactions page', async ({ page }) => {
|
|
feature('Discounts Operations');
|
|
tag('functional');
|
|
severity('critical');
|
|
description('Get latest transaction date, calculate MDR+VAT+FEE from transactions, then verify matches Discounts page totals');
|
|
epic('Discounts & Fees');
|
|
|
|
let latestDate = '';
|
|
let txnTotalMDR = 0, txnTotalVAT = 0, txnTotalFee = 0, txnTotalDiscount = 0;
|
|
|
|
// Step 1: Go to Transactions page
|
|
await test.step('Step 1: Go to Transactions page', async () => {
|
|
await login(page);
|
|
await navigateToTransactions(page);
|
|
await page.waitForTimeout(2000);
|
|
console.log('✓ On Transactions page');
|
|
});
|
|
|
|
// Step 2: Find latest date with transactions
|
|
await test.step('Step 2: Find latest date with transactions', async () => {
|
|
await page.waitForTimeout(1000);
|
|
|
|
// Scroll the table horizontally to reveal the init date column
|
|
const tableContent = page.locator('.ant-table-content').first();
|
|
await tableContent.evaluate((element) => {
|
|
// Scroll to the right to reveal the init date column
|
|
element.scrollLeft = element.scrollWidth;
|
|
});
|
|
await page.waitForTimeout(500);
|
|
console.log('✓ Scrolled table horizontally to reveal init date column');
|
|
|
|
// Get the first real data row (skip the hidden ant-table-measure-row)
|
|
const firstRow = page.locator('tr.ant-table-row').first();
|
|
const rowText = await firstRow.textContent();
|
|
|
|
console.log('Full first row text after scroll:', rowText?.substring(0, 200));
|
|
|
|
// Extract date from the row (format: "Mon DD, YYYY" like "Jun 19, 2026")
|
|
const dateMatch = rowText?.match(/(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/);
|
|
|
|
if (dateMatch) {
|
|
latestDate = dateMatch[0];
|
|
console.log('\n========== DATE EXTRACTED ==========');
|
|
console.log('✓ Latest transaction date:', latestDate);
|
|
console.log('====================================\n');
|
|
} else {
|
|
console.log('⚠ Could not extract date from first row');
|
|
throw new Error('Failed to extract date from transaction row');
|
|
}
|
|
});
|
|
|
|
// Step 3: Count how many transaction rows fall within the latest date
|
|
await test.step('Step 3: Count transactions within the latest date', async () => {
|
|
await page.waitForTimeout(500);
|
|
|
|
const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/;
|
|
|
|
let totalMatching = 0;
|
|
let currentPage = 1;
|
|
let keepPaging = true;
|
|
|
|
while (keepPaging) {
|
|
// Wait for the table body to be present before reading
|
|
await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 });
|
|
|
|
const rows = page.locator('tr.ant-table-row');
|
|
const rowCount = await rows.count();
|
|
|
|
// Capture the first row key on this page (used to detect page change later)
|
|
const firstKey = await rows.first().getAttribute('data-row-key');
|
|
const lastKey = await rows.nth(rowCount - 1).getAttribute('data-row-key');
|
|
|
|
// Read the actual page footer ("Page X of Y") for verification
|
|
const footer = await page.locator('text=/Page\\s+\\d+\\s+of\\s+\\d+/').first().textContent().catch(() => null);
|
|
|
|
let pageMatching = 0;
|
|
for (let i = 0; i < rowCount; i++) {
|
|
const rowText = await rows.nth(i).textContent();
|
|
const match = rowText?.match(monthRegex);
|
|
const rowDate = match ? match[0] : '';
|
|
if (rowDate === latestDate) {
|
|
pageMatching++;
|
|
}
|
|
}
|
|
|
|
totalMatching += pageMatching;
|
|
console.log(`[${footer?.trim() || 'page ' + currentPage}] firstID=${firstKey} lastID=${lastKey} -> ${pageMatching}/${rowCount} on ${latestDate}`);
|
|
|
|
// If not every row on this page matched, we've reached older dates - stop
|
|
if (pageMatching < rowCount) {
|
|
keepPaging = false;
|
|
break;
|
|
}
|
|
|
|
// All rows matched - go to next page
|
|
const nextPage = currentPage + 1;
|
|
const nextPageBtn = page.locator(`button:has-text("${nextPage}")`).first();
|
|
const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
|
|
|
if (!hasNextPage) {
|
|
keepPaging = false;
|
|
break;
|
|
}
|
|
|
|
await nextPageBtn.click();
|
|
|
|
// Wait until the first row's key actually changes (confirms the page re-rendered)
|
|
await page.waitForFunction(
|
|
(prevKey) => {
|
|
const row = document.querySelector('tr.ant-table-row');
|
|
return row && row.getAttribute('data-row-key') !== prevKey;
|
|
},
|
|
firstKey,
|
|
{ timeout: 10000 }
|
|
).catch(() => {
|
|
console.log('⚠ Page did not change after clicking next - stopping');
|
|
});
|
|
|
|
currentPage = nextPage;
|
|
}
|
|
|
|
console.log('\n========== ROW COUNT ==========');
|
|
console.log(`✓ Total transactions on ${latestDate}: ${totalMatching}`);
|
|
console.log('===============================\n');
|
|
});
|
|
|
|
// Step 4: Enable MDR, VAT, Fee columns via Manage columns modal
|
|
await test.step('Step 4: Enable MDR, VAT, Fee columns', async () => {
|
|
// Open the Manage columns modal
|
|
const manageColumnsBtn = page.locator('button:has-text("Manage columns"), button:has-text("columns")').first();
|
|
await manageColumnsBtn.click();
|
|
await page.waitForTimeout(1000);
|
|
console.log('✓ Opened Manage columns modal');
|
|
|
|
// Scope everything to the modal dialog so we don't hit the page's own search/checkboxes
|
|
const modal = page.locator('.ant-modal-content').first();
|
|
const searchInput = modal.locator('input[placeholder="Search"]').first();
|
|
|
|
// Helper: search for a column and enable its checkbox if not already checked
|
|
const enableColumn = async (term: string) => {
|
|
await searchInput.fill('');
|
|
await page.waitForTimeout(300);
|
|
await searchInput.fill(term);
|
|
await page.waitForTimeout(700);
|
|
|
|
// After filtering, the matching column's checkbox should be the visible one
|
|
const checkbox = modal.locator('input.ant-checkbox-input').first();
|
|
const exists = await checkbox.isVisible({ timeout: 3000 }).catch(() => false);
|
|
|
|
if (!exists) {
|
|
console.log(`⚠ No checkbox found for "${term}"`);
|
|
return;
|
|
}
|
|
|
|
const isChecked = await checkbox.isChecked().catch(() => false);
|
|
if (isChecked) {
|
|
console.log(`• "${term}" column already enabled`);
|
|
} else {
|
|
await checkbox.click();
|
|
await page.waitForTimeout(400);
|
|
console.log(`✓ Enabled "${term}" column`);
|
|
}
|
|
};
|
|
|
|
await enableColumn('mdr');
|
|
await enableColumn('vat');
|
|
await enableColumn('fee');
|
|
|
|
// Clear the search before closing
|
|
await searchInput.fill('');
|
|
await page.waitForTimeout(300);
|
|
|
|
// Click Done to apply
|
|
const doneBtn = modal.locator('button:has-text("Done")').first();
|
|
await doneBtn.click();
|
|
await page.waitForTimeout(1000);
|
|
console.log('✓ Applied column selection (clicked Done)');
|
|
});
|
|
|
|
// Step 5: Sum MDR + VAT + Fee across all transactions on the latest date
|
|
await test.step('Step 5: Sum MDR + VAT + Fee for the latest date', async () => {
|
|
const monthRegex = /(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s*\d{4}/;
|
|
|
|
// Go back to page 1 (confirm the table actually re-renders)
|
|
const beforeKey = await page.locator('tr.ant-table-row').first().getAttribute('data-row-key');
|
|
const firstPageBtn = page.getByRole('button', { name: '1', exact: true }).first();
|
|
if (await firstPageBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
await firstPageBtn.scrollIntoViewIfNeeded().catch(() => {});
|
|
await firstPageBtn.click();
|
|
await page.waitForFunction(
|
|
(prevKey) => {
|
|
const r = document.querySelector('tr.ant-table-row');
|
|
return r && r.getAttribute('data-row-key') !== prevKey;
|
|
},
|
|
beforeKey,
|
|
{ timeout: 10000 }
|
|
).catch(() => console.log('⚠ Did not return to page 1 cleanly'));
|
|
await page.waitForTimeout(500);
|
|
}
|
|
console.log(`Back on page 1, first row ID: ${await page.locator('tr.ant-table-row').first().getAttribute('data-row-key')}`);
|
|
|
|
// Determine column indexes for MDR, VAT, Fee from the table header
|
|
const headerCells = page.locator('thead th');
|
|
const headerCount = await headerCells.count();
|
|
let mdrIdx = -1, vatIdx = -1, feeIdx = -1;
|
|
for (let i = 0; i < headerCount; i++) {
|
|
const t = (await headerCells.nth(i).textContent())?.trim();
|
|
if (t === 'MDR') mdrIdx = i;
|
|
else if (t === 'VAT') vatIdx = i;
|
|
else if (t === 'Fee') feeIdx = i;
|
|
}
|
|
console.log(`Column indexes -> MDR:${mdrIdx} VAT:${vatIdx} Fee:${feeIdx}`);
|
|
if (mdrIdx < 0 || vatIdx < 0 || feeIdx < 0) {
|
|
throw new Error('Could not locate MDR/VAT/Fee columns in header');
|
|
}
|
|
|
|
// Read a numeric value from a cell: the value sits in the last <article>,
|
|
// separate from the currency-icon SVG article.
|
|
const parseCell = async (cell: ReturnType<typeof page.locator>): Promise<number> => {
|
|
const valArticle = cell.locator('article').last();
|
|
const txt = (await valArticle.textContent().catch(() => '')) || '';
|
|
const num = parseFloat(txt.replace(/[^0-9.\-]/g, ''));
|
|
return isNaN(num) ? 0 : num;
|
|
};
|
|
|
|
let totalMDR = 0, totalVAT = 0, totalFee = 0, totalRows = 0;
|
|
let currentPage = 1;
|
|
let keepPaging = true;
|
|
|
|
while (keepPaging) {
|
|
await page.locator('tr.ant-table-row').first().waitFor({ state: 'visible', timeout: 10000 });
|
|
const rows = page.locator('tr.ant-table-row');
|
|
const rowCount = await rows.count();
|
|
const firstKey = await rows.first().getAttribute('data-row-key');
|
|
|
|
let pageMatching = 0;
|
|
for (let i = 0; i < rowCount; i++) {
|
|
const row = rows.nth(i);
|
|
const rowText = await row.textContent();
|
|
const m = rowText?.match(monthRegex);
|
|
const rowDate = m ? m[0] : '';
|
|
if (rowDate !== latestDate) continue;
|
|
|
|
const cells = row.locator('td');
|
|
const mdr = await parseCell(cells.nth(mdrIdx));
|
|
const vat = await parseCell(cells.nth(vatIdx));
|
|
const fee = await parseCell(cells.nth(feeIdx));
|
|
const rowDiscount = mdr + vat + fee;
|
|
|
|
totalMDR += mdr;
|
|
totalVAT += vat;
|
|
totalFee += fee;
|
|
totalRows++;
|
|
pageMatching++;
|
|
|
|
const id = await row.getAttribute('data-row-key');
|
|
console.log(` Row ${id}: MDR=${mdr.toFixed(2)} VAT=${vat.toFixed(2)} Fee=${fee.toFixed(2)} -> ${rowDiscount.toFixed(2)}`);
|
|
}
|
|
|
|
console.log(`[page ${currentPage}] matched ${pageMatching}/${rowCount} rows`);
|
|
|
|
// If not every row matched, we've reached older dates - stop
|
|
if (pageMatching < rowCount) {
|
|
keepPaging = false;
|
|
break;
|
|
}
|
|
|
|
// Go to next page
|
|
const nextPage = currentPage + 1;
|
|
const nextPageBtn = page.getByRole('button', { name: `${nextPage}`, exact: true }).first();
|
|
const hasNextPage = await nextPageBtn.isVisible({ timeout: 2000 }).catch(() => false);
|
|
if (!hasNextPage) {
|
|
keepPaging = false;
|
|
break;
|
|
}
|
|
|
|
await nextPageBtn.click();
|
|
await page.waitForFunction(
|
|
(prevKey) => {
|
|
const row = document.querySelector('tr.ant-table-row');
|
|
return row && row.getAttribute('data-row-key') !== prevKey;
|
|
},
|
|
firstKey,
|
|
{ timeout: 10000 }
|
|
).catch(() => {
|
|
console.log('⚠ Page did not change after clicking next - stopping');
|
|
});
|
|
currentPage = nextPage;
|
|
}
|
|
|
|
const grandTotal = totalMDR + totalVAT + totalFee;
|
|
txnTotalMDR = totalMDR;
|
|
txnTotalVAT = totalVAT;
|
|
txnTotalFee = totalFee;
|
|
txnTotalDiscount = grandTotal;
|
|
console.log('\n========== DISCOUNT TOTALS (Transactions page) ==========');
|
|
console.log(`Date: ${latestDate}`);
|
|
console.log(`Rows summed: ${totalRows}`);
|
|
console.log(`Total MDR: ${totalMDR.toFixed(2)}`);
|
|
console.log(`Total VAT: ${totalVAT.toFixed(2)}`);
|
|
console.log(`Total Fee: ${totalFee.toFixed(2)}`);
|
|
console.log(`Total Discount (MDR+VAT+Fee): ${grandTotal.toFixed(2)}`);
|
|
console.log('=========================================================\n');
|
|
});
|
|
|
|
// Step 6: Go to Discounts page and filter by the transaction date
|
|
await test.step('Step 6: Go to Discounts page and filter by date', async () => {
|
|
await navigateToDiscounts(page);
|
|
await page.waitForTimeout(2000);
|
|
console.log('✓ On Discounts & Fees page');
|
|
|
|
// Convert latestDate ("Jun 19, 2026") to picker title format ("2026-06-19")
|
|
const months: Record<string, string> = {
|
|
Jan: '01', Feb: '02', Mar: '03', Apr: '04', May: '05', Jun: '06',
|
|
Jul: '07', Aug: '08', Sep: '09', Oct: '10', Nov: '11', Dec: '12',
|
|
};
|
|
const parts = latestDate.match(/(\w+)\s+(\d{1,2}),\s*(\d{4})/);
|
|
if (!parts) {
|
|
throw new Error(`Could not parse latestDate: ${latestDate}`);
|
|
}
|
|
const titleDate = `${parts[3]}-${months[parts[1]]}-${parts[2].padStart(2, '0')}`;
|
|
console.log(`Target date cell title: ${titleDate}`);
|
|
|
|
// Open the date range picker by clicking the start date input
|
|
const startInput = page.locator('input[placeholder="Start date"]').first();
|
|
await startInput.click();
|
|
await page.waitForTimeout(1000);
|
|
console.log('✓ Opened date range picker');
|
|
|
|
// The calendar opens on the current month - page back until the
|
|
// target month is in view (this dataset's dates can be well over a
|
|
// year old, so the calendar needs more than a couple of clicks back).
|
|
const dayCell = page.locator(`td[title="${titleDate}"]`).first();
|
|
const prevBtn = page.locator('.ant-picker-header-prev-btn').first();
|
|
let dayCellFound = false;
|
|
for (let attempt = 0; attempt < 60; attempt++) {
|
|
dayCellFound = await dayCell.isVisible().catch(() => false);
|
|
if (dayCellFound) break;
|
|
await prevBtn.click();
|
|
await page.waitForTimeout(300);
|
|
}
|
|
expect(dayCellFound, `Target date ${titleDate} should become available in the calendar`).toBe(true);
|
|
|
|
// Click the target day cell for the start of the range
|
|
await dayCell.click();
|
|
await page.waitForTimeout(500);
|
|
console.log('✓ Selected start date');
|
|
|
|
// Click the same day cell again for the end of the range (single-day filter)
|
|
const dayCellEnd = page.locator(`td[title="${titleDate}"]`).first();
|
|
await dayCellEnd.click();
|
|
await page.waitForTimeout(1500);
|
|
console.log('✓ Selected end date (same day)');
|
|
|
|
// Verify the filter actually took: read back the start/end input values
|
|
const startVal = await page.locator('input[date-range="start"], input[placeholder="Start date"]').first().inputValue().catch(() => '');
|
|
const endVal = await page.locator('input[date-range="end"], input[placeholder="End date"]').first().inputValue().catch(() => '');
|
|
console.log(`Start input value: "${startVal}"`);
|
|
console.log(`End input value: "${endVal}"`);
|
|
|
|
const pickerOpen = await page.locator('.ant-picker-dropdown:visible').count().catch(() => 0);
|
|
console.log(`Picker dropdown still open: ${pickerOpen > 0 ? 'yes' : 'no'}`);
|
|
|
|
if (startVal === titleDate && endVal === titleDate) {
|
|
console.log(`✓ Date range correctly applied: ${titleDate} to ${titleDate}`);
|
|
} else {
|
|
console.log(`⚠ Date range may not have applied correctly (expected ${titleDate} for both)`);
|
|
}
|
|
|
|
console.log(`✓ Filtered Discounts page to ${latestDate}`);
|
|
});
|
|
|
|
// Step 7: Read the Discounts-page total and cross-check against Transactions
|
|
await test.step('Step 7: Cross-check total discount', async () => {
|
|
await page.waitForTimeout(1500);
|
|
|
|
const parseNum = (s: string | null | undefined): number => {
|
|
const n = parseFloat((s || '').replace(/[^0-9.\-]/g, ''));
|
|
return isNaN(n) ? 0 : n;
|
|
};
|
|
|
|
// Top "Total Discount" card (large number) - this is the source of truth
|
|
const totalDiscountCard = page.locator('article.text-4xl').first();
|
|
const cardText = await totalDiscountCard.textContent().catch(() => '');
|
|
const discountPageTotal = parseNum(cardText);
|
|
|
|
// Scheme Discount Breakdown table (scoped via its heading) for internal detail
|
|
const schemeTable = page.locator('xpath=//*[contains(text(),"Scheme Discount Breakdown")]/following::table[1]').first();
|
|
let breakdownMDR = 0, breakdownVAT = 0, breakdownFee = 0, breakdownRed = 0;
|
|
|
|
if (await schemeTable.isVisible({ timeout: 3000 }).catch(() => false)) {
|
|
// Map header columns to indexes
|
|
const headers = schemeTable.locator('thead th');
|
|
const headerCount = await headers.count();
|
|
let mdrIdx = -1, vatIdx = -1, feeIdx = -1, dfIdx = -1;
|
|
for (let i = 0; i < headerCount; i++) {
|
|
const t = (await headers.nth(i).textContent())?.trim().toLowerCase();
|
|
if (t === 'mdr') mdrIdx = i;
|
|
else if (t === 'vat') vatIdx = i;
|
|
else if (t?.includes('processing') || t === 'fee') feeIdx = i;
|
|
else if (t?.includes('discounts')) dfIdx = i;
|
|
}
|
|
|
|
// Sum across all card-scheme rows. Each amount cell renders a
|
|
// currency-icon SVG (with an embedded <style>.cls-1{fill:#231f20}...)
|
|
// in one <article> and the actual number in a later <article> - reading
|
|
// the whole <td>'s text (like the naive version below did) drags the
|
|
// icon's hex-color digits into the string and corrupts the parsed
|
|
// number. Scope to the last <article>, same fix already used for the
|
|
// Transactions-page MDR/VAT/Fee columns above.
|
|
const readCellNum = async (row: ReturnType<typeof schemeTable.locator>, idx: number): Promise<number> => {
|
|
if (idx < 0) return 0;
|
|
const cell = row.locator('td').nth(idx);
|
|
const valArticle = cell.locator('article').last();
|
|
const txt = (await valArticle.textContent().catch(() => '')) || (await cell.textContent().catch(() => '')) || '';
|
|
return parseNum(txt);
|
|
};
|
|
|
|
const rows = schemeTable.locator('tbody tr:not([aria-hidden="true"])');
|
|
const rowCount = await rows.count();
|
|
for (let r = 0; r < rowCount; r++) {
|
|
const row = rows.nth(r);
|
|
breakdownMDR += await readCellNum(row, mdrIdx);
|
|
breakdownVAT += await readCellNum(row, vatIdx);
|
|
breakdownFee += await readCellNum(row, feeIdx);
|
|
breakdownRed += await readCellNum(row, dfIdx);
|
|
}
|
|
}
|
|
|
|
const breakdownSum = breakdownMDR + breakdownVAT + breakdownFee;
|
|
|
|
console.log('\n========== CROSS-CHECK ==========');
|
|
console.log(`Date: ${latestDate}`);
|
|
console.log(`Transactions page sum (rounded per-row MDR+VAT+Fee): ${txnTotalDiscount.toFixed(2)}`);
|
|
console.log(` - MDR: ${txnTotalMDR.toFixed(2)}, VAT: ${txnTotalVAT.toFixed(2)}, Fee: ${txnTotalFee.toFixed(2)}`);
|
|
console.log(`Discounts page Total Discount card: ${discountPageTotal.toFixed(2)}`);
|
|
console.log(`Discounts page breakdown -> MDR: ${breakdownMDR.toFixed(2)}, VAT: ${breakdownVAT.toFixed(2)}, Fee: ${breakdownFee.toFixed(2)} (sum ${breakdownSum.toFixed(2)})`);
|
|
console.log(`Discounts page breakdown "Discounts & fees" column: ${breakdownRed.toFixed(2)}`);
|
|
|
|
const info = test.info();
|
|
|
|
// NOTE: the "Total Discount" card is scoped to the selected date range,
|
|
// but the Scheme/Terminal Discount Breakdown tables show all-time
|
|
// cumulative totals regardless of the date filter (confirmed against
|
|
// the live Terminal Discount Breakdown, which shows values far larger
|
|
// than any single day could produce). So card vs breakdown-sum is NOT
|
|
// expected to match - this is informational only, not an assertion.
|
|
const internalDiff = Math.abs(discountPageTotal - breakdownSum);
|
|
if (internalDiff < 0.01) {
|
|
console.log(`Discounts page card and breakdown happen to match (${discountPageTotal.toFixed(2)}) - breakdown is all-time, card is date-filtered`);
|
|
} else {
|
|
console.log(`Discounts page card (date-filtered: ${discountPageTotal.toFixed(2)}) vs breakdown (all-time: ${breakdownSum.toFixed(2)}) - expected to differ, not a bug`);
|
|
}
|
|
|
|
// Cross-check Transactions sum vs Discounts total
|
|
const diff = Math.abs(txnTotalDiscount - discountPageTotal);
|
|
const summary =
|
|
`Date: ${latestDate}\n` +
|
|
`Transactions page sum (rounded per-row): ${txnTotalDiscount.toFixed(2)}\n` +
|
|
`Discounts page total: ${discountPageTotal.toFixed(2)}\n` +
|
|
`Difference: ${diff.toFixed(2)}`;
|
|
|
|
await info.attach('discount-cross-check', { body: summary, contentType: 'text/plain' });
|
|
|
|
if (diff < 0.01) {
|
|
console.log(`✓ MATCH: totals are equal (${discountPageTotal.toFixed(2)})`);
|
|
} else {
|
|
const msg = `Calculation mismatch on ${latestDate}: Transactions page sum = ${txnTotalDiscount.toFixed(2)}, Discounts page total = ${discountPageTotal.toFixed(2)} (difference ${diff.toFixed(2)})`;
|
|
console.log(`⚠ ${msg}`);
|
|
info.annotations.push({ type: 'mismatch', description: msg });
|
|
// Surface as a soft failure so the Playwright report flags it without aborting
|
|
expect.soft(txnTotalDiscount, msg).toBeCloseTo(discountPageTotal, 2);
|
|
}
|
|
console.log('=================================\n');
|
|
});
|
|
|
|
// Step 8: Read configured discount rates from Settings (source of truth)
|
|
await test.step('Step 8: Read discount rates from Settings', async () => {
|
|
await navigateToSettings(page);
|
|
await page.waitForTimeout(2000);
|
|
console.log('✓ On Settings page');
|
|
|
|
// Click the "Discount rates" tab
|
|
const discountRatesTab = page.getByText('Discount rates', { exact: true }).first();
|
|
await discountRatesTab.click();
|
|
await page.waitForTimeout(1500);
|
|
console.log('✓ Opened Discount rates');
|
|
|
|
const parseNum = (s: string | null | undefined): number => {
|
|
const n = parseFloat((s || '').replace(/[^0-9.\-]/g, ''));
|
|
return isNaN(n) ? 0 : n;
|
|
};
|
|
|
|
// Scope to the Discount Rate Configuration table
|
|
const ratesTable = page.locator('xpath=//*[contains(text(),"Discount Rate Configuration")]/following::table[1]').first();
|
|
await ratesTable.waitFor({ state: 'visible', timeout: 10000 });
|
|
|
|
// Map header columns to indexes
|
|
const headers = ratesTable.locator('thead th');
|
|
const headerCount = await headers.count();
|
|
let cardIdx = -1, mdrIdx = -1, vatIdx = -1, capIdx = -1, feeIdx = -1;
|
|
for (let i = 0; i < headerCount; i++) {
|
|
const t = (await headers.nth(i).textContent())?.trim().toLowerCase();
|
|
if (t === 'card') cardIdx = i;
|
|
else if (t === 'mdr') mdrIdx = i;
|
|
else if (t === 'vat') vatIdx = i;
|
|
else if (t?.includes('cap')) capIdx = i;
|
|
else if (t?.includes('processing') || t === 'fee') feeIdx = i;
|
|
}
|
|
console.log(`Rate columns -> card:${cardIdx} mdr:${mdrIdx} vat:${vatIdx} cap:${capIdx} fee:${feeIdx}`);
|
|
|
|
const rateMap: Record<string, { mdr: number; vat: number; cap: number; fee: number }> = {};
|
|
|
|
const rows = ratesTable.locator('tbody tr');
|
|
const rowCount = await rows.count();
|
|
for (let r = 0; r < rowCount; r++) {
|
|
const cellTexts = await rows.nth(r).locator('td').allTextContents();
|
|
const cardText = cellTexts[cardIdx] || '';
|
|
// Extract the English card-scheme name after the "/"
|
|
const nameMatch = cardText.match(/\/\s*([A-Za-z]+)/);
|
|
const cardName = (nameMatch ? nameMatch[1] : cardText.trim()).toLowerCase();
|
|
|
|
const mdr = parseNum(cellTexts[mdrIdx]);
|
|
const vat = parseNum(cellTexts[vatIdx]);
|
|
const cap = capIdx >= 0 ? parseNum(cellTexts[capIdx]) : 0;
|
|
const fee = feeIdx >= 0 ? parseNum(cellTexts[feeIdx]) : 0;
|
|
|
|
rateMap[cardName] = { mdr, vat, cap, fee };
|
|
console.log(` ${cardName}: MDR ${mdr}% | VAT ${vat}% | Cap ${cap} | Fee ${fee}%`);
|
|
}
|
|
|
|
console.log('\n========== DISCOUNT RATES (Settings) ==========');
|
|
console.log(JSON.stringify(rateMap, null, 2));
|
|
console.log('===============================================\n');
|
|
});
|
|
});
|
|
});
|