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.