1import { Controller } from "@hotwired/stimulus" 2import { t } from "i18n" 3 4// Lane C (Amir 2026-09-29): the attended payment of a failed renewal. It began as 3ds-pay-now 5// (ruling S3), where it confirmed a 3-D Secure demand; now the client pays every failed renewal 6// here, present, with one of two options: 7// 8// the card on file ("Pay ⦠with Visa ending 4242") â paySaved: ask the server for the intent 9// (POST prepare, option=saved), then stripe.confirmCardPayment(clientSecret, { payment_method }) 10// with that card, or stripe.handleNextAction when the intent already waits on the bank; 11// another card ("Pay with another card") â reveal mounts the Payment Element (card only, the 12// card saved for the renewals) the first time the field opens; payNew validates it, asks the 13// server for the intent (option=new) and calls stripe.confirmPayment. 14// 15// The bank's window opens inside Stripe.js when the bank asks. Whatever Stripe answers â paid, or 16// refused by the bank â the form then posts to the finalize with the intent id, and the SERVER 17// reads the intent from Stripe: it books the renewal only when Stripe says it is paid, and a 18// refusal comes back to this page with the new reason. `attempted=1` tells the server that Stripe 19// answered a confirmation this time. Without Stripe.js (blocked, or no key) the plain POST goes 20// through untouched and the server's answer stands. 21export default class extends Controller { 22 static targets = ["savedForm", "newForm", "mount", "button", "error"] 23 static values = { key: String, locale: String, prepareUrl: String, returnUrl: String, amountCents: Number, 24 currency: String, country: String } 25 26 connect() { 27 if (!this.keyValue || typeof Stripe === "undefined") return 28 this.stripe = Stripe(this.keyValue, this.localeValue ? { locale: this.localeValue } : {}) 29 // The only way to pay: the card form is open on arrival and gets its field at once. 30 const open = this.element.querySelector('details[data-attended-option="new_card"][open]') 31 if (open) this.reveal({ target: open }) 32 } 33 34 // The card field is created and mounted the first time it opens: Stripe measures the container 35 // at mount time, and a closed <details> has no size. 36 reveal(event) { 37 if (!event.target.open || !this.stripe || this.paymentElement || !this.hasMountTarget) return 38 this.elements = this.stripe.elements({ 39 mode: "payment", amount: this.amountCentsValue, currency: this.currencyValue, 40 paymentMethodTypes: ["card"], setupFutureUsage: "off_session", 41 appearance: { theme: "stripe", variables: { colorPrimary: "#0084d1", borderRadius: "4px", 42 fontFamily: getComputedStyle(document.body).fontFamily } } 43 }) 44 this.paymentElement = this.elements.create("payment", { 45 wallets: { applePay: "never", googlePay: "never", link: "never" }, terms: { card: "never" }, 46 // The account's billing country is known: the field is not asked for, it is sent at confirmation. 47 fields: this.countryValue ? { billingDetails: { address: { country: "never" } } } : undefined 48 }) 49 this.paymentElement.mount(this.mountTarget) 50 } 51 52 paySaved(event) { 53 if (!this.intercept(event)) return 54 this.run(event.target, async () => { 55 const prepared = await this.prepare("saved") 56 if (!prepared) return null 57 if (prepared.status === "paid") return { paid: true, intent: prepared.intent_id } 58 const result = prepared.intent_status === "requires_action" 59 ? await this.stripe.handleNextAction({ clientSecret: prepared.client_secret }) 60 : await this.stripe.confirmCardPayment(prepared.client_secret, 61 { payment_method: prepared.payment_method, return_url: this.returnUrlValue }) 62 return { result, intent: prepared.intent_id } 63 }) 64 } 65 66 payNew(event) { 67 if (!this.intercept(event)) return 68 if (!this.elements) return this.fail(t("js.stripe_payment.confirm_failed")) 69 this.run(event.target, async () => { 70 const { error: fieldError } = await this.elements.submit() 71 if (fieldError) return null // Stripe shows the field's own message under the field 72 const prepared = await this.prepare("new") 73 if (!prepared) return null 74 if (prepared.status === "paid") return { paid: true, intent: prepared.intent_id } 75 const result = await this.stripe.confirmPayment({ 76 elements: this.elements, clientSecret: prepared.client_secret, redirect: "if_required", 77 confirmParams: this.confirmParams() 78 }) 79 return { result, intent: prepared.intent_id } 80 }) 81 } 82 83 confirmParams() { 84 const params = { return_url: this.returnUrlValue } 85 if (this.countryValue) { 86 params.payment_method_data = { billing_details: { address: { country: this.countryValue } } } 87 } 88 return params 89 } 90 91 // Only with Stripe.js on the page; otherwise the plain POST goes through. 92 intercept(event) { 93 if (event.target._passthrough || !this.stripe) return false 94 event.preventDefault() 95 return !this.busy 96 } 97 98 async run(form, work) { 99 this.busy = true 100 this.hideError() 101 this.lock(true) 102 try { 103 const done = await work() 104 if (!done) return this.lock(false) 105 if (done.paid) return this.finalize(form, done.intent, false) 106 const { result } = done 107 if (!result.error) return this.finalize(form, done.intent, true) 108 // Stripe answered: the bank refused, or the intent cannot be paid any more (another charge 109 // paid the order). The server reads the intent and says which. 110 if (result.error.type === "card_error" || result.error.payment_intent || 111 result.error.code === "payment_intent_unexpected_state") { 112 return this.finalize(form, done.intent, true) 113 } 114 this.fail(result.error.message || t("js.stripe_payment.confirm_failed")) 115 } catch (_) { 116 this.fail(t("js.stripe_payment.confirm_failed")) 117 } finally { 118 this.busy = false 119 } 120 } 121 122 async prepare(option) { 123 const res = await fetch(this.prepareUrlValue, { 124 method: "POST", 125 headers: { "Accept": "application/json", "Content-Type": "application/json", "X-CSRF-Token": this.csrfToken() }, 126 body: JSON.stringify({ option, amount_cents: this.amountCentsValue }) 127 }) 128 const data = await res.json().catch(() => ({})) 129 // data.error is a server-side message, already in the reader's language. 130 if (!res.ok || !(data.client_secret || data.status === "paid")) { 131 // The amount changed since the page was drawn, or the last payment is being processed or checked: 132 // the page is drawn again and its box says so before anything is paid. 133 if (data.reload) return window.location.reload() 134 this.fail(data.error || t("js.stripe_payment.confirm_failed")) 135 return null 136 } 137 return data 138 } 139 140 // A real document POST, so the server's redirect and flash render like every other completion. 141 finalize(form, intentId, attempted) { 142 this.setField(form, "payment_intent", intentId) 143 this.setField(form, "attempted", attempted ? "1" : "0") 144 const token = form.querySelector('input[name="authenticity_token"]') 145 if (token && this.csrfToken()) token.value = this.csrfToken() 146 form._passthrough = true 147 form.requestSubmit() 148 } 149 150 setField(form, name, value) { 151 let input = form.querySelector(`input[name="${name}"]`) 152 if (!input) { 153 input = document.createElement("input") 154 input.type = "hidden" 155 input.name = name 156 form.appendChild(input) 157 } 158 input.value = value 159 } 160 161 fail(message) { 162 if (this.hasErrorTarget) { 163 this.errorTarget.textContent = message 164 this.errorTarget.classList.remove("hidden") 165 } 166 this.lock(false) 167 } 168 169 hideError() { 170 if (this.hasErrorTarget) this.errorTarget.classList.add("hidden") 171 } 172 173 lock(on) {
174 this.buttonTargets.forEach((button) => { 175 if (on) { 176 button.setAttribute("aria-busy", "true") 177 button.classList.add("pointer-events-none") 178 } else { 179 button.removeAttribute("aria-busy") 180 button.classList.remove("pointer-events-none") 181 } 182 }) 183 if (!on) this.unlockFeedback() 184 } 185 186 // The page-wide feedback controller locks a form's button on every submit ("Workingâ¦", disabled). 187 // This controller stops that submit and answers in the page itself, so on every answer that is not 188 // a completed payment (a refusal, an error, a field message) the button gets its label and its 189 // enabled state back (demo lane F5). 190 unlockFeedback() { 191 const restore = () => this.element.querySelectorAll("[data-feedback-locked]").forEach((button) => { 192 button.disabled = false 193 button.removeAttribute("aria-busy") 194 button.removeAttribute("data-feedback-locked") 195 if (button.dataset.originalHtml) { 196 button.innerHTML = button.dataset.originalHtml 197 delete button.dataset.originalHtml 198 } 199 button.classList.remove("opacity-75", "cursor-default") 200 }) 201 restore() 202 setTimeout(restore, 0) // the feedback controller locks on the next tick 203 } 204 205 csrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || "" } 206}
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.