PageSourceSearch

https://www.picklebeastpickleball.com/assets/controllers/financial…9ae64620e9dcc1d9cb25638d6de87f9f647dfffe.js

js picklebeastpickleball.com collected 2026-09-24 14:17:49 UTC 13,958 bytes, 420 lines download raw bytes

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.