A medida que aumentan las transacciones digitales, la capacidad de integrar perfectamente las pasarelas de pago se ha convertido en una habilidad crítica para los desarrolladores. Ya sea para marketplaces o productos SaaS, un procesador de pagos es crucial para cobrar y procesar los pagos de los usuarios.

Este artículo explica cómo integrar Stripe en un entorno Spring Boot, cómo configurar suscripciones, ofrecer pruebas gratuitas y crear páginas de autoservicio para que tus clientes descarguen sus facturas.

¿Qué es Stripe?

Stripe es una plataforma de procesamiento de pagos de renombre mundial disponible en 46 países. Es una gran elección si quieres crear una integración de pagos en tu aplicación web gracias a su gran alcance, su reputación y su detallada documentación.

Comprender los conceptos comunes de Stripe

Es útil entender algunos conceptos comunes que Stripe utiliza para coordinar y llevar a cabo operaciones de pago entre múltiples partes. Stripe ofrece dos enfoques para implementar la integración de pagos en tu aplicación.

Puedes incrustar los formularios de Stripe dentro de tu aplicación para una experiencia de cliente dentro de la aplicación (Payment Intent) o redirigir a los clientes a una página de pago alojada en Stripe, donde Stripe gestiona el proceso e informa a tu aplicación cuando un pago se realiza correctamente o no (Payment Link).

Payment Intent (Intención de pago)

Al gestionar los pagos, es importante recopilar por adelantado los detalles del cliente y del producto antes de solicitarles la información de la tarjeta y el pago. Estos datos incluyen la descripción, el importe total, el modo de pago, etc.

Stripe requiere que recopiles estos datos en tu aplicación y generes un objeto PaymentIntent en su backend. Este enfoque permite a Stripe formular una solicitud de pago para esa intención. Una vez concluido el pago, puedes recuperar sistemáticamente los detalles del pago, incluido su propósito, a través del objeto PaymentIntent.

Payment Link (Enlace de pago)

Para evitar las complejidades de integrar Stripe directamente en tu código base, considera utilizar Stripe Checkouts para una solución de pago alojada. Al igual que al crear un PaymentIntent, crearás un CheckoutSession con los detalles del pago y del cliente. En lugar de iniciar un pago en la aplicación PaymentIntent, el CheckoutSession genera un enlace de pago al que rediriges a tus clientes. Este es el aspecto de una página de pago alojada:

La página de pago alojada en Stripe.
La página de pago alojada en Stripe.

Tras el pago, Stripe redirige de nuevo a tu aplicación, permitiendo tareas posteriores al pago como confirmaciones y solicitudes de entrega. Para mayor fiabilidad, configura un webhook backend para actualizar Stripe, asegurando la retención de los datos de pago incluso si los clientes cierran accidentalmente la página después del pago.

Aunque es eficaz, este método carece de flexibilidad en la personalización y el diseño. También puede ser complicado configurarlo correctamente para aplicaciones móviles, donde una integración nativa resultaría mucho más fluida.

Claves API

Cuando trabajes con la API de Stripe, necesitarás acceder a claves API para que tus aplicaciones cliente y servidor interactúen con el backend de Stripe. Puedes acceder a tus claves API de Stripe en tu panel de desarrollador de Stripe. Este es el aspecto que tendría

El panel de Stripe mostrando las claves API
El panel de Stripe mostrando las claves API

¿Cómo funcionan los pagos en Stripe?

Para entender cómo funcionan los pagos en Stripe, tienes que entender a todas las partes implicadas. En cada transacción de pago intervienen cuatro partes interesadas:

  1. Cliente: La persona que tiene la intención de pagar por un servicio/producto.
  2. Comerciante: Tú, el empresario, eres el responsable de recibir los pagos y vender los servicios/productos.
  3. Adquirente: Un banco que procesa los pagos en tu nombre (el comerciante) y dirige tu solicitud de pago a los bancos de tus clientes. Las entidades adquirentes pueden asociarse con terceros para ayudar a procesar los pagos.
  4. Banco emisor: El banco que concede créditos y emite tarjetas, y otros medios de pago a los consumidores.

Aquí puedes ver un flujo de pago típico entre estas partes interesadas a muy alto nivel.

Cómo funcionan los pagos online
Cómo funcionan los pagos online

El cliente hace saber al comerciante que está dispuesto a pagar. A continuación, el comerciante envía los detalles relacionados con el pago a su banco adquirente, que recoge el pago del banco emisor del cliente y comunica al comerciante que el pago se ha realizado correctamente.

Este es un resumen de muy alto nivel del proceso de pago. Como comerciante, sólo tienes que preocuparte de recoger la intención de pago, transmitirla al procesador de pagos y gestionar el resultado del pago. Sin embargo, como ya hemos comentado, existen dos formas de hacerlo.

Cuando se crea una sesión de pago gestionada por Stripe en la que Stripe se encarga de la recogida de los datos de pago, el flujo típico es el siguiente:

El flujo de trabajo del pago gestionado por Stripe.
El flujo de trabajo del pago gestionado por Stripe. (Fuente: Stripe Docs)

Con los flujos de pago personalizados, realmente depende de ti. Puedes diseñar la interacción entre tu cliente, el servidor, el cliente y la API de Stripe en función de las necesidades de tu aplicación. Puedes añadir a este flujo de trabajo la recogida de direcciones, la generación de facturas, la cancelación, las pruebas gratuitas, etc., según necesites.

Ahora que entiendes cómo funcionan los pagos con Stripe, estás listo para empezar a integrarlo en tu aplicación Java.

Integración de Stripe en una Aplicación Spring Boot

Para comenzar la integración de Stripe, crea una aplicación frontend para interactuar con el backend Java e iniciar los pagos. En este tutorial, construirás una app React para activar varios tipos de pago y suscripciones, de forma que obtengas una clara comprensión de sus mecanismos.

Nota: Este tutorial no cubre la construcción de un sitio de comercio electrónico completo; su objetivo principal es guiarte a través del sencillo proceso de integración de Stripe en Spring Boot.

Configuración de los proyectos frontend y backend

Crea un nuevo directorio y monta un proyecto React utilizando Vite ejecutando el siguiente comando:

npm create vite@latest

Establece el nombre del proyecto como frontend (o el nombre que prefieras), framework como React y variante como TypeScript. Navega hasta el directorio del proyecto e instala Chakra UI para un andamiaje rápido de los elementos de la interfaz de usuario ejecutando el siguiente comando:

npm i @chakra-ui/react @emotion/react @emotion/styled framer-motion @chakra-ui/icons

También instalarás react-router-dom en tu proyecto para el enrutamiento del lado del cliente ejecutando el siguiente comando:

npm i react-router-dom

Ahora, estás listo para empezar a construir tu aplicación frontend. Esta es la página de inicio que vas a construir.

La página de inicio completada para la aplicación del frontend.
La página de inicio completada para la aplicación del frontend.

Si haces clic en cualquier botón de esta página, accederás a páginas de pago independientes con formularios de pago. Para empezar, crea una nueva carpeta llamada routes en tu directorio frontend/src. Dentro de esta carpeta, crea un archivo Home.tsx. Este archivo contendrá el código de la ruta de inicio de tu aplicación (/). Pega el siguiente código en el archivo:

import {Button, Center, Heading, VStack} from "@chakra-ui/react";

import { useNavigate } from "react-router-dom";

function Home() {
    const navigate = useNavigate()
    const navigateToIntegratedCheckout = () => {
        navigate("/integrated-checkout")
    }

    const navigateToHostedCheckout = () => {
        navigate("/hosted-checkout")
    }

    const navigateToNewSubscription = () => {
        navigate("/new-subscription")
    }

    const navigateToCancelSubscription = () => {
        navigate("/cancel-subscription")
    }

    const navigateToSubscriptionWithTrial = () => {
        navigate("/subscription-with-trial")
    }

    const navigateToViewInvoices = () => {
        navigate("/view-invoices")
    }

    return (
        <>
            <Center h={'100vh'} color='black'>
                <VStack spacing='24px'>
                    <Heading>Stripe Payments With React & Java</Heading>
                    <Button
                        colorScheme={'teal'}
                        onClick={navigateToIntegratedCheckout}>
                        Integrated Checkout
                    </Button>
                    <Button
                        colorScheme={'blue'}
                        onClick={navigateToHostedCheckout}>
                        Hosted Checkout
                    </Button>
                    <Button
                        colorScheme={'yellow'}
                        onClick={navigateToNewSubscription}>
                        New Subscription
                    </Button>
                    <Button
                        colorScheme={'purple'}
                        onClick={navigateToCancelSubscription}>
                        Cancel Subscription
                    </Button>
                    <Button
                        colorScheme={'facebook'}
                        onClick={navigateToSubscriptionWithTrial}>
                        Subscription With Trial
                    </Button>
                    <Button
                        colorScheme={'pink'}
                        onClick={navigateToViewInvoices}>
                        View Invoices
                    </Button>
                </VStack>
            </Center>
        </>
    )
}

export default Home

Para habilitar la navegación en tu aplicación, actualiza tu archivo App.tsx para configurar la clase RouteProvider de react-router-dom.

import Home from "./routes/Home.tsx";
import {
    createBrowserRouter,
    RouterProvider,
} from "react-router-dom";

function App() {

    const router = createBrowserRouter([
        {
            path: "/",
            element: (
                <Home/>
            ),
        },
    ]);

  return (
    <RouterProvider router={router}/>
  )
}

export default App

Ejecuta el comando npm run dev para previsualizar tu aplicación en https://localhost:5173.

Esto completa la configuración inicial necesaria para el frontend de la app. A continuación, crea una aplicación backend utilizando Spring Boot. Para inicializar la aplicación, puedes utilizar el sitio web spring initializr (si tu IDE admite la creación de aplicaciones Spring, no necesitas utilizar el sitio web).

IntelliJ IDEA soporta la creación de aplicaciones Spring Boot. Empieza eligiendo la opción New Project en IntelliJ IDEA. A continuación, elige Spring Initializr en el panel izquierdo. Introduce los detalles de tu proyecto backend: name (backend), location (directorio stripe-payments-java), language (Java) y type (Maven). Para los nombres de grupo y artefacto, utiliza com.kinsta.stripe-java y backend , respectivamente.

El diálogo de nuevo proyecto de IDEA.
El diálogo de nuevo proyecto de IDEA.

Haz clic en el botón Next. A continuación, añade dependencias a tu proyecto eligiendo Spring Web en el desplegable Web del panel de dependencias y haz clic en el botón Create.

Eligiendo las dependencias.
Eligiendo las dependencias.

Esto creará el proyecto Java y lo abrirá en tu IDE. Ahora puedes proceder a crear los distintos flujos de pago utilizando Stripe.

Aceptar Pagos Online para la Compra de Productos

La funcionalidad más importante y utilizada de Stripe es aceptar pagos únicos de los clientes. En esta sección, aprenderás dos formas de integrar el procesamiento de pagos en tu aplicación con Stripe.

Hosted Checkout (Pago Alojado)

En primer lugar, construyes una página de pago que activa un flujo de trabajo de pago alojado en el que sólo activas un pago desde tu aplicación frontend. Entonces Stripe se encarga de recopilar los datos de la tarjeta del cliente y de cobrar el pago, y sólo comparte el resultado de la operación de pago al final.

Este es el aspecto que tendría la página de pago:

La página de pago alojada finalizada.
La página de pago alojada finalizada.

Esta página tiene tres componentes principales: CartItem – representa cada artículo del carrito; TotalFooter – muestra el importe total; CustomerDetails – recoge los datos del cliente. Puedes reutilizar estos componentes para crear formularios de pago para otros escenarios de este artículo, como el pago integrado y las suscripciones.

Construir el frontend

Crea una carpeta components en tu directorio frontend/src. En la carpeta components, crea un nuevo archivo CartItem.tsx y pega el siguiente código:

import {Button, Card, CardBody, CardFooter, Heading, Image, Stack, Text, VStack} from "@chakra-ui/react";

function CartItem(props: CartItemProps) {
    return <Card direction={{base: 'column', sm: 'row'}}
                 overflow='hidden'
                 width={'xl'}
                 variant='outline'>
        <Image
            objectFit='cover'
            maxW={{base: '100%', sm: '200px'}}
            src={props.data.image}
        />
        <Stack mt='6' spacing='3'>
            <CardBody>
                <VStack spacing={'3'} alignItems={"flex-start"}>
                    <Heading size='md'>{props.data.name}</Heading>
                    <VStack spacing={'1'} alignItems={"flex-start"}>
                        <Text>
                            {props.data.description}
                        </Text>
                        {(props.mode === "checkout" ? <Text>
                            {"Quantity: " + props.data.quantity}
                        </Text> : <></>)}
                    </VStack>
                </VStack>
            </CardBody>

            <CardFooter>
                <VStack alignItems={'flex-start'}>
                    <Text color='blue.600' fontSize='2xl'>
                        {"$" + props.data.price}
                    </Text>
                </VStack>
            </CardFooter>
        </Stack>
    </Card>
}

export interface ItemData {
    name: string
    price: number
    quantity: number
    image: string
    description: string
    id: string
}

interface CartItemProps {
    data: ItemData
    mode: "subscription" | "checkout"
    onCancelled?: () => void
}

export default CartItem

El código anterior define dos interfaces que se utilizarán como tipos para las propiedades pasadas al componente. El tipo ItemData se exporta para reutilizarlo en otros componentes.

El código devuelve el diseño de un componente de artículo de carrito. Utiliza las propiedades proporcionadas para representar el elemento en la pantalla.

A continuación, crea un archivo TotalFooter.tsx en el directorio de components y pega el siguiente código:

import {Divider, HStack, Text} from "@chakra-ui/react";

function TotalFooter(props: TotalFooterProps) {
    return <>
        <Divider />
        <HStack>
            <Text>Total</Text>
            <Text color='blue.600' fontSize='2xl'>
                {"$" + props.total}
            </Text>
        </HStack>
        {props.mode === "subscription" &&
            <Text fontSize={"xs"}>(Monthly, starting today)</Text>
        }
        {props.mode === "trial" &&
            <Text fontSize={"xs"}>(Monthly, starting next month)</Text>
        }
    </>
}

interface TotalFooterProps {
    total: number
    mode: "checkout" | "subscription" | "trial"
}

export default TotalFooter

El componente TotalFooter muestra el valor total del carrito y utiliza el valor mode para mostrar condicionalmente un texto específico.

Por último, crea el componente CustomerDetails.tsx y pega el siguiente código:

import {ItemData} from "./CartItem.tsx";
import {Button, Input, VStack} from "@chakra-ui/react";
import {useState} from "react";

function CustomerDetails(props: CustomerDetailsProp) {
    const [name, setName] = useState("")
    const [email, setEmail] = useState("")
    const onCustomerNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
        setName(ev.target.value)
    }



    const onCustomerEmailChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
        setEmail(ev.target.value)
    }

    const initiatePayment = () => {
        fetch(process.env.VITE_SERVER_BASE_URL + props.endpoint, {
            method: "POST",
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({
                items: props.data.map(elem => ({name: elem.name, id: elem.id})),
                customerName: name,
                customerEmail: email,
            })
        })
            .then(r => r.text())
            .then(r => {
                window.location.href = r
            })

    }

    return <>
        <VStack spacing={3} width={'xl'}>
            <Input variant='filled' placeholder='Customer Name' onChange={onCustomerNameChange} value={name}/>
            <Input variant='filled' placeholder='Customer Email' onChange={onCustomerEmailChange} value={email}/>
            <Button onClick={initiatePayment} colorScheme={'green'}>Checkout</Button>
        </VStack>
    </>
}

interface CustomerDetailsProp {
    data: ItemData[]
    endpoint: string
}

export default CustomerDetails

El código anterior muestra un formulario con dos campos de entrada, para recoger el nombre y el correo electrónico del usuario. Cuando se pulsa el botón de Checkout, se invoca el método initiatePayment para enviar la solicitud de pago al backend.

Solicita el punto final que has pasado al componente y envía la información del cliente y los artículos del carrito como parte de la solicitud, luego redirige al usuario a la URL recibida del servidor. Esta URL llevará al usuario a una página de pago alojada en el servidor de Stripe. Más adelante te ocuparás de la construcción de esta URL.

Nota: Este componente utiliza la variable de entorno VITE_SERVER_BASE_URL para la URL del servidor backend. Establécela creando un archivo .env en la raíz de tu proyecto:

VITE_SERVER_BASE_URL=http://localhost:8080

Todos los componentes han sido creados. Ahora, vamos a construir la ruta de pago alojado utilizando los componentes. Para ello, crea un nuevo archivo HostedCheckout.tsx en tu carpeta de routes con el siguiente código:

import {Center, Heading, VStack} from "@chakra-ui/react";
import {useState} from "react";
import CartItem, {ItemData} from "../components/CartItem.tsx";
import TotalFooter from "../components/TotalFooter.tsx";
import CustomerDetails from "../components/CustomerDetails.tsx";
import {Products} from '../data.ts'

function HostedCheckout() {
    const [items] = useState<ItemData[]>(Products)
    return <>
        <Center h={'100vh'} color='black'>
            <VStack spacing='24px'>
                <Heading>Hosted Checkout Example</Heading>
                {items.map(elem => {
                    return <CartItem data={elem} mode={'checkout'}/>
                })}
                <TotalFooter total={30} mode={"checkout"}/>
                <CustomerDetails data={items} endpoint={"/checkout/hosted"} mode={"checkout"}/>
            </VStack>
        </Center>
    </>
}

export default HostedCheckout

Esta ruta utiliza los tres componentes que acabas de construir para montar una pantalla de pago. Todos los modos de los componentes están configurados como checkout, y se proporciona el endpoint /checkout/hosted al componente del formulario para iniciar la solicitud de checkout con precisión.

El componente utiliza un objeto Products para rellenar la matriz de elementos. En el mundo real, estos datos proceden de la API de tu carrito, y contienen los artículos seleccionados por el usuario. Sin embargo, para este tutorial, una lista estática de un script rellena el array. Define la matriz creando un archivo data.ts en la raíz de tu proyecto frontend y almacenando el siguiente código en él:

import {ItemData} from "./components/CartItem.tsx";

export const Products: ItemData[] = [
    {
        description: "Premium Shoes",
        image: "https://source.unsplash.com/NUoPWImmjCU",
        name: "Puma Shoes",
        price: 20,
        quantity: 1,
        id: "shoe"
    },
    {
        description: "Comfortable everyday slippers",
        image: "https://source.unsplash.com/K_gIPI791Jo",
        name: "Nike Sliders",
        price: 10,
        quantity: 1,
        id: "slippers"
    },
]

Este archivo define dos elementos en la matriz de productos que se muestran en el carrito. Puedes modificar tranquilamente los valores de los productos.

Como último paso en la construcción del frontend, crea dos nuevas rutas para gestionar el éxito y el fracaso. La página de pago alojada en Stripe redirigirá a los usuarios a tu aplicación por estas dos rutas en función del resultado de la transacción. Stripe también proporcionará a tus rutas la carga útil relacionada con la transacción, como el ID de sesión de pago, que puedes utilizar para recuperar el objeto de sesión de pago correspondiente y acceder a los datos relacionados con el pago, como el método de pago, los detalles de la factura, etc.

Para ello, crea un archivo Success.tsx en el directorio src/routes y guarda en él el siguiente código:

import {Button, Center, Heading, Text, VStack} from "@chakra-ui/react";
import {useNavigate} from "react-router-dom";

function Success() {
    const queryParams = new URLSearchParams(window.location.search)
    const navigate = useNavigate()
    const onButtonClick = () => {
        navigate("/")
    }
    return <Center h={'100vh'} color='green'>
        <VStack spacing={3}>
            <Heading fontSize={'4xl'}>Success!</Heading>
            <Text color={'black'}>{queryParams.toString().split("&").join("n")}</Text>
            <Button onClick={onButtonClick} colorScheme={'green'}>Go Home</Button>
        </VStack>
    </Center>
}

export default Success

Al renderizarse, este componente muestra el mensaje «¡Success!» e imprime en pantalla cualquier parámetro de consulta de la URL. También incluye un botón para redirigir a los usuarios a la página de inicio de la aplicación.

Cuando construyas aplicaciones para el mundo real, esta página es donde gestionarás las transacciones no críticas del lado de la aplicación que dependan del éxito de la transacción en cuestión. Por ejemplo, si estás creando una página de pago para una tienda online, podrías utilizar esta página para mostrar una confirmación al usuario y un tiempo estimado de entrega de los productos adquiridos.

A continuación, crea un archivo Failure.tsx con el siguiente código:

import {Button, Center, Heading, Text, VStack} from "@chakra-ui/react";
import {useNavigate} from "react-router-dom";

function Failure() {
    const queryParams = new URLSearchParams(window.location.search)
    const navigate = useNavigate()
    const onButtonClick = () => {
        navigate("/")
    }

    return <Center h={'100vh'} color='red'>
        <VStack spacing={3}>
            <Heading fontSize={'4xl'}>Failure!</Heading>
            <Text color={'black'}>{queryParams.toString().split("&").join("n")}</Text>
            <Button onClick={onButtonClick} colorScheme={'red'}>Try Again</Button>
        </VStack>
    </Center>
}

export default Failure

Este componente es similar al de Success.tsx y muestra el mensaje «¡Failure!» cuando se procesa.

.

Para tareas esenciales como la entrega del producto, el envío de correos electrónicos o cualquier parte crítica de tu flujo de compra, utiliza webhooks. Los webhooks son rutas API en tu servidor que Stripe puede invocar cuando se produce una transacción.

El webhook recibe todos los detalles de la transacción (a través del objeto CheckoutSession ), lo que te permite registrarla en la base de datos de tu aplicación y desencadenar los correspondientes flujos de trabajo de éxito o fracaso. Como tu servidor está siempre accesible para Stripe, no se pierde ninguna transacción, lo que garantiza la funcionalidad consistente de tu tienda online.

Por último, actualiza el archivo App.tsx para que tenga este aspecto:

import Home from "./routes/Home.tsx";
import {createBrowserRouter, RouterProvider,} from "react-router-dom";
import HostedCheckout from "./routes/HostedCheckout.tsx";
import Success from "./routes/Success.tsx";
import Failure from "./routes/Failure.tsx";

function App() {

    const router = createBrowserRouter([
        {
            path: "/",
            element: (
                <Home/>
            ),
        },
        {
            path: "/hosted-checkout",
            element: (
                <HostedCheckout/>
            )
        },
        {
            path: '/success',
            element: (
                <Success/>
            )
        },
        {
            path: '/failure',
            element: (
                <Failure/>
            )
        },
    ]);

    return (
        <RouterProvider router={router}/>
    )
}

export default App

Esto asegurará que los componentes Success y Failure se rendericen en las rutas /success y /failure, respectivamente.

Esto completa la configuración del frontend. A continuación, configura el backend para crear el endpoint /checkout/hosted.

Construir el backend

Abre el proyecto del backend e instala el SDK de Stripe añadiendo las siguientes líneas en la matriz de dependencias de tu archivo pom.xml:

        <dependency>
            <groupId>com.stripe</groupId>
            <artifactId>stripe-java</artifactId>
            <version>22.29.0</version>
        </dependency>

A continuación, carga los cambios de Maven en tu proyecto para instalar las dependencias. Si tu IDE no lo permite a través de la interfaz de usuario, ejecuta el comando maven dependency:resolve o maven install. Si no tienes la CLI maven, utiliza la envoltura mvnw de Spring initializr al crear el proyecto.

Una vez instaladas las dependencias, crea un nuevo controlador REST para gestionar las solicitudes HTTP entrantes de tu aplicación backend. Para ello, crea un archivo PaymentController.java en el directorio src/main/java/com/kinsta/stripe-java/backend y añade el siguiente código:

package com.kinsta.stripejava.backend;

import com.stripe.Stripe;
import com.stripe.exception.StripeException;
import com.stripe.model.Customer;
import com.stripe.model.Product;
import com.stripe.model.checkout.Session;
import com.stripe.param.checkout.SessionCreateParams;
import com.stripe.param.checkout.SessionCreateParams.LineItem.PriceData;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@CrossOrigin
public class PaymentController {

    String STRIPE_API_KEY = System.getenv().get("STRIPE_API_KEY");

    @PostMapping("/checkout/hosted")
    String hostedCheckout(@RequestBody RequestDTO requestDTO) throws StripeException {
      return "Hello World!";
    }

}

El código anterior importa las dependencias esenciales de Stripe y establece la clase PaymentController. Esta clase lleva dos anotaciones: @RestController y @CrossOrigin. La anotación @RestController indica a Spring Boot que trate esta clase como un controlador, y sus métodos pueden utilizar ahora las anotaciones @Mapping para gestionar las solicitudes HTTP entrantes.

La anotación @CrossOrigin marca todos los endpoints definidos en esta clase como abiertos a todos los orígenes según las reglas CORS. Sin embargo, esta práctica se desaconseja en producción debido a las posibles vulnerabilidades de seguridad de varios dominios de Internet.

Para obtener resultados óptimos, es aconsejable alojar los servidores backend y frontend en el mismo dominio para eludir los problemas CORS. Alternativamente, si esto no es factible, puedes especificar el dominio de tu cliente frontend (que envía peticiones al servidor backend) utilizando la anotación @CrossOrigin, de la siguiente manera:

@CrossOrigin(origins = "http://frontend.com")

La clase PaymentController extraerá la clave de la API de Stripe de las variables de entorno para proporcionársela posteriormente al SDK de Stripe. Cuando ejecutes la aplicación, deberás proporcionar tu clave de la API de Stripe a la aplicación a través de variables de entorno.

Localmente, puedes crear una nueva variable de entorno en tu sistema, ya sea temporalmente (añadiendo una frase KEY=VALUE antes del comando utilizado para iniciar tu servidor de desarrollo) o permanentemente (actualizando los archivos de configuración de tu terminal o estableciendo una variable de entorno en el panel de control en Windows).

En entornos de producción, tu proveedor de despliegue (como Kinsta) te proporcionará una opción independiente para rellenar las variables de entorno utilizadas por tu aplicación.

Si utilizas IntelliJ IDEA (o un IDE similar), haz clic en Run Configurations en la parte superior derecha del IDE y haz clic en la opción Edit Configurations… de la lista desplegable que se abre para actualizar tu comando de ejecución y establecer la variable de entorno.

Abriendo el cuadro de diálogo de configuraciones de ejecución/depuración.
Abriendo el cuadro de diálogo de configuraciones de ejecución/depuración.

Se abrirá un cuadro de diálogo en el que puedes proporcionar las variables de entorno para tu aplicación utilizando el campo Environment variables. Introduce la variable de entorno STRIPE_API_KEY en el formato VAR1=VALUE. Puedes encontrar tu clave API en el sitio web de desarrolladores de Stripe. Debes proporcionar el valor de la Clave Secreta desde esta página.

El panel de control de Stripe mostrando las claves API.
El panel de control de Stripe mostrando las claves API.

Si aún no lo has hecho, crea una nueva cuenta de Stripe para acceder a las claves API.

Una vez que hayas configurado la clave API, procede a construir el endpoint. Este endpoint recopilará los datos del cliente (nombre y correo electrónico), creará un perfil de cliente para ellos en Stripe si aún no existe uno, y creará una Sesión de Pago para permitir a los usuarios pagar los artículos del carrito.

Este es el aspecto del código del método hostedCheckout:

    @PostMapping("/checkout/hosted")
    String hostedCheckout(@RequestBody RequestDTO requestDTO) throws StripeException {

        Stripe.apiKey = STRIPE_API_KEY;
        String clientBaseURL = System.getenv().get("CLIENT_BASE_URL");

        // Start by finding an existing customer record from Stripe or creating a new one if needed
        Customer customer = CustomerUtil.findOrCreateCustomer(requestDTO.getCustomerEmail(), requestDTO.getCustomerName());

        // Next, create a checkout session by adding the details of the checkout
        SessionCreateParams.Builder paramsBuilder =
                SessionCreateParams.builder()
                        .setMode(SessionCreateParams.Mode.PAYMENT)
                        .setCustomer(customer.getId())
                        .setSuccessUrl(clientBaseURL + "/success?session_id={CHECKOUT_SESSION_ID}")
                        .setCancelUrl(clientBaseURL + "/failure");

        for (Product product : requestDTO.getItems()) {
            paramsBuilder.addLineItem(
                    SessionCreateParams.LineItem.builder()
                            .setQuantity(1L)
                            .setPriceData(
                                    PriceData.builder()
                                            .setProductData(
                                                    PriceData.ProductData.builder()
                                                            .putMetadata("app_id", product.getId())
                                                            .setName(product.getName())
                                                            .build()
                                            )
                                            .setCurrency(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getCurrency())
                                            .setUnitAmountDecimal(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getUnitAmountDecimal())
                                            .build())
                            .build());
        }

        }

        Session session = Session.create(paramsBuilder.build());

        return session.getUrl();
    }

Al crear la sesión de pago, el código utiliza el nombre del producto recibido del cliente, pero no utiliza los detalles del precio de la solicitud. Este enfoque evita la posible manipulación de precios por parte del cliente, donde los actores maliciosos podrían enviar precios reducidos en la solicitud de pago para pagar menos por productos y servicios.

Para evitarlo, el método hostedCheckout consulta tu base de datos de productos (a través de ProductDAO) para recuperar el precio correcto del artículo.

Además, Stripe ofrece varias clases Builder que siguen el patrón de diseño constructor. Estas clases ayudan a crear objetos parámetro para las peticiones de Stripe. El fragmento de código proporcionado también hace referencia a variables de entorno para obtener la URL de la aplicación cliente. Esta URL es necesaria para que el objeto de sesión de pago redirija adecuadamente tras un pago correcto o fallido.

Para ejecutar este código, establece la URL de la aplicación cliente mediante variables de entorno, de forma similar a como se proporcionó la clave API de Stripe. Como la aplicación cliente se ejecuta a través de Vite, la URL de la aplicación local debe ser http://localhost:5173. Incluye esto en tus variables de entorno a través de tu IDE, terminal o panel de control del sistema.

CLIENT_BASE_URL=http://localhost:5173

Además, proporciona a la app un ProductDAO desde el que buscar los precios de los productos. El Objeto de Acceso a Datos (DAO -Data access object) interactúa con fuentes de datos (como bases de datos) para acceder a los datos relacionados con la app. Aunque configurar una base de datos de productos quedaría fuera del alcance de este tutorial, una implementación sencilla que puedes hacer sería añadir un nuevo archivo ProductDAO.java en el mismo directorio que el PaymentController.java y pegar el siguiente código:

package com.kinsta.stripejava.backend;

import com.stripe.model.Price;
import com.stripe.model.Product;

import java.math.BigDecimal;

public class ProductDAO {

    static Product[] products;

    static {
        products = new Product[4];

        Product sampleProduct = new Product();
        Price samplePrice = new Price();

        sampleProduct.setName("Puma Shoes");
        sampleProduct.setId("shoe");
        samplePrice.setCurrency("usd");
        samplePrice.setUnitAmountDecimal(BigDecimal.valueOf(2000));
        sampleProduct.setDefaultPriceObject(samplePrice);
        products[0] = sampleProduct;

        sampleProduct = new Product();
        samplePrice = new Price();

        sampleProduct.setName("Nike Sliders");
        sampleProduct.setId("slippers");
        samplePrice.setCurrency("usd");
        samplePrice.setUnitAmountDecimal(BigDecimal.valueOf(1000));
        sampleProduct.setDefaultPriceObject(samplePrice);
        products[1] = sampleProduct;

        sampleProduct = new Product();
        samplePrice = new Price();

        sampleProduct.setName("Apple Music+");
        sampleProduct.setId("music");
        samplePrice.setCurrency("usd");
        samplePrice.setUnitAmountDecimal(BigDecimal.valueOf(499));
        sampleProduct.setDefaultPriceObject(samplePrice);
        products[2] = sampleProduct;

    }

    public static Product getProduct(String id) {

        if ("shoe".equals(id)) {
            return products[0];
        } else if ("slippers".equals(id)) {
            return products[1];
        } else if ("music".equals(id)) {
            return products[2];
        } else return new Product();

    }
}

Esto inicializará una matriz de productos y te permitirá consultar los datos del producto utilizando su identificador (ID). También tendrás que crear un Objeto de Transferencia de Datos (DTO – Data transfer object) para permitir que Spring Boot serialice automáticamente la carga útil entrante desde el cliente y te presente un objeto sencillo para acceder a los datos. Para ello, crea un nuevo archivo RequestDTO.java y pega el siguiente código:

package com.kinsta.stripejava.backend;

import com.stripe.model.Product;

public class RequestDTO {
    Product[] items;
    String customerName;
    String customerEmail;

    public Product[] getItems() {
        return items;
    }

    public String getCustomerName() {
        return customerName;
    }

    public String getCustomerEmail() {
        return customerEmail;
    }

}

Este archivo define un POJO que contiene el nombre del cliente, su correo electrónico y la lista de artículos que está comprando.

Por último, implementa el método CustomerUtil.findOrCreateCustomer() para crear el objeto Cliente en Stripe si aún no existe. Para ello, crea un archivo con el nombre CustomerUtil y añádele el siguiente código:

package com.kinsta.stripejava.backend;

import com.stripe.exception.StripeException;
import com.stripe.model.Customer;
import com.stripe.model.CustomerSearchResult;
import com.stripe.param.CustomerCreateParams;
import com.stripe.param.CustomerSearchParams;

public class CustomerUtil {

    public static Customer findCustomerByEmail(String email) throws StripeException {
        CustomerSearchParams params =
                CustomerSearchParams
                        .builder()
                        .setQuery("email:'" + email + "'")
                        .build();

        CustomerSearchResult result = Customer.search(params);

        return result.getData().size() > 0 ? result.getData().get(0) : null;
    }

    public static Customer findOrCreateCustomer(String email, String name) throws StripeException {
        CustomerSearchParams params =
                CustomerSearchParams
                        .builder()
                        .setQuery("email:'" + email + "'")
                        .build();

        CustomerSearchResult result = Customer.search(params);

        Customer customer;

        // If no existing customer was found, create a new record
        if (result.getData().size() == 0) {

            CustomerCreateParams customerCreateParams = CustomerCreateParams.builder()
                    .setName(name)
                    .setEmail(email)
                    .build();

            customer = Customer.create(customerCreateParams);
        } else {
            customer = result.getData().get(0);
        }

        return customer;
    }
}

Esta clase también contiene otro método findCustomerByEmail que te permite buscar clientes en Stripe utilizando sus direcciones de correo electrónico. La API de búsqueda de clientes se utiliza para buscar los registros de clientes en la base de datos de Stripe y la API de creación de clientes se utiliza para crear los registros de clientes según sea necesario.

Esto completa la configuración del backend necesaria para el flujo de pago alojado. Ahora puedes probar la aplicación ejecutando las aplicaciones frontend y backend en sus IDEs o terminales independientes. Este es el aspecto que tendría el flujo de éxito:

Un hosted checkout con éxito.
Un hosted checkout con éxito.

Cuando pruebes integraciones con Stripe, siempre puedes utilizar los siguientes datos de tarjeta para simular transacciones con tarjeta:

Número de tarjeta: 4111 1111 1111 1111
Mes y año de caducidad: 12 / 25
CVV: Cualquier número de tres dígitos
Nombre en la tarjeta: Cualquier nombre

Si eliges cancelar la transacción en lugar de pagar, así es como sería el flujo de fallo:

Un flujo de pago alojado fallido.
Un flujo de pago alojado fallido.

Esto completa la configuración de una experiencia de pago alojada en Stripe integrada en tu aplicación. Puedes consultar la documentación de Stripe para obtener más información sobre cómo personalizar tu página de pago, recopilar más datos del cliente, etc.

Integrated Checkout

Una experiencia con integrated checkout consiste en crear un flujo de pago que no redirija a tus usuarios fuera de tu aplicación (como ocurría en el flujo de hosted checkout) y que muestre el formulario de pago en tu propia aplicación.

Construir una experiencia de pago integrada significa manejar los detalles de pago de los clientes, lo que implica información sensible como números de tarjetas de crédito, ID de Google Pay, etc. No todas las aplicaciones están diseñadas para gestionar estos datos de forma segura.

Para eliminar la carga de cumplir normas como la PCI-DSS, Stripe proporciona elementos que puedes utilizar dentro de la aplicación para recopilar datos de pago, dejando que Stripe gestione la seguridad y procese los pagos de forma segura.

Construir el Frontend

Para empezar, instala el SDK React de Stripe en tu aplicación frontend para acceder a los elementos de Stripe ejecutando el siguiente comando en el directorio de tu frontend:

npm i @stripe/react-stripe-js @stripe/stripe-js

A continuación, crea un nuevo archivo llamado IntegratedCheckout.tsx en tu directorio frontend/src/routes y guarda el siguiente código en él:

import {Button, Center, Heading, Input, VStack} from "@chakra-ui/react";
import {useEffect, useState} from "react";
import CartItem, {ItemData} from "../components/CartItem.tsx";
import TotalFooter from "../components/TotalFooter.tsx";
import {Products} from '../data.ts'
import {Elements, PaymentElement, useElements, useStripe} from '@stripe/react-stripe-js';
import {loadStripe, Stripe} from '@stripe/stripe-js';

function IntegratedCheckout() {

    const [items] = useState<ItemData[]>(Products)
    const [transactionClientSecret, setTransactionClientSecret] = useState("")
    const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null)
    const [name, setName] = useState("")
    const [email, setEmail] = useState("")
    const onCustomerNameChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
        setName(ev.target.value)
    }

    const onCustomerEmailChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
        setEmail(ev.target.value)
    }

    useEffect(() => {
        // Make sure to call `loadStripe` outside of a component’s render to avoid
        // recreating the `Stripe` object on every render.
        setStripePromise(loadStripe(process.env.VITE_STRIPE_API_KEY || ""));

    }, [])

    const createTransactionSecret = () => {
        fetch(process.env.VITE_SERVER_BASE_URL + "/checkout/integrated", {
            method: "POST",
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({
                items: items.map(elem => ({name: elem.name, id: elem.id})),
                customerName: name,
                customerEmail: email,
            })
        })
            .then(r => r.text())
            .then(r => {
                setTransactionClientSecret(r)
            })
    }

    return <>
        <Center h={'100vh'} color='black'>
            <VStack spacing='24px'>
                <Heading>Integrated Checkout Example</Heading>
                {items.map(elem => {
                    return <CartItem data={elem} mode={'checkout'}/>
                })}
                <TotalFooter total={30} mode={"checkout"}/>

                <Input variant='filled' placeholder='Customer Name' onChange={onCustomerNameChange}
                       value={name}/>
                <Input variant='filled' placeholder='Customer Email' onChange={onCustomerEmailChange}
                       value={email}/>
                <Button onClick={createTransactionSecret} colorScheme={'green'}>Initiate Payment</Button>

                {(transactionClientSecret === "" ?
                    <></>
                    : <Elements stripe={stripePromise} options={{clientSecret: transactionClientSecret}}>
                        <CheckoutForm/>
                    </Elements>)}
            </VStack>
        </Center>
    </>
}

const CheckoutForm = () => {

    const stripe = useStripe();
    const elements = useElements();
    const handleSubmit = async (event: React.MouseEvent<HTMLButtonElement>) => {
        event.preventDefault();

        if (!stripe || !elements) {
            return;
        }

        const result = await stripe.confirmPayment({
            elements,
            confirmParams: {
                return_url: process.env.VITE_CLIENT_BASE_URL + "/success",
            },
        });

        if (result.error) {
            console.log(result.error.message);
        }
    };

    return <>
        <VStack>
            <PaymentElement/>
            <Button colorScheme={'green'} disabled={!stripe} onClick={handleSubmit}>Pay</Button>
        </VStack>
    </>
}

export default IntegratedCheckout

Este archivo define dos componentes, IntegratedCheckout y CheckoutForm. El CheckoutForm define un formulario sencillo con un PaymentElement de Stripe que recoge los datos de pago de los clientes y un botón Pagar que desencadena una solicitud de cobro del pago.

Este componente también llama al hook useStripe() y useElements() para crear una instancia del SDK de Stripe que puedes utilizar para crear solicitudes de pago. Una vez que haces clic en el botón Pagar, se llama al método stripe.confirmPayment() del SDK de Stripe que recoge los datos de pago del usuario de la instancia de elementos y los envía al backend de Stripe con una URL de éxito a la que redirigir si la transacción se realiza correctamente.

El formulario de pago se ha separado del resto de la página porque los hooks useStripe() y useElements() necesitan ser llamados desde el contexto de un proveedor Elements, lo que se ha hecho en la sentencia return de IntegratedCheckout. Si trasladaras las llamadas al gancho Stripe al componente IntegratedCheckout directamente, quedarían fuera del ámbito del proveedor Elements y, por tanto, no funcionarían.

El componente IntegratedCheckout reutiliza los componentes CartItem y TotalFooter para mostrar los artículos del carrito y el importe total. También muestra dos campos de entrada para recoger la información del cliente y un botón Iniciar pago que envía una solicitud al servidor Java backend para crear la clave secreta del cliente utilizando los datos del cliente y del carrito. Una vez recibida la clave secreta del cliente, se muestra CheckoutForm, que se encarga de recoger los datos de pago del cliente.

Aparte de eso, useEffect se utiliza para llamar al método loadStripe. Este efecto sólo se ejecuta una vez cuando se renderiza el componente, para que el SDK de Stripe no se cargue varias veces cuando se actualicen los estados internos del componente.

Para ejecutar el código anterior, también tendrás que añadir dos nuevas variables de entorno a tu proyecto frontend: VITE_STRIPE_API_KEY y VITE_CLIENT_BASE_URL. La variable clave API de Stripe contendrá la clave API publicable del panel de control de Stripe, y la variable URL base del cliente contendrá el enlace a la aplicación cliente (que es la propia aplicación del frontend) para que pueda pasarse al SDK de Stripe para gestionar las redirecciones de éxito y fracaso.

Para ello, añade el siguiente código a tu archivo .env en el directorio del frontend:

VITE_STRIPE_API_KEY=pk_test_xxxxxxxxxx # Your key here
VITE_CLIENT_BASE_URL=http://localhost:5173

Por último, actualiza el archivo App.tsx para incluir el componente IntegratedCheckout en la ruta /integrated-checkout de la aplicación del frontend. Añade el siguiente código en el array pasado a la llamada createBrowserRouter en el componente App:

       {
            path: '/integrated-checkout',
            element: (
                <IntegratedCheckout/>
            )
        },

Esto completa la configuración necesaria en el frontend. A continuación, crea una nueva ruta en tu servidor backend que cree la clave secreta de cliente necesaria para gestionar las sesiones de pago integradas en tu aplicación frontend.

Construir el backend

Para garantizar que los atacantes no abusan de la integración del frontend (ya que el código del frontend es más fácil de descifrar que el del backend), Stripe requiere que generes un secreto de cliente único en tu servidor backend y verifica cada solicitud de pago integrado con el secreto de cliente generado en el backend para asegurarse de que es realmente tu aplicación la que está intentando cobrar los pagos. Para ello, tienes que configurar otra ruta en el backend que cree secretos de cliente basados en la información del cliente y del carrito.

Para crear la clave secreta de cliente en tu servidor, crea un nuevo método en tu clase PaymentController con el nombre integratedCheckout y guarda en él el siguiente código:

@PostMapping("/checkout/integrated")
    String integratedCheckout(@RequestBody RequestDTO requestDTO) throws StripeException {

        Stripe.apiKey = STRIPE_API_KEY;

        // Start by finding existing customer or creating a new one if needed
        Customer customer = CustomerUtil.findOrCreateCustomer(requestDTO.getCustomerEmail(), requestDTO.getCustomerName());

        // Create a PaymentIntent and send it's client secret to the client
        PaymentIntentCreateParams params =
                PaymentIntentCreateParams.builder()
                       .setAmount(Long.parseLong(calculateOrderAmount(requestDTO.getItems())))
                        .setCurrency("usd")
                        .setCustomer(customer.getId())
                        .setAutomaticPaymentMethods(
                                PaymentIntentCreateParams.AutomaticPaymentMethods
                                        .builder()
                                        .setEnabled(true)
                                        .build()
                        )
                        .build();

        PaymentIntent paymentIntent = PaymentIntent.create(params);

        // Send the client secret from the payment intent to the client
        return paymentIntent.getClientSecret();
    }

De forma similar a cómo se construyó la sesión de pago utilizando una clase constructora que acepta la configuración para la solicitud de pago, el flujo de pago integrado requiere que construyas una sesión de pago con el importe, la moneda y los métodos de pago. A diferencia de la sesión de pago, no puedes asociar partidas a una sesión de pago a menos que crees una factura, lo que aprenderás en una sección posterior del tutorial.

Como no estás pasando las partidas al constructor de la sesión de pago, tienes que calcular manualmente el importe total de los artículos del carrito y enviar el importe al backend de Stripe. Utiliza tu ProductDAO para encontrar y añadir los precios de cada producto del carrito.

Para ello, define un nuevo método calculateOrderAmount y añade el siguiente código en él:

     static String calculateOrderAmount(Product[] items) {
        long total = 0L;

        for (Product item: items) {
            // Look up the application database to find the prices for the products in the given list
            total += ProductDAO.getProduct(item.getId()).getDefaultPriceObject().getUnitAmountDecimal().floatValue();
        }
        return String.valueOf(total);
    }

Esto debería ser suficiente para configurar el flujo de pago integrado tanto en el frontend como en el backend. Puedes reiniciar los servidores de desarrollo para el servidor y el cliente y probar el nuevo flujo de pago integrado en la aplicación del frontend. Este es el aspecto que tendrá el flujo integrado:

Integrated checkout.
Integrated checkout.

Esto completa un flujo de pago integrado básico en tu aplicación. Ahora puedes seguir explorando la documentación de Stripe para personalizar los métodos de pago o integrar más componentes que te ayuden con otras operaciones, como la recopilación de direcciones, las solicitudes de pago, la integración de enlaces, ¡y mucho más!

Configurar Suscripciones para Servicios Recurrentes

Una oferta habitual de las tiendas online hoy en día es la suscripción. Tanto si estás creando una tienda de servicios, como si ofreces un producto digital periódicamente, una suscripción es la solución perfecta para dar a tus clientes acceso periódico a tu servicio por una pequeña cuota en comparación con una compra única.

Stripe puede ayudarte a configurar y cancelar suscripciones fácilmente. También puedes ofrecer pruebas gratuitas como parte de tu suscripción para que los usuarios puedan probar tu oferta antes de comprometerse con ella.

Configurar una nueva suscripción

Configurar una nueva suscripción es sencillo utilizando el flujo de hosted checkout. Sólo tendrás que cambiar algunos parámetros al crear la solicitud de pago y crear una nueva página (reutilizando los componentes existentes) para mostrar una página de pago para una nueva suscripción. Para empezar, crea un archivo NewSubscription.tsx en la carpeta de components del frontend. Pega en él el siguiente código:

import {Center, Heading, VStack} from "@chakra-ui/react";
import {useState} from "react";
import CartItem, {ItemData} from "../components/CartItem.tsx";
import TotalFooter from "../components/TotalFooter.tsx";
import CustomerDetails from "../components/CustomerDetails.tsx";
import {Subscriptions} from "../data.ts";

function NewSubscription() {
    const [items] = useState<ItemData[]>(Subscriptions)
    return <>
        <Center h={'100vh'} color='black'>
            <VStack spacing='24px'>
                <Heading>New Subscription Example</Heading>
                {items.map(elem => {
                    return <CartItem data={elem} mode={'subscription'}/>
                })}
                <TotalFooter total={4.99} mode={"subscription"}/>
                <CustomerDetails data={items} endpoint={"/subscriptions/new"} />
            </VStack>
        </Center>
    </>
}

export default NewSubscription

En el código anterior, los datos del carrito se toman del archivo data.ts, y sólo contiene un elemento para simplificar el proceso. En situaciones reales, puedes tener varios artículos como parte de un pedido de suscripción.

Para renderizar este componente en la ruta correcta, añade el siguiente código en la matriz pasada a la llamada createBrowserRouter en el componente App.tsx:

       {
            path: '/new-subscription',
            element: (
                <NewSubscription/>
            )
        },

Esto completa la configuración necesaria en el frontend. En el backend, crea una nueva ruta /subscription/new para crear una nueva sesión de pago alojada para un producto de suscripción. Crea un método newSubscription en el directorio backend/src/main/java/com/kinsta/stripejava/backend y guarda en él el siguiente código:

@PostMapping("/subscriptions/new")
    String newSubscription(@RequestBody RequestDTO requestDTO) throws StripeException {

        Stripe.apiKey = STRIPE_API_KEY;

        String clientBaseURL = System.getenv().get("CLIENT_BASE_URL");

        // Start by finding existing customer record from Stripe or creating a new one if needed
        Customer customer = CustomerUtil.findOrCreateCustomer(requestDTO.getCustomerEmail(), requestDTO.getCustomerName());

        // Next, create a checkout session by adding the details of the checkout
        SessionCreateParams.Builder paramsBuilder =
                SessionCreateParams.builder()
                        // For subscriptions, you need to set the mode as subscription
                        .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
                        .setCustomer(customer.getId())
                        .setSuccessUrl(clientBaseURL + "/success?session_id={CHECKOUT_SESSION_ID}")
                        .setCancelUrl(clientBaseURL + "/failure");

        for (Product product : requestDTO.getItems()) {
            paramsBuilder.addLineItem(
                    SessionCreateParams.LineItem.builder()
                            .setQuantity(1L)
                            .setPriceData(
                                    PriceData.builder()
                                            .setProductData(
                                                    PriceData.ProductData.builder()
                                                            .putMetadata("app_id", product.getId())
                                                            .setName(product.getName())
                                                            .build()
                                            )
                                            .setCurrency(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getCurrency())
                                            .setUnitAmountDecimal(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getUnitAmountDecimal())
                                            // For subscriptions, you need to provide the details on how often they would recur
                                            .setRecurring(PriceData.Recurring.builder().setInterval(PriceData.Recurring.Interval.MONTH).build())
                                            .build())
                            .build());
        }

        Session session = Session.create(paramsBuilder.build());

        return session.getUrl();
    }

El código de este método es bastante similar al del método hostedCheckout, salvo que el modo establecido para crear la sesión es suscripción en lugar de producto y que, antes de crear la sesión, se establece un valor para el intervalo de recurrencia de la suscripción.

Esto indica a Stripe que trate este pago como un pago de suscripción en lugar de un pago único. De forma similar al método hostedCheckout, este método también devuelve la URL de la página de pago alojada como respuesta HTTP al cliente. El cliente está configurado para redirigirse a la URL recibida, permitiendo al cliente completar el pago.

Puedes reiniciar los servidores de desarrollo tanto del cliente como del servidor y ver la nueva página de suscripción en acción. Este es su aspecto

Flujo de pago de una suscripción alojada.
Flujo de pago de una suscripción alojada.

Cancelar una suscripción existente

Ahora que ya sabes cómo crear nuevas suscripciones, vamos a aprender a permitir que tus clientes cancelen las suscripciones existentes. Dado que la aplicación de demostración creada en este tutorial no contiene ninguna configuración de autenticación, utiliza un formulario para permitir que el cliente introduzca su correo electrónico para buscar sus suscripciones y, a continuación, proporciona a cada elemento de suscripción un botón de cancelación para que el usuario pueda cancelarla.

Para ello, tendrás que hacer lo siguiente:

  1. Actualizar el componente CartItem para que muestre un botón de cancelación en la página de cancelación de suscripciones.
  2. Crea un componente CancelSubscription que muestre primero un campo de entrada y un botón para que el cliente busque suscripciones utilizando su dirección de correo electrónico y, a continuación, muestre una lista de suscripciones utilizando el componente CartItem actualizado.
  3. Crea un nuevo método en el servidor backend que pueda buscar suscripciones desde el backend de Stripe utilizando la dirección de correo electrónico del cliente.
  4. Crea un nuevo método en el servidor backend que pueda cancelar una suscripción basándose en el ID de suscripción que se le haya pasado.

Empieza actualizando el componente CartItem para que tenga este aspecto:

// Existing imports here

function CartItem(props: CartItemProps) {

    // Add this hook call and the cancelSubscription method to cancel the selected subscription
    const toast = useToast()
    const cancelSubscription = () => {

        fetch(process.env.VITE_SERVER_BASE_URL + "/subscriptions/cancel", {
            method: "POST",
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({
                subscriptionId: props.data.stripeSubscriptionData?.subscriptionId
            })
        })
            .then(r => r.text())
            .then(() => {
                toast({
                    title: 'Subscription cancelled.',
                    description: "We've cancelled your subscription for you.",
                    status: 'success',
                    duration: 9000,
                    isClosable: true,
                })

                if (props.onCancelled)
                    props.onCancelled()
            })
    }

    return <Card direction={{base: 'column', sm: 'row'}}
                 overflow='hidden'
                 width={'xl'}
                 variant='outline'>
        <Image
            objectFit='cover'
            maxW={{base: '100%', sm: '200px'}}
            src={props.data.image}
        />
        <Stack mt='6' spacing='3'>
            <CardBody>
                <VStack spacing={'3'} alignItems={"flex-start"}>
                    <Heading size='md'>{props.data.name}</Heading>
                    <VStack spacing={'1'} alignItems={"flex-start"}>
                        <Text>
                            {props.data.description}
                        </Text>
                        {(props.mode === "checkout" ? <Text>
                            {"Quantity: " + props.data.quantity}
                        </Text> : <></>)}
                    </VStack>

                    {/* <----------------------- Add this block ----------------------> */}
                    {(props.mode === "subscription" && props.data.stripeSubscriptionData ?
                        <VStack spacing={'1'} alignItems={"flex-start"}>
                            <Text>
                                {"Next Payment Date: " + props.data.stripeSubscriptionData.nextPaymentDate}
                            </Text>
                            <Text>
                                {"Subscribed On: " + props.data.stripeSubscriptionData.subscribedOn}
                            </Text>
                            {(props.data.stripeSubscriptionData.trialEndsOn ? <Text>
                                {"Free Trial Running Until: " + props.data.stripeSubscriptionData.trialEndsOn}
                            </Text> : <></>)}
                        </VStack> : <></>)}
                </VStack>

            </CardBody>

            <CardFooter>
                <VStack alignItems={'flex-start'}>
                    <Text color='blue.600' fontSize='2xl'>
                        {"$" + props.data.price}
                    </Text>
                    {/* <----------------------- Add this block ----------------------> */}
                    {(props.data.stripeSubscriptionData ?
                        <Button colorScheme={'red'} onClick={cancelSubscription}>Cancel Subscription</Button>
                        : <></>)}
                </VStack>
            </CardFooter>
        </Stack>
    </Card>
}

// Existing types here

export default CartItem

A continuación, crea un componente CancelSubscription.tsx en el directorio de routes de tu fronted y guarda en él el siguiente código:

import {Button, Center, Heading, Input, VStack} from "@chakra-ui/react";
import {useState} from "react";
import CartItem, {ItemData, ServerSubscriptionsResponseType} from "../components/CartItem.tsx";
import {Subscriptions} from "../data.ts";

function CancelSubscription() {
    const [email, setEmail] = useState("")
    const [subscriptions, setSubscriptions] = useState<ItemData[]>([])

    const onCustomerEmailChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
        setEmail(ev.target.value)
    }

    const listSubscriptions = () => {

        fetch(process.env.VITE_SERVER_BASE_URL + "/subscriptions/list", {
            method: "POST",
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({
                customerEmail: email,
            })
        })
            .then(r => r.json())
            .then((r: ServerSubscriptionsResponseType[]) => {

                const subscriptionsList: ItemData[] = []

                r.forEach(subscriptionItem => {

                    let subscriptionDetails = Subscriptions.find(elem => elem.id === subscriptionItem.appProductId) || undefined

                    if (subscriptionDetails) {

                        subscriptionDetails = {
                            ...subscriptionDetails,
                            price: Number.parseInt(subscriptionItem.price) / 100,
                            stripeSubscriptionData: subscriptionItem,
                        }

                        subscriptionsList.push(subscriptionDetails)
                    } else {
                        console.log("Item not found!")
                    }
                })

                setSubscriptions(subscriptionsList)
            })

    }

    const removeSubscription = (id: string | undefined) => {
        const newSubscriptionsList = subscriptions.filter(elem => (elem.stripeSubscriptionData?.subscriptionId !== id))
        setSubscriptions(newSubscriptionsList)
    }

    return <>
        <Center h={'100vh'} color='black'>
            <VStack spacing={3} width={'xl'}>
                <Heading>Cancel Subscription Example</Heading>
                {(subscriptions.length === 0 ? <>
                    <Input variant='filled' placeholder='Customer Email' onChange={onCustomerEmailChange}
                           value={email}/>
                    <Button onClick={listSubscriptions} colorScheme={'green'}>List Subscriptions</Button>
                </> : <></>)}
                {subscriptions.map(elem => {
                    return <CartItem data={elem} mode={'subscription'} onCancelled={() => removeSubscription(elem.stripeSubscriptionData?.subscriptionId)}/>
                })}
            </VStack>
        </Center>
    </>
}

export default CancelSubscription

Este componente muestra un campo de entrada y un botón para que los clientes introduzcan su correo electrónico y comiencen a buscar suscripciones. Si se encuentran suscripciones, el campo de entrada y el botón se ocultan, y se muestra una lista de suscripciones en la pantalla. Para cada elemento de suscripción, el componente pasa un método removeSubscription que solicita al servidor Java backend que cancele la suscripción en el backend de Stripe.

Para adjuntarlo a la ruta /cancel-subscription en tu aplicación frontend, añade el siguiente código en el array pasado a la llamada createBrowserRouter en el componente App:

       {
            path: '/cancel-subscription',
            element: (
                <CancelSubscription/>
            )
        },

Para buscar suscripciones en el servidor backend, añade un método viewSubscriptions en la clase PaymentController de tu proyecto backend con el siguiente contenido:

@PostMapping("/subscriptions/list")
    List<Map<String, String>> viewSubscriptions(@RequestBody RequestDTO requestDTO) throws StripeException {

        Stripe.apiKey = STRIPE_API_KEY;

        // Start by finding existing customer record from Stripe
        Customer customer = CustomerUtil.findCustomerByEmail(requestDTO.getCustomerEmail());

        // If no customer record was found, no subscriptions exist either, so return an empty list
        if (customer == null) {
            return new ArrayList<>();
        }

        // Search for subscriptions for the current customer
        SubscriptionCollection subscriptions = Subscription.list(
                SubscriptionListParams.builder()
                        .setCustomer(customer.getId())
                        .build());

        List<Map<String, String>> response = new ArrayList<>();

        // For each subscription record, query its item records and collect in a list of objects to send to the client
        for (Subscription subscription : subscriptions.getData()) {
            SubscriptionItemCollection currSubscriptionItems =
                    SubscriptionItem.list(SubscriptionItemListParams.builder()
                            .setSubscription(subscription.getId())
                            .addExpand("data.price.product")
                            .build());

            for (SubscriptionItem item : currSubscriptionItems.getData()) {
                HashMap<String, String> subscriptionData = new HashMap<>();
                subscriptionData.put("appProductId", item.getPrice().getProductObject().getMetadata().get("app_id"));
                subscriptionData.put("subscriptionId", subscription.getId());
                subscriptionData.put("subscribedOn", new SimpleDateFormat("dd/MM/yyyy").format(new Date(subscription.getStartDate() * 1000)));
                subscriptionData.put("nextPaymentDate", new SimpleDateFormat("dd/MM/yyyy").format(new Date(subscription.getCurrentPeriodEnd() * 1000)));
                subscriptionData.put("price", item.getPrice().getUnitAmountDecimal().toString());

                if (subscription.getTrialEnd() != null && new Date(subscription.getTrialEnd() * 1000).after(new Date()))
                    subscriptionData.put("trialEndsOn", new SimpleDateFormat("dd/MM/yyyy").format(new Date(subscription.getTrialEnd() * 1000)));
                response.add(subscriptionData);
            }

        }

        return response;
    }

El método anterior encuentra primero el objeto cliente para el usuario dado en Stripe. A continuación, busca las suscripciones activas del cliente. Una vez recibida la lista de suscripciones, extrae los artículos de las mismas y busca los productos correspondientes en la base de datos de productos de la app para enviarlos al frontend. Esto es importante porque el ID con el que el frontend identifica cada producto en la base de datos de la app puede o no ser el mismo que el ID del producto almacenado en Stripe.

Por último, crea un cancelSubscription</code method in the PaymentController class and paste the code below to delete a subscription based on the subscription ID passed.

@PostMapping("/subscriptions/cancel")
    String cancelSubscription(@RequestBody RequestDTO requestDTO) throws StripeException {
        Stripe.apiKey = STRIPE_API_KEY;

        Subscription subscription =
                Subscription.retrieve(
                        requestDTO.getSubscriptionId()
                );

        Subscription deletedSubscription =
                subscription.cancel();

        return deletedSubscription.getStatus();
    }

Este método recupera el objeto de suscripción de Stripe, llama al método cancelar en él y, a continuación, devuelve el estado de la suscripción al cliente. Sin embargo, para poder ejecutar esto, necesitas actualizar tu objeto DTO para añadir el campo subscriptionId. Hazlo añadiendo el siguiente campo y método en la clase RequestDTO:

package com.kinsta.stripejava.backend;

import com.stripe.model.Product;

public class RequestDTO {
    // … other fields …

    // Add this
    String subscriptionId;

    // … other getters …

    // Add this
    public String getSubscriptionId() {
        return subscriptionId;
    }

}

Una vez añadido esto, ya puedes volver a ejecutar el servidor de desarrollo tanto para el backend como para la aplicación del frontend y ver el flujo de cancelación en acción:

Un flujo de cancelación de suscripciones.
Un flujo de cancelación de suscripciones.

Configurar pruebas gratuitas para suscripciones con transacciones de valor cero

Una característica común a la mayoría de las suscripciones modernas es ofrecer un breve periodo de prueba gratuito antes de cobrar al usuario. Esto permite a los usuarios explorar el producto o servicio sin invertir en él. Sin embargo, lo mejor es almacenar los datos de pago del cliente al inscribirlo para la prueba gratuita, de modo que puedas cobrarle fácilmente en cuanto termine la prueba.

Stripe simplifica enormemente la creación de este tipo de suscripciones. Para empezar, genera un nuevo componente dentro del directorio frontend/routes llamado SubscriptionWithTrial.tsx, y pega el siguiente código:

import {Center, Heading, VStack} from "@chakra-ui/react";
import {useState} from "react";
import CartItem, {ItemData} from "../components/CartItem.tsx";
import TotalFooter from "../components/TotalFooter.tsx";
import CustomerDetails from "../components/CustomerDetails.tsx";
import {Subscriptions} from "../data.ts";

function SubscriptionWithTrial() {
    const [items] = useState<ItemData[]>(Subscriptions)
    return <>
        <Center h={'100vh'} color='black'>
            <VStack spacing='24px'>
                <Heading>New Subscription With Trial Example</Heading>
                {items.map(elem => {
                    return <CartItem data={elem} mode={'subscription'}/>
                })}
                <TotalFooter total={4.99} mode={"trial"}/>
                <CustomerDetails data={items} endpoint={"/subscriptions/trial"}/>
            </VStack>
        </Center>
    </>
}

export default SubscriptionWithTrial

Este componente reutiliza los componentes creados anteriormente. La diferencia clave entre éste y el componente NewSubscription es que pasa el modo para TotalFooter como trial en lugar de subscription. Esto hace que el componente TotalFooter muestre un texto que dice que el cliente puede empezar la prueba gratuita ahora, pero que se le cobrará al cabo de un mes.

Para adjuntar este componente a la ruta /subscription-with-trial en tu aplicación frontend, añade el siguiente código en el array pasado a la llamada createBrowserRouter en el componente App:

       {
            path: '/subscription-with-trial',
            element: (
                <SubscriptionWithTrial/>
            )
        },

Para construir el flujo de pago para suscripciones con trial en el backend, crea un nuevo método llamado newSubscriptionWithTrial en la clase PaymentController y añade el siguiente código:

    @PostMapping("/subscriptions/trial")
    String newSubscriptionWithTrial(@RequestBody RequestDTO requestDTO) throws StripeException {

        Stripe.apiKey = STRIPE_API_KEY;

        String clientBaseURL = System.getenv().get("CLIENT_BASE_URL");

        // Start by finding existing customer record from Stripe or creating a new one if needed
        Customer customer = CustomerUtil.findOrCreateCustomer(requestDTO.getCustomerEmail(), requestDTO.getCustomerName());

        // Next, create a checkout session by adding the details of the checkout
        SessionCreateParams.Builder paramsBuilder =
                SessionCreateParams.builder()
                        .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
                        .setCustomer(customer.getId())
                        .setSuccessUrl(clientBaseURL + "/success?session_id={CHECKOUT_SESSION_ID}")
                        .setCancelUrl(clientBaseURL + "/failure")
                        // For trials, you need to set the trial period in the session creation request
                        .setSubscriptionData(SessionCreateParams.SubscriptionData.builder().setTrialPeriodDays(30L).build());

        for (Product product : requestDTO.getItems()) {
            paramsBuilder.addLineItem(
                    SessionCreateParams.LineItem.builder()
                            .setQuantity(1L)
                            .setPriceData(
                                    PriceData.builder()
                                            .setProductData(
                                                    PriceData.ProductData.builder()
                                                            .putMetadata("app_id", product.getId())
                                                            .setName(product.getName())
                                                            .build()
                                            )
                                            .setCurrency(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getCurrency())
                                            .setUnitAmountDecimal(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getUnitAmountDecimal())
                                            .setRecurring(PriceData.Recurring.builder().setInterval(PriceData.Recurring.Interval.MONTH).build())
                                            .build())
                            .build());
        }

        Session session = Session.create(paramsBuilder.build());

        return session.getUrl();
    }

Este código es bastante similar al del método newSubscription. La única diferencia (y la más importante) es que se pasa un periodo de prueba al objeto de parámetros de creación de sesión con el valor 30, que indica un periodo de prueba gratuito de 30 días.

Ahora puedes guardar los cambios y volver a ejecutar el servidor de desarrollo para el backend y el frontend para ver en acción el flujo de trabajo de suscripción con periodo de prueba gratuito:

Un flujo de suscripción con prueba gratuita.
Un flujo de suscripción con prueba gratuita.

Crear Facturas para Pagos

Para las suscripciones, Stripe genera automáticamente facturas para cada pago, incluso si se trata de una transacción de valor cero para la inscripción a la prueba. Para pagos puntuales, puedes elegir crear facturas si es necesario.

Para empezar a asociar todos los pagos con facturas, actualiza el cuerpo de la carga útil que se envía en la función initiatePayment del componente CustomerDetails en la aplicación del frontend para que contenga la siguiente propiedad:

invoiceNeeded: true

También tendrás que añadir esta propiedad en el cuerpo de la carga útil que se envía al servidor en la función createTransactionSecret del componente IntegratedCheckout.

A continuación, actualiza las rutas del backend para que comprueben esta nueva propiedad y actualiza las llamadas al SDK de Stripe en consecuencia.

En el método de pago alojado, para añadir la funcionalidad de facturación, actualiza el método hostedCheckout añadiendo las siguientes líneas de código:

@PostMapping("/checkout/hosted")
    String hostedCheckout(@RequestBody RequestDTO requestDTO) throws StripeException {

        // … other operations being done after creating the SessionCreateParams builder instance       

        // Add the following block of code just before the SessionCreateParams are built from the builder instance
        if (requestDTO.isInvoiceNeeded()) {
            paramsBuilder.setInvoiceCreation(SessionCreateParams.InvoiceCreation.builder().setEnabled(true).build());
        }

        Session session = Session.create(paramsBuilder.build());

        return session.getUrl();
    }

Esto comprobará el campo invoiceNeeded y establecerá los parámetros de creación en consecuencia.

Añadir una factura a un pago integrado es un poco complicado. No puedes simplemente establecer un parámetro para indicar a Stripe que cree una factura con el pago automáticamente. Debes crear manualmente la factura y después una intención de pago vinculada.

Si la intención de pago se paga con éxito y se completa, la factura se marca como pagada; de lo contrario, la factura permanece sin pagar. Aunque esto tiene sentido lógico, puede ser un poco complejo de implementar (especialmente cuando no hay ejemplos claros o referencias a seguir).

Para implementarlo, actualiza el método integratedCheckout para que tenga este aspecto:

String integratedCheckout(@RequestBody RequestDTO requestDTO) throws StripeException {

        Stripe.apiKey = STRIPE_API_KEY;

        // Start by finding an existing customer or creating a new one if needed
        Customer customer = CustomerUtil.findOrCreateCustomer(requestDTO.getCustomerEmail(), requestDTO.getCustomerName());

        PaymentIntent paymentIntent;

        if (!requestDTO.isInvoiceNeeded()) {
            // If the invoice is not needed, create a PaymentIntent directly and send it to the client
            PaymentIntentCreateParams params =
                    PaymentIntentCreateParams.builder()
                            .setAmount(Long.parseLong(calculateOrderAmount(requestDTO.getItems())))
                            .setCurrency("usd")
                            .setCustomer(customer.getId())
                            .setAutomaticPaymentMethods(
                                    PaymentIntentCreateParams.AutomaticPaymentMethods
                                            .builder()
                                            .setEnabled(true)
                                            .build()
                            )
                            .build();

            paymentIntent = PaymentIntent.create(params);
        } else {
            // If invoice is needed, create the invoice object, add line items to it, and finalize it to create the PaymentIntent automatically
            InvoiceCreateParams invoiceCreateParams = new InvoiceCreateParams.Builder()
                    .setCustomer(customer.getId())
                    .build();

            Invoice invoice = Invoice.create(invoiceCreateParams);

            // Add each item to the invoice one by one
            for (Product product : requestDTO.getItems()) {

                // Look for existing Product in Stripe before creating a new one
                Product stripeProduct;

                ProductSearchResult results = Product.search(ProductSearchParams.builder()
                        .setQuery("metadata['app_id']:'" + product.getId() + "'")
                        .build());

                if (results.getData().size() != 0)
                    stripeProduct = results.getData().get(0);
                else {

                    // If a product is not found in Stripe database, create it
                    ProductCreateParams productCreateParams = new ProductCreateParams.Builder()
                            .setName(product.getName())
                            .putMetadata("app_id", product.getId())
                            .build();

                    stripeProduct = Product.create(productCreateParams);
                }

                // Create an invoice line item using the product object for the line item
                InvoiceItemCreateParams invoiceItemCreateParams = new InvoiceItemCreateParams.Builder()
                        .setInvoice(invoice.getId())
                        .setQuantity(1L)
                        .setCustomer(customer.getId())
                        .setPriceData(
                                InvoiceItemCreateParams.PriceData.builder()
                                        .setProduct(stripeProduct.getId())
                                        .setCurrency(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getCurrency())
                                        .setUnitAmountDecimal(ProductDAO.getProduct(product.getId()).getDefaultPriceObject().getUnitAmountDecimal())
                                        .build())
                        .build();

                InvoiceItem.create(invoiceItemCreateParams);
            }

            // Mark the invoice as final so that a PaymentIntent is created for it
            invoice = invoice.finalizeInvoice();

            // Retrieve the payment intent object from the invoice
            paymentIntent = PaymentIntent.retrieve(invoice.getPaymentIntent());
        }

        // Send the client secret from the payment intent to the client
        return paymentIntent.getClientSecret();
    }

El código antiguo de este método se traslada al bloque if que comprueba si el campo invoiceNeeded es false. Si se comprueba que es cierto, el método crea ahora una factura con partidas de factura y la marca como finalizada para que se pueda pagar.

A continuación, recupera la intención de pago creada automáticamente cuando se finalizó la factura y envía el secreto de cliente de esta intención de pago al cliente. Una vez que el cliente completa el flujo de pago integrado, se cobra el pago y la factura se marca como pagada.

Esto completa la configuración necesaria para empezar a generar facturas desde tu aplicación. Puedes dirigirte a la sección de facturas de tu panel de control de Stripe para ver las facturas que genera tu aplicación con cada compra y pago de suscripción.

Sin embargo, Stripe también te permite acceder a las facturas a través de su API para crear una experiencia de autoservicio para que los clientes puedan descargar las facturas cuando lo deseen.

Para ello, crea un nuevo componente en el directorio frontend/routes llamado ViewInvoices.tsx. Pega en él el siguiente código:

import {Button, Card, Center, Heading, HStack, IconButton, Input, Text, VStack} from "@chakra-ui/react";
import {useState} from "react";
import {DownloadIcon} from "@chakra-ui/icons";

function ViewInvoices() {
    const [email, setEmail] = useState("")
    const [invoices, setInvoices] = useState<InvoiceData[]>([])

    const onCustomerEmailChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
        setEmail(ev.target.value)
    }

    const listInvoices = () => {

        fetch(process.env.VITE_SERVER_BASE_URL + "/invoices/list", {
            method: "POST",
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({
                customerEmail: email,
            })
        })
            .then(r => r.json())
            .then((r: InvoiceData[]) => {
                setInvoices(r)
            })

    }

    return <>
        <Center h={'100vh'} color='black'>
            <VStack spacing={3} width={'xl'}>
                <Heading>View Invoices for Customer</Heading>
                {(invoices.length === 0 ? <>
                    <Input variant='filled' placeholder='Customer Email' onChange={onCustomerEmailChange}
                           value={email}/>
                    <Button onClick={listInvoices} colorScheme={'green'}>Look up Invoices</Button>
                </> : <></>)}
                {invoices.map(elem => {
                    return <Card direction={{base: 'column', sm: 'row'}}
                                 overflow='hidden'
                                 alignItems={'center'}
                                 justifyContent={'space-between'}
                                 padding={'8px'}
                                 width={500}
                                 variant='outline'>
                        <Text>
                            {elem.number}
                        </Text>
                        <HStack spacing={"3"}>
                            <Text color='blue.600' fontSize='2xl'>
                                {"$" + elem.amount}
                            </Text>
                            <IconButton onClick={() => {
                                window.location.href = elem.url
                            }} icon={<DownloadIcon/>} aria-label={'Download invoice'}/>
                        </HStack>
                    </Card>
                })}
            </VStack>
        </Center>
    </>
}

interface InvoiceData {
    number: string,
    amount: string,
    url: string
}

export default ViewInvoices

Similar al componente CancelSubscription, este componente muestra un campo de entrada para que el cliente introduzca su correo electrónico y un botón para buscar facturas. Una vez encontradas las facturas, el campo de entrada y el botón se ocultan, y se muestra al cliente una lista de facturas con el número de factura, el importe total y un botón para descargar el PDF de la factura.

Para implementar el método del backend que busca las facturas del cliente dado y devuelve la información relevante (número de factura, importe y URL del PDF), añade el siguiente método en tu clase PaymentController del backend;

@PostMapping("/invoices/list")
    List<Map<String, String>> listInvoices(@RequestBody RequestDTO requestDTO) throws StripeException {

        Stripe.apiKey = STRIPE_API_KEY;

        // Start by finding existing customer record from Stripe
        Customer customer = CustomerUtil.findCustomerByEmail(requestDTO.getCustomerEmail());

        // If no customer record was found, no subscriptions exist either, so return an empty list
        if (customer == null) {
            return new ArrayList<>();
        }

        // Search for invoices for the current customer
        Map<String, Object> invoiceSearchParams = new HashMap<>();
        invoiceSearchParams.put("customer", customer.getId());
        InvoiceCollection invoices =
                Invoice.list(invoiceSearchParams);

        List<Map<String, String>> response = new ArrayList<>();

        // For each invoice, extract its number, amount, and PDF URL to send to the client
        for (Invoice invoice : invoices.getData()) {
            HashMap<String, String> map = new HashMap<>();

            map.put("number", invoice.getNumber());
            map.put("amount", String.valueOf((invoice.getTotal() / 100f)));
            map.put("url", invoice.getInvoicePdf());

            response.add(map);
        }

        return response;
    }

El método busca primero al cliente por la dirección de correo electrónico que se le ha proporcionado. A continuación, busca las facturas de este cliente que estén marcadas como pagadas. Una vez encontrada la lista de facturas, extrae el número de factura, el importe y la URL del PDF, y devuelve una lista con esta información a la aplicación cliente.

Este es el aspecto del flujo de facturas:

Visualización de facturas
Visualización de facturas

Esto completa el desarrollo de nuestra aplicación Java de demostración (frontend y backend). En la siguiente sección, aprenderás a desplegar esta aplicación en Kinsta para que puedas acceder a ella online.

Desplegar Tu Aplicación en Kinsta

Una vez que tu aplicación esté lista, puedes desplegarla en Kinsta. Kinsta soporta despliegues desde tu proveedor Git preferido (Bitbucket, GitHub o GitLab). Conecta los repositorios de código fuente de tu aplicación a Kinsta, y automáticamente desplegará tu aplicación cada vez que haya un cambio en el código.

Prepara tus proyectos

Para desplegar tus aplicaciones en producción, identifica los comandos de compilación y despliegue que utilizará Kinsta. Para el frontend, asegúrate de que tu archivo package.json tiene definidos los siguientes scripts:

"scripts": {
    "dev": "vite",
    "build": "NODE_ENV=production tsc && vite build",
    "start": "serve ./dist",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },

También necesitarás instalar el paquete serve npm que te permite servir sitios web estáticos. Este paquete se utilizará para servir la compilación de producción de tu aplicación desde el entorno de despliegue de Kinsta. Puedes instalarlo ejecutando el siguiente comando:

npm i serve

Una vez que construyas tu aplicación utilizando vite, toda la aplicación se empaquetará en un único archivo, index.html, ya que la configuración de React que estás utilizando en este tutorial está pensada para crear aplicaciones de una sola página. Aunque esto no suponga una gran diferencia para tus usuarios, deberás establecer alguna configuración adicional para gestionar el enrutamiento y la navegación del navegador en este tipo de aplicaciones.

Con la configuración actual, sólo puedes acceder a tu aplicación en la URL base de tu despliegue. Si la URL base de la implantación es example.com, cualquier petición a example.com/some-route conducirá a errores HTTP 404.

Esto se debe a que tu servidor sólo tiene un archivo que servir, el archivo index.html. Una solicitud enviada a example.com/some-route empezará a buscar el archivo some-route/index.html, que no existe; por lo tanto, recibirá una respuesta 404 No encontrado.

Para solucionarlo, crea un archivo llamado serve.json en tu carpeta frontend/public y guarda en él el siguiente código:

{
  "rewrites": [
    { "source": "*", "destination": "/index.html" }
  ]
}

Este archivo indicará a serve que reescriba todas las solicitudes entrantes para dirigirlas al archivo index.html, sin dejar de mostrar en la respuesta la ruta a la que se envió la solicitud original. Esto te ayudará a servir correctamente las páginas de éxito y fracaso de tu aplicación cuando Stripe redirija a tus clientes de vuelta a tu aplicación.

Para el backend, crea un Dockerfile para configurar el entorno adecuado para tu aplicación Java. El uso de un Dockerfile garantiza que el entorno proporcionado a tu aplicación Java es el mismo en todos los hosts (ya sea tu host de desarrollo local o el host de despliegue de Kinsta) y puedes asegurarte de que tu aplicación se ejecuta como se espera.

Para ello, crea un archivo llamado Dockerfile en la carpeta backend y guarda en él el siguiente contenido:

FROM openjdk:22-oraclelinux8

LABEL maintainer="krharsh17"

WORKDIR /app

COPY . /app

RUN ./mvnw clean package

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "/app/target/backend.jar"]

Este archivo indica al tiempo de ejecución que utilice la imagen Java OpenJDK como base para el contenedor de despliegue, ejecute el comando ./mvnw clean package para crear el archivo JAR de tu aplicación y utilice el comando java -jar <jar-file> para ejecutarlo. Esto completa la preparación del código fuente para su despliegue en Kinsta.

Configurar los repositorios de GitHub

Para empezar a desplegar las aplicaciones, crea dos repositorios de GitHub para alojar el código fuente de tus aplicaciones. Si utilizas la CLI de GitHub, puedes hacerlo a través del terminal ejecutando los siguientes comandos:

# Run these in the backend folder
gh repo create stripe-payments-java-react-backend --public --source=. --remote=origin
git init
git add .
git commit -m "Initial commit"
git push origin main

# Run these in the frontend folder
gh repo create stripe-payments-java-react-frontend --public --source=. --remote=origin
git init
git add .
git commit -m "Initial commit"
git push origin main

Esto debería crear nuevos repositorios de GitHub en tu cuenta y enviar el código de tus aplicaciones a ellos. Deberías poder acceder a los repositorios frontend y backend. A continuación, despliega estos repositorios en Kinsta siguiendo estos pasos:

  1. Inicia sesión o crea tu cuenta Kinsta en el panel MyKinsta.
  2. En la barra lateral izquierda, haz clic en Aplicaciones y luego en Añadir aplicación.
  3. En el modal que aparece, elige el repositorio que quieres desplegar. Si tienes varias ramas, puedes seleccionar la rama deseada y dar un nombre a tu aplicación.
  4. Selecciona una de las ubicaciones de centros de datos disponibles en la lista de opciones 25. Kinsta detecta automáticamente el comando de inicio de tu aplicación.

Recuerda que debes proporcionar a tus aplicaciones frontend y backend algunas variables de entorno para que funcionen correctamente. La aplicación del frontend necesita las siguientes variables de entorno:

  • VITE_STRIPE_API_KEY
  • VITE_SERVIDOR_BASE_URL
  • VITE_CLIENT_BASE_URL

Para desplegar la aplicación backend, haz exactamente lo mismo que hicimos para el frontend, pero para el paso Entorno de compilación, selecciona el botón de opción Utilizar Dockerfile para configurar la imagen del contenedor e introduce Dockerfile como ruta Dockerfile para tu aplicación backend.

Configuración de los detalles del entorno de compilación
Configuración de los detalles del entorno de compilación

Recuerda añadir las variables de entorno del backend:

  • CLIENT_BASE_URL
  • STRIPE_API_KEY

Una vez completado el despliegue, dirígete a la página de detalles de tus aplicaciones y accede desde allí a la URL del despliegue.

La URL alojada de las aplicaciones desplegadas en Kinsta
La URL alojada de las aplicaciones desplegadas en Kinsta

Extrae las URLs de las dos aplicaciones desplegadas. Dirígete al panel de Stripe para obtener tus claves API secretas y publicables.

Asegúrate de proporcionar la clave publicable de Stripe a tu aplicación frontend (no la clave secreta). Asegúrate también de que tus URLs base no tienen una barra inclinada (/) al final. Las rutas ya tienen barras inclinadas al principio, por lo que tener una barra inclinada al final de las URL base hará que se añadan dos barras inclinadas a las URL finales.

Para tu aplicación backend, añade la clave secreta del panel de control de Stripe (no la clave publicable). Además, asegúrate de que la URL de tu cliente no tiene una barra oblicua (/) al final.

Una vez añadidas las variables, ve a la pestaña Despliegues de la aplicación y haz clic en el botón de redespliegue de tu aplicación backend. Esto completa la configuración única que necesitas para proporcionar a tus despliegues Kinsta las credenciales a través de las variables de entorno.

A continuación, puedes confirmar los cambios en tu control de versiones. Kinsta redistribuirá automáticamente tu aplicación si marcaste la opción durante la distribución; de lo contrario, tendrás que activar la redistribución manualmente.

Resumen

En este artículo, has aprendido cómo funciona Stripe y los flujos de pago que ofrece. También has aprendido, a través de un ejemplo detallado, cómo integrar Stripe en tu aplicación Java para aceptar pagos únicos, establecer suscripciones, ofrecer pruebas gratuitas y generar facturas de pago.

Utilizando Stripe y Java juntos, puedes ofrecer una solución de pago robusta a tus clientes que puede escalar bien e integrarse perfectamente con tu ecosistema existente de aplicaciones y herramientas.

¿Utilizas Stripe en tu aplicación para cobrar pagos? En caso afirmativo, ¿cuál de los dos flujos prefieres: alojado, personalizado o in-app? Háznoslo saber en los comentarios más abajo.

Jeremy Holcombe Kinsta

Editor de Contenidos y Marketing en Kinsta, Desarrollador Web de WordPress y Redactor de Contenidos. Aparte de todo lo relacionado con WordPress, me gusta la playa, el golf y el cine. También tengo problemas con la gente alta ;).