1 2 3// â ï¸ BR-594 round 12. A player's own name reached an organizer's authenticated screen unescaped here. 4// escHtml for text between tags; escJsAttr for a value inside a quoted HTML attribute (backslash first, 5// then quote, then HTML-escape). See app/assets/javascripts/leagues/financial.js for the full write-up. 6function escHtml(v) { 7 const d = document.createElement('div'); 8 d.textContent = String(v == null ? '' : v); 9 return d.innerHTML; 10} 11 12import { Controller } from "@hotwired/stimulus" 13 14export default class extends Controller { 15 static targets = [ 16 "summary", "totalRevenue", "paidCount", "pendingCount", "refundedAmount", 17 "divisionRevenue", "platformFees", "stripeFees", "netRevenue", 18 "paymentsList", "searchInput", 19 "refundPlayerName", "refundOriginalAmount", "refundAlreadyRefunded", 20 "refundAmountInput", "refundMaxAmount", "refundBtn" 21 ] 22 23 static values = { 24 tournamentId: String 25 } 26 27 connect() { 28 this.loadSummary() 29 this.loadPayments() 30 } 31 32 async loadSummary() { 33 try { 34 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/financial/summary`, { 35 headers: { 'Accept': 'application/json' } 36 }) 37 38 if (response.ok) { 39 const data = await response.json() 40 this.updateSummary(data) 41 } 42 } catch (error) { 43 console.error('Error loading financial summary:', error) 44 } 45 } 46 47 updateSummary(data) { 48 // Update revenue cards 49 if (this.hasTotalRevenueTarget) { 50 this.totalRevenueTarget.textContent = this.formatCurrency(data.total_revenue) 51 } 52 if (this.hasPaidCountTarget) { 53 this.paidCountTarget.textContent = data.paid_registrations || 0 54 } 55 if (this.hasPendingCountTarget) { 56 this.pendingCountTarget.textContent = data.pending_registrations || 0 57 } 58 if (this.hasRefundedAmountTarget) { 59 this.refundedAmountTarget.textContent = this.formatCurrency(data.refunded_amount) 60 } 61 62 // Update fee breakdown 63 if (this.hasPlatformFeesTarget) { 64 this.platformFeesTarget.textContent = this.formatCurrency(data.platform_fees) 65 } 66 if (this.hasStripeFeesTarget) { 67 this.stripeFeesTarget.textContent = this.formatCurrency(data.stripe_fees) 68 } 69 if (this.hasNetRevenueTarget) { 70 const netRevenue = (data.total_revenue || 0) - (data.platform_fees || 0) - (data.stripe_fees || 0) - (data.refunded_amount || 0) 71 this.netRevenueTarget.textContent = this.formatCurrency(netRevenue) 72 } 73 74 // Update division revenue 75 if (this.hasDivisionRevenueTarget) { 76 this.renderDivisionRevenue(data.revenue_by_division || []) 77 } 78 } 79 80 renderDivisionRevenue(divisions) { 81 if (divisions.length === 0) { 82 this.divisionRevenueTarget.innerHTML = ` 83 <div class="no-revenue"> 84 <i class="fas fa-info-circle"></i> 85 <span>No revenue data yet</span> 86 </div> 87 ` 88 return 89 } 90 91 const totalRevenue = divisions.reduce((sum, d) => sum + d.total_revenue, 0) 92 93 this.divisionRevenueTarget.innerHTML = divisions.map(division => { 94 const percentage = totalRevenue > 0 ? ((division.total_revenue / totalRevenue) * 100).toFixed(1) : 0 95 return ` 96 <div class="division-revenue-item"> 97 <div class="division-info"> 98 <span class="division-name">${division.division_name}</span> 99 <span class="division-count">${division.registrations_count} registration${division.registrations_count !== 1 ? 's' : ''}</span> 100 </div> 101 <div class="division-progress"> 102 <div class="progress-bar" style="width: ${percentage}%"></div> 103 </div> 104 <div class="division-amount"> 105 <span class="amount">${this.formatCurrency(division.total_revenue)}</span> 106 <span class="percent">${percentage}%</span> 107 </div> 108 </div> 109 ` 110 }).join("") 111 } 112 113 async loadPayments() { 114 try { 115 const searchTerm = this.hasSearchInputTarget ? this.searchInputTarget.value : '' 116 const url = `/api/tournaments/${this.tournamentIdValue}/financial/payments${searchTerm ? `?search=${encodeURIComponent(searchTerm)}` : ''}` 117 118 const response = await fetch(url, { 119 headers: { 'Accept': 'application/json' } 120 }) 121 122 if (response.ok) { 123 const data = await response.json() 124 this.renderPayments(data.payments || []) 125 } 126 } catch (error) { 127 console.error('Error loading payments:', error) 128 } 129 } 130 131 renderPayments(payments) { 132 if (payments.length === 0) { 133 this.paymentsListTarget.innerHTML = ` 134 <div class="no-payments"> 135 <i class="fas fa-receipt"></i> 136 <h4>No Payments Found</h4> 137 <p>No payment records match your criteria.</p> 138 </div> 139 ` 140 return 141 } 142 143 this.paymentsListTarget.innerHTML = ` 144 <div class="payments-table"> 145 <div class="payments-table-header">
146 <span class="col-player">Player</span> 147 <span class="col-division">Division</span> 148 <span class="col-team">Team</span> 149 <span class="col-amount">Amount</span> 150 <span class="col-status">Status</span> 151 <span class="col-date">Date</span> 152 <span class="col-actions">Actions</span> 153 </div> 154 <div class="payments-table-body"> 155 ${payments.map(payment => this.renderPaymentRow(payment)).join("")} 156 </div> 157 </div> 158 ` 159 } 160 161 renderPaymentRow(payment) { 162 const statusClass = this.getStatusClass(payment.payment_status) 163 const statusLabel = this.getStatusLabel(payment.payment_status) 164 const paidDate = payment.paid_at ? new Date(payment.paid_at).toLocaleDateString() : '-' 165 const refundedDisplay = payment.amount_refunded > 0 ? ` (Refunded: ${this.formatCurrency(payment.amount_refunded)})` : '' 166 167 return ` 168 <div class="payment-row"> 169 <div class="col-player"> 170 <span class="player-name">${escHtml(payment.user.name)}</span> 171 <span class="player-email">${escHtml(payment.user.email)}</span> 172 </div> 173 <div class="col-division">${escHtml(payment.division.name)}</div> 174 <div class="col-team">${escHtml(payment.team_name || '-')}</div> 175 <div class="col-amount"> 176 <span class="amount">${this.formatCurrency(payment.amount_paid)}</span> 177 ${refundedDisplay ? `<span class="refunded">${refundedDisplay}</span>` : ''} 178 </div> 179 <div class="col-status"> 180 <span class="status-badge status-${statusClass}">${statusLabel}</span> 181 </div> 182 <div class="col-date">${paidDate}</div> 183 <div class="col-actions"> 184 ${payment.can_refund ? ` 185 <button class="btn-action btn-refund-action" 186 data-action="click->financial-dashboard#openRefundModal" 187 data-payment-id="${payment.id}" 188 data-player-name="${escHtml(payment.user.name)}" 189 data-amount-paid="${payment.amount_paid}" 190 data-amount-refunded="${payment.amount_refunded}"> 191 <i class="fas fa-undo"></i> Refund 192 </button> 193 ` : ''} 194 </div> 195 </div> 196 ` 197 } 198 199 getStatusClass(status) { 200 const classes = { 201 'paid': 'success', 202 'pending': 'warning', 203 'refunded': 'danger', 204 'cancelled': 'muted' 205 } 206 return classes[status] || 'muted' 207 } 208 209 getStatusLabel(status) { 210 const labels = { 211 'paid': 'Paid', 212 'pending': 'Pending', 213 'refunded': 'Refunded', 214 'cancelled': 'Cancelled' 215 } 216 return labels[status] || status 217 } 218 219 search() { 220 // Debounce search 221 clearTimeout(this.searchTimeout) 222 this.searchTimeout = setTimeout(() => { 223 this.loadPayments() 224 }, 300) 225 } 226 227 refresh() { 228 this.loadSummary() 229 this.loadPayments() 230 this.showToast('Data refreshed', 'success') 231 } 232 233 async exportCSV() { 234 try { 235 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/financial/payments`, { 236 headers: { 'Accept': 'application/json' } 237 }) 238 239 if (response.ok) { 240 const data = await response.json() 241 this.downloadCSV(data.payments || []) 242 } 243 } catch (error) { 244 console.error('Error exporting:', error) 245 this.showToast('Failed to export data', 'error') 246 } 247 } 248 249 downloadCSV(payments) { 250 const headers = ['Player Name', 'Email', 'Division', 'Team', 'Amount Paid', 'Amount Refunded', 'Status', 'Paid Date'] 251 const rows = payments.map(p => [ 252 p.user.name, 253 p.user.email, 254 p.division.name, 255 p.team_name || '', 256 p.amount_paid, 257 p.amount_refunded, 258 p.payment_status, 259 p.paid_at ? new Date(p.paid_at).toLocaleDateString() : '' 260 ]) 261 262 const csvContent = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n') 263 const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) 264 const link = document.createElement('a') 265 link.href = URL.createObjectURL(blob) 266 link.download = `tournament_payments_${new Date().toISOString().split('T')[0]}.csv` 267 link.click() 268 269 this.showToast('CSV exported successfully', 'success') 270 } 271 272 // Refund Modal Methods 273 openRefundModal(event) { 274 const btn = event.currentTarget 275 this.currentRefundPaymentId = btn.dataset.paymentId 276 const playerName = btn.dataset.playerName 277 const amountPaid = parseFloat(btn.dataset.amountPaid) || 0 278 const amountRefunded = parseFloat(btn.dataset.amountRefunded) || 0 279 const maxRefundable = amountPaid - amountRefunded 280 281 if (this.hasRefundPlayerNameTarget) { 282 this.refundPlayerNameTarget.textContent = playerName 283 } 284 if (this.hasRefundOriginalAmountTarget) { 285 this.refundOriginalAmountTarget.textContent = this.formatCurrency(amountPaid) 286 } 287 if (this.hasRefundAlreadyRefundedTarget) { 288 this.refundAlreadyRefundedTarget.textContent = this.formatCurrency(amountRefunded) 289 } 290 if (this.hasRefundMaxAmountTarget) { 291 this.refundMaxAmountTarget.textContent = this.formatCurrency(maxRefundable) 292 } 293 if (this.hasRefundAmountInputTarget) { 294 this.refundAmountInputTarget.value = maxRefundable.toFixed(2) 295 this.refundAmountInputTarget.max = maxRefundable 296 } 297 298 this.maxRefundable = maxRefundable 299 document.getElementById('refund-modal').style.display = 'flex' 300 } 301 302 closeRefundModal() { 303 document.getElementById('refund-modal').style.display = 'none' 304 this.currentRefundPaymentId = null 305 } 306 307 setFullRefund() { 308 if (this.hasRefundAmountInputTarget) { 309 this.refundAmountInputTarget.value = this.maxRefundable.toFixed(2) 310 } 311 } 312 313 setPartialRefund(event) { 314 const percent = parseInt(event.currentTarget.dataset.percent) || 50 315 if (this.hasRefundAmountInputTarget) { 316 const partialAmount = (this.maxRefundable * (percent / 100)).toFixed(2) 317 this.refundAmountInputTarget.value = partialAmount 318 } 319 } 320 321 validateRefundAmount() { 322 if (!this.hasRefundAmountInputTarget || !this.hasRefundBtnTarget) return 323 324 const amount = parseFloat(this.refundAmountInputTarget.value) || 0 325 const isValid = amount > 0 && amount <= this.maxRefundable 326 327 this.refundBtnTarget.disabled = !isValid 328 } 329 330 async processRefund() { 331 if (!this.currentRefundPaymentId || !this.hasRefundAmountInputTarget) return 332 333 const refundAmount = parseFloat(this.refundAmountInputTarget.value) 334 if (isNaN(refundAmount) || refundAmount <= 0) { 335 this.showToast('Please enter a valid refund amount', 'error') 336 return 337 } 338 339 if (this.hasRefundBtnTarget) { 340 this.refundBtnTarget.disabled = true 341 this.refundBtnTarget.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...' 342 } 343 344 try { 345 const response = await fetch(`/api/tournaments/${this.tournamentIdValue}/financial/refund`, { 346 method: 'POST', 347 headers: { 348 'Content-Type': 'application/json', 349 'X-CSRF-Token': this.csrfToken 350 }, 351 body: JSON.stringify({ 352 payment_id: this.currentRefundPaymentId, 353 refund_amount: refundAmount 354 }) 355 }) 356 357 const data = await response.json() 358 359 if (response.ok) { 360 this.showToast(`Refund of ${this.formatCurrency(refundAmount)} processed successfully`, 'success') 361 this.closeRefundModal() 362 this.refresh() 363 } else { 364 throw new Error(data.error || 'Failed to process refund') 365 } 366 } catch (error) { 367 console.error('Error processing refund:', error) 368 this.showToast(error.message, 'error') 369 } finally { 370 if (this.hasRefundBtnTarget) { 371 this.refundBtnTarget.disabled = false 372 this.refundBtnTarget.innerHTML = '<i class="fas fa-undo"></i> Process Refund' 373 } 374 } 375 } 376 377 formatCurrency(amount) { 378 return new Intl.NumberFormat('en-US', { 379 style: 'currency', 380 currency: 'USD' 381 }).format(amount || 0) 382 } 383 384 showToast(message, type = 'info') { 385 const toast = document.createElement('div') 386 toast.className = `toast toast-${type}` 387 toast.innerHTML = ` 388 <i class="fas fa-${type === 'success' ? 'check-circle' : type === 'error' ? 'exclamation-circle' : 'info-circle'}"></i>
389 <span>${message}</span> 390 ` 391 toast.style.cssText = ` 392 position: fixed; 393 bottom: 24px; 394 left: 50%; 395 transform: translateX(-50%); 396 background: ${type === 'success' ? '#10b981' : type === 'error' ? '#ef4444' : '#3b82f6'}; 397 color: white; 398 padding: 12px 24px; 399 border-radius: 8px; 400 display: flex; 401 align-items: center; 402 gap: 8px; 403 font-size: 14px; 404 font-weight: 500; 405 z-index: 9999; 406 box-shadow: 0 4px 12px rgba(0,0,0,0.3); 407 ` 408 document.body.appendChild(toast) 409 410 setTimeout(() => { 411 toast.style.opacity = '0' 412 toast.style.transition = 'opacity 0.3s' 413 setTimeout(() => toast.remove(), 300) 414 }, 3000) 415 } 416 417 get csrfToken() { 418 return document.querySelector('meta[name="csrf-token"]')?.content 419 } 420};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.