Skip to content

Commit

Permalink
Clean up PayPal JS and corresponding controller code (#9244)
Browse files Browse the repository at this point in the history
* Fix PayPal JS code

* Generalize StripeAmount API for other gateways

* Clean up API routing in PayPal JS code

* Fix URL and AJAX payload typos

* Introduce SDK-conform error handling

* Introduce cancellation handler

* Remove pending TODO

* Fix tests

* Actually fix tests
  • Loading branch information
gregorbg committed Apr 19, 2024
1 parent 402dd85 commit 7e298c7
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 152 deletions.
36 changes: 21 additions & 15 deletions app/controllers/registrations_controller.rb
Expand Up @@ -3,7 +3,7 @@
require "csv"

class RegistrationsController < ApplicationController
before_action :authenticate_user!, except: [:create, :index, :psych_sheet, :psych_sheet_event, :register, :stripe_webhook, :stripe_denomination, :create_paypal_order]
before_action :authenticate_user!, except: [:create, :index, :psych_sheet, :psych_sheet_event, :register, :stripe_webhook, :payment_denomination, :create_paypal_order]
# Stripe has its own authenticity mechanism with Webhook Secrets.
protect_from_forgery except: [:stripe_webhook]

Expand All @@ -20,10 +20,10 @@ class RegistrationsController < ApplicationController
end

before_action -> { redirect_to_root_unless_user(:can_manage_competition?, competition_from_params) },
except: [:create, :index, :psych_sheet, :psych_sheet_event, :register, :payment_completion, :load_payment_intent, :stripe_webhook, :stripe_denomination, :destroy,
except: [:create, :index, :psych_sheet, :psych_sheet_event, :register, :payment_completion, :load_payment_intent, :stripe_webhook, :payment_denomination, :destroy,
:update, :create_paypal_order, :capture_paypal_payment, :refund_paypal_payment]

before_action :competition_must_be_using_wca_registration!, except: [:import, :do_import, :add, :do_add, :index, :psych_sheet, :psych_sheet_event, :stripe_webhook, :stripe_denomination]
before_action :competition_must_be_using_wca_registration!, except: [:import, :do_import, :add, :do_add, :index, :psych_sheet, :psych_sheet_event, :stripe_webhook, :payment_denomination]
private def competition_must_be_using_wca_registration!
if !competition_from_params.use_wca_registration?
flash[:danger] = I18n.t('registrations.flash.not_using_wca')
Expand Down Expand Up @@ -442,16 +442,19 @@ def register
end
end

def stripe_denomination
def payment_denomination
ruby_denomination = params.require(:amount)
currency_iso = params.require(:currency_iso)

stripe_amount = StripeRecord.amount_to_stripe(ruby_denomination, currency_iso.downcase)

ruby_money = Money.new(ruby_denomination, currency_iso)
human_amount = helpers.format_money(ruby_money)

render json: { stripe_amount: stripe_amount, human_amount: human_amount }
api_amounts = {
stripe: StripeRecord.amount_to_stripe(ruby_denomination, currency_iso),
paypal: PaypalRecord.paypal_amount(ruby_denomination, currency_iso),
}

render json: { api_amounts: api_amounts, human_amount: human_amount }
end

# Respond to asynchronous payment updates from Stripe.
Expand Down Expand Up @@ -743,18 +746,21 @@ def create
def create_paypal_order
return head :forbidden if PaypalInterface.paypal_disabled?

@registration = registration_from_params
render json: PaypalInterface.create_order(@registration, params[:total_charge])
registration = registration_from_params
amount = params.require(:amount)

render json: PaypalInterface.create_order(registration, amount)
end

def capture_paypal_payment
return head :forbidden if PaypalInterface.paypal_disabled?

@registration = registration_from_params
@competition = @registration.competition
order_id = params[:order_id]
registration = registration_from_params
competition = registration.competition

response = PaypalInterface.capture_payment(@competition, order_id)
order_id = params.require(:orderID)

response = PaypalInterface.capture_payment(competition, order_id)
if response['status'] == 'COMPLETED'

# TODO: Handle the case where there are multiple captures for a payment
Expand All @@ -780,11 +786,11 @@ def capture_paypal_payment
)

# Record the payment
@registration.record_payment(
registration.record_payment(
amount,
currency_code,
record, # TODO: Add error handling for the PaypalRecord not being found
@registration.user.id,
registration.user.id,
)
end

Expand Down
183 changes: 55 additions & 128 deletions app/views/registrations/_paypal_payment_form.html.erb
Expand Up @@ -20,57 +20,37 @@
<%= f.input :subtotal, label: t('registrations.payment_form.labels.subtotal'), hint: false do %>
<p class="form-control-static" id="money-subtotal"><%= format_money(@registration.outstanding_entry_fees) %></p>
<% end %>
<div id="stripe-elements">
<div id="paypal-buttons">
<%= f.input :payment_information, label: t("registrations.payment_form.labels.payment_information"), hint: false, wrapper_html: { id: 'payment-element-wrapper' } do %>
<div id="payment-element"></div>
<div id="payment-buttons"></div>
<% end %>
<%= f.input :payment_service_error, label: t('registrations.payment_form.labels.payment_service_error'), hint: false, wrapper_html: { id: 'payment-service-error-wrapper', class: 'text-danger' } do %>
<%= f.input :payment_service_error, label: t('registrations.payment_form.labels.payment_service_error'), hint: false, wrapper_html: { id: 'paypal-error-wrapper', class: 'text-danger' } do %>
<p class="form-control-static" id="paypal-sdk-error"></p>
<% end %>
</div>



<% merchant_id = @competition.payment_account_for(:paypal).paypal_merchant_id %>
<script src="https://www.paypal.com/sdk/js?client-id=<%= AppSecrets.PAYPAL_CLIENT_ID%>&merchant-id=<%= merchant_id %>&currency=<%= @registration.outstanding_entry_fees.currency.iso_code %>"></script>
<% currency_iso = @registration.outstanding_entry_fees.currency.iso_code %>

<script>
<script src="https://www.paypal.com/sdk/js?client-id=<%= AppSecrets.PAYPAL_CLIENT_ID%>&merchant-id=<%= merchant_id %>&currency=<%= currency_iso %>"></script>

<script>
// ----------
// I18N and currency code data
// ----------

// From https://stripe.com/docs/js/appendix/supported_locales
const supported_locales = ['ar', 'bg', 'cs', 'da', 'de', 'el', 'en', 'en-GB', 'es', 'es-419', 'et', 'fi', 'fil', 'fr', 'fr-CA', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ko', 'lt', 'lv', 'ms', 'mt', 'nb', 'nl', 'pl', 'pt-BR', 'pt', 'ro', 'ru', 'sk', 'sl', 'sv', 'th', 'tr', 'vi', 'zh', 'zh-HK', 'zh-TW'];
const wca_locale = '<%= I18n.locale %>';

const pendingAmountRuby = parseInt('<%= @registration.outstanding_entry_fees.cents %>');
const currencyIsoCode = '<%= @registration.outstanding_entry_fees.currency.iso_code %>';

// ----------
// Init Stripe PaymentElement
// ----------


// deferred payment (show the PaymentElement without pre-loading a PaymentIntent)
// as per https://stripe.com/docs/payments/accept-a-payment-deferred?type=payment

const $paymentButton = $('#payment-button');
$paymentButton.on('click', function(e) {
e.preventDefault();

toggleSaving(true);
processPayment();
});

// ----------
// Init jQuery variables and reset their state
// ----------

const $ajaxErrorRow = $('#wca-error-wrapper');
$ajaxErrorRow.hide();

const $stripeErrorRow = $('#stripe-error-wrapper');
$stripeErrorRow.hide();
const $paypalErrorRow = $('#paypal-error-wrapper');
$paypalErrorRow.hide();

const $paymentElementRow = $('#payment-element-wrapper');
$paymentElementRow.removeClass("has-error");
Expand All @@ -90,15 +70,12 @@
// ----------

function toggleSaving(saving) {
$paymentButton.prop("disabled", saving);
$paymentButton.toggleClass("saving", saving);

$donationInputField.prop("disabled", saving);
$donationInputField.toggleClass("saving", saving);

if (saving) {
$ajaxErrorRow.hide();
$stripeErrorRow.hide();
$paypalErrorRow.hide();

$paymentElementRow.removeClass("has-error");
}
Expand All @@ -108,31 +85,40 @@
// Init PayPal PaymentElement
// ----------

paypal.Buttons({
function wrapAjaxPromise(queryKey, settings) {
return new Promise((resolve, reject) => {
window.wca.cancelPendingAjaxAndAjax(queryKey, {
...settings,
success: resolve,
error: reject,
})
});
}

const paypalButtons = paypal.Buttons({
// Order is created on the server and the order id is returned
createOrder: (data, actions) => {
return fetch("/registration/<%= @registration.id %>/create-paypal-order/", {
method: "post",
body: JSON.stringify({ total_charge: getCurrentRubyAmount() }),
headers: {
'Content-Type': 'application/json' // Set the content type to JSON
}
})
.then((response) => response.json())
.then((order) => order.id);
const amount = getCurrentRubyAmount();

return wrapAjaxPromise('load-payment-intent', {
url: '<%= registration_create_paypal_order_path(@registration) %>',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ amount: amount }),
})
.then(({ id: orderId }) => orderId)
.catch(handleAjaxError);
},

// Finalize the transaction on the server after payer approval
onApprove: (data, actions) => {
return fetch(`/registration/<%= @registration.id %>/capture-paypal-payment/${data.orderID}`, {
method: "post",
body: JSON.stringify({ competition_id: '<%= @competition.id %>' }),
headers: {
'Content-Type': 'application/json' // Set the content type to JSON
}
return wrapAjaxPromise('capture-paypal-order', {
url: '<%= registration_capture_paypal_payment_path(@registration) %>',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(data),
})
.then((response) => response.json())
.then((orderData) => {
.then((orderData) => {
// Successful capture! For dev/demo purposes:
console.log('Capture result', orderData, JSON.stringify(orderData, null, 2));
const transaction = orderData.purchase_units[0].payments.captures[0];
Expand All @@ -142,65 +128,15 @@
// const element = document.getElementById('paypal-button-container');
// element.innerHTML = '<h3>Thank you for your payment!</h3>';
// Or go to another URL: actions.redirect('thank_you.html');
});
}
}).render('#payment-element');

// ----------
// Two-step workflow to handle payments
// 1. Do simple frontend validations - catches cases like bad checksums (aka typos) before they reach our backend
// 2. Create a PI and submit it to Stripe for handling
// ----------

async function processPayment() {
const amount = getCurrentRubyAmount();

if (isNaN(amount)) {
alert('<%= t("registrations.payment_form.alerts.not_a_number") %>');
} else {
// Trigger form validation and wallet collection
const { error: userInputError } = await elements.submit();

if (userInputError) {
handleStripeError(userInputError);
} else {
// NOTE: The factor two is tied to the string literal of the confirm message ("You're about to pay more than double")
// If you change this threshold, please remember to change the translation string in en.yml!
const amountOverThreshold = amount >= (2 * pendingAmountRuby);
const confirmedAmount = !amountOverThreshold || confirm('<%= t("registrations.payment_form.alerts.amount_rather_high") %>')

if (confirmedAmount) {
// Fetches a payment intent and captures the client secret
window.wca.cancelPendingAjaxAndAjax('load-payment-intent', {
url: '<%= registration_payment_intent_path(@registration) %>',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ amount: amount }),
success: submitPaymentIntent,
error: handleAjaxError,
});
} else {
toggleSaving(false);
}
}
}
}
})
.catch(handleAjaxError);
},

async function submitPaymentIntent(data) {
const { client_secret: clientSecret } = data;
onError: (err) => handlePaypalError(err, false),
onCancel: (data) => handlePaypalError(data, true),
});

const { error: stripeBackendError } = await stripe.confirmPayment({
elements,
clientSecret,
confirmParams: {
return_url: '<%= registration_payment_completion_url(@registration, host: EnvConfig.ROOT_URL) %>',
}
});

if (stripeBackendError) {
handleStripeError(stripeBackendError);
}
}
paypalButtons.render('#payment-buttons')

// ----------
// Error handling
Expand All @@ -217,19 +153,21 @@
$ajaxErrorRow.show();
}

function handleStripeError(error) {
function handlePaypalError(errPayload, isManualCancel = false) {
toggleSaving(false);

const $stripeErrorDiv = $('#stripe-sdk-error');
const $paypalErrorDiv = $('#paypal-sdk-error');

if (error.type === 'card_error' || error.type === 'validation_error') {
$stripeErrorDiv.text(error.message);
if (isManualCancel) {
$paypalErrorDiv.text('<%= t("registrations.payment_form.errors.paypal_canceled") %>');
} else {
$stripeErrorDiv.text('<%= t("registrations.payment_form.errors.stripe_failed") %>');
// Quote from the PayPal documentation: (see https://developer.paypal.com/sdk/js/reference/#onerror)
// Note: This error handler is a catch-all. Errors at this point aren't expected to be handled beyond showing a generic error message or page.
$paypalErrorDiv.text('<%= t("registrations.payment_form.errors.paypal_failed") %>');
}

$paymentElementRow.addClass("has-error");
$stripeErrorRow.show();
$paypalErrorRow.show();
}

// ----------
Expand All @@ -239,8 +177,7 @@
const $donationToggle = $('#toggle-show-donation');
const $subtotalText = $('#money-subtotal');

// PI is pre-loaded with the correct amount, no need to update it straight away
updateSubtotal;
updateSubtotal();

function getCurrentRubyAmount() {
if ($donationToggle.is(':checked')) {
Expand All @@ -255,23 +192,13 @@
function updateSubtotal() {
const amount = getCurrentRubyAmount();

const buttonDisabled = $paymentButton.prop("disabled");
const buttonToggled = $paymentButton.hasClass("saving");

// disable the payment button as long as we're relaying new payment information to Stripe
$paymentButton.prop("disabled", true);
$paymentButton.toggleClass("saving", true);

window.wca.cancelPendingAjaxAndAjax('refresh-payment-subtotal', {
url: '<%= registration_stripe_denomination_path %>',
url: '<%= registration_payment_denomination_path %>',
data: { amount: amount, currency_iso: currencyIsoCode },
success: function (data) {
const { stripe_amount: stripeAmount, human_amount: humanAmount } = data;
const { human_amount: humanAmount } = data;

$subtotalText.text(humanAmount);

$paymentButton.prop("disabled", buttonDisabled);
$paymentButton.toggleClass("saving", buttonToggled);
},
error: handleAjaxError,
});
Expand Down
4 changes: 2 additions & 2 deletions app/views/registrations/_stripe_payment_form.html.erb
Expand Up @@ -248,10 +248,10 @@
$paymentButton.toggleClass("saving", true);

window.wca.cancelPendingAjaxAndAjax('refresh-payment-subtotal', {
url: '<%= registration_stripe_denomination_path %>',
url: '<%= registration_payment_denomination_path %>',
data: { amount: amount, currency_iso: currencyIsoCode },
success: function (data) {
const { stripe_amount: stripeAmount, human_amount: humanAmount } = data;
const { api_amounts: { stripe: stripeAmount }, human_amount: humanAmount } = data;

if (refreshStripe) {
elements.update({ amount: stripeAmount });
Expand Down
2 changes: 2 additions & 0 deletions config/locales/en.yml
Expand Up @@ -1220,6 +1220,8 @@ en:
already_paid: "The charge was not placed because the registration fees are already paid."
not_allowed: "You can't pay for that registration."
stripe_failed: "Please check the Stripe transaction."
paypal_failed: "Please check the PayPal order."
paypal_canceled: "PayPal reported that the order was manually canceled. Please try again."
stripe_not_found: "Stripe reported back a transaction ID that couldn't be found."
stripe_secret_invalid: "Stripe reported back a transaction secret that doesn't match our records."
payment_reset: "The payment was rejected by Stripe."
Expand Down

0 comments on commit 7e298c7

Please sign in to comment.