1import { Controller } from "@hotwired/stimulus" 2 3// The admin refund composer (/admin/orders/:id/refunds/new). 4// 5// Keeps the running refund total honest while the admin edits it: change a 6// quantity and the line's amount re-prices to the pro-rated value, unless the 7// admin has typed an amount themselves â in which case their figure is left 8// alone. Every cap (per-line units, per-line dollars, shipping, and the total 9// the order can bear) is enforced again server-side by 10// Ecommerce::Admin::RefundOrderService; this is here so the number the admin is 11// about to commit is visible before they commit it, not to be the gate. 12// 13// data-controller="ecommerce--admin-refund-composer" 14// data-ecommerce--admin-refund-composer-max-value="3370" (cents) 15export default class extends Controller { 16 static targets = ["quantity", "amount", "shipping", "total", "submit", "overLimit", "lineMax"] 17 static values = { max: Number } 18 19 connect() { 20 this.recalculate() 21 } 22 23 // Quantity changed on a line: re-price that line unless the admin has taken 24 // manual control of its amount. 25 quantityChanged(event) { 26 const row = event.target.closest("[data-refund-row]") 27 if (!row) return 28 29 const amount = row.querySelector("[data-ecommerce--admin-refund-composer-target='amount']") 30 const qty = this.clampQuantity(event.target) 31 32 if (amount && amount.dataset.touched !== "true") { 33 amount.value = this.suggestedAmount(row, qty) 34 } 35 this.recalculate() 36 } 37 38 // Mark an amount as admin-owned so quantity changes stop overwriting it. 39 // Emptying it hands control back â an empty box means "price it from the 40 // quantity", which is what the server does with a blank amount. 41 amountChanged(event) { 42 event.target.dataset.touched = event.target.value === "" ? "false" : "true" 43 this.recalculate() 44 } 45 46 // "Refund everything still refundable" â fills every line to its remaining 47 // units and the shipping to whatever is left of it. 48 refundAll(event) { 49 event.preventDefault() 50 this.quantityTargets.forEach((input) => { 51 input.value = input.max || 0 52 const row = input.closest("[data-refund-row]") 53 const amount = row && row.querySelector("[data-ecommerce--admin-refund-composer-target='amount']") 54 if (amount) { 55 amount.dataset.touched = "false" 56 amount.value = this.suggestedAmount(row, Number(input.value)) 57 } 58 }) 59 if (this.hasShippingTarget) { 60 this.shippingTarget.value = this.centsToDollars(Number(this.shippingTarget.dataset.maxCents || 0)) 61 } 62 this.recalculate() 63 } 64 65 clear(event) { 66 event.preventDefault() 67 this.quantityTargets.forEach((input) => { 68 input.value = 0 69 }) 70 this.amountTargets.forEach((input) => { 71 input.value = "" 72 input.dataset.touched = "false" 73 }) 74 if (this.hasShippingTarget) this.shippingTarget.value = "" 75 this.recalculate() 76 } 77 78 recalculate() {
79 let cents = 0 80 81 // Per row, mirror exactly what the server will do: an amount box with a 82 // figure in it wins; an empty one with a quantity set means "pro-rate those 83 // units" (RefundOrderService#resolve_lines!). Totalling only the amount 84 // boxes would show $0.00 for a refund composed purely with the quantity 85 // steppers â and then move the money anyway. 86 this.rows().forEach((row) => { 87 const amount = row.querySelector("[data-ecommerce--admin-refund-composer-target='amount']") 88 const qty = row.querySelector("[data-ecommerce--admin-refund-composer-target='quantity']") 89 90 if (amount && amount.value !== "") { 91 cents += this.readCents(amount) 92 } else if (qty) { 93 cents += this.readCents({ value: this.suggestedAmount(row, Number(qty.value || 0)) }) 94 } 95 }) 96 if (this.hasShippingTarget) cents += this.readCents(this.shippingTarget) 97 98 if (this.hasTotalTarget) this.totalTarget.textContent = this.formatMoney(cents) 99 100 const over = this.hasMaxValue && this.maxValue > 0 && cents > this.maxValue 101 const empty = cents <= 0 102 103 if (this.hasOverLimitTarget) { 104 this.overLimitTarget.classList.toggle("hidden", !over) 105 } 106 if (this.hasSubmitTarget) { 107 this.submitTarget.disabled = over || empty 108 this.submitTarget.classList.toggle("opacity-50", over || empty) 109 this.submitTarget.classList.toggle("cursor-not-allowed", over || empty) 110 } 111 } 112 113 // ââ helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 114 115 rows() { 116 return Array.from(this.element.querySelectorAll("[data-refund-row]")) 117 } 118 119 // Pro-rate the line's remaining value across its remaining units, matching 120 // LineItem#suggested_refund_cents: asking for every remaining unit returns 121 // exactly what's left rather than the sum of rounded slices. 122 suggestedAmount(row, qty) { 123 if (!qty || qty <= 0) return "" 124 125 const remainingUnits = Number(row.dataset.refundableUnits || 0) 126 const remainingCents = Number(row.dataset.refundableCents || 0) 127 const lineTotalCents = Number(row.dataset.lineTotalCents || 0) 128 const lineQuantity = Number(row.dataset.lineQuantity || 0) 129 if (remainingUnits <= 0 || lineQuantity <= 0) return "" 130 131 const units = Math.min(qty, remainingUnits) 132 const cents = 133 units === remainingUnits 134 ? remainingCents 135 : Math.min(Math.floor((lineTotalCents * units) / lineQuantity), remainingCents) 136 return this.centsToDollars(cents) 137 } 138 139 clampQuantity(input) { 140 const max = Number(input.max || 0) 141 let value = Number(input.value || 0) 142 if (Number.isNaN(value) || value < 0) value = 0 143 if (max > 0 && value > max) value = max 144 input.value = value 145 return value 146 } 147 148 readCents(input) { 149 if (!input || !input.value) return 0 150 const value = Number(String(input.value).replace(/[$,\s]/g, "")) 151 if (Number.isNaN(value) || value < 0) return 0 152 return Math.round(value * 100) 153 } 154 155 centsToDollars(cents) { 156 return (cents / 100).toFixed(2) 157 } 158 159 formatMoney(cents) { 160 return `$${(cents / 100).toFixed(2)}` 161 } 162}
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.