9 changed files with 2295 additions and 47 deletions
@ -0,0 +1,748 @@ |
|||
{% extends "base.html" %} |
|||
|
|||
{% block title %}Add Payment Method - Plutus{% endblock %} |
|||
|
|||
{% block content %} |
|||
<nav class="breadcrumb" aria-label="breadcrumbs"> |
|||
<ul> |
|||
<li><a href="{{ url_for('main.index') }}">Dashboard</a></li> |
|||
<li><a href="{{ url_for('main.single_payments_list') }}">Single Payments</a></li> |
|||
<li class="is-active"><a href="#" aria-current="page">Add Payment Method</a></li> |
|||
</ul> |
|||
</nav> |
|||
|
|||
<div class="level"> |
|||
<div class="level-left"> |
|||
<div> |
|||
<h1 class="title">Add Payment Method</h1> |
|||
<p class="subtitle">Add credit cards or BECS Direct Debit to customer accounts</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Add Payment Method Form --> |
|||
<div class="box"> |
|||
<!-- Step 1: Enter Splynx ID --> |
|||
<div id="step1" class="payment-step"> |
|||
<h2 class="title is-4"> |
|||
<span class="icon"><i class="fas fa-search"></i></span> |
|||
Customer Lookup |
|||
</h2> |
|||
|
|||
<div class="field"> |
|||
<label class="label" for="lookup_splynx_id">Splynx Customer ID</label> |
|||
<div class="control"> |
|||
<input class="input" type="number" id="lookup_splynx_id" placeholder="Enter customer ID" required> |
|||
</div> |
|||
<p class="help">Enter the Splynx customer ID to fetch customer details</p> |
|||
</div> |
|||
|
|||
<!-- Loading State --> |
|||
<div id="loading" class="has-text-centered py-5 is-hidden"> |
|||
<div class="spinner"></div> |
|||
<p class="mt-3">Fetching customer details...</p> |
|||
</div> |
|||
|
|||
<!-- Error State --> |
|||
<div id="customerError" class="notification is-danger is-hidden"> |
|||
<span class="icon"><i class="fas fa-exclamation-triangle"></i></span> |
|||
<span id="errorMessage">Customer not found or error occurred</span> |
|||
</div> |
|||
|
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-primary" id="nextBtn" onclick="fetchCustomerDetails()"> |
|||
<span class="icon"><i class="fas fa-arrow-right"></i></span> |
|||
<span>Next</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Step 2: Confirm Customer & Select Payment Method Type --> |
|||
<div id="step2" class="payment-step is-hidden"> |
|||
<h2 class="title is-4"> |
|||
<span class="icon"><i class="fas fa-user-check"></i></span> |
|||
Confirm Customer & Select Payment Method Type |
|||
</h2> |
|||
|
|||
<div class="box has-background-light mb-5"> |
|||
<h3 class="subtitle is-5">Customer Information</h3> |
|||
<div id="customerDetails"> |
|||
<!-- Customer details will be populated here --> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Current Payment Methods --> |
|||
<div class="box has-background-info-light mb-5"> |
|||
<h3 class="subtitle is-5"> |
|||
<span class="icon"><i class="fas fa-credit-card"></i></span> |
|||
Current Payment Methods |
|||
</h3> |
|||
<div id="currentPaymentMethods"> |
|||
<div class="has-text-centered py-4"> |
|||
<div class="spinner"></div> |
|||
<p class="mt-3">Loading current payment methods...</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="field"> |
|||
<label class="label">Payment Method Type</label> |
|||
<div class="control"> |
|||
<div class="columns"> |
|||
<div class="column"> |
|||
<div class="box payment-type-card" onclick="selectPaymentType('card')" id="cardOption"> |
|||
<div class="has-text-centered"> |
|||
<span class="icon is-large has-text-info"> |
|||
<i class="fas fa-credit-card fa-2x"></i> |
|||
</span> |
|||
<h4 class="title is-5 mt-3">Credit/Debit Card</h4> |
|||
<p class="content">Add a new credit or debit card</p> |
|||
<ul class="content is-small"> |
|||
<li>Domestic cards: 1.7% + $0.30</li> |
|||
<li>International cards: 3.5% + $0.30</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="column"> |
|||
<div class="box payment-type-card" onclick="selectPaymentType('au_becs_debit')" id="becsOption"> |
|||
<div class="has-text-centered"> |
|||
<span class="icon is-large has-text-success"> |
|||
<i class="fas fa-university fa-2x"></i> |
|||
</span> |
|||
<h4 class="title is-5 mt-3">BECS Direct Debit</h4> |
|||
<p class="content">Add Australian bank account</p> |
|||
<ul class="content is-small"> |
|||
<li>1.0% + $0.30 (capped at $3.50)</li> |
|||
<li>Lower fees than cards</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-light" onclick="goBackToStep1()"> |
|||
<span class="icon"><i class="fas fa-arrow-left"></i></span> |
|||
<span>Back</span> |
|||
</button> |
|||
</div> |
|||
<div class="control"> |
|||
<button class="button is-primary" id="continueBtn" onclick="setupPaymentMethod()" disabled> |
|||
<span class="icon"><i class="fas fa-arrow-right"></i></span> |
|||
<span>Continue</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Step 3: Payment Method Collection --> |
|||
<div id="step3" class="payment-step is-hidden"> |
|||
<h2 class="title is-4"> |
|||
<span class="icon"><i class="fas fa-plus-circle"></i></span> |
|||
Add Payment Method |
|||
</h2> |
|||
|
|||
<div class="box has-background-light mb-5"> |
|||
<h3 class="subtitle is-5">Selected Type</h3> |
|||
<div id="selectedTypeDisplay"> |
|||
<!-- Selected payment method type will be shown here --> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Stripe Elements Container --> |
|||
<div class="field"> |
|||
<label class="label" id="paymentElementLabel">Payment Details</label> |
|||
<div class="control"> |
|||
<div id="payment-element" class="stripe-element"> |
|||
<!-- Stripe Elements will be mounted here --> |
|||
</div> |
|||
</div> |
|||
<div id="payment-element-errors" role="alert" class="help is-danger is-hidden"></div> |
|||
</div> |
|||
|
|||
<!-- BECS Mandate Agreement (only shown for BECS) --> |
|||
<div id="becsMandate" class="notification is-info is-hidden"> |
|||
<div class="content"> |
|||
<h4>Direct Debit Request Service Agreement</h4> |
|||
<p>By providing your bank account details and confirming this payment, you acknowledge that:</p> |
|||
<ul> |
|||
<li>You have read and agree to the Direct Debit Request Service Agreement</li> |
|||
<li>You authorize debits to your account according to the arrangement outlined</li> |
|||
<li>This authorization will remain in effect until cancelled by you</li> |
|||
</ul> |
|||
<label class="checkbox"> |
|||
<input type="checkbox" id="becsAgreement"> |
|||
I agree to the Direct Debit Request Service Agreement |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Set as Default Option --> |
|||
<div class="field"> |
|||
<div class="control"> |
|||
<label class="checkbox"> |
|||
<input type="checkbox" id="setAsDefault" checked> |
|||
Set as default payment method for this customer |
|||
</label> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-light" onclick="goBackToStep2()"> |
|||
<span class="icon"><i class="fas fa-arrow-left"></i></span> |
|||
<span>Back</span> |
|||
</button> |
|||
</div> |
|||
<div class="control"> |
|||
<button class="button is-primary" id="savePaymentMethodBtn" onclick="savePaymentMethod()"> |
|||
<span class="icon"><i class="fas fa-save"></i></span> |
|||
<span>Save Payment Method</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Processing State --> |
|||
<div id="processingPayment" class="notification is-info is-hidden"> |
|||
<div class="has-text-centered py-4"> |
|||
<div class="spinner"></div> |
|||
<p class="mt-3">Processing payment method...</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Step 4: Success --> |
|||
<div id="step4" class="payment-step is-hidden"> |
|||
<h2 class="title is-4 has-text-success"> |
|||
<span class="icon"><i class="fas fa-check-circle"></i></span> |
|||
Payment Method Added Successfully |
|||
</h2> |
|||
|
|||
<div class="box has-background-success-light"> |
|||
<div id="successDetails"> |
|||
<!-- Success details will be populated here --> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-success" onclick="addAnotherPaymentMethod()"> |
|||
<span class="icon"><i class="fas fa-plus"></i></span> |
|||
<span>Add Another Payment Method</span> |
|||
</button> |
|||
</div> |
|||
<div class="control"> |
|||
<a class="button is-info" href="{{ url_for('main.single_payments_list') }}"> |
|||
<span class="icon"><i class="fas fa-list"></i></span> |
|||
<span>View Payments</span> |
|||
</a> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Include Stripe.js --> |
|||
<script src="https://js.stripe.com/v3/"></script> |
|||
|
|||
<script> |
|||
// Global variables |
|||
let currentCustomer = null; |
|||
let selectedPaymentType = null; |
|||
let stripe = null; |
|||
let elements = null; |
|||
let paymentElement = null; |
|||
let setupIntentClientSecret = null; |
|||
|
|||
// Initialize Stripe (we'll get the publishable key from the backend) |
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
// We'll initialize Stripe when we need it in step 3 |
|||
}); |
|||
|
|||
function fetchCustomerDetails() { |
|||
const splynxId = document.getElementById('lookup_splynx_id').value; |
|||
|
|||
if (!splynxId) { |
|||
showError('Please enter a Splynx ID'); |
|||
return; |
|||
} |
|||
|
|||
showLoading(true); |
|||
hideError(); |
|||
|
|||
// Use existing API endpoint to get Stripe customer ID |
|||
fetch(`/api/stripe-customer-id/${splynxId}`) |
|||
.then(response => response.json()) |
|||
.then(data => { |
|||
if (data.success) { |
|||
// Get Splynx customer details |
|||
return fetch(`/api/splynx/${splynxId}`) |
|||
.then(response => response.json()) |
|||
.then(splynxData => { |
|||
// Combine the data |
|||
const combinedData = { |
|||
success: true, |
|||
splynx_id: splynxId, |
|||
stripe_customer_id: data.stripe_customer_id, |
|||
customer_name: `${splynxData.name || ''} ${splynxData.lastname || ''}`.trim(), |
|||
customer_email: splynxData.email || '', |
|||
splynx_data: splynxData |
|||
}; |
|||
|
|||
currentCustomer = combinedData; |
|||
displayCustomerDetails(combinedData); |
|||
loadCurrentPaymentMethods(data.stripe_customer_id); |
|||
showStep(2); |
|||
showLoading(false); |
|||
}); |
|||
} else { |
|||
showLoading(false); |
|||
showError(data.error || 'Customer not found or no Stripe customer ID available'); |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
showLoading(false); |
|||
showError('Error fetching customer details: ' + error.message); |
|||
}); |
|||
} |
|||
|
|||
function displayCustomerDetails(customer) { |
|||
const html = ` |
|||
<div class="columns"> |
|||
<div class="column"> |
|||
<strong>Name:</strong> ${customer.customer_name || 'N/A'}<br> |
|||
<strong>Email:</strong> ${customer.customer_email || 'N/A'}<br> |
|||
<strong>Splynx ID:</strong> ${customer.splynx_id} |
|||
</div> |
|||
<div class="column"> |
|||
<strong>Stripe Customer ID:</strong> ${customer.stripe_customer_id}<br> |
|||
<strong>Status:</strong> <span class="tag is-success">Active</span> |
|||
</div> |
|||
</div> |
|||
`; |
|||
document.getElementById('customerDetails').innerHTML = html; |
|||
} |
|||
|
|||
function loadCurrentPaymentMethods(stripeCustomerId) { |
|||
fetch(`/api/stripe-payment-methods/${stripeCustomerId}`) |
|||
.then(response => response.json()) |
|||
.then(data => { |
|||
if (data.success) { |
|||
displayCurrentPaymentMethods(data.payment_methods); |
|||
} else { |
|||
document.getElementById('currentPaymentMethods').innerHTML = |
|||
'<p class="has-text-danger">Error loading payment methods: ' + (data.error || 'Unknown error') + '</p>'; |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
document.getElementById('currentPaymentMethods').innerHTML = |
|||
'<p class="has-text-danger">Error loading payment methods: ' + error.message + '</p>'; |
|||
}); |
|||
} |
|||
|
|||
function displayCurrentPaymentMethods(methods) { |
|||
const container = document.getElementById('currentPaymentMethods'); |
|||
|
|||
if (!methods || methods.length === 0) { |
|||
container.innerHTML = '<p class="has-text-grey">No payment methods found for this customer.</p>'; |
|||
return; |
|||
} |
|||
|
|||
let html = '<div class="columns is-multiline">'; |
|||
methods.forEach(method => { |
|||
let methodInfo = ''; |
|||
let icon = ''; |
|||
|
|||
if (method.type === 'card' && method.card) { |
|||
icon = 'fas fa-credit-card'; |
|||
methodInfo = `${method.card.brand.toUpperCase()} ending in ${method.card.last4}`; |
|||
} else if (method.type === 'au_becs_debit' && method.au_becs_debit) { |
|||
icon = 'fas fa-university'; |
|||
methodInfo = `Bank account ${method.au_becs_debit.bsb_number} ending in ${method.au_becs_debit.last4}`; |
|||
} else { |
|||
icon = 'fas fa-question-circle'; |
|||
methodInfo = `${method.type} payment method`; |
|||
} |
|||
|
|||
html += ` |
|||
<div class="column is-half"> |
|||
<div class="box is-small"> |
|||
<div class="media"> |
|||
<div class="media-left"> |
|||
<span class="icon has-text-info"> |
|||
<i class="${icon}"></i> |
|||
</span> |
|||
</div> |
|||
<div class="media-content"> |
|||
<p class="is-size-6">${methodInfo}</p> |
|||
<p class="is-size-7 has-text-grey">Added: ${new Date(method.created * 1000).toLocaleDateString()}</p> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
`; |
|||
}); |
|||
html += '</div>'; |
|||
|
|||
container.innerHTML = html; |
|||
} |
|||
|
|||
function selectPaymentType(type) { |
|||
selectedPaymentType = type; |
|||
|
|||
// Update UI |
|||
document.querySelectorAll('.payment-type-card').forEach(card => { |
|||
card.classList.remove('has-background-primary-light', 'has-border-primary'); |
|||
}); |
|||
|
|||
const selectedCard = document.getElementById(type === 'card' ? 'cardOption' : 'becsOption'); |
|||
selectedCard.classList.add('has-background-primary-light', 'has-border-primary'); |
|||
|
|||
document.getElementById('continueBtn').disabled = false; |
|||
} |
|||
|
|||
function setupPaymentMethod() { |
|||
if (!selectedPaymentType) { |
|||
showError('Please select a payment method type'); |
|||
return; |
|||
} |
|||
|
|||
// Update selected type display |
|||
const typeDisplay = selectedPaymentType === 'card' ? |
|||
'<span class="icon"><i class="fas fa-credit-card"></i></span> Credit/Debit Card' : |
|||
'<span class="icon"><i class="fas fa-university"></i></span> BECS Direct Debit'; |
|||
document.getElementById('selectedTypeDisplay').innerHTML = typeDisplay; |
|||
|
|||
// Show/hide BECS mandate |
|||
if (selectedPaymentType === 'au_becs_debit') { |
|||
document.getElementById('becsMandate').classList.remove('is-hidden'); |
|||
document.getElementById('paymentElementLabel').textContent = 'Bank Account Details'; |
|||
} else { |
|||
document.getElementById('becsMandate').classList.add('is-hidden'); |
|||
document.getElementById('paymentElementLabel').textContent = 'Card Details'; |
|||
} |
|||
|
|||
showStep(3); |
|||
initializeStripeElements(); |
|||
} |
|||
|
|||
function initializeStripeElements() { |
|||
console.log('Initializing Stripe elements for payment type:', selectedPaymentType); |
|||
console.log('Customer:', currentCustomer); |
|||
|
|||
// Get Stripe publishable key and create setup intent |
|||
fetch('/api/create-setup-intent', { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
body: JSON.stringify({ |
|||
stripe_customer_id: currentCustomer.stripe_customer_id, |
|||
payment_method_types: [selectedPaymentType] |
|||
}) |
|||
}) |
|||
.then(response => { |
|||
console.log('Setup intent response status:', response.status); |
|||
return response.json(); |
|||
}) |
|||
.then(data => { |
|||
console.log('Setup intent response data:', data); |
|||
if (data.success) { |
|||
setupIntentClientSecret = data.client_secret; |
|||
console.log('Client secret received:', data.client_secret.substring(0, 20) + '...'); |
|||
initializeStripe(data.stripe_publishable_key, data.client_secret); |
|||
} else { |
|||
console.error('Setup intent failed:', data.error); |
|||
showError('Failed to initialize payment method setup: ' + data.error); |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
console.error('Setup intent error:', error); |
|||
showError('Error initializing setup: ' + error.message); |
|||
}); |
|||
} |
|||
|
|||
function initializeStripe(publishableKey, clientSecret) { |
|||
console.log('Initializing Stripe with publishable key:', publishableKey.substring(0, 12) + '...'); |
|||
console.log('Selected payment type:', selectedPaymentType); |
|||
|
|||
stripe = Stripe(publishableKey); |
|||
|
|||
// Create elements with specific appearance and payment method options |
|||
const appearance = { |
|||
theme: 'stripe', |
|||
variables: { |
|||
colorPrimary: '#3273dc', |
|||
colorBackground: '#ffffff', |
|||
colorText: '#30313d', |
|||
colorDanger: '#df1b41', |
|||
fontFamily: 'Ideal Sans, system-ui, sans-serif', |
|||
spacingUnit: '2px', |
|||
borderRadius: '4px' |
|||
} |
|||
}; |
|||
|
|||
elements = stripe.elements({ |
|||
clientSecret: clientSecret, |
|||
appearance: appearance |
|||
}); |
|||
|
|||
// Configure payment element with specific payment method types |
|||
const paymentElementOptions = { |
|||
layout: 'tabs', |
|||
paymentMethodOrder: selectedPaymentType === 'card' |
|||
? ['card'] |
|||
: ['au_becs_debit'], |
|||
fields: { |
|||
billingDetails: { |
|||
name: 'auto', |
|||
email: 'auto' |
|||
} |
|||
} |
|||
}; |
|||
|
|||
console.log('Creating payment element with options:', paymentElementOptions); |
|||
|
|||
paymentElement = elements.create('payment', paymentElementOptions); |
|||
|
|||
console.log('Mounting payment element to #payment-element'); |
|||
const mountElement = document.getElementById('payment-element'); |
|||
if (mountElement) { |
|||
console.log('Mount element found:', mountElement); |
|||
try { |
|||
paymentElement.mount('#payment-element'); |
|||
console.log('Payment element mounted successfully'); |
|||
} catch (error) { |
|||
console.error('Error mounting payment element:', error); |
|||
showError('Error mounting payment form: ' + error.message); |
|||
} |
|||
} else { |
|||
console.error('Mount element #payment-element not found'); |
|||
showError('Payment form container not found'); |
|||
} |
|||
|
|||
paymentElement.on('change', function(event) { |
|||
console.log('Payment element change event:', event); |
|||
const errorElement = document.getElementById('payment-element-errors'); |
|||
if (event.error) { |
|||
errorElement.textContent = event.error.message; |
|||
errorElement.classList.remove('is-hidden'); |
|||
} else { |
|||
errorElement.textContent = ''; |
|||
errorElement.classList.add('is-hidden'); |
|||
} |
|||
}); |
|||
|
|||
paymentElement.on('ready', function() { |
|||
console.log('Payment element is ready and visible'); |
|||
}); |
|||
|
|||
paymentElement.on('loaderror', function(event) { |
|||
console.error('Payment element load error:', event); |
|||
showError('Error loading payment form: ' + event.error.message); |
|||
}); |
|||
} |
|||
|
|||
function savePaymentMethod() { |
|||
// Validate BECS agreement if needed |
|||
if (selectedPaymentType === 'au_becs_debit') { |
|||
const becsAgreement = document.getElementById('becsAgreement'); |
|||
if (!becsAgreement.checked) { |
|||
showError('Please agree to the Direct Debit Request Service Agreement'); |
|||
return; |
|||
} |
|||
} |
|||
|
|||
const saveBtn = document.getElementById('savePaymentMethodBtn'); |
|||
saveBtn.disabled = true; |
|||
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-spinner fa-spin"></i></span><span>Processing...</span>'; |
|||
|
|||
document.getElementById('processingPayment').classList.remove('is-hidden'); |
|||
|
|||
// Confirm the setup intent |
|||
stripe.confirmSetup({ |
|||
elements, |
|||
confirmParams: { |
|||
return_url: window.location.origin + '/single-payments/add-payment-method/success', |
|||
}, |
|||
redirect: 'if_required' |
|||
}).then(function(result) { |
|||
if (result.error) { |
|||
// Handle error |
|||
showError('Payment method setup failed: ' + result.error.message); |
|||
saveBtn.disabled = false; |
|||
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>'; |
|||
document.getElementById('processingPayment').classList.add('is-hidden'); |
|||
} else { |
|||
// Setup succeeded |
|||
handleSetupSuccess(result.setupIntent); |
|||
} |
|||
}); |
|||
} |
|||
|
|||
function handleSetupSuccess(setupIntent) { |
|||
const setAsDefault = document.getElementById('setAsDefault').checked; |
|||
|
|||
// Attach payment method and optionally set as default |
|||
fetch('/api/finalize-payment-method', { |
|||
method: 'POST', |
|||
headers: { |
|||
'Content-Type': 'application/json', |
|||
}, |
|||
body: JSON.stringify({ |
|||
setup_intent_id: setupIntent.id, |
|||
stripe_customer_id: currentCustomer.stripe_customer_id, |
|||
set_as_default: setAsDefault, |
|||
splynx_id: currentCustomer.splynx_id |
|||
}) |
|||
}) |
|||
.then(response => response.json()) |
|||
.then(data => { |
|||
document.getElementById('processingPayment').classList.add('is-hidden'); |
|||
|
|||
if (data.success) { |
|||
displaySuccess(data); |
|||
showStep(4); |
|||
} else { |
|||
showError('Failed to finalize payment method: ' + data.error); |
|||
const saveBtn = document.getElementById('savePaymentMethodBtn'); |
|||
saveBtn.disabled = false; |
|||
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>'; |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
document.getElementById('processingPayment').classList.add('is-hidden'); |
|||
showError('Error finalizing setup: ' + error.message); |
|||
const saveBtn = document.getElementById('savePaymentMethodBtn'); |
|||
saveBtn.disabled = false; |
|||
saveBtn.innerHTML = '<span class="icon"><i class="fas fa-save"></i></span><span>Save Payment Method</span>'; |
|||
}); |
|||
} |
|||
|
|||
function displaySuccess(data) { |
|||
const paymentMethod = data.payment_method; |
|||
let methodInfo = ''; |
|||
|
|||
if (paymentMethod.type === 'card' && paymentMethod.card) { |
|||
methodInfo = `${paymentMethod.card.brand.toUpperCase()} ending in ${paymentMethod.card.last4}`; |
|||
} else if (paymentMethod.type === 'au_becs_debit' && paymentMethod.au_becs_debit) { |
|||
methodInfo = `Bank account ${paymentMethod.au_becs_debit.bsb_number} ending in ${paymentMethod.au_becs_debit.last4}`; |
|||
} |
|||
|
|||
const html = ` |
|||
<div class="content"> |
|||
<h4>Payment Method Added</h4> |
|||
<p><strong>Type:</strong> ${methodInfo}</p> |
|||
<p><strong>Customer:</strong> ${currentCustomer.customer_name} (${currentCustomer.customer_email})</p> |
|||
<p><strong>Set as Default:</strong> ${data.is_default ? 'Yes' : 'No'}</p> |
|||
<p><strong>Payment Method ID:</strong> <code>${paymentMethod.id}</code></p> |
|||
</div> |
|||
`; |
|||
document.getElementById('successDetails').innerHTML = html; |
|||
} |
|||
|
|||
// Utility functions |
|||
function showStep(step) { |
|||
document.querySelectorAll('.payment-step').forEach(div => div.classList.add('is-hidden')); |
|||
document.getElementById('step' + step).classList.remove('is-hidden'); |
|||
} |
|||
|
|||
function goBackToStep1() { |
|||
showStep(1); |
|||
selectedPaymentType = null; |
|||
} |
|||
|
|||
function goBackToStep2() { |
|||
showStep(2); |
|||
} |
|||
|
|||
function addAnotherPaymentMethod() { |
|||
// Reset form |
|||
document.getElementById('lookup_splynx_id').value = ''; |
|||
currentCustomer = null; |
|||
selectedPaymentType = null; |
|||
setupIntentClientSecret = null; |
|||
showStep(1); |
|||
} |
|||
|
|||
function showLoading(show) { |
|||
const loading = document.getElementById('loading'); |
|||
const nextBtn = document.getElementById('nextBtn'); |
|||
|
|||
if (show) { |
|||
loading.classList.remove('is-hidden'); |
|||
nextBtn.disabled = true; |
|||
} else { |
|||
loading.classList.add('is-hidden'); |
|||
nextBtn.disabled = false; |
|||
} |
|||
} |
|||
|
|||
function showError(message) { |
|||
document.getElementById('errorMessage').textContent = message; |
|||
document.getElementById('customerError').classList.remove('is-hidden'); |
|||
} |
|||
|
|||
function hideError() { |
|||
document.getElementById('customerError').classList.add('is-hidden'); |
|||
} |
|||
|
|||
// Allow Enter key to trigger next button in step 1 |
|||
document.getElementById('lookup_splynx_id').addEventListener('keypress', function(e) { |
|||
if (e.key === 'Enter') { |
|||
fetchCustomerDetails(); |
|||
} |
|||
}); |
|||
</script> |
|||
|
|||
<style> |
|||
.payment-type-card { |
|||
cursor: pointer; |
|||
border: 2px solid transparent; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.payment-type-card:hover { |
|||
border-color: #dbdbdb; |
|||
} |
|||
|
|||
.has-border-primary { |
|||
border-color: #3273dc !important; |
|||
} |
|||
|
|||
#payment-element { |
|||
border: 1px solid #dbdbdb; |
|||
border-radius: 4px; |
|||
padding: 12px; |
|||
background-color: white; |
|||
min-height: 60px; |
|||
} |
|||
|
|||
.stripe-element { |
|||
border: 1px solid #dbdbdb; |
|||
border-radius: 4px; |
|||
padding: 12px; |
|||
background-color: white; |
|||
} |
|||
|
|||
.spinner { |
|||
width: 40px; |
|||
height: 40px; |
|||
border: 4px solid #f3f3f3; |
|||
border-top: 4px solid #3273dc; |
|||
border-radius: 50%; |
|||
animation: spin 1s linear infinite; |
|||
margin: 0 auto; |
|||
} |
|||
|
|||
@keyframes spin { |
|||
0% { transform: rotate(0deg); } |
|||
100% { transform: rotate(360deg); } |
|||
} |
|||
</style> |
|||
{% endblock %} |
|||
@ -0,0 +1,510 @@ |
|||
{% extends "base.html" %} |
|||
|
|||
{% block title %}System Logs - 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">System Logs</a></li> |
|||
</ul> |
|||
</nav> |
|||
|
|||
<div class="level"> |
|||
<div class="level-left"> |
|||
<div> |
|||
<h1 class="title">System Logs</h1> |
|||
<p class="subtitle">User activity and system audit trail</p> |
|||
</div> |
|||
</div> |
|||
<div class="level-right"> |
|||
<div class="field is-grouped"> |
|||
<div class="control"> |
|||
<button class="button is-info" onclick="exportLogs()"> |
|||
<span class="icon"><i class="fas fa-download"></i></span> |
|||
<span>Export Logs</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Filter Controls --> |
|||
<div class="box"> |
|||
<h2 class="title is-5"> |
|||
<span class="icon"><i class="fas fa-filter"></i></span> |
|||
Filters |
|||
</h2> |
|||
|
|||
<div class="field is-grouped is-grouped-multiline"> |
|||
<div class="control"> |
|||
<label class="label is-small">Search:</label> |
|||
<div class="field has-addons"> |
|||
<div class="control has-icons-left is-expanded"> |
|||
<input class="input" type="text" id="searchInput" placeholder="Search logs, actions, details..."> |
|||
<span class="icon is-small is-left"> |
|||
<i class="fas fa-search"></i> |
|||
</span> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="control"> |
|||
<label class="label is-small">User:</label> |
|||
<div class="select"> |
|||
<select id="userFilter"> |
|||
<option value="">All Users</option> |
|||
{% for user in users %} |
|||
<option value="{{ user.id }}">{{ user.FullName }}</option> |
|||
{% endfor %} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="control"> |
|||
<label class="label is-small">Action:</label> |
|||
<div class="select"> |
|||
<select id="actionFilter"> |
|||
<option value="">All Actions</option> |
|||
{% for action in actions %} |
|||
<option value="{{ action }}">{{ action }}</option> |
|||
{% endfor %} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="control"> |
|||
<label class="label is-small">Entity Type:</label> |
|||
<div class="select"> |
|||
<select id="entityTypeFilter"> |
|||
<option value="">All Types</option> |
|||
{% for entity_type in entity_types %} |
|||
<option value="{{ entity_type }}">{{ entity_type }}</option> |
|||
{% endfor %} |
|||
</select> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="control"> |
|||
<label class="label is-small">Date From:</label> |
|||
<input class="input" type="date" id="dateFromFilter"> |
|||
</div> |
|||
|
|||
<div class="control"> |
|||
<label class="label is-small">Date To:</label> |
|||
<input class="input" type="date" id="dateToFilter"> |
|||
</div> |
|||
|
|||
<div class="control"> |
|||
<button class="button is-small is-info" onclick="applyFilters()"> |
|||
<span class="icon"><i class="fas fa-search"></i></span> |
|||
<span>Apply Filters</span> |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="control"> |
|||
<button class="button is-small is-light" onclick="clearFilters()"> |
|||
<span class="icon"><i class="fas fa-times"></i></span> |
|||
<span>Clear</span> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Results Summary --> |
|||
<div class="notification is-info is-light" id="filterResults" style="display: none;"> |
|||
<span id="resultCount">0</span> of {{ logs|length }} log entries shown |
|||
</div> |
|||
|
|||
<!-- Logs Table --> |
|||
<div class="box"> |
|||
<h2 class="title is-5"> |
|||
<span class="icon"><i class="fas fa-list"></i></span> |
|||
Log Entries |
|||
</h2> |
|||
|
|||
{% if logs %} |
|||
<div class="table-container"> |
|||
<table class="table is-fullwidth is-striped is-hoverable" id="logsTable"> |
|||
<thead> |
|||
<tr> |
|||
<th>Timestamp</th> |
|||
<th>User</th> |
|||
<th>Action</th> |
|||
<th>Entity</th> |
|||
<th>Details</th> |
|||
<th>IP Address</th> |
|||
<th>Actions</th> |
|||
</tr> |
|||
</thead> |
|||
<tbody id="logsTableBody"> |
|||
{% for log in logs %} |
|||
<tr> |
|||
<td> |
|||
<span class="is-size-7">{{ log.Added.strftime('%Y-%m-%d') }}</span><br> |
|||
<span class="is-size-7 has-text-grey">{{ log.Added.strftime('%H:%M:%S') }}</span> |
|||
</td> |
|||
<td> |
|||
<div class="media"> |
|||
<div class="media-content"> |
|||
<strong>{{ log.user_name or 'System' }}</strong> |
|||
{% if log.User_ID %} |
|||
<br><small class="has-text-grey">ID: {{ log.User_ID }}</small> |
|||
{% endif %} |
|||
</div> |
|||
</div> |
|||
</td> |
|||
<td> |
|||
{% if log.Action %} |
|||
<span class="tag is-info is-light">{{ log.Action }}</span> |
|||
{% else %} |
|||
<span class="has-text-grey">-</span> |
|||
{% endif %} |
|||
</td> |
|||
<td> |
|||
{% if log.Entity_Type %} |
|||
<div> |
|||
<span class="tag is-primary is-light">{{ log.Entity_Type }}</span> |
|||
{% if log.Entity_ID %} |
|||
<br><small class="has-text-grey">ID: {{ log.Entity_ID }}</small> |
|||
{% endif %} |
|||
</div> |
|||
{% else %} |
|||
<span class="has-text-grey">-</span> |
|||
{% endif %} |
|||
</td> |
|||
<td> |
|||
{% if log.Log_Entry %} |
|||
<div class="content is-small"> |
|||
{{ log.Log_Entry[:100] }}{% if log.Log_Entry|length > 100 %}...{% endif %} |
|||
</div> |
|||
{% else %} |
|||
<span class="has-text-grey">-</span> |
|||
{% endif %} |
|||
</td> |
|||
<td> |
|||
{% if log.IP_Address %} |
|||
<code class="is-small">{{ log.IP_Address }}</code> |
|||
{% else %} |
|||
<span class="has-text-grey">-</span> |
|||
{% endif %} |
|||
</td> |
|||
<td> |
|||
<div class="buttons are-small"> |
|||
<button class="button is-info is-outlined" onclick="showLogDetail({{ log.id }})"> |
|||
<span class="icon"><i class="fas fa-eye"></i></span> |
|||
<span>View</span> |
|||
</button> |
|||
</div> |
|||
</td> |
|||
</tr> |
|||
{% endfor %} |
|||
</tbody> |
|||
</table> |
|||
</div> |
|||
|
|||
<!-- Pagination --> |
|||
{% if pagination %} |
|||
<nav class="pagination is-centered" role="navigation" aria-label="pagination"> |
|||
{% if pagination.has_prev %} |
|||
<a class="pagination-previous" href="{{ url_for('main.logs_list', page=pagination.prev_num, **request.args) }}">Previous</a> |
|||
{% else %} |
|||
<a class="pagination-previous" disabled>Previous</a> |
|||
{% endif %} |
|||
|
|||
{% if pagination.has_next %} |
|||
<a class="pagination-next" href="{{ url_for('main.logs_list', page=pagination.next_num, **request.args) }}">Next page</a> |
|||
{% else %} |
|||
<a class="pagination-next" disabled>Next page</a> |
|||
{% endif %} |
|||
|
|||
<ul class="pagination-list"> |
|||
{% for page_num in pagination.iter_pages() %} |
|||
{% if page_num %} |
|||
{% if page_num != pagination.page %} |
|||
<li><a class="pagination-link" href="{{ url_for('main.logs_list', page=page_num, **request.args) }}">{{ page_num }}</a></li> |
|||
{% else %} |
|||
<li><a class="pagination-link is-current" href="#">{{ page_num }}</a></li> |
|||
{% endif %} |
|||
{% else %} |
|||
<li><span class="pagination-ellipsis">…</span></li> |
|||
{% endif %} |
|||
{% endfor %} |
|||
</ul> |
|||
</nav> |
|||
{% endif %} |
|||
|
|||
{% else %} |
|||
<div class="notification is-info"> |
|||
<p>No log entries found.</p> |
|||
</div> |
|||
{% endif %} |
|||
</div> |
|||
|
|||
<!-- Log Detail Modal --> |
|||
<div class="modal" id="logDetailModal"> |
|||
<div class="modal-background" onclick="hideModal('logDetailModal')"></div> |
|||
<div class="modal-card"> |
|||
<header class="modal-card-head"> |
|||
<p class="modal-card-title"> |
|||
<span class="icon"><i class="fas fa-file-alt"></i></span> |
|||
Log Entry Details |
|||
</p> |
|||
<button class="delete" aria-label="close" onclick="hideModal('logDetailModal')"></button> |
|||
</header> |
|||
<section class="modal-card-body"> |
|||
<div id="logDetailContent"> |
|||
<!-- Log details will be populated here --> |
|||
</div> |
|||
</section> |
|||
<footer class="modal-card-foot"> |
|||
<button class="button is-info" onclick="copyLogDetails()"> |
|||
<span class="icon"><i class="fas fa-copy"></i></span> |
|||
<span>Copy Details</span> |
|||
</button> |
|||
<button class="button" onclick="hideModal('logDetailModal')">Close</button> |
|||
</footer> |
|||
</div> |
|||
</div> |
|||
|
|||
<script> |
|||
let allLogs = []; |
|||
let filteredLogs = []; |
|||
|
|||
// Initialize logs and filters when page loads |
|||
document.addEventListener('DOMContentLoaded', function() { |
|||
initializeLogs(); |
|||
setupEventListeners(); |
|||
}); |
|||
|
|||
function initializeLogs() { |
|||
const tableBody = document.getElementById('logsTableBody'); |
|||
const rows = tableBody.querySelectorAll('tr'); |
|||
|
|||
allLogs = Array.from(rows).map(row => { |
|||
const cells = row.querySelectorAll('td'); |
|||
return { |
|||
element: row, |
|||
timestamp: cells[0] ? (cells[0].textContent.trim() || '') : '', |
|||
user: cells[1] ? (cells[1].textContent.trim() || '') : '', |
|||
action: cells[2] ? (cells[2].textContent.trim() || '') : '', |
|||
entityType: cells[3] ? (cells[3].textContent.trim() || '') : '', |
|||
details: cells[4] ? (cells[4].textContent.trim() || '') : '', |
|||
ipAddress: cells[5] ? (cells[5].textContent.trim() || '') : '' |
|||
}; |
|||
}); |
|||
|
|||
filteredLogs = [...allLogs]; |
|||
updateResultCount(); |
|||
} |
|||
|
|||
function setupEventListeners() { |
|||
document.getElementById('searchInput').addEventListener('input', applyFilters); |
|||
document.getElementById('userFilter').addEventListener('change', applyFilters); |
|||
document.getElementById('actionFilter').addEventListener('change', applyFilters); |
|||
document.getElementById('entityTypeFilter').addEventListener('change', applyFilters); |
|||
document.getElementById('dateFromFilter').addEventListener('change', applyFilters); |
|||
document.getElementById('dateToFilter').addEventListener('change', applyFilters); |
|||
} |
|||
|
|||
function applyFilters() { |
|||
const searchTerm = document.getElementById('searchInput').value.toLowerCase(); |
|||
const userFilter = document.getElementById('userFilter').value; |
|||
const actionFilter = document.getElementById('actionFilter').value; |
|||
const entityTypeFilter = document.getElementById('entityTypeFilter').value; |
|||
const dateFromFilter = document.getElementById('dateFromFilter').value; |
|||
const dateToFilter = document.getElementById('dateToFilter').value; |
|||
|
|||
// Filter logs |
|||
filteredLogs = allLogs.filter(log => { |
|||
// Search filter |
|||
const searchMatch = !searchTerm || |
|||
log.user.toLowerCase().includes(searchTerm) || |
|||
log.action.toLowerCase().includes(searchTerm) || |
|||
log.entityType.toLowerCase().includes(searchTerm) || |
|||
log.details.toLowerCase().includes(searchTerm) || |
|||
log.ipAddress.toLowerCase().includes(searchTerm); |
|||
|
|||
// User filter |
|||
const userMatch = !userFilter || log.user.includes(`ID: ${userFilter}`); |
|||
|
|||
// Action filter |
|||
const actionMatch = !actionFilter || log.action.includes(actionFilter); |
|||
|
|||
// Entity type filter |
|||
const entityTypeMatch = !entityTypeFilter || log.entityType.includes(entityTypeFilter); |
|||
|
|||
// Date filters would need server-side implementation for full functionality |
|||
// For now, we'll implement basic client-side date filtering on visible text |
|||
let dateMatch = true; |
|||
if (dateFromFilter || dateToFilter) { |
|||
const logDate = log.timestamp.split(' ')[0]; // Get just the date part |
|||
if (dateFromFilter && logDate < dateFromFilter) dateMatch = false; |
|||
if (dateToFilter && logDate > dateToFilter) dateMatch = false; |
|||
} |
|||
|
|||
return searchMatch && userMatch && actionMatch && entityTypeMatch && dateMatch; |
|||
}); |
|||
|
|||
// Update display |
|||
updateTable(); |
|||
updateResultCount(); |
|||
} |
|||
|
|||
function updateTable() { |
|||
const tableBody = document.getElementById('logsTableBody'); |
|||
|
|||
// Hide all rows first |
|||
allLogs.forEach(log => { |
|||
log.element.style.display = 'none'; |
|||
}); |
|||
|
|||
// Show filtered rows |
|||
filteredLogs.forEach(log => { |
|||
log.element.style.display = ''; |
|||
tableBody.appendChild(log.element); // Re-append to maintain order |
|||
}); |
|||
} |
|||
|
|||
function updateResultCount() { |
|||
const resultCount = document.getElementById('resultCount'); |
|||
const filterResults = document.getElementById('filterResults'); |
|||
|
|||
resultCount.textContent = filteredLogs.length; |
|||
|
|||
if (filteredLogs.length === allLogs.length) { |
|||
filterResults.style.display = 'none'; |
|||
} else { |
|||
filterResults.style.display = 'block'; |
|||
} |
|||
} |
|||
|
|||
function clearFilters() { |
|||
document.getElementById('searchInput').value = ''; |
|||
document.getElementById('userFilter').value = ''; |
|||
document.getElementById('actionFilter').value = ''; |
|||
document.getElementById('entityTypeFilter').value = ''; |
|||
document.getElementById('dateFromFilter').value = ''; |
|||
document.getElementById('dateToFilter').value = ''; |
|||
applyFilters(); |
|||
} |
|||
|
|||
function showLogDetail(logId) { |
|||
// Fetch log details via AJAX |
|||
fetch(`/logs/detail/${logId}`) |
|||
.then(response => response.json()) |
|||
.then(data => { |
|||
if (data.success) { |
|||
const log = data.log; |
|||
const detailHtml = ` |
|||
<div class="content"> |
|||
<table class="table is-fullwidth"> |
|||
<tbody> |
|||
<tr> |
|||
<td><strong>ID</strong></td> |
|||
<td>#${log.id}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Timestamp</strong></td> |
|||
<td>${log.timestamp}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>User</strong></td> |
|||
<td>${log.user_name || 'System'} ${log.User_ID ? `(ID: ${log.User_ID})` : ''}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Action</strong></td> |
|||
<td>${log.Action || '-'}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Entity Type</strong></td> |
|||
<td>${log.Entity_Type || '-'}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>Entity ID</strong></td> |
|||
<td>${log.Entity_ID || '-'}</td> |
|||
</tr> |
|||
<tr> |
|||
<td><strong>IP Address</strong></td> |
|||
<td>${log.IP_Address || '-'}</td> |
|||
</tr> |
|||
</tbody> |
|||
</table> |
|||
|
|||
${log.Log_Entry ? ` |
|||
<div class="field"> |
|||
<label class="label">Full Details:</label> |
|||
<div class="box"> |
|||
<pre class="has-text-dark">${log.Log_Entry}</pre> |
|||
</div> |
|||
</div> |
|||
` : ''} |
|||
</div> |
|||
`; |
|||
|
|||
document.getElementById('logDetailContent').innerHTML = detailHtml; |
|||
document.getElementById('logDetailModal').classList.add('is-active'); |
|||
} else { |
|||
alert('Failed to load log details: ' + data.error); |
|||
} |
|||
}) |
|||
.catch(error => { |
|||
console.error('Error fetching log details:', error); |
|||
alert('Failed to load log details. Please try again.'); |
|||
}); |
|||
} |
|||
|
|||
function hideModal(modalId) { |
|||
document.getElementById(modalId).classList.remove('is-active'); |
|||
} |
|||
|
|||
function copyLogDetails() { |
|||
const content = document.getElementById('logDetailContent').innerText; |
|||
navigator.clipboard.writeText(content).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); |
|||
alert('Failed to copy to clipboard'); |
|||
}); |
|||
} |
|||
|
|||
function exportLogs() { |
|||
// Create form data with current filters |
|||
const params = new URLSearchParams(); |
|||
|
|||
const searchTerm = document.getElementById('searchInput').value; |
|||
const userFilter = document.getElementById('userFilter').value; |
|||
const actionFilter = document.getElementById('actionFilter').value; |
|||
const entityTypeFilter = document.getElementById('entityTypeFilter').value; |
|||
const dateFromFilter = document.getElementById('dateFromFilter').value; |
|||
const dateToFilter = document.getElementById('dateToFilter').value; |
|||
|
|||
if (searchTerm) params.append('search', searchTerm); |
|||
if (userFilter) params.append('user', userFilter); |
|||
if (actionFilter) params.append('action', actionFilter); |
|||
if (entityTypeFilter) params.append('entity_type', entityTypeFilter); |
|||
if (dateFromFilter) params.append('date_from', dateFromFilter); |
|||
if (dateToFilter) params.append('date_to', dateToFilter); |
|||
|
|||
// Open export URL in new window |
|||
window.open(`/logs/export?${params.toString()}`, '_blank'); |
|||
} |
|||
|
|||
// 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 %} |
|||
Loading…
Reference in new issue