How to: implement payments in your app
Tags: typescript, javascript, react, webdev
Published: 2023-07-25 17:43:33

Hi there! It's been a while since I didn't post, so I hope I didn't lost my magic.

Today we are gonna talk about payments and how we can set up single payments in our webapp. Also, we will cover **subscriptions** (or recurring payments) in _upcoming posts_.

Personally, I think that payments are one of the less explored universe but one of the greatests. I've learned a lot working on a fintech startup.

We are going to use [Stripe](https://stripe.com/), [Remix](https://remix.run/), [Typescript](https://www.typescriptlang.org/) and [Tailwind](https://tailwindcss.com/). I'm choosing these technologies because they are one of the most (if not the most) popular in the web development.

First of all, let's create our project and get our app running.
To do this, follow me.

> You can check the final project here: https://dev-to-stripe.vercel.app

> Payment data:
Card: 4242 4242 4242 4242
Date: 12/34
CVV: 123

> repository: https://github.com/AlvaroJSnish/dev-to-stripe

# Create the project

Open your terminal and create a new remix app:
```bash
npx create-remix@latest
```
It's very straightforward, choose you app name, then just a basic app. For the server, you can choose wathever you want, but I'm sticking with Vercel. Finally, Typescript and we are good to go.

> Selecting Vercel **is not necessary** anymore to deploy on Vercel, maybe you see a warning or maybe not. Anyways, is not important or blocking right now.

Before we get to code, let's deploy our app.

# Deploy

First, we initialize git inside our repository:
```bash
git init
```

Now we need to create a repository on Github (or the tool you are using for).

Add your origin to the project:
```bash
git remote add origin https://github.com/yourusername/yourrepository.git
```
Change the branch to main, add the code, commit and push:
```bash
git branch -M main
git add .
git commit -m "First commit"
git push -u origin main
```

Now, head to Vercel and log in or create an account (you can link your github account). Then you can create a new project and import the repository that we just created. It takes less than a minute. Continue to the dashboard to see your project; it will live in an url like: yourreponame.vercel.app

Now everytime that we push to main it will be deployed on our URL, so we can test our changes live.

# Add Tailwind

Finally in the funny part! Before we get to code, let's add a dependency to make our app pretty: tailwind.

In **_remix.config.js_**, let's add tailwind support:
```js
module.exports = {
  ...
  tailwind: true,
  ...
};
```

Now install Tailwind and initialize its config:
```bash
npm install -D tailwindcss
npx tailwindcss init --ts
```

Let's tell Tailwind which files to generate classes from. In **_tailwind.config.js_**:
```bash
content: ["./app/**/*.{js,jsx,ts,tsx}"],
```

In the app directory, create a **_tailwind.css_** file and add these lines:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

Finally in **_app/root.ts_**, let's add tailwind:

```js
import styles from "./tailwind.css";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: styles },
  ...(cssBundleHref
    ? [
        { rel: "stylesheet", href: cssBundleHref },
      ]
    : []),
];
```

Now if we go to **_app/routes/_index.tsx_** and we change the content to:

```tsx
export default function Index() {
  return <h1 className="text-xl text-green-400">Hello World!</h1>;
}
```

We can see that tailwind it's working.

# Set up Stripe

The hardest part of everything is signing up for Stripe. Not joking. Go to https://stripe.com and create an account. There is _a lot_ of information to fill, but don't worry, not everything is necessary and I believe that you can skip most of it. Just think that they ask for that much because it's intended to go live a make real payments.

Once you have your account, make sure you are in test mode: top right, check it.

If you go to developers > API Keys, you can see your publish and private keys. Copy them.

Now, go to your project and create **_.env_** at the root, then paste them:

```
STRIPE_PUBLIC_KEY=...
STRIPE_SECRET_KEY=...
```

Now let's install dotenv and Stripe:

```bash
npm i dotenv stripe
```

In **_app/entry.server.tsx_** import dotenv at the top:

```ts
import "dotenv/config";
```

Now, inside app, create a file named **stripe.server.ts**:

```ts
import Stripe from "stripe";

if (!process.env.STRIPE_SECRET_KEY) {
  throw new Error("Missing Stripe secret key");
}

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2022-11-15",
});
```

Here we are just making sure that we have the key and create a new stripe client.
We are done by now.

It's important to declare our variables in Vercel too:

> In your project, go to settings > environment variables. There you can create the same variables that we just create on the .env file.

# Create and sell products

Before we get into it, it's necessary to clarify something: in Stripe, like in the real world, you can sell one-time products (a shirt, car, computer, etc) or you can sell recurrent products (netflix subscription, gym pass, etc).

Depending on which flow you choose to checkout your customers, you may be required to create the products on the Stripe dashboard first. Recurrent products **always** have to exists in the dashboard, but one-time products not.

Go to the Stripe dashboard > products > create a new product. Create a product (one time, please), add the information, images, all that you want.

Once you have it, save it.

Go to **_app/routes/_index.tsx_** and add these lines to the code:

```tsx
import { stripe } from "~/stripe.server";

export const loader: LoaderFunction = async () => {
  const products = await stripe.products.list();

  return { products: products.data };
};
```

Here, we are using the Stripe client we created before, and with the [loader](https://remix.run/docs/en/main/route/loader) function we are server-side fetching our products.

You can choose the classnames and styles that you want, but I keep it very simple:

```tsx
export default function Index() {
  const data = useLoaderData<{
    products: Array<Stripe.Product & { default_price: Stripe.Price }>;
  }>();

  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-2">
      <div className="flex flex-col space-y-10">
        {data.products.map((product) => (
          <Product key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

function Product({
  product,
}: {
  product: Stripe.Product & { default_price: Stripe.Price };
}) {
  const amount = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: product.default_price.currency,
  }).format((product.default_price.unit_amount || 0) / 100);

  return (
    <div className="flex flex-row max-w-[400px]">
      <div className="w-200 mr-4">
        <img src={product.images[0]} alt={product.name} />
      </div>
      <div className="flex flex-col justify-center">
        <h2 className="text-xl font-bold mb-2">{product.name}</h2>
        <p className="text-sm font-light mb-3">{product.description}</p>
        <p>{amount}</p>
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-4 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-opacity-50">
          Buy
        </button>
      </div>
    </div>
  );
}
```

Now we are going to create a server action to actually buy our product.

Modify the the Product component to include a hidden input field and pass a new property, to check if we are loading the checkout:

```tsx
function Product({
  product,
  loading,
}: {
  product: Stripe.Product & { default_price: Stripe.Price };
  loading: boolean;
}) {
  const amount = new Intl.NumberFormat("en-US", {
    style: "currency",
    currency: product.default_price.currency,
  }).format((product.default_price.unit_amount || 0) / 100);

  return (
    <div className="flex flex-row max-w-[400px]">
      <input type="hidden" name="product_id" value={product.id} />
      <div className="w-200 mr-4">
        <img src={product.images[0]} alt={product.name} />
      </div>
      <div className="flex flex-col justify-center">
        <h2 className="text-xl font-bold mb-2">{product.name}</h2>
        <p className="text-sm font-light mb-3">{product.description}</p>
        <p>{amount}</p>
        <button
          disabled={loading}
          className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mt-4 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-opacity-50 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {loading ? "Loading..." : "Buy"}
        </button>
      </div>
    </div>
  );
}
```
Now modify the main component:

```tsx
export default function Index() {
  const data = useLoaderData<{
    products: Array<Stripe.Product & { default_price: Stripe.Price }>;
  }>();

  const navigation = useNavigation();

  const isBuying = navigation.state !== "idle";

  return (
    <Form
      method="post"
      className="flex flex-col items-center justify-center min-h-screen py-2"
    >
      <div className="flex flex-col space-y-10">
        {data.products.map((product) => (
          <Product key={product.id} product={product} loading={isBuying} />
        ))}
      </div>
    </Form>
  );
}
```

All that's left here is the [action](https://remix.run/docs/en/1.19.1/route/action) function. Without that, nothing works.

```tsx
export const action: ActionFunction = async ({ request }) => {
  const formData = await request.formData();
  const product_id = formData.get("product_id")?.toString();

  if (!product_id) {
    return new Response("Product not found", { status: 404 });
  }

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ["card"],
    line_items: [
      {
        quantity: 1,
        price: product_id,
      },
    ],
    mode: "payment",
    success_url: `${process.env.APP_URL}/success`,
    cancel_url: `${process.env.APP_URL}/cancel`,
  });

  if (!session.url) {
    return new Response("Session not found", { status: 404 });
  }

  return redirect(session.url, {
    headers: {
      "cache-control": "no-cache",
    },
  });
};
```

If you noticed, we created a new env variable named **APP_URL**. Add that to your .env:
```
APP_URL=http://localhost:XXXX
```

And don't forget to add that to Vercel too! It should look like this:
**https://yourappname.vercel.app**

Now we have everything!

Oh... wait! We need our **success** and **cancel** routes!

Inside **_app/routes_** create **cancel.tsx** and **success.tsx**:

```tsx
import { Link } from "@remix-run/react";

export default function Cancel() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-2">
      <div className="flex flex-col space-y-10">
        <h1 className="text-3xl font-bold">Payment Cancelled</h1>
        <p className="text-xl">Your payment has been cancelled.</p>

        <Link to="/">Go back home</Link>
      </div>
    </div>
  );
}
```

```tsx
import { Link } from "@remix-run/react";

export default function Success() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen py-2">
      <div className="flex flex-col space-y-10">
        <h1 className="text-3xl font-bold">Payment Successful!</h1>
        <p className="text-xl">Your payment has been successful.</p>

        <Link to="/">Go back home</Link>
      </div>
    </div>
  );
}
```

# Test the app

Click on your buy button. You should be redirected to the checkout page, great! Now fill the inputs with your info, and use:

Card: 4242 4242 4242 4242
Date: 12/34
CVV: 123

If you click the buy button and everything goes fine, you should redirected to the success page, but if you cancel of something goes wrong, you will be redirected to the cancel page.

If you go to: https://dashboard.stripe.com/test/payments you can check all the payments you received.

Maybe it's time to go live? That's up to you!

I hope that you learned something new today, and if that's the case, I'm more than happy!

Feel free to reach for any inquiries.

Thank you all!
Álvaro.