PageSourceSearch

https://www.georgetowncoc.com/assets/controllers/payment_review_controller-4008d531.js

js georgetowncoc.com collected 2026-09-25 04:05:36 UTC 8,863 bytes, 233 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3export default class extends Controller {
4  static targets = [
5    "amount",
6    "method",
7    "amountLabel",
8    "amountHelp",
9    "cardWarning",
10    "partialMessage",
11    "partialKind",
12    "partialRemaining",
13    "invalidMessage",
14    "invalidText",
15    "addOn",
16    "recurringAddOn",
17    "addOnSummary",
18    "addOnReview",
19    "reviewTitle",
20    "receivedLabel",
21    "receivedAmount",
22    "currentTotal",
23    "selectedAddOns",
24    "newTotal",
25    "reviewRemaining",
26    "futureSummary",
27    "submit",
28    "outcome",
29  ]
30
31  static values = {
32    invoiceTotalCents: Number,
33    outstandingCents: Number,
34    onlineCheck: Boolean,
35  }
36
37  connect() {
38    this.review()
39  }
40
41  changeAddOn(event) {
42    if (!event.currentTarget.checked) {
43      const recurringAddOn = this.recurringAddOnTargets.find(
44        (checkbox) => checkbox.dataset.addOnId === event.currentTarget.dataset.addOnId
45      )
46      if (recurringAddOn) recurringAddOn.checked = false
47    }
48
49    this.review()
50  }
51
52  review() {
53    this.updateMethodCopy()
54    this.updateCardWarning()
55    this.syncRecurringOptions()
56
57    const selectedAddOns = this.addOnTargets.filter((checkbox) => checkbox.checked)
58    const selectedAddOnCents = this.totalCents(selectedAddOns)
59    const paymentCents = this.parseCents(this.amountTarget.value)
60    const updatedOutstandingCents = this.outstandingCentsValue + selectedAddOnCents
61    const remainingCents = paymentCents === null ? null : updatedOutstandingCents - paymentCents
62    const invalidReason = this.invalidReason(paymentCents, remainingCents)
63
64    this.updateAddOnSummary(selectedAddOns, selectedAddOnCents)
65    this.updateAddOnReview(selectedAddOns, selectedAddOnCents, paymentCents, remainingCents)
66    this.updateMessages(selectedAddOns, paymentCents, remainingCents, invalidReason)
67    this.updateSubmitButton(paymentCents, remainingCents, invalidReason)
68  }
69
70  updateMethodCopy() {
71    if (this.isWriteOff) {
72      this.amountLabelTarget.textContent = "Amount to write off"
73      this.amountHelpTarget.textContent = "Enter the balance amount to write off. No money received will be recorded."
74      this.partialKindTarget.textContent = "write-off"
75      if (this.hasReviewTitleTarget) this.reviewTitleTarget.textContent = "Review write-off and invoice changes"
76      if (this.hasReceivedLabelTarget) this.receivedLabelTarget.textContent = "Amount written off"
77      return
78    }
79
80    this.amountLabelTarget.textContent = "Amount received"
81    this.amountHelpTarget.textContent = "Enter the amount actually received. Adding items to the invoice below will not change it."
82    this.partialKindTarget.textContent = "payment"
83    if (this.hasReviewTitleTarget) this.reviewTitleTarget.textContent = "Review payment and invoice changes"
84    if (this.hasReceivedLabelTarget) this.receivedLabelTarget.textContent = "Amount received"
85  }
86
87  updateCardWarning() {
88    if (!this.hasCardWarningTarget) return
89
90    this.cardWarningTarget.classList.toggle("hidden", this.methodTarget.value !== "credit_card")
91  }
92
93  syncRecurringOptions() {
94    this.recurringAddOnTargets.forEach((checkbox) => {
95      const addOn = this.addOnTargets.find((candidate) => candidate.dataset.addOnId === checkbox.dataset.addOnId)
96      const enabled = Boolean(addOn?.checked)
97      const row = checkbox.closest("[data-payment-review-recurring-row]")
98
99      checkbox.disabled = !enabled
100      row?.classList.toggle("opacity-50", !enabled)
101      row?.classList.toggle("cursor-not-allowed", !enabled)
102      row?.classList.toggle("cursor-pointer", enabled)
103    })
104  }
105
106  updateAddOnSummary(selectedAddOns, selectedAddOnCents) {
107    if (!this.hasAddOnSummaryTarget) return
108
109    if (selectedAddOns.length === 0) {
110      this.addOnSummaryTarget.textContent = "None added"
111      return
112    }
113
114    this.addOnSummaryTarget.textContent = `${selectedAddOns.length} added · +${this.formatCents(selectedAddOnCents)}`
115  }
116
117  updateAddOnReview(selectedAddOns, selectedAddOnCents, paymentCents, remainingCents) {
118    if (!this.hasAddOnReviewTarget) return
119
120    const hasSelections = selectedAddOns.length > 0
121    this.addOnReviewTarget.classList.toggle("hidden", !hasSelections)
122    if (!hasSelections) return
123
124    const names = selectedAddOns.map((checkbox) => checkbox.dataset.name)
125    const futureNames = this.recurringAddOnTargets
126      .filter((checkbox) =>
126 checkbox.checked && !checkbox.disabled)
127      .map((checkbox) => checkbox.dataset.name)
128
129    this.receivedAmountTarget.textContent = paymentCents === null ? "—" : this.formatCents(paymentCents)
130    this.currentTotalTarget.textContent = this.formatCents(this.invoiceTotalCentsValue)
131    this.selectedAddOnsTarget.textContent = `${names.join(", ")} (+${this.formatCents(selectedAddOnCents)})`
132    this.newTotalTarget.textContent = this.formatCents(this.invoiceTotalCentsValue + selectedAddOnCents)
133    this.reviewRemainingTarget.textContent = this.remainingText(remainingCents)
134    this.futureSummaryTarget.textContent = futureNames.length === 0
135      ? "Not included"
136      : `${futureNames.join(", ")} included`
137  }
138
139  updateMessages(selectedAddOns, paymentCents, remainingCents, invalidReason) {
140    const showPartialMessage =
141      selectedAddOns.length === 0 &&
142      invalidReason === null &&
143      paymentCents > 0 &&
144      remainingCents > 0
145
146    this.partialMessageTarget.classList.toggle("hidden", !showPartialMessage)
147    if (showPartialMessage) this.partialRemainingTarget.textContent = this.formatCents(remainingCents)
148
149    const showInvalidMessage = invalidReason !== null
150    this.invalidMessageTarget.classList.toggle("hidden", !showInvalidMessage)
151    if (showInvalidMessage) this.invalidTextTarget.textContent = invalidReason
152    this.amountTarget.setAttribute("aria-invalid", showInvalidMessage ? "true" : "false")
153  }
154
155  updateSubmitButton(paymentCents, remainingCents, invalidReason) {
156    const disabled = invalidReason !== null
157    this.submitTarget.disabled = disabled
158
159    let label
160    if (paymentCents === null || paymentCents <= 0) {
161      label = this.isWriteOff ? "Enter amount to write off" : "Enter amount received"
162    } else if (remainingCents < 0) {
163      label = "Amount exceeds balance"
164    } else if (this.isWriteOff && this.onlineCheckValue) {
165      label = remainingCents === 0
166        ? `Check, write off ${this.formatCents(paymentCents)}, and mark paid`
167        : `Check, write off ${this.formatCents(paymentCents)}, and leave ${this.formatCents(remainingCents)} due`
168    } else if (this.isWriteOff) {
169      label = remainingCents === 0
170        ? `Write off ${this.formatCents(paymentCents)} and mark paid`
171        : `Write off ${this.formatCents(paymentCents)} and leave ${this.formatCents(remainingCents)} due`
172    } else if (this.onlineCheckValue) {
173      label = remainingCents === 0
174        ? `Check, record ${this.formatCents(paymentCents)}, and mark paid`
175        : `Check, record ${this.formatCents(paymentCents)}, and leave ${this.formatCents(remainingCents)} due`
176    } else {
177      label = remainingCents === 0
178        ? `Record ${this.formatCents(paymentCents)} and mark paid`
179        : `Record ${this.formatCents(paymentCents)} and leave ${this.formatCents(remainingCents)} due`
180    }
181
182    if (this.submitTarget.textContent.trim() === label) return
183
184    this.submitTarget.textContent = label
185    this.outcomeTarget.textContent = label
186  }
187
188  invalidReason(paymentCents, remainingCents) {
189    if (paymentCents === null || paymentCents <= 0) {
190      return this.isWriteOff ? "Enter an amount greater than $0 to write off." : "Enter an amount received greater than $0."
191    }
192    if (remainingCents < 0) {
193      const subject = this.isWriteOff ? "Amount to write off" : "Amount received"
194      return `${subject} exceeds the outstanding balance by ${this.formatCents(-remainingCents)}.`
195    }
196
197    return null
198  }
199
200  remainingText(remainingCents) {
201    if (remainingCents === null) return "—"
202    if (remainingCents === 0) return "$0.00 (paid in full)"
203    if (remainingCents < 0) return `${this.formatCents(-remainingCents)} over`
204
205    return `${this.formatCents(remainingCents)} due`
206  }
207
208  totalCents(checkboxes) {
209    return checkboxes.reduce((sum, checkbox) => {
210      const cents = Number.parseInt(checkbox.dataset.cents, 10)
211      return Number.isFinite(cents) ? sum + cents : sum
212    }, 0)
213  }
214
215  parseCents(value) {
216    const normalized = value.toString().trim().replace(/[$,\s]/g, "")
217    if (!/^\d+(\.\d{0,2})?$/.test(normalized)) return null
218
219    const [dollars, cents = ""] = normalized.split(".")
220    return Number.parseInt(dollars, 10) * 100 + Number.parseInt(cents.padEnd(2, "0"), 10)
221  }
222
223  formatCents(cents) {
224    return new Intl.NumberFormat("en-US", {
225      style: "currency",
226      currency: "USD",
227    }).format(cents / 100)
228  }
229
230  get isWriteOff() {
231    return this.methodTarget.value === "write_off"
232  }
233}

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.