- Fix billing components ConnectError type compatibility with exactOptionalPropertyTypes - Update Select component usage to match proper API (options vs children) - Remove unused imports and fix optional property assignments in system components - Resolve duplicate Order/Ticket type definitions and add null safety checks - Handle optional branding properties correctly in organization features - Add window property type declarations for test environment - Fix Playwright API usage (page.setOffline → page.context().setOffline) - Clean up unused imports, variables, and parameters across codebase - Add comprehensive global type declarations for test window extensions Resolves major TypeScript compilation issues and improves type safety throughout the application. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1102 lines
41 KiB
TypeScript
1102 lines
41 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
|
|
|
// Mock data removed - was unused
|
|
|
|
const mockLedgerEntries = [
|
|
{
|
|
id: 'ledger-1',
|
|
orgId: 'test-org-1',
|
|
eventId: 'test-event-1',
|
|
orderId: 'test-order-1',
|
|
type: 'sale',
|
|
amountCents: 15000,
|
|
currency: 'USD',
|
|
createdAt: new Date().toISOString(),
|
|
stripe: {
|
|
balanceTxnId: 'txn_mock_1',
|
|
chargeId: 'ch_mock_1',
|
|
accountId: 'acct_mock_1',
|
|
},
|
|
},
|
|
{
|
|
id: 'ledger-2',
|
|
orgId: 'test-org-1',
|
|
eventId: 'test-event-1',
|
|
orderId: 'test-order-1',
|
|
type: 'platform_fee',
|
|
amountCents: 450,
|
|
currency: 'USD',
|
|
createdAt: new Date().toISOString(),
|
|
stripe: {
|
|
balanceTxnId: 'txn_mock_1',
|
|
chargeId: 'ch_mock_1',
|
|
accountId: 'acct_mock_1',
|
|
},
|
|
},
|
|
{
|
|
id: 'ledger-3',
|
|
orgId: 'test-org-1',
|
|
eventId: 'test-event-1',
|
|
orderId: 'test-order-1',
|
|
type: 'fee',
|
|
amountCents: -465,
|
|
currency: 'USD',
|
|
createdAt: new Date().toISOString(),
|
|
stripe: {
|
|
balanceTxnId: 'txn_mock_1',
|
|
chargeId: 'ch_mock_1',
|
|
accountId: 'acct_mock_1',
|
|
},
|
|
},
|
|
];
|
|
|
|
test.describe('Refunds and Disputes System', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
// Mock network requests for refunds API
|
|
await page.route('**/createRefund', async (route) => {
|
|
const request = await route.request().postDataJSON();
|
|
|
|
// Simulate validation errors
|
|
if (!request.orderId) {
|
|
await route.fulfill({
|
|
status: 400,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'orderId is required' })
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.amountCents && request.amountCents > 15000) {
|
|
await route.fulfill({
|
|
status: 400,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
error: 'Invalid refund amount: exceeds order total',
|
|
details: `Refund amount ${request.amountCents} exceeds order total 15000`
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Simulate successful refund
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
refundId: 'refund_mock_123',
|
|
stripeRefundId: 're_mock_123',
|
|
amountCents: request.amountCents || 15000,
|
|
status: 'succeeded'
|
|
})
|
|
});
|
|
});
|
|
|
|
// Mock get order refunds API
|
|
await page.route('**/getOrderRefunds', async (route) => {
|
|
const request = await route.request().postDataJSON();
|
|
|
|
if (request.orderId === 'test-order-1') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
refunds: [
|
|
{
|
|
id: 'refund_mock_123',
|
|
amountCents: 7500,
|
|
status: 'succeeded',
|
|
createdAt: new Date().toISOString(),
|
|
reason: 'Customer request'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
} else {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ refunds: [] })
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mock reconciliation API
|
|
await page.route('**/getReconciliationData', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
summary: {
|
|
grossSales: 15000,
|
|
refunds: 0,
|
|
stripeFees: 465,
|
|
platformFees: 450,
|
|
disputeFees: 0,
|
|
netToOrganizer: 14085,
|
|
totalTransactions: 1,
|
|
period: {
|
|
start: '2024-01-01',
|
|
end: '2024-12-31'
|
|
}
|
|
},
|
|
entries: mockLedgerEntries,
|
|
total: mockLedgerEntries.length
|
|
})
|
|
});
|
|
});
|
|
|
|
// Mock events API for reconciliation
|
|
await page.route('**/getReconciliationEvents', async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
events: [
|
|
{
|
|
id: 'test-event-1',
|
|
name: 'Test Concert 2024',
|
|
startAt: '2024-08-01T19:00:00Z'
|
|
}
|
|
]
|
|
})
|
|
});
|
|
});
|
|
|
|
// Mock dispute API
|
|
await page.route('**/getOrderDisputes', async (route) => {
|
|
const request = await route.request().postDataJSON();
|
|
|
|
if (request.orderId === 'disputed-order') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
orderId: 'disputed-order',
|
|
dispute: {
|
|
disputeId: 'dp_mock_789',
|
|
status: 'warning_needs_response',
|
|
reason: 'fraudulent',
|
|
createdAt: new Date().toISOString()
|
|
}
|
|
})
|
|
});
|
|
} else {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
orderId: request.orderId,
|
|
dispute: null
|
|
})
|
|
});
|
|
}
|
|
});
|
|
|
|
// Mock Firebase authentication
|
|
await page.addInitScript(() => {
|
|
// @ts-ignore
|
|
window.mockUser = {
|
|
email: 'admin@example.com',
|
|
organization: {
|
|
id: 'test-org-1',
|
|
name: 'Test Organization'
|
|
},
|
|
role: 'admin'
|
|
};
|
|
});
|
|
});
|
|
|
|
test('should display orders table with refund actions', async ({ page }) => {
|
|
// Mock the orders management page
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Orders Management</title>
|
|
<meta charset="utf-8">
|
|
</head>
|
|
<body>
|
|
<div id="orders-container">
|
|
<h1>Event Orders</h1>
|
|
<div class="order-card" data-order-id="test-order-1">
|
|
<div class="order-header">
|
|
<h3>Order #test-order-1</h3>
|
|
<span class="status-badge paid">Paid</span>
|
|
<span class="amount">$150.00</span>
|
|
</div>
|
|
<div class="order-details">
|
|
<p>Customer: test@example.com</p>
|
|
<p>Tickets: 2</p>
|
|
</div>
|
|
<div class="order-actions">
|
|
<button id="refund-btn" class="refund-button">Create Refund</button>
|
|
<button class="view-details-btn">View Details</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Refund Modal -->
|
|
<div id="refund-modal" class="modal" style="display: none;">
|
|
<div class="modal-content">
|
|
<h2>Create Refund</h2>
|
|
<div class="refund-type-selection">
|
|
<label>
|
|
<input type="radio" name="refundType" value="full" checked>
|
|
Full Order Refund ($150.00)
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="refundType" value="partial">
|
|
Custom Amount
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="refundType" value="tickets">
|
|
Specific Tickets
|
|
</label>
|
|
</div>
|
|
|
|
<div id="custom-amount" style="display: none;">
|
|
<label>Refund Amount:</label>
|
|
<input type="number" id="refund-amount" step="0.01" max="150.00" placeholder="0.00">
|
|
</div>
|
|
|
|
<div id="ticket-selection" style="display: none;">
|
|
<h4>Select Tickets:</h4>
|
|
<label>
|
|
<input type="checkbox" data-ticket-id="ticket-1" data-amount="75.00">
|
|
General Admission - $75.00 (issued)
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" data-ticket-id="ticket-2" data-amount="75.00">
|
|
General Admission - $75.00 (scanned)
|
|
</label>
|
|
</div>
|
|
|
|
<div class="refund-summary">
|
|
<div id="refund-total">Refund Amount: $150.00</div>
|
|
</div>
|
|
|
|
<div class="reason-input">
|
|
<label>Reason (Optional):</label>
|
|
<input type="text" id="refund-reason" placeholder="Reason for refund...">
|
|
</div>
|
|
|
|
<div class="modal-actions">
|
|
<button id="cancel-refund">Cancel</button>
|
|
<button id="confirm-refund">Create Refund</button>
|
|
</div>
|
|
|
|
<div id="refund-result" style="display: none;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const refundBtn = document.getElementById('refund-btn');
|
|
const modal = document.getElementById('refund-modal');
|
|
const cancelBtn = document.getElementById('cancel-refund');
|
|
const confirmBtn = document.getElementById('confirm-refund');
|
|
const customAmountDiv = document.getElementById('custom-amount');
|
|
const ticketSelectionDiv = document.getElementById('ticket-selection');
|
|
const refundTotalDiv = document.getElementById('refund-total');
|
|
const resultDiv = document.getElementById('refund-result');
|
|
|
|
let selectedRefundType = 'full';
|
|
let refundAmount = 15000; // $150.00 in cents
|
|
|
|
refundBtn.addEventListener('click', () => {
|
|
modal.style.display = 'block';
|
|
});
|
|
|
|
cancelBtn.addEventListener('click', () => {
|
|
modal.style.display = 'none';
|
|
resultDiv.style.display = 'none';
|
|
resultDiv.className = '';
|
|
});
|
|
|
|
// Handle refund type changes
|
|
document.querySelectorAll('input[name="refundType"]').forEach(radio => {
|
|
radio.addEventListener('change', (e) => {
|
|
selectedRefundType = e.target.value;
|
|
customAmountDiv.style.display = selectedRefundType === 'partial' ? 'block' : 'none';
|
|
ticketSelectionDiv.style.display = selectedRefundType === 'tickets' ? 'block' : 'none';
|
|
updateRefundAmount();
|
|
});
|
|
});
|
|
|
|
// Handle custom amount input
|
|
document.getElementById('refund-amount').addEventListener('input', updateRefundAmount);
|
|
|
|
// Handle ticket selection
|
|
document.querySelectorAll('input[type="checkbox"][data-ticket-id]').forEach(checkbox => {
|
|
checkbox.addEventListener('change', updateRefundAmount);
|
|
});
|
|
|
|
function updateRefundAmount() {
|
|
if (selectedRefundType === 'full') {
|
|
refundAmount = 15000;
|
|
} else if (selectedRefundType === 'partial') {
|
|
const customAmount = parseFloat(document.getElementById('refund-amount').value) || 0;
|
|
refundAmount = Math.round(customAmount * 100);
|
|
} else if (selectedRefundType === 'tickets') {
|
|
refundAmount = 0;
|
|
document.querySelectorAll('input[type="checkbox"][data-ticket-id]:checked').forEach(checkbox => {
|
|
refundAmount += Math.round(parseFloat(checkbox.dataset.amount) * 100);
|
|
});
|
|
}
|
|
|
|
refundTotalDiv.textContent = 'Refund Amount: $' + (refundAmount / 100).toFixed(2);
|
|
}
|
|
|
|
confirmBtn.addEventListener('click', async () => {
|
|
const reason = document.getElementById('refund-reason').value;
|
|
const refundRequest = {
|
|
orderId: 'test-order-1',
|
|
reason: reason || undefined
|
|
};
|
|
|
|
if (selectedRefundType === 'partial' || (selectedRefundType === 'tickets' &&
|
|
document.querySelectorAll('input[type="checkbox"][data-ticket-id]:checked').length > 1)) {
|
|
refundRequest.amountCents = refundAmount;
|
|
} else if (selectedRefundType === 'tickets') {
|
|
const selectedTicket = document.querySelector('input[type="checkbox"][data-ticket-id]:checked');
|
|
if (selectedTicket) {
|
|
refundRequest.ticketId = selectedTicket.dataset.ticketId;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/createRefund', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(refundRequest)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
resultDiv.innerHTML = '<p class="success">Refund created successfully: ' + result.refundId + '</p>';
|
|
resultDiv.className = 'success';
|
|
confirmBtn.disabled = true;
|
|
} else {
|
|
resultDiv.innerHTML = '<p class="error">Error: ' + result.error + '</p>';
|
|
resultDiv.className = 'error';
|
|
}
|
|
|
|
resultDiv.style.display = 'block';
|
|
} catch (error) {
|
|
resultDiv.innerHTML = '<p class="error">Network error: ' + error.message + '</p>';
|
|
resultDiv.className = 'error';
|
|
resultDiv.style.display = 'block';
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 1000; }
|
|
.modal-content { position: relative; margin: 50px auto; width: 500px; background: white; padding: 20px; border-radius: 8px; }
|
|
.refund-type-selection label { display: block; margin: 10px 0; }
|
|
.modal-actions { margin-top: 20px; text-align: right; }
|
|
.modal-actions button { margin-left: 10px; padding: 8px 16px; }
|
|
#refund-result.success { color: green; }
|
|
#refund-result.error { color: red; }
|
|
.order-card { border: 1px solid #ddd; padding: 16px; margin: 16px 0; border-radius: 8px; }
|
|
.status-badge.paid { background: green; color: white; padding: 4px 8px; border-radius: 4px; }
|
|
.refund-button { background: #f39c12; color: white; padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; }
|
|
</style>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
// Verify initial page content
|
|
await expect(page.locator('h1')).toContainText('Event Orders');
|
|
await expect(page.locator('.order-card')).toBeVisible();
|
|
await expect(page.locator('.status-badge.paid')).toContainText('Paid');
|
|
await expect(page.locator('.amount')).toContainText('$150.00');
|
|
|
|
// Click refund button to open modal
|
|
await page.click('#refund-btn');
|
|
await expect(page.locator('#refund-modal')).toBeVisible();
|
|
await expect(page.locator('#refund-modal h2')).toContainText('Create Refund');
|
|
|
|
// Test full refund (default)
|
|
await expect(page.locator('input[value="full"]')).toBeChecked();
|
|
await expect(page.locator('#refund-total')).toContainText('$150.00');
|
|
|
|
// Create full refund
|
|
await page.click('#confirm-refund');
|
|
|
|
// Verify success message
|
|
await expect(page.locator('#refund-result')).toBeVisible();
|
|
await expect(page.locator('#refund-result')).toContainText('Refund created successfully');
|
|
await expect(page.locator('#refund-result')).toContainText('refund_mock_123');
|
|
});
|
|
|
|
test('should handle partial refund with custom amount', async ({ page }) => {
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<button id="open-refund-modal">Open Refund Modal</button>
|
|
|
|
<div id="refund-modal">
|
|
<div class="refund-type-selection">
|
|
<label>
|
|
<input type="radio" name="refundType" value="full">
|
|
Full Order Refund ($150.00)
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="refundType" value="partial">
|
|
Custom Amount
|
|
</label>
|
|
</div>
|
|
|
|
<div id="custom-amount" style="display: none;">
|
|
<label>Refund Amount:</label>
|
|
<input type="number" id="refund-amount" step="0.01" max="150.00" placeholder="0.00">
|
|
</div>
|
|
|
|
<div id="refund-total">Refund Amount: $150.00</div>
|
|
<button id="confirm-refund">Create Refund</button>
|
|
<div id="refund-result" style="display: none;"></div>
|
|
</div>
|
|
|
|
<script>
|
|
let selectedRefundType = 'full';
|
|
let refundAmount = 15000;
|
|
|
|
document.querySelectorAll('input[name="refundType"]').forEach(radio => {
|
|
radio.addEventListener('change', (e) => {
|
|
selectedRefundType = e.target.value;
|
|
document.getElementById('custom-amount').style.display =
|
|
selectedRefundType === 'partial' ? 'block' : 'none';
|
|
updateRefundAmount();
|
|
});
|
|
});
|
|
|
|
document.getElementById('refund-amount').addEventListener('input', updateRefundAmount);
|
|
|
|
function updateRefundAmount() {
|
|
if (selectedRefundType === 'full') {
|
|
refundAmount = 15000;
|
|
} else if (selectedRefundType === 'partial') {
|
|
const customAmount = parseFloat(document.getElementById('refund-amount').value) || 0;
|
|
refundAmount = Math.round(customAmount * 100);
|
|
}
|
|
|
|
document.getElementById('refund-total').textContent = 'Refund Amount: $' + (refundAmount / 100).toFixed(2);
|
|
}
|
|
|
|
document.getElementById('confirm-refund').addEventListener('click', async () => {
|
|
const refundRequest = {
|
|
orderId: 'test-order-1',
|
|
amountCents: refundAmount
|
|
};
|
|
|
|
try {
|
|
const response = await fetch('/createRefund', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(refundRequest)
|
|
});
|
|
|
|
const result = await response.json();
|
|
const resultDiv = document.getElementById('refund-result');
|
|
|
|
if (response.ok) {
|
|
resultDiv.innerHTML = 'Success: $' + (result.amountCents / 100).toFixed(2) + ' refunded';
|
|
} else {
|
|
resultDiv.innerHTML = 'Error: ' + result.error;
|
|
}
|
|
|
|
resultDiv.style.display = 'block';
|
|
} catch (error) {
|
|
document.getElementById('refund-result').innerHTML = 'Network error: ' + error.message;
|
|
document.getElementById('refund-result').style.display = 'block';
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
// Select partial refund type
|
|
await page.click('input[value="partial"]');
|
|
await expect(page.locator('#custom-amount')).toBeVisible();
|
|
|
|
// Enter custom amount
|
|
await page.fill('#refund-amount', '75.50');
|
|
await expect(page.locator('#refund-total')).toContainText('$75.50');
|
|
|
|
// Create partial refund
|
|
await page.click('#confirm-refund');
|
|
|
|
// Verify success
|
|
await expect(page.locator('#refund-result')).toContainText('Success: $75.50 refunded');
|
|
});
|
|
|
|
test('should handle ticket-specific refunds', async ({ page }) => {
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<div class="refund-type-selection">
|
|
<label>
|
|
<input type="radio" name="refundType" value="full">
|
|
Full Order Refund
|
|
</label>
|
|
<label>
|
|
<input type="radio" name="refundType" value="tickets">
|
|
Specific Tickets
|
|
</label>
|
|
</div>
|
|
|
|
<div id="ticket-selection" style="display: none;">
|
|
<h4>Select Tickets:</h4>
|
|
<label>
|
|
<input type="checkbox" data-ticket-id="ticket-1" data-amount="75.00">
|
|
General Admission - $75.00 (issued)
|
|
</label>
|
|
<label>
|
|
<input type="checkbox" data-ticket-id="ticket-2" data-amount="75.00">
|
|
General Admission - $75.00 (scanned)
|
|
</label>
|
|
</div>
|
|
|
|
<div id="refund-total">Refund Amount: $150.00</div>
|
|
<button id="confirm-refund">Create Refund</button>
|
|
<div id="refund-result" style="display: none;"></div>
|
|
|
|
<script>
|
|
let selectedRefundType = 'full';
|
|
|
|
document.querySelectorAll('input[name="refundType"]').forEach(radio => {
|
|
radio.addEventListener('change', (e) => {
|
|
selectedRefundType = e.target.value;
|
|
document.getElementById('ticket-selection').style.display =
|
|
selectedRefundType === 'tickets' ? 'block' : 'none';
|
|
updateRefundAmount();
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('input[type="checkbox"][data-ticket-id]').forEach(checkbox => {
|
|
checkbox.addEventListener('change', updateRefundAmount);
|
|
});
|
|
|
|
function updateRefundAmount() {
|
|
let refundAmount = 0;
|
|
|
|
if (selectedRefundType === 'full') {
|
|
refundAmount = 15000;
|
|
} else if (selectedRefundType === 'tickets') {
|
|
document.querySelectorAll('input[type="checkbox"][data-ticket-id]:checked').forEach(checkbox => {
|
|
refundAmount += Math.round(parseFloat(checkbox.dataset.amount) * 100);
|
|
});
|
|
}
|
|
|
|
document.getElementById('refund-total').textContent = 'Refund Amount: $' + (refundAmount / 100).toFixed(2);
|
|
}
|
|
|
|
document.getElementById('confirm-refund').addEventListener('click', async () => {
|
|
let refundRequest = { orderId: 'test-order-1' };
|
|
|
|
if (selectedRefundType === 'tickets') {
|
|
const selectedTickets = document.querySelectorAll('input[type="checkbox"][data-ticket-id]:checked');
|
|
if (selectedTickets.length === 1) {
|
|
refundRequest.ticketId = selectedTickets[0].dataset.ticketId;
|
|
} else if (selectedTickets.length > 1) {
|
|
let totalAmount = 0;
|
|
selectedTickets.forEach(ticket => {
|
|
totalAmount += Math.round(parseFloat(ticket.dataset.amount) * 100);
|
|
});
|
|
refundRequest.amountCents = totalAmount;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/createRefund', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(refundRequest)
|
|
});
|
|
|
|
const result = await response.json();
|
|
const resultDiv = document.getElementById('refund-result');
|
|
|
|
if (response.ok) {
|
|
resultDiv.innerHTML = 'Ticket refund successful';
|
|
} else {
|
|
resultDiv.innerHTML = 'Error: ' + result.error;
|
|
}
|
|
|
|
resultDiv.style.display = 'block';
|
|
} catch (error) {
|
|
document.getElementById('refund-result').innerHTML = 'Error: ' + error.message;
|
|
document.getElementById('refund-result').style.display = 'block';
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
// Select tickets refund type
|
|
await page.click('input[value="tickets"]');
|
|
await expect(page.locator('#ticket-selection')).toBeVisible();
|
|
|
|
// Select first ticket only
|
|
await page.click('input[data-ticket-id="ticket-1"]');
|
|
await expect(page.locator('#refund-total')).toContainText('$75.00');
|
|
|
|
// Create single ticket refund
|
|
await page.click('#confirm-refund');
|
|
await expect(page.locator('#refund-result')).toContainText('Ticket refund successful');
|
|
});
|
|
|
|
test('should validate refund amounts and show errors', async ({ page }) => {
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<div class="refund-type-selection">
|
|
<label>
|
|
<input type="radio" name="refundType" value="partial" checked>
|
|
Custom Amount
|
|
</label>
|
|
</div>
|
|
|
|
<div id="custom-amount">
|
|
<label>Refund Amount:</label>
|
|
<input type="number" id="refund-amount" step="0.01" max="150.00" placeholder="0.00">
|
|
</div>
|
|
|
|
<div id="refund-total">Refund Amount: $0.00</div>
|
|
<button id="confirm-refund">Create Refund</button>
|
|
<div id="refund-result" style="display: none;"></div>
|
|
|
|
<script>
|
|
function updateRefundAmount() {
|
|
const customAmount = parseFloat(document.getElementById('refund-amount').value) || 0;
|
|
const refundAmount = Math.round(customAmount * 100);
|
|
document.getElementById('refund-total').textContent = 'Refund Amount: $' + (refundAmount / 100).toFixed(2);
|
|
}
|
|
|
|
document.getElementById('refund-amount').addEventListener('input', updateRefundAmount);
|
|
|
|
document.getElementById('confirm-refund').addEventListener('click', async () => {
|
|
const customAmount = parseFloat(document.getElementById('refund-amount').value) || 0;
|
|
const refundAmountCents = Math.round(customAmount * 100);
|
|
|
|
const refundRequest = {
|
|
orderId: 'test-order-1',
|
|
amountCents: refundAmountCents
|
|
};
|
|
|
|
try {
|
|
const response = await fetch('/createRefund', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(refundRequest)
|
|
});
|
|
|
|
const result = await response.json();
|
|
const resultDiv = document.getElementById('refund-result');
|
|
|
|
if (response.ok) {
|
|
resultDiv.innerHTML = 'Success: ' + result.refundId;
|
|
} else {
|
|
resultDiv.innerHTML = 'Error: ' + result.error;
|
|
}
|
|
|
|
resultDiv.style.display = 'block';
|
|
} catch (error) {
|
|
document.getElementById('refund-result').innerHTML = 'Error: ' + error.message;
|
|
document.getElementById('refund-result').style.display = 'block';
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
// Test excessive refund amount
|
|
await page.fill('#refund-amount', '200.00');
|
|
await page.click('#confirm-refund');
|
|
|
|
await expect(page.locator('#refund-result')).toContainText('Error: Invalid refund amount: exceeds order total');
|
|
});
|
|
|
|
test('should display reconciliation report with correct calculations', async ({ page }) => {
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<div id="reconciliation-container">
|
|
<h1>Reconciliation Report</h1>
|
|
|
|
<div class="filters">
|
|
<input type="date" id="start-date" value="2024-01-01">
|
|
<input type="date" id="end-date" value="2024-12-31">
|
|
<select id="event-select">
|
|
<option value="all">All Events</option>
|
|
</select>
|
|
<button id="load-data">Load Data</button>
|
|
</div>
|
|
|
|
<div id="summary-cards" style="display: none;">
|
|
<div class="summary-card">
|
|
<h3>Gross Sales</h3>
|
|
<div id="gross-sales">$0.00</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h3>Refunds</h3>
|
|
<div id="refunds">$0.00</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h3>Stripe Fees</h3>
|
|
<div id="stripe-fees">$0.00</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h3>Platform Fees</h3>
|
|
<div id="platform-fees">$0.00</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<h3>Net to Organizer</h3>
|
|
<div id="net-amount">$0.00</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="transactions-table" style="display: none;">
|
|
<h2>Detailed Breakdown</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Date</th>
|
|
<th>Type</th>
|
|
<th>Amount</th>
|
|
<th>Order</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="transactions-body">
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<button id="export-csv" style="display: none;">Export CSV</button>
|
|
<div id="loading" style="display: none;">Loading...</div>
|
|
</div>
|
|
|
|
<script>
|
|
const formatCurrency = (cents) => {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'USD',
|
|
}).format(cents / 100);
|
|
};
|
|
|
|
const formatDate = (dateString) => {
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
};
|
|
|
|
document.getElementById('load-data').addEventListener('click', async () => {
|
|
const loadingDiv = document.getElementById('loading');
|
|
const summaryDiv = document.getElementById('summary-cards');
|
|
const tableDiv = document.getElementById('transactions-table');
|
|
const exportBtn = document.getElementById('export-csv');
|
|
|
|
loadingDiv.style.display = 'block';
|
|
summaryDiv.style.display = 'none';
|
|
tableDiv.style.display = 'none';
|
|
exportBtn.style.display = 'none';
|
|
|
|
try {
|
|
const response = await fetch('/getReconciliationData', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
orgId: 'test-org-1',
|
|
startDate: document.getElementById('start-date').value,
|
|
endDate: document.getElementById('end-date').value,
|
|
eventId: document.getElementById('event-select').value
|
|
})
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const summary = data.summary;
|
|
|
|
// Update summary cards
|
|
document.getElementById('gross-sales').textContent = formatCurrency(summary.grossSales);
|
|
document.getElementById('refunds').textContent = formatCurrency(summary.refunds);
|
|
document.getElementById('stripe-fees').textContent = formatCurrency(summary.stripeFees);
|
|
document.getElementById('platform-fees').textContent = formatCurrency(summary.platformFees);
|
|
document.getElementById('net-amount').textContent = formatCurrency(summary.netToOrganizer);
|
|
|
|
// Update transactions table
|
|
const tbody = document.getElementById('transactions-body');
|
|
tbody.innerHTML = '';
|
|
|
|
data.entries.forEach(entry => {
|
|
const row = tbody.insertRow();
|
|
row.insertCell().textContent = formatDate(entry.createdAt);
|
|
row.insertCell().textContent = entry.type.replace('_', ' ');
|
|
row.insertCell().textContent = formatCurrency(entry.amountCents);
|
|
row.insertCell().textContent = entry.orderId.slice(-8);
|
|
});
|
|
|
|
summaryDiv.style.display = 'block';
|
|
tableDiv.style.display = 'block';
|
|
exportBtn.style.display = 'block';
|
|
} else {
|
|
alert('Failed to load reconciliation data');
|
|
}
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
} finally {
|
|
loadingDiv.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
document.getElementById('export-csv').addEventListener('click', () => {
|
|
alert('CSV export functionality would trigger download here');
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.summary-card { display: inline-block; margin: 10px; padding: 16px; border: 1px solid #ddd; border-radius: 8px; }
|
|
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
|
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
|
.filters { margin: 16px 0; }
|
|
.filters > * { margin-right: 8px; }
|
|
</style>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
// Verify initial page content
|
|
await expect(page.locator('h1')).toContainText('Reconciliation Report');
|
|
|
|
// Load reconciliation data
|
|
await page.click('#load-data');
|
|
|
|
// Wait for loading to complete
|
|
await expect(page.locator('#loading')).toBeVisible();
|
|
await expect(page.locator('#summary-cards')).toBeVisible();
|
|
|
|
// Verify summary calculations
|
|
await expect(page.locator('#gross-sales')).toContainText('$150.00');
|
|
await expect(page.locator('#stripe-fees')).toContainText('$4.65');
|
|
await expect(page.locator('#platform-fees')).toContainText('$4.50');
|
|
await expect(page.locator('#net-amount')).toContainText('$140.85');
|
|
|
|
// Verify transactions table
|
|
await expect(page.locator('#transactions-table')).toBeVisible();
|
|
await expect(page.locator('#transactions-body tr')).toHaveCount(3); // 3 ledger entries
|
|
|
|
// Verify export button appears
|
|
await expect(page.locator('#export-csv')).toBeVisible();
|
|
});
|
|
|
|
test('should handle dispute status display', async ({ page }) => {
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<div class="order-card" data-order-id="disputed-order">
|
|
<div class="order-header">
|
|
<h3>Order #disputed-order</h3>
|
|
<span class="status-badge paid">Paid</span>
|
|
</div>
|
|
<div id="dispute-alert" style="display: none;" class="dispute-warning">
|
|
<h4>Payment Dispute</h4>
|
|
<p id="dispute-details"></p>
|
|
</div>
|
|
<div class="tickets">
|
|
<div class="ticket" data-status="locked_dispute">
|
|
<span class="ticket-status locked-dispute">Dispute</span>
|
|
<span>VIP Access - $100.00</span>
|
|
</div>
|
|
</div>
|
|
<button id="load-dispute-info">Load Dispute Info</button>
|
|
</div>
|
|
|
|
<script>
|
|
document.getElementById('load-dispute-info').addEventListener('click', async () => {
|
|
try {
|
|
const response = await fetch('/getOrderDisputes', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ orderId: 'disputed-order' })
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
|
|
if (data.dispute) {
|
|
const alertDiv = document.getElementById('dispute-alert');
|
|
const detailsP = document.getElementById('dispute-details');
|
|
|
|
detailsP.textContent = 'Status: ' + data.dispute.status + ' • Reason: ' + data.dispute.reason;
|
|
alertDiv.style.display = 'block';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load dispute info:', error);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.dispute-warning { background: #fff3cd; border: 1px solid #ffeaa7; padding: 12px; margin: 8px 0; border-radius: 4px; }
|
|
.ticket-status.locked-dispute { background: #ffc107; color: #212529; padding: 2px 6px; border-radius: 3px; }
|
|
</style>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
// Load dispute information
|
|
await page.click('#load-dispute-info');
|
|
|
|
// Verify dispute alert appears
|
|
await expect(page.locator('#dispute-alert')).toBeVisible();
|
|
await expect(page.locator('#dispute-details')).toContainText('Status: warning_needs_response');
|
|
await expect(page.locator('#dispute-details')).toContainText('Reason: fraudulent');
|
|
|
|
// Verify ticket shows dispute status
|
|
await expect(page.locator('.ticket-status.locked-dispute')).toContainText('Dispute');
|
|
});
|
|
|
|
test('should validate permission requirements for refunds', async ({ page }) => {
|
|
// Mock unauthorized request
|
|
await page.route('**/createRefund', async (route) => {
|
|
await route.fulfill({
|
|
status: 403,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'Insufficient permissions' })
|
|
});
|
|
});
|
|
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<button id="create-refund">Create Refund</button>
|
|
<div id="result"></div>
|
|
|
|
<script>
|
|
document.getElementById('create-refund').addEventListener('click', async () => {
|
|
try {
|
|
const response = await fetch('/createRefund', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
orderId: 'test-order-1',
|
|
amountCents: 5000
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (!response.ok) {
|
|
document.getElementById('result').textContent = 'Permission denied: ' + result.error;
|
|
}
|
|
} catch (error) {
|
|
document.getElementById('result').textContent = 'Error: ' + error.message;
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
await page.click('#create-refund');
|
|
await expect(page.locator('#result')).toContainText('Permission denied: Insufficient permissions');
|
|
});
|
|
});
|
|
|
|
test.describe('Idempotency and Concurrency', () => {
|
|
test('should handle duplicate refund requests gracefully', async ({ page }) => {
|
|
let requestCount = 0;
|
|
|
|
await page.route('**/createRefund', async (route) => {
|
|
requestCount++;
|
|
|
|
if (requestCount === 1) {
|
|
// First request - create refund
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
refundId: 'refund_mock_123',
|
|
status: 'succeeded',
|
|
amountCents: 5000
|
|
})
|
|
});
|
|
} else {
|
|
// Subsequent requests - return existing refund
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
refundId: 'refund_mock_123',
|
|
status: 'succeeded',
|
|
message: 'Refund already exists'
|
|
})
|
|
});
|
|
}
|
|
});
|
|
|
|
await page.setContent(`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<button id="create-refund">Create Refund</button>
|
|
<div id="results"></div>
|
|
|
|
<script>
|
|
let clickCount = 0;
|
|
|
|
document.getElementById('create-refund').addEventListener('click', async () => {
|
|
clickCount++;
|
|
|
|
try {
|
|
const response = await fetch('/createRefund', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
orderId: 'test-order-1',
|
|
amountCents: 5000
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
const resultDiv = document.createElement('div');
|
|
resultDiv.textContent = 'Click ' + clickCount + ': ' + (result.message || 'Created') + ' - ' + result.refundId;
|
|
document.getElementById('results').appendChild(resultDiv);
|
|
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
`);
|
|
|
|
// Click multiple times rapidly
|
|
await page.click('#create-refund');
|
|
await page.click('#create-refund');
|
|
await page.click('#create-refund');
|
|
|
|
// Verify idempotency handling
|
|
const results = page.locator('#results div');
|
|
await expect(results).toHaveCount(3);
|
|
|
|
await expect(results.nth(0)).toContainText('Click 1: Created - refund_mock_123');
|
|
await expect(results.nth(1)).toContainText('Click 2: Refund already exists - refund_mock_123');
|
|
await expect(results.nth(2)).toContainText('Click 3: Refund already exists - refund_mock_123');
|
|
});
|
|
}); |