-3

I have successfully implemented Stripe using their Embedded Components API. As I understand correctly, for maximum security products are set server-site in advance. This absolutely makes sense for a multi-page checkout process.

However, now I have a dashboard with user data already present, where the client can choose from a list of just 2 products and add them to his booking. The Stripe payment button is placed directly below the list. For everything else I used the template from Stripe's documentation.

checkout.html

  <!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="utf-8" />
    <script src="https://js.stripe.com/clover/stripe.js"></script>
    <script src="checkout.js" defer></script>
  </head>
  <body>
    <!-- Display a payment form -->
    <form id="payment-form">
      <div class="product">
        <label>Product 1 ($10)</label>
        <input type="number" id="p1" value="0" min="0">
      </div>
      <div class="product">
        <label>Product 2 ($20)</label>
        <input type="number" id="p2" value="0" min="0">
      </div>  
      <label>
        Email
        <input type="email" id="email"/>
      </label>
      <div id="email-errors"></div>
      <h4>Payment</h4>
      <div id="payment-element">
        <!--Stripe.js injects the Payment Element-->
      </div>
      <button id="submit">
        <span id="button-text">Pay now</span>
      </button>
      <div id="payment-message" class="hidden"></div>
    </form>
  </body>
</html>

checkout.js

const stripe = Stripe("pk_test_***");

let checkout;
let actions;
initialize();
const emailInput = document.getElementById("email");
const emailErrors = document.getElementById("email-errors");

const validateEmail = async (email) => {
  const updateResult = await actions.updateEmail(email);
  const isValid = updateResult.type !== "error";

  return { isValid, message: !isValid ? updateResult.error.message : null };
};

document
  .querySelector("#payment-form")
  .addEventListener("submit", handleSubmit);

// Fetches a Checkout Session and captures the client secret
async function initialize() {
  const promise = fetch('/create.php', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
  })
    .then((r) => r.json())
    .then((r) => r.clientSecret);

  const appearance = {
    theme: 'stripe',
  };
  checkout = stripe.initCheckout({
    clientSecret: promise,
    elementsOptions: { appearance },
  });

  checkout.on('change', (session) => {
    // Handle changes to the checkout session
  });

  const loadActionsResult = await checkout.loadActions();
  if (loadActionsResult.type === 'success') {
    actions = loadActionsResult.actions;
    const session = loadActionsResult.actions.getSession();
    document.querySelector("#button-text").textContent = `Pay ${
      session.total.total.amount
    } now`;
  }

  emailInput.addEventListener("input", () => {
    // Clear any validation errors
    emailErrors.textContent = "";
    emailInput.classList.remove("error");
  });

  emailInput.addEventListener("blur", async () => {
    const newEmail = emailInput.value;
    if (!newEmail) {
      return;
    }

    const { isValid, message } = await validateEmail(newEmail);
    if (!isValid) {
      emailInput.classList.add("error");
      emailErrors.textContent = message;
    }
  });

  const paymentElement = checkout.createPaymentElement();
  paymentElement.mount("#payment-element");
}

async function handleSubmit(e) {
  e.preventDefault();
  setLoading(true);

  const email = document.getElementById("email").value;
  const { isValid, message } = await validateEmail(email);
  if (!isValid) {
    emailInput.classList.add("error");
    emailErrors.textContent = message;
    showMessage(message);
    setLoading(false);
    return;
  }

  const { error } = await actions.confirm();

  showMessage(error.message);

  setLoading(false);
}

// ------- UI helpers -------

function showMessage(messageText) {
  const messageContainer = document.querySelector("#payment-message");

  messageContainer.classList.remove("hidden");
  messageContainer.textContent = messageText;

  setTimeout(function () {
    messageContainer.classList.add("hidden");
    messageContainer.textContent = "";
  }, 4000);
}

create.php

<?php

require_once '../vendor/autoload.php';
require_once '../secrets.php';

$stripe = new \Stripe\StripeClient([
  "api_key" => $stripeSecretKey,
  "stripe_version" => "2025-11-17.clover"
]);
header('Content-Type: application/json');

$checkout_session = $stripe->checkout->sessions->create([
  'ui_mode' => 'custom',
  'line_items' => [
    [
      'price_data' => [
        'currency' => 'usd',
        'unit_amount' => 1000,
        'product_data' => [
          'name' => 'Product 1',
          'description' => 'Product 1 is what you need',
        ]
      ],
      'adjustable_quantity' => [
        'enabled' => true,
        'minimum' => 0,
        'maximum' => 10,
      ],
      'quantity' => 1
    ],
    [
      'price_data' => [
        'currency' => 'usd',
        'unit_amount' => 2000,
        'product_data' => [
          'name' => 'Product 2',
          'description' => 'You also need product 2',
        ]
      ],
      'adjustable_quantity' => [
        'enabled' => true,
        'minimum' => 0,
        'maximum' => 10,
      ],
      'quantity' => 1
    ],
  ],
  'mode' => 'payment',
  'return_url' => $_SERVER['HTTP_REFERER'] . '/complete.html?session_id={CHECKOUT_SESSION_ID}',
]);

echo json_encode(array('clientSecret' => $checkout_session->client_secret));

?>

How can the product quantity of the Payment Intent be changed in this environment?

Thanks a lot in advance!

1
  • Can you share your current code? Hard to help without seeing how you're creating the Payment Intent and handling the component also check How do I ask a good question? to improve your question. Commented Nov 22 at 22:55

2 Answers 2

0

You should be able to enable customers to adjust line-item quantities during Checkout by following this guide: https://docs.stripe.com/payments/advanced/adjustable-quantity?client=html.

Do note that you must return the line_item ID from create.php’s response and pass it to the updateLineItemQuantity method for the quantity to be updated. Below are the steps that worked for me:

  1. Add in adjustable_quantity field with 'expand' =>['line_items']
$checkout_session = $stripe->checkout->sessions->create([
  'ui_mode' => 'custom',
  'line_items' => [[
    'price' => 'price_xxx',
    'quantity' => 1,
    'adjustable_quantity' => [
        'enabled' => true,
        'maximum' => 100,
        'minimum' => 0,
      ],
  ]],
  'mode' => 'payment',
  'return_url' => $YOUR_DOMAIN . '/complete.html?session_id={CHECKOUT_SESSION_ID}',
  'automatic_tax' => [
    'enabled' => true,
  ],
  'expand' =>['line_items']
]);

echo json_encode(array('clientSecret' => $checkout_session->client_secret, 'lineItemId' => $checkout_session['line_items']['data'][0]['id'],)); //assumes one line item

2. Add increment button to the checkout.html page

<button type="button" class="increment-quantity-button" id="increment">+</button>

3.Receive the line item id from the create.php response, initialize the increment button and update the line Item quantity with the actions.updateLineItemQuantity method:

let lineItemId;
initialize();
...//your other code here

async function initialize() { 
 const promise = fetch('/create.php', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  })
    .then((r) => r.json())
    .then((r) => {
      lineItemId = r.lineItemId;
      return r.clientSecret
    });
  checkout = stripe.initCheckout({
    clientSecret: promise,
    elementsOptions: { appearance },
  });

  // Wait for the promise to resolve to get the lineItemId
  await promise;

  // Setup increment button after checkout and lineItemId are ready
  const incrementbutton = document.querySelector('.increment-quantity-button');
  incrementbutton.setAttribute('data-line-item', lineItemId);
  const lineItem = incrementbutton.getAttribute("data-line-item");
  const loadActionsResult = await checkout.loadActions();
  if (loadActionsResult.type === 'success') {
    const { actions } = loadActionsResult;
    incrementbutton.addEventListener('click', () => {
      // Get fresh session data each time to read current quantity
      const session = actions.getSession();
      const currentQuantity = session.lineItems.find((li) => li.id === lineItem).quantity;
      console.log('Current quantity:', currentQuantity);
      actions.updateLineItemQuantity({
        lineItem,
        quantity: currentQuantity + 1,
      });
    });
  } else {
    const { error } = loadActionsResult;
    console.log(error);
  }

  checkout.on('change', (session) => {
    // Handle changes to the checkout session
    document.querySelector("#button-text").textContent = `Pay ${session.total.total.amount
      } now`;
  });

......//your other code here
}
Sign up to request clarification or add additional context in comments.

Comments

-1

The issue is that you're creating the checkout session with fixed quantities in create.php, but you're not sending the quantities that the user selects in the frontend. The session gets created with quantity: 1 for both products regardless of what the user inputs.

Also, adjustable_quantity only works with Stripe's hosted checkout UI, not with embedded components. For embedded checkout, you need to handle quantities yourself.

so you need to send the selected quantities from your form to the backend.

async function initialize() {
  const p1Qty = document.getElementById('p1').value;
  const p2Qty = document.getElementById('p2').value;
  
  const promise = fetch('/create.php', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      quantities: [parseInt(p1Qty), parseInt(p2Qty)]
    })
  })
    .then((r) => r.json())
    .then((r) => r.clientSecret);
  
  // ... the rest of your code
}

and then, basically read those quantities in PHP and use them when creating the session

$data = json_decode(file_get_contents('php://input'), true);
$quantities = $data['quantities'] ?? [1, 1];

$checkout_session = $stripe->checkout->sessions->create([
  'ui_mode' => 'custom',
  'line_items' => [
    [
      'price_data' => [
        'currency' => 'usd',
        'unit_amount' => 1000,
        'product_data' => ['name' => 'Product 1']
      ],
      'quantity' => $quantities[0]
    ],
    [
      'price_data' => [
        'currency' => 'usd',
        'unit_amount' => 2000,
        'product_data' => ['name' => 'Product 2']
      ],
      'quantity' => $quantities[1]
    ],
  ],
  'mode' => 'payment',
  'return_url' => $_SERVER['HTTP_REFERER'] . '/complete.html?session_id={CHECKOUT_SESSION_ID}',
]);

If you want to update quantities after the session is created, you will need to either recreate the session entirely or use actions.updateLineItem() though that's more complex and requires tracking line item IDs from the session response.

The simplest approach: add a "Update Total" button that calls initialize() again with the new quantities, or just make sure quantities are set correctly before the user clicks "Pay now".

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.