Broadleaf Microservices
  • v1.0.0-latest-prod

Executing Checkout.com KNET Payment

The Big Picture

KNET payments processed by Checkout.com will always navigate the customer to KNET hosted page where they will enter their debit card details securely. When this page is submitted, an AuthorizeAndCapture transaction is executed immediately. From there, the Broadleaf system must be made aware of the transaction results in order to finalize the checkout attempt.

Before the Hosted Payment Page

To validate the cart’s checkout-readiness & gather the url for the hosted payment page, this interaction should start with an attempt to submit checkout. This will cause the cart to be evaluated via the checkout workflow’s validation activities, followed by a payment failure including a CheckoutResponse#failureType of PAYMENT_REQUIRES_EXTERNAL_VERIFICATION, a related payment transaction failure detail (CheckoutResponse#paymentTransactionFailureDetails) will have a failureType of REQUIRES_HOSTED_PAYMENT_PAGE_INTERACTION & a populated NextAction#redirectUrl containing the redirect URL provided by Checkout.com. The user’s browser should be redirected to this location to render the hosted payment page.

After the Hosted Payment Page

Once the hosted payment page has been submitted, an AuthorizeAndCapture transaction is executed. At this point, a second checkout request should be submitted, where we’ll attempt to lookup the external transaction results from Checkout.com, & continue the checkout process if the transaction was successful. Simultaneously, we also leverage webhook events as a secondary means of gathering & recording transaction results asynchronously.

Preparing Your Environment

Prerequisites

Before configuring KNET with Checkout.com, you’ll first need to set up your environment for the overall Checkout.com integration as described in the Environment Setup Guide.

CartOperationService Configuration

Add the following properties to declare the CHECKOUT_COM gateway as an available payment method.

broadleaf:
  cartoperation:
    service:
      checkout:
        checkout-payment-method-options:
          - payment-method-type: KNET
            payment-method-gateway-type: CHECKOUT_COM
Note
KNET only supports the KWD currency, so make sure to only enable this payment method in a context that also uses KWD.

CHECKOUT_COM should be added to the following property to declare when transactions for this gateway should be executed, relative to other gateways, during checkout processing.

broadleaf:
  cartoperation:
    service:
      checkout:
        payment-gateway-priorities:
          - ...
          - CHECKOUT_COM
          - ...

Since Checkout.com KNET interactions always result in an AuthorizeAndCapture transaction, the checkout transaction type must be declared in the following way:

broadleaf:
  cartoperation:
    service:
      checkout:
        checkout-transaction-types:
          CHECKOUT_COM.KNET: AUTHORIZE_AND_CAPTURE

To enable this transaction lookup in the checkout workflow, add the following required property:

broadleaf:
  cartoperation:
    service:
      checkout:
        checkout-lookup-external-transactions-enabled:
          CHECKOUT_COM: true

AuthenticationService Data Configuration

The following permissions must be defined for Checkout.com KNET:

-- EXTERNAL_TRANSACTION_RESULT
INSERT INTO auth.blc_security_scope (id, "name", "open") VALUES('-1800', 'EXTERNAL_TRANSACTION_RESULT', 'N');

INSERT INTO auth.blc_permission_scope(id, permission, is_permission_root, scope_id) VALUES('-1800', 'EXTERNAL_TRANSACTION_RESULT', 'Y', '-1800');
INSERT INTO auth.blc_permission_scope(id, permission, is_permission_root, scope_id) VALUES('-920', 'EXTERNAL_TRANSACTION_RESULT', 'Y', '-100');

INSERT INTO auth.blc_user_permission(id, archived, last_updated, "name") VALUES('-1100', 'N', '1970-01-01 00:00:00.000', 'ALL_EXTERNAL_TRANSACTION_RESULT');

INSERT INTO auth.blc_client_scopes(id, "scope") VALUES('anonymous', 'EXTERNAL_TRANSACTION_RESULT');
INSERT INTO auth.blc_client_scopes(id, "scope") VALUES('cartopsclient', 'EXTERNAL_TRANSACTION_RESULT');

INSERT INTO auth.blc_client_permissions(id, permission) VALUES('anonymous', 'ALL_EXTERNAL_TRANSACTION_RESULT');
INSERT INTO auth.blc_client_permissions(id, permission) VALUES('cartopsclient', 'ALL_EXTERNAL_TRANSACTION_RESULT');

INSERT INTO auth.blc_role_permission_xref (role_id, permission_id) VALUES ('-100', '-1100');

If you’re consuming the openapi client for use with OpenAPI, the following permission updates are necessary:

INSERT INTO auth.blc_client_scopes (id, scope) VALUES ('openapi', 'EXTERNAL_TRANSACTION_RESULT');
INSERT INTO auth.blc_client_permissions (id, permission) VALUES ('openapi', 'ALL_EXTERNAL_TRANSACTION_RESULT');
Note
Based on the Auth data configured in your environment, you may need modify the ids defined in the scripts above.

Frontend Integration

Example Payment Stage of the Checkout Flow

When a KNET payment method is used, the payment section of the checkout flow doesn’t have any information to gather from the customer, but a PaymentTransactionServices Payment should still be created.

Note
Data about the payment method (i.e. the debit card details) will be collected by the hosted payment page after checkout submission.
import { FC, useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { ApiError } from '@broadleaf/commerce-core';
import { useSubmitPaymentRequest } from '@broadleaf/payment-react';

import { useCartContext, usePaymentsContext } from '@app/cart/contexts';
import {
  useGetPaymentAmount,
  useRemainingTotalToPay,
} from '@app/checkout/hooks';
import { usePaymentClient } from '@app/common/contexts';
import { usePaymentAuthState } from '@app/common/hooks';

type CheckoutComKnetPaymentFormType = FC & {
  TYPE: 'CHECKOUT_COM';
};

export const CheckoutComKnetPaymentForm: CheckoutComKnetPaymentFormType =
  () => {
    const router = useRouter();
    const { isSubmitting, onSubmit, error } = useHandleSubmit();

    const {
      payments: cartPayments,
      refetchPayments,
      isStale,
      setIsStale,
    } = usePaymentsContext();
    const remainingTotalToPay = useRemainingTotalToPay();

    useEffect(() => {
      refetchPayments().then(() => {
        setIsStale(false);
      });
    }, [refetchPayments, setIsStale]);

    return (
      <>
        <div className="my-2">
          The payment data will be collected when the order is submitted.
        </div>

        {error && (
          <strong className="block my-4 text-red-600 text-lg font-normal">
            Error
          </strong>
        )}
        <button type="submit">Submit</button>
      </>
    );
  };

type UseHandleSubmitResponse = {
  isSubmitting: boolean;
  onSubmit: () => Promise<void>;
  error?: ApiError;
};

const useHandleSubmit = (): UseHandleSubmitResponse => {
  const cartState = useCartContext();
  const { cart } = cartState;
  const { payments } = usePaymentsContext();
  const paymentClient = usePaymentClient();
  const authState = usePaymentAuthState();
  const { handleSubmitPaymentInfo } = useSubmitPaymentRequest({
    authState,
    payments,
    ownerId: cart?.id,
    paymentClient,
    multiplePaymentsAllowed: true,
    rejectOnError: true,
  });

  const getPaymentAmount = useGetPaymentAmount({
    gatewayType: 'CHECKOUT_COM',
  });

  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
  const [error, setError] = useState<ApiError>();

  const onSubmit = async (): Promise<void> => {
    setIsSubmitting(true);

    const protocol = window.location.protocol.slice(0, -1);
    const host = window.location.host;

    // these are required to pass to the backend as part of the payment
    const paymentMethodProperties = {
      success_url: `${protocol}://${host}/checkout/external-payment-verification?gatewayType=CHECKOUT_COM`,
      failure_url: `${protocol}://${host}/checkout/external-payment-verification?external-payment-verification-success=false`,
    };

    const amount = getPaymentAmount(cart);
    const paymentRequest = {
      name: 'KNET',
      type: 'KNET',
      gatewayType: 'CHECKOUT_COM',
      amount,
      isSingleUsePaymentMethod: true,
      paymentMethodProperties,
      displayAttributes: {},
    } as unknown as PaymentRequest;

    try {
      const paymentSummary = await handleSubmitPaymentInfo(paymentRequest);
      ...
    } catch (err) {
      console.error('There was an error adding payment information', err);
      setError(err);
    } finally {
      ...
    }
  };

  return { isSubmitting, error, onSubmit };
};

Preparing a PaymentTransactionServices Payment for Checkout.com KNET Transactions

To execute Checkout.com KNET transactions via PaymentTransactionServices, we must first create a Payment in PaymentTransactionServices.

We expect the frontend to provide a success_url & failure_url in paymentMethodProperties, and a payment type of KNET. The request payload should include the following:

{
  ...
  gatewayType: "CHECKOUT_COM",
  type: "KNET",
  paymentMethodProperties: {
    "success_url": "https://${host}/checkout/external-payment-verification?gatewayType=CHECKOUT_COM",
    "failure_url": "https://${host}/checkout/external-payment-verification?external-payment-verification-success=false"
  },
  isSingleUsePaymentMethod: true,
  ...
}

Example Order Submission to Engage the Hosted Payment Page

This code snippet is meant to serve as an example of how to submit checkout to verify the checkout-readiness of the cart, followed by how to gather the KNET hosted payment page url & navigate the user’s browser.

import { FC, useEffect } from 'react';
import { useRouter } from 'next/router';
import { isEmpty, get, find } from 'lodash';

import { useCartContext, usePaymentsContext } from '@app/cart/contexts';

import {
  useHandleSubmitCart,
} from '@app/checkout/hooks';

const CheckoutReview: FC = () => {
  const router = useRouter();
  const { query } = router;
  const { cart, resolving } = useCartContext();

  const { payments } = usePaymentsContext();

  const { error, submitting, onSubmit } = useHandleSubmitCart();

  const externalVerificationResult = query['external-payment-verification-success'];

  useEffect(() => {
    if (externalVerificationResult === 'true') {
      if (!resolving && cart && !submitting) {
        // submit the order if the payment result is successful
        onSubmit();
      }

      if (error) {
        delete router.query['external-payment-verification-success'];
        router.replace(router, undefined, { shallow: true });
      }
    }
  }, [
    externalVerificationResult,
    router,
    onSubmit,
    cart,
    error,
    resolving,
    submitting,
  ]);

  useEffect(() => {
    if (
      !isEmpty(payments?.content) &&
      get(error, 'failureType') === 'PAYMENT_REQUIRES_EXTERNAL_INTERACTION'
    ) {
      const externalVerification = find(
        get(error, 'paymentTransactionFailureDetails'),
        ({ failureType }) =>
          failureType === 'REQUIRES_HOSTED_PAYMENT_PAGE_INTERACTION'
      );
      const paymentId = get(externalVerification, 'paymentId');
      const payment = find(payments.content, ['paymentId', paymentId]);
      const redirectUrl = get(externalVerification, 'nextAction.redirectUrl');

      if (payment.type === 'KNET' && redirectUrl) {
        // redirect to the KNET verification page beacause KNET doesn't allow using the iframe
        router.push(redirectUrl);
      }
    }
  }, [error, payments, router]);

  const externalInteractionVerificationFailedError =
    router.query['external-payment-verification-success'] === 'false'
      ? 'The payment verification failed. Please try a different form of payment.'
      : undefined;

  const externalInteractionTransactionFailedError =
    router.query['external-payment-success'] === 'false'
      ? 'General error'
      : undefined;
  return (
    <div>
      {error && <div>{...}</div>}

      {externalInteractionVerificationFailedError && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          {externalInteractionVerificationFailedError}
        </strong>
      )}
      {externalInteractionTransactionFailedError && (
        <strong className="block my-4 text-red-600 text-lg font-normal">
          {externalInteractionTransactionFailedError}
        </strong>
      )}

      <button type="submit">Submit</button>
    </div>
  );
};

Example Handling of KNET Hosted Payment Page Submission

This snippet is meant to serve as an example of how to handle the return from the KNET hosted payment page, & how to attempt finalizing the checkout attempt following the payment interaction.

import React, { FC, useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { isEmpty, set } from 'lodash';
import { useHandleExternalTransactionResult } from '@broadleaf/payment-react';
import { usePaymentClient } from '@app/common/contexts';
import { usePaymentAuthState } from '@app/common/hooks';

const ExternalPaymentVerification: FC = () => {
  const router = useRouter();
  const { query } = router;

  const paymentClient = usePaymentClient();
  const authState = usePaymentAuthState();

  const [transactionResult, setTransactionResult] =
    useState<Record<string, unknown>>();
  const { handleExternalTransactionResult, error } =
    useHandleExternalTransactionResult({
      paymentClient,
      authState,
    });

  const protocol = window.location.protocol.slice(0, -1);
  const host = window.location.host;
  const search = window.location.search;

  const externalSuccessRedirectUrl = `${protocol}://${host}/checkout/review?external-payment-verification-success=true`;
  const externalFailureRedirectUrl = `${protocol}://${host}/checkout/review?external-payment-verification-success=false`;
  const transactionFailureRedirectUrl = `${protocol}://${host}/checkout/review?external-payment-success=false`;

  const externalVerificationResult = query['external-payment-verification-success'];
  useEffect(() => {
    if (externalVerificationResult !== 'false') {
      const { gatewayType, ...request } = parseParams(search);

      handleExternalTransactionResult(gatewayType, request).then(response => {
        setTransactionResult(response);
      });
    } else {
      redirectTo(externalFailureRedirectUrl);
    }
  }, [
    externalFailureRedirectUrl,
    handleExternalTransactionResult,
    router,
    search,
    externalVerificationResult,
  ]);

  const transactionStatus =
    transactionResult?.transactionExecutionDetails[0]?.transactionStatus;
  if (
    transactionResult &&
    transactionStatus !== 'SUCCESS' &&
    transactionStatus !== 'AWAITING_ASYNC_RESULTS'
  ) {
    redirectTo(transactionFailureRedirectUrl);
  }

  if (error) {
    /*
    if there's an error, we'll still submit the order after the timeout to leverage other
    verification fall back options, The timeout is to ensure
    webhooks would be processed by the time we re-submit the order
     */
    console.error(
      'Error encountered while verifying transaction result: ',
      error
    );
    setTimeout(() => redirectAndSubmitOrder(externalSuccessRedirectUrl), 3000);
  }

  if (
    transactionStatus === 'SUCCESS' ||
    transactionStatus === 'AWAITING_ASYNC_RESULTS'
  ) {
    redirectAndSubmitOrder(externalSuccessRedirectUrl);
  }

  return <div>Loading...</div>;
};

const parseParams = (search?: string): Record<string, never> => {
  if (isEmpty(search)) {
    return {};
  }

  search = search?.startsWith('?') ? search.slice(1) : search;

  return search
    ?.split('&')
    .map(param => {
      // eslint-disable-next-line prefer-const
      let [key, value] = param.split('=');
      key = decodeURIComponent(key);
      return { key, value };
    })
    .reduce((accumulator, { key, value }) => {
      set(accumulator, key, value);
      return accumulator;
    }, {});
};

const redirectAndSubmitOrder = verificationSuccessRedirectUrl => {
  window.location.href = verificationSuccessRedirectUrl;
};

const redirectTo = redirectUrl => {
  window.location.href = redirectUrl;
};