PageSourceSearch

https://app.wpml.org/assets/controllers/renewal_confirm_controller-eab704bc.js

js wpml.org collected 2026-10-01 06:59:38 UTC 9,219 bytes, 206 lines download raw bytes

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.