Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(stripe): integrate pay-by-card #45

Draft
wants to merge 10 commits into
base: main
Choose a base branch
from
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ PRIVATE_KEY=
WALLET_ADDRESS=
REDIRECT_URL=
INTERLEDGER_PAY_HOST=
SESSION_COOKIE_SECRET_KEY=
SESSION_COOKIE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
17 changes: 17 additions & 0 deletions app/lib/stripe.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function createPaymentIntent(amount: number, assetCode: string) {
return await stripe.paymentIntents.create({
amount,
currency: assetCode,
automatic_payment_methods: {
enabled: true
}
})
}

export async function retrievePaymentIntent(id: string) {
return await stripe.paymentIntents.retrieve(id)
}
76 changes: 76 additions & 0 deletions app/routes/card-payment-result.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import type { LoaderFunctionArgs } from '@remix-run/node'
import { json, Link, useLoaderData } from '@remix-run/react'
import { useEffect, useState } from 'react'
import { retrievePaymentIntent } from '../lib/stripe.server'
import { FinishCheck, FinishError } from '~/components/icons'
import { formatAmount } from '~/utils/helpers'
import { Loader } from '~/components/loader'

export async function loader({ request }: LoaderFunctionArgs) {
const params = new URL(request.url).searchParams
const paymentIntentId = params.get('payment_intent')
const clientSecret = params.get('payment_intent_client_secret')
const errorMessage = params.get('error')

let paymentIntent
if (errorMessage === null) {
paymentIntent = await retrievePaymentIntent(paymentIntentId!)
}

return json({
id: paymentIntentId,
paymentIntent,
clientSecret,
errorMessage
})
}

export default function CardPaymentResult() {
const [paymentIntent, setPaymentIntent] = useState(null)
const data = useLoaderData<typeof loader>() as any
const amount = formatAmount({
value: data.paymentIntent.amount,
assetCode: data.paymentIntent.currency,
assetScale: 2
})

useEffect(() => {
if (data.paymentIntent) {
setPaymentIntent(data.paymentIntent)
}
}, [data.paymentIntent])

if (!paymentIntent) {
return (
<div className="text-center text-gray-500">
<Loader type="large" />
</div>
)
}

return (
<div className="flex justify-center items-center flex-col h-full px-5 gap-8">
{data.errorMessage ? (
<>
<FinishError />
<div className="text-destructive uppercase sm:text-2xl font-medium text-center">
{data.errorMessage}
</div>
</>
) : (
<>
<FinishCheck color="green-1" />
<div className="text-green-1">
Payment of {amount.amountWithCurrency} is successfully completed.
</div>
</>
)}
<Link
to="/"
className="flex gap-2 items-center justify-end border rounded-xl px-4 py-2"
>
<span className="hover:text-green-1">Close</span>
</Link>
</div>
)
}
122 changes: 122 additions & 0 deletions app/routes/checkout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { LoaderFunctionArgs } from '@remix-run/node'
import { json, Link, redirect, useLoaderData } from '@remix-run/react'
import {
Elements,
PaymentElement,
useElements,
useStripe
} from '@stripe/react-stripe-js'
import { loadStripe } from '@stripe/stripe-js'
import { createPaymentIntent } from '../lib/stripe.server'
import { Header } from '~/components/header'
import { BackNav, Card } from '~/components/icons'
import { Button } from '~/components/ui/button'
import type { WalletAddress } from '@interledger/open-payments/dist/types'
import { getValidWalletAddress } from '~/lib/validators.server'
import { AmountDisplay } from '~/components/dialpad'
import { formatAmount } from '~/utils/helpers'

const stripePromise = loadStripe('pk_test_B4Mlg9z1svOsuVjovpcLaK0d00lWym58fF')

export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url)
const searchParams = url.searchParams
const receiver = searchParams.get('receiver') || ''
const amount = searchParams.get('amount') || ''

let receiverWalletAddress = {} as WalletAddress

if (receiver !== '') {
try {
receiverWalletAddress = await getValidWalletAddress(receiver)
} catch (error) {
throw new Error(
'Receiver Wallet Address is not valid. Please check and try again.'
)
}
}

return json({
paymentIntent: await createPaymentIntent(
Number(amount) * 100,
receiverWalletAddress.assetCode
),
amountWithCurrency: formatAmount({
value: (Number(amount) * 100).toString(),
assetCode: receiverWalletAddress.assetCode,
assetScale: receiverWalletAddress.assetScale
}).amountWithCurrency,
finishUrl: `${url.protocol}//${url.host}/card-payment-result`
})
}

export default function CheckoutPage() {
const data: any = useLoaderData<typeof loader>()

const options = {
clientSecret: data.paymentIntent.client_secret
}

return (
<Elements stripe={stripePromise} options={options}>
<CheckoutForm
amountWithCurrency={data.amountWithCurrency}
finishUrl={data.finishUrl}
/>
</Elements>
)
}

type CheckoutFormProps = {
amountWithCurrency: string
finishUrl: string
}

function CheckoutForm({ amountWithCurrency, finishUrl }: CheckoutFormProps) {
const stripe = useStripe()
const elements = useElements()

const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
if (!stripe || !elements) return

const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: finishUrl
}
})
if (error) {
redirect(`/card-payment-result?error=${error.message}`)
}
}

return (
<>
<Header />
<Link to="/" className="flex gap-2 items-center justify-end">
<BackNav />
<span className="hover:text-green-1">Home</span>
</Link>
<div className="flex justify-center items-center flex-col h-full">
<AmountDisplay displayAmount={amountWithCurrency} />
<form
onSubmit={handleSubmit}
className="flex flex-col items-center border-light-green border-4 rounded-lg p-10 mt-10"
>
<PaymentElement />
<Button
aria-label="pay"
type="submit"
disabled={!stripe || !elements}
size="xl"
className="mt-5"
>
Pay with card
<Card width="20" height="20" className="ml-2" />
</Button>
</form>
</div>
</>
)
}
103 changes: 74 additions & 29 deletions app/routes/ilpay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,41 @@ import { DialPad, DialPadIds } from '~/components/dialpad'
import { Header } from '~/components/header'
import { Link, useLoaderData } from '@remix-run/react'
import { useDialPadContext } from '~/lib/context/dialpad'
import { BackNav } from '~/components/icons'
import { BackNav, Card } from '~/components/icons'
import { json, type LoaderFunctionArgs } from '@remix-run/node'
import { getSession } from '~/session'
import { useEffect } from 'react'
import { getValidWalletAddress } from '~/lib/validators.server'
import type { WalletAddress } from '@interledger/open-payments/dist/types'

export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const walletAddressInfo = session.get('wallet-address')
const searchParams = new URL(request.url).searchParams
const receiver = searchParams.get('receiver') || ''

if (walletAddressInfo === undefined) {
let receiverWalletAddress = {} as WalletAddress
let isPayWithCard = false

if (receiver !== '') {
try {
receiverWalletAddress = await getValidWalletAddress(receiver)
isPayWithCard = true
} catch (error) {
throw new Error(
'Receiver Wallet Address is not valid. Please check and try again.'
)
}
} else if (walletAddressInfo === undefined) {
throw new Error('Payment session expired.')
}

return json({
assetCode: walletAddressInfo.walletAddress.assetCode
assetCode: isPayWithCard
? receiverWalletAddress.assetCode
: walletAddressInfo.walletAddress.assetCode,
isPayWithCard: isPayWithCard,
receiver: receiverWalletAddress.id
} as const)
}

Expand All @@ -38,30 +59,9 @@ export default function Ilpay() {
<div className="flex justify-center items-center flex-col h-full px-5">
<div className="h-2/3 items-center justify-center flex flex-col gap-10 w-full max-w-sm">
<DialPad />
<div className="flex gap-2">
<Link
to={`/request`}
onClick={(e: React.MouseEvent<HTMLElement>) => {
if (
amountValue.indexOf(DialPadIds.Dot) === -1 ||
amountValue.endsWith(DialPadIds.Dot)
) {
setAmountValue(Number(amountValue).toFixed(2).toString())
}
if (Number(amountValue) === 0) e.preventDefault()
}}
>
<Button
aria-label="request"
variant="outline"
size="sm"
disabled={Number(amountValue) === 0}
>
Request
</Button>
</Link>
{data.isPayWithCard ? (
<Link
to={`/pay`}
to={`/checkout?receiver=${data.receiver}&amount=${amountValue}`}
onClick={(e: React.MouseEvent<HTMLElement>) => {
if (
amountValue.indexOf(DialPadIds.Dot) === -1 ||
Expand All @@ -73,14 +73,59 @@ export default function Ilpay() {
}}
>
<Button
aria-label="pay"
aria-label="pay with card"
size={'sm'}
disabled={Number(amountValue) === 0}
>
Pay
Pay with card
<Card width="20" height="20" className="ml-2" />
</Button>
</Link>
</div>
) : (
<div className="flex gap-2">
<Link
to={`/request`}
onClick={(e: React.MouseEvent<HTMLElement>) => {
if (
amountValue.indexOf(DialPadIds.Dot) === -1 ||
amountValue.endsWith(DialPadIds.Dot)
) {
setAmountValue(Number(amountValue).toFixed(2).toString())
}
if (Number(amountValue) === 0) e.preventDefault()
}}
>
<Button
aria-label="request"
variant="outline"
size="sm"
disabled={Number(amountValue) === 0}
>
Request
</Button>
</Link>
<Link
to={`/pay`}
onClick={(e: React.MouseEvent<HTMLElement>) => {
if (
amountValue.indexOf(DialPadIds.Dot) === -1 ||
amountValue.endsWith(DialPadIds.Dot)
) {
setAmountValue(Number(amountValue).toFixed(2).toString())
}
if (Number(amountValue) === 0) e.preventDefault()
}}
>
<Button
aria-label="pay"
size={'sm'}
disabled={Number(amountValue) === 0}
>
Pay
</Button>
</Link>
</div>
)}
</div>
</div>
</>
Expand Down
2 changes: 1 addition & 1 deletion app/routes/payment-choice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default function PaymentChoice() {
</span>
</Link>
<Link
to={`/checkout?receiver=${data.receiver}`}
to={`/ilpay?receiver=${data.receiver}`}
className={`w-56 h-32 text-right ease-in-out transition-[box-shadow,transform] duration-200 aspect-[5/3] rounded-lg flex flex-col p-3 border-2
hover:scale-105 focus:scale-105 hover:bg-green-2 hover:border-green-2`}
>
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1",
"@remix-run/serve": "^2.8.1",
"@stripe/react-stripe-js": "^3.0.0",
"@stripe/stripe-js": "^5.2.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^11.1.7",
Expand All @@ -35,6 +37,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"remix": "^2.8.1",
"stripe": "^17.4.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"web-share-shim": "^1.0.4",
Expand Down
Loading