You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

514 lines
21 KiB

{% extends "base.html" %}
{% block title %}Single Payments - Plutus{% endblock %}
{% block content %}
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li>
<li class="is-active"><a href="#" aria-current="page">Single Payments</a></li>
</ul>
</nav>
<div class="level">
<div class="level-left">
<div>
<h1 class="title">Single Payments</h1>
<p class="subtitle">Individual payment processing history</p>
</div>
</div>
<div class="level-right">
<a class="button is-primary" href="{{ url_for('main.single_payment') }}">
<span class="icon"><i class="fas fa-plus"></i></span>
<span>New Payment</span>
</a>
</div>
</div>
<!-- Payment Details Table -->
<div class="box">
<div class="level">
<div class="level-left">
<h2 class="title is-4">Payment History</h2>
</div>
<div class="level-right">
<div class="field">
<p class="control has-icons-left">
<input class="input" type="text" id="searchInput" placeholder="Search Splynx ID, Customer ID, Payment Intent...">
<span class="icon is-small is-left">
<i class="fas fa-search"></i>
</span>
</p>
</div>
</div>
</div>
<!-- Filter Controls -->
<div class="field is-grouped is-grouped-multiline">
<div class="control">
<label class="label is-small">Filter by Status:</label>
<div class="select is-small">
<select id="statusFilter">
<option value="all">All Payments</option>
<option value="success">Successful Only</option>
<option value="failed">Failed Only</option>
<option value="pending">Pending Only</option>
<option value="error">Has Errors</option>
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Filter by Payment Method:</label>
<div class="select is-small">
<select id="paymentMethodFilter">
<option value="all">All Methods</option>
<!-- Options will be populated by JavaScript -->
</select>
</div>
</div>
<div class="control">
<label class="label is-small">Sort by:</label>
<div class="select is-small">
<select id="sortFilter">
<option value="date_desc">Date (Newest First)</option>
<option value="date_asc">Date (Oldest First)</option>
<option value="splynx_asc">Splynx ID (Ascending)</option>
<option value="splynx_desc">Splynx ID (Descending)</option>
<option value="amount_desc">Amount (High to Low)</option>
<option value="amount_asc">Amount (Low to High)</option>
<option value="status">Status</option>
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-info" onclick="clearFilters()">
<span class="icon"><i class="fas fa-times"></i></span>
<span>Clear Filters</span>
</button>
</div>
</div>
<!-- Results Counter -->
<div class="notification is-info is-light" id="filterResults" style="display: none;">
<span id="resultCount">0</span> of {{ payments|length }} payments shown
</div>
{% if payments %}
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable" id="paymentsTable">
<thead>
<tr>
<th>Payment ID</th>
<th>Date</th>
<th>Splynx ID</th>
<th>Stripe Customer</th>
<th>Payment Intent</th>
<th>Payment Method</th>
<th>Stripe Fee</th>
<th>Amount</th>
<th>Processed By</th>
<th>Data</th>
<th>Status</th>
</tr>
</thead>
<tbody id="paymentsTableBody">
{% for payment in payments %}
{% set row_class = '' %}
{% if payment.Success == True %}
{% set row_class = 'has-background-success-light' %}
{% elif payment.Success == False and payment.Error %}
{% set row_class = 'has-background-danger-light' %}
{% elif payment.Success == None %}
{% set row_class = 'has-background-info-light' %}
{% endif %}
<tr class="{{ row_class }}">
<td>
<strong>#{{ payment.id }}</strong>
</td>
<td>
<span class="is-size-7">{{ payment.Created.strftime('%Y-%m-%d') }}</span><br>
<span class="is-size-7 has-text-grey">{{ payment.Created.strftime('%H:%M:%S') }}</span>
</td>
<td>
{% if payment.Splynx_ID %}
<a href="https://billing.interphone.com.au/admin/customers/view?id={{ payment.Splynx_ID }}"
target="_blank" class="has-text-weight-semibold">
{{ payment.Splynx_ID }}
</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% elif payment.Success == None %}
<code class="is-small has-background-info has-text-white">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% else %}
<code class="is-small has-background-grey-light has-text-black">{{ payment.Stripe_Customer_ID or '-' }}</code>
{% endif %}
</td>
<td>
{% if payment.Payment_Intent %}
{% if payment.Success == True %}
<code class="is-small has-background-success has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == False and payment.Error %}
<code class="is-small has-background-danger has-text-white">{{ payment.Payment_Intent }}</code>
{% elif payment.Success == None %}
<code class="is-small has-background-info has-text-white">{{ payment.Payment_Intent }}</code>
{% else %}
<code class="is-small has-background-grey-light has-text-black">{{ payment.Payment_Intent }}</code>
{% endif %}
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Payment_Method %}
<span class="tag is-info is-light">{{ payment.Payment_Method }}</span>
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Fee_Stripe %}
{{ payment.Fee_Stripe | currency }}
{% else %}
-
{% endif %}
</td>
<td>
{% if payment.Payment_Amount %}
<strong>{{ payment.Payment_Amount | currency }}</strong>
{% else %}
-
{% endif %}
</td>
<td>
<span class="is-size-7">{{ payment.processed_by or 'Unknown' }}</span>
</td>
<td>
<div class="buttons are-small">
{% if payment.PI_JSON %}
<button class="button is-info is-outlined" onclick="showModal('json-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-code"></i></span>
<span>JSON</span>
</button>
{% endif %}
{% if payment.Error %}
<button class="button is-danger is-outlined" onclick="showModal('error-modal-{{ payment.id }}')">
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span>
<span>Error</span>
</button>
{% endif %}
</div>
</td>
<td>
{% if payment.Success == True %}
<span class="tag is-success">Success</span>
{% elif payment.Success == False %}
<span class="tag is-danger">Failed</span>
{% else %}
<span class="tag is-warning">Pending</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="notification is-info">
<p>No single payments found. <a href="{{ url_for('main.single_payment') }}">Process your first payment</a>.</p>
</div>
{% endif %}
</div>
<!-- Modals for JSON/Error data -->
{% for payment in payments %}
<!-- PI_JSON Modal -->
{% if payment.PI_JSON %}
<div class="modal" id="json-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('json-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment JSON - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('json-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<pre><code class="language-json">{{ payment.PI_JSON | format_json }}</code></pre>
<button class="button is-small is-info" onclick="copyFormattedJSON('json-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy JSON</span>
</button>
<div id="json-content-{{ payment.id }}" style="display: none;">{{ payment.PI_JSON | format_json }}</div>
</section>
</div>
</div>
{% endif %}
<!-- Error Modal -->
{% if payment.Error %}
<div class="modal" id="error-modal-{{ payment.id }}">
<div class="modal-background" onclick="hideModal('error-modal-{{ payment.id }}')"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Payment Error - Payment #{{ payment.id }}</p>
<button class="delete" aria-label="close" onclick="hideModal('error-modal-{{ payment.id }}')"></button>
</header>
<section class="modal-card-body">
<div class="notification is-danger is-light">
<pre>{{ payment.Error }}</pre>
</div>
<button class="button is-small is-danger" onclick="copyFormattedJSON('error-content-{{ payment.id }}')">
<span class="icon"><i class="fas fa-copy"></i></span>
<span>Copy Error</span>
</button>
<div id="error-content-{{ payment.id }}" style="display: none;">{{ payment.Error }}</div>
</section>
</div>
</div>
{% endif %}
{% endfor %}
<script>
// Payment filtering and sorting functionality
let allPayments = [];
let filteredPayments = [];
// Initialize payment data and filters when page loads
document.addEventListener('DOMContentLoaded', function() {
initializePayments();
populatePaymentMethodFilter();
setupEventListeners();
});
function initializePayments() {
const tableBody = document.getElementById('paymentsTableBody');
const rows = tableBody.querySelectorAll('tr');
allPayments = Array.from(rows).map(row => {
const cells = row.querySelectorAll('td');
return {
element: row,
paymentId: cells[0] ? (cells[0].textContent.trim() || '') : '',
date: cells[1] ? (cells[1].textContent.trim() || '') : '',
splynxId: cells[2] ? (cells[2].textContent.trim() || '') : '',
stripeCustomerId: cells[3] ? (cells[3].textContent.trim() || '') : '',
paymentIntent: cells[4] ? (cells[4].textContent.trim() || '') : '',
paymentMethod: cells[5] ? (cells[5].textContent.trim() || '') : '',
stripeFee: cells[6] ? (cells[6].textContent.trim() || '') : '',
amount: cells[7] ? (cells[7].textContent.trim() || '') : '',
processedBy: cells[8] ? (cells[8].textContent.trim() || '') : '',
status: cells[10] ? (cells[10].textContent.trim() || '') : '',
success: row.classList.contains('has-background-success-light'),
failed: row.classList.contains('has-background-danger-light'),
pending: row.classList.contains('has-background-info-light'),
hasError: cells[9] && cells[9].querySelector('button.is-danger')
};
});
filteredPayments = [...allPayments];
updateResultCount();
}
function populatePaymentMethodFilter() {
const select = document.getElementById('paymentMethodFilter');
const methods = [...new Set(allPayments
.map(p => p.paymentMethod)
.filter(method => method && method !== '-')
)].sort();
// Clear existing options except "All Methods"
select.innerHTML = '<option value="all">All Methods</option>';
methods.forEach(method => {
const option = document.createElement('option');
option.value = method;
option.textContent = method;
select.appendChild(option);
});
}
function setupEventListeners() {
document.getElementById('searchInput').addEventListener('input', applyFilters);
document.getElementById('statusFilter').addEventListener('change', applyFilters);
document.getElementById('paymentMethodFilter').addEventListener('change', applyFilters);
document.getElementById('sortFilter').addEventListener('change', applyFilters);
}
function applyFilters() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const statusFilter = document.getElementById('statusFilter').value;
const paymentMethodFilter = document.getElementById('paymentMethodFilter').value;
const sortFilter = document.getElementById('sortFilter').value;
// Filter payments
filteredPayments = allPayments.filter(payment => {
// Search filter
const searchMatch = !searchTerm ||
payment.splynxId.toLowerCase().includes(searchTerm) ||
payment.stripeCustomerId.toLowerCase().includes(searchTerm) ||
payment.paymentIntent.toLowerCase().includes(searchTerm) ||
payment.paymentId.toLowerCase().includes(searchTerm);
// Status filter
let statusMatch = true;
switch(statusFilter) {
case 'success':
statusMatch = payment.success;
break;
case 'failed':
statusMatch = payment.failed;
break;
case 'pending':
statusMatch = payment.pending;
break;
case 'error':
statusMatch = payment.hasError;
break;
}
// Payment method filter
const methodMatch = paymentMethodFilter === 'all' ||
payment.paymentMethod === paymentMethodFilter;
return searchMatch && statusMatch && methodMatch;
});
// Sort payments
sortPayments(sortFilter);
// Update display
updateTable();
updateResultCount();
}
function sortPayments(sortBy) {
switch(sortBy) {
case 'date_desc':
// Already sorted by date desc in backend query
break;
case 'date_asc':
filteredPayments.reverse();
break;
case 'splynx_asc':
filteredPayments.sort((a, b) => parseInt(a.splynxId) - parseInt(b.splynxId));
break;
case 'splynx_desc':
filteredPayments.sort((a, b) => parseInt(b.splynxId) - parseInt(a.splynxId));
break;
case 'amount_asc':
filteredPayments.sort((a, b) => parseFloat(a.amount.replace(/[$,]/g, '')) - parseFloat(b.amount.replace(/[$,]/g, '')));
break;
case 'amount_desc':
filteredPayments.sort((a, b) => parseFloat(b.amount.replace(/[$,]/g, '')) - parseFloat(a.amount.replace(/[$,]/g, '')));
break;
case 'status':
filteredPayments.sort((a, b) => a.status.localeCompare(b.status));
break;
}
}
function updateTable() {
const tableBody = document.getElementById('paymentsTableBody');
// Hide all rows first
allPayments.forEach(payment => {
payment.element.style.display = 'none';
});
// Show filtered rows
filteredPayments.forEach(payment => {
payment.element.style.display = '';
tableBody.appendChild(payment.element); // Re-append to maintain sort order
});
}
function updateResultCount() {
const resultCount = document.getElementById('resultCount');
const filterResults = document.getElementById('filterResults');
resultCount.textContent = filteredPayments.length;
if (filteredPayments.length === allPayments.length) {
filterResults.style.display = 'none';
} else {
filterResults.style.display = 'block';
}
}
function clearFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('statusFilter').value = 'all';
document.getElementById('paymentMethodFilter').value = 'all';
document.getElementById('sortFilter').value = 'date_desc';
applyFilters();
}
// Modal functionality
function showModal(modalId) {
document.getElementById(modalId).classList.add('is-active');
}
function hideModal(modalId) {
document.getElementById(modalId).classList.remove('is-active');
}
// Copy to clipboard functionality
function copyFormattedJSON(elementId) {
const element = document.getElementById(elementId);
const text = element.textContent || element.innerText;
navigator.clipboard.writeText(text).then(function() {
// Show temporary success message
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
const button = event.target.closest('button');
const originalText = button.innerHTML;
button.innerHTML = '<span class="icon"><i class="fas fa-check"></i></span><span>Copied!</span>';
button.classList.add('is-success');
setTimeout(function() {
button.innerHTML = originalText;
button.classList.remove('is-success');
}, 2000);
} catch (fallbackErr) {
console.error('Fallback copy failed: ', fallbackErr);
}
document.body.removeChild(textArea);
});
}
// Close modal on Escape key
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
const activeModals = document.querySelectorAll('.modal.is-active');
activeModals.forEach(modal => modal.classList.remove('is-active'));
}
});
</script>
{% endblock %}