PageSourceSearch

https://littlebirdelectronics.com.au/assets/admin_controllers/ecom…dmin_refund_composer_controller-f2ba6523.js

js littlebirdelectronics.com.au collected 2026-09-25 14:12:04 UTC 6,128 bytes, 162 lines download raw bytes

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.