chore: setting up rapyd api stuff

This commit is contained in:
Gunnar Torfi 2025-03-15 10:00:34 +00:00
parent 3e8d97331d
commit 5f4b245ea7
7 changed files with 370 additions and 48 deletions

36
app/actions/checkout.ts Normal file
View File

@ -0,0 +1,36 @@
"use server";
import {
createCheckout,
type CreateCheckoutParams,
} from "@/lib/rapyd/checkout";
import { z } from "zod";
const checkoutSchema = z.object({
amount: z.number().positive(),
merchantReferenceId: z.string(),
completeCheckoutUrl: z.string().url(),
cancelCheckoutUrl: z.string().url(),
description: z.string().optional(),
});
export const createCheckoutAction = async (data: CreateCheckoutParams) => {
try {
// Validate input
const validatedData = checkoutSchema.parse(data);
// Create checkout
const checkout = await createCheckout(validatedData);
return {
success: true,
data: checkout,
};
} catch (error) {
console.error("Checkout creation failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error occurred",
};
}
};

View File

@ -0,0 +1,48 @@
"use client";
import { createCheckoutAction } from "@/app/actions/checkout";
import { Button } from "@/components/ui/button";
import { useRouter } from "next/navigation";
import { useTransition } from "react";
import { toast } from "sonner";
interface CheckoutButtonProps {
amount: number;
description?: string;
}
export const CheckoutButton = ({
amount,
description,
}: CheckoutButtonProps) => {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleCheckout = () => {
startTransition(async () => {
const merchantReferenceId = crypto.randomUUID();
const result = await createCheckoutAction({
amount,
merchantReferenceId,
completeCheckoutUrl: `${window.location.origin}/checkout/complete`,
cancelCheckoutUrl: `${window.location.origin}/checkout/cancel`,
description,
});
if (!result.success) {
toast.error("Failed to create checkout session");
return;
}
// Redirect to Rapyd checkout page
router.push(result.data.redirect_url);
});
};
return (
<Button onClick={handleCheckout} disabled={isPending}>
{isPending ? "Creating checkout..." : "Proceed to Checkout"}
</Button>
);
};

69
lib/rapyd/checkout.ts Normal file
View File

@ -0,0 +1,69 @@
import { makeRequest } from "@/lib/rapyd/utilities";
import { cache } from "react";
import "server-only";
// Icelandic card payment methods
const ICELANDIC_PAYMENT_METHODS = [
"is_visa_card",
"is_mastercard_card",
] as const;
interface CheckoutResponse {
id: string;
redirect_url: string;
status: string;
payment: {
id: string;
amount: number;
currency: string;
status: string;
};
}
interface CreateCheckoutParams {
amount: number;
merchantReferenceId: string;
completeCheckoutUrl: string;
cancelCheckoutUrl: string;
description?: string;
}
const DEFAULT_CHECKOUT_CONFIG = {
country: "IS",
currency: "ISK",
} as const;
export const preloadCheckout = (params: CreateCheckoutParams) => {
void createCheckout(params);
};
export const createCheckout = cache(
async ({
amount,
merchantReferenceId,
completeCheckoutUrl,
cancelCheckoutUrl,
description,
}: CreateCheckoutParams): Promise<CheckoutResponse> => {
const checkoutBody = {
amount,
merchant_reference_id: merchantReferenceId,
complete_checkout_url: completeCheckoutUrl,
cancel_checkout_url: cancelCheckoutUrl,
country: DEFAULT_CHECKOUT_CONFIG.country,
currency: DEFAULT_CHECKOUT_CONFIG.currency,
payment_method_types_include: ICELANDIC_PAYMENT_METHODS,
...(description && { description }),
};
const response = await makeRequest({
method: "post",
path: "/v1/checkout",
body: checkoutBody,
});
return response as CheckoutResponse;
}
);
export type { CheckoutResponse, CreateCheckoutParams };

147
lib/rapyd/utilities.ts Normal file
View File

@ -0,0 +1,147 @@
import crypto from "crypto";
const BASE_URL = process.env.RAPYD_BASE_URL;
const SECRET_KEY = process.env.RAPYD_SECRET_KEY;
const ACCESS_KEY = process.env.RAPYD_ACCESS_KEY;
if (!SECRET_KEY || !ACCESS_KEY) {
throw new Error("RAPYD_SECRET_KEY and RAPYD_ACCESS_KEY must be set");
}
type HttpMethod = "get" | "put" | "post" | "delete";
interface SignatureHeaders {
access_key: string;
salt: string;
timestamp: string;
signature: string;
idempotency: string;
}
interface RequestConfig {
method: HttpMethod;
path: string;
body?: Record<string, unknown>;
}
const generateSalt = (length = 12): string => {
const characters =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
return Array.from(
{ length },
() => characters[Math.floor(Math.random() * characters.length)]
).join("");
};
const getUnixTime = ({
days = 0,
hours = 0,
minutes = 0,
seconds = 0,
} = {}): number => {
const now = new Date();
now.setDate(now.getDate() + days);
now.setHours(now.getHours() + hours);
now.setMinutes(now.getMinutes() + minutes);
now.setSeconds(now.getSeconds() + seconds);
return Math.floor(now.getTime() / 1000);
};
const updateTimestampSaltSig = ({
method,
path,
body,
}: RequestConfig): {
salt: string;
timestamp: number;
signature: string;
} => {
const normalizedPath = path.startsWith("http")
? path.substring(path.indexOf("/v1"))
: path;
const salt = generateSalt();
const timestamp = getUnixTime();
const bodyString = body ? JSON.stringify(body) : "";
const toSign = [
method,
normalizedPath,
salt,
timestamp.toString(),
ACCESS_KEY,
SECRET_KEY,
bodyString,
].join("");
const hmac = crypto.createHmac("sha256", SECRET_KEY);
hmac.update(toSign);
const signature = Buffer.from(hmac.digest("hex")).toString("base64url");
return { salt, timestamp, signature };
};
const createHeaders = ({
method,
path,
body,
}: RequestConfig): {
headers: SignatureHeaders;
body: string;
} => {
const { salt, timestamp, signature } = updateTimestampSaltSig({
method,
path,
body,
});
const headers: SignatureHeaders = {
access_key: ACCESS_KEY,
salt,
timestamp: timestamp.toString(),
signature,
idempotency: `${getUnixTime()}${salt}`,
};
return {
headers,
body: body ? JSON.stringify(body) : "",
};
};
const makeRequest = async ({
method,
path,
body,
}: RequestConfig): Promise<unknown> => {
const { headers, body: stringifiedBody } = createHeaders({
method,
path,
body,
});
const url = `${BASE_URL}${path}`;
const requestConfig: RequestInit = {
method: method.toUpperCase(),
headers: {
...headers,
"Content-Type": "application/json",
},
};
if (method !== "get" && stringifiedBody) {
requestConfig.body = stringifiedBody;
}
const response = await fetch(url, requestConfig);
if (!response.ok) {
throw new Error(
`Request failed: ${response.status} ${response.statusText}`
);
}
return response.json();
};
export { makeRequest, type HttpMethod, type RequestConfig };

View File

@ -13,10 +13,11 @@
"@heroicons/react": "^2.2.0",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"next": "15.2.0-canary.67",
"next": "15.3.0-canary.9",
"react": "19.0.0",
"react-dom": "19.0.0",
"sonner": "^2.0.1"
"sonner": "^2.0.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@tailwindcss/container-queries": "^0.1.1",
@ -30,5 +31,11 @@
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4.0.8",
"typescript": "5.7.3"
},
"pnpm": {
"overrides": {
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4"
}
}
}

102
pnpm-lock.yaml generated
View File

@ -4,6 +4,10 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
overrides:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4
importers:
.:
@ -19,10 +23,10 @@ importers:
version: 2.1.1
geist:
specifier: ^1.3.1
version: 1.3.1(next@15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
version: 1.3.1(next@15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
next:
specifier: 15.2.0-canary.67
version: 15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
specifier: 15.3.0-canary.9
version: 15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react:
specifier: 19.0.0
version: 19.0.0
@ -32,6 +36,9 @@ importers:
sonner:
specifier: ^2.0.1
version: 2.0.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
zod:
specifier: ^3.24.2
version: 3.24.2
devDependencies:
'@tailwindcss/container-queries':
specifier: ^0.1.1
@ -214,53 +221,53 @@ packages:
cpu: [x64]
os: [win32]
'@next/env@15.2.0-canary.67':
resolution: {integrity: sha512-Is8AU8GcBrDoyXTmEKPTM+K87Xb5SA545jkw0E6+51Zb/1sg5MSCH9OmQf2KlvbqSrkiVuQ8UA23pY5+xGFGpw==}
'@next/env@15.3.0-canary.9':
resolution: {integrity: sha512-kvABHn6GmJbyf02wozUzrC4evHdVSmc6FYV8I7Q4g3qZW1x64v6ppi3Hw1KEUzKieC1Car/maGT+r3oRalCg4Q==}
'@next/swc-darwin-arm64@15.2.0-canary.67':
resolution: {integrity: sha512-BNBt0qWhnZR2pSxlofSBsmy5PYRa7/t4txnYH5z41lSs0B9OlhlsYyiokefmiw6EKKLxzT23hmb+ZPtxTCjiFw==}
'@next/swc-darwin-arm64@15.3.0-canary.9':
resolution: {integrity: sha512-llJnHJGXQGux7sHJ4t0q5HbMnID+M3+s5ghvYBw79uP4QDkH5XVXRC2oQUwTvEPzHXUhWpB/kf6KUpWmOEI8xQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.2.0-canary.67':
resolution: {integrity: sha512-ZPC0/iL3dhexN+MQ6QOfaOO6y3WMhyxns01KA9mae0V0sp/uC+KwpSbNCVXptjmiZQ9j0Q9TYjqQBQ4KwtBK8Q==}
'@next/swc-darwin-x64@15.3.0-canary.9':
resolution: {integrity: sha512-igGqAeBB/3tJ4XLqbdcuzbgwgdNh9kRp2AFSME/Ok4jyetSPmcQFX43+C6piuMj2gQ06Q6gDWj3qib0MNf5IWw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.2.0-canary.67':
resolution: {integrity: sha512-t+i9tRB0uYj3OoZS2qhPwDrlcf5bRTKQY5GtFwboyCzBLv9KcU1xa3cwXulNxq1soo8wTiWRnhq8CkvUT+Fbvw==}
'@next/swc-linux-arm64-gnu@15.3.0-canary.9':
resolution: {integrity: sha512-Ym9FxqbBmQyUjoe2bW7MsDkrYV3sSR8WXCEqJQthETjAqSsG6zwUfL86dMAKe2RUetqlNzWlXDH/+FM9fdPVOw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.2.0-canary.67':
resolution: {integrity: sha512-9jffwDN4X2ER5eqj16XJHCf4MmRI3QI0L52JLYH9/3OPxD86bDeQlH/+NK3iEu/3X4KX1rDb7cF9uulB9hjfXw==}
'@next/swc-linux-arm64-musl@15.3.0-canary.9':
resolution: {integrity: sha512-aB9umTo1HHcQWRTXffWSrt6wTMvhg+fYbtZ8PR7h28gBrQaYL6Lu8Kg7BQynYEx8Ze42GqVcS0MlwVsTQrpwMw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.2.0-canary.67':
resolution: {integrity: sha512-pWp5NIAbMLKy6tfZF22tsDC3A9IJm/p+UQf9l906NClYKtMXLYDFmapXVpTUB7fdb9xDOvB+DtAX11nQk5bukw==}
'@next/swc-linux-x64-gnu@15.3.0-canary.9':
resolution: {integrity: sha512-d+tU/H5SaPAuHcxGJ9fiqt0qzXpkOmksu1lF9JQNHd6WKtBnnJMzpYL8onLLYXThrIPaETVSLpBiv1wvwIgwFg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.2.0-canary.67':
resolution: {integrity: sha512-RjSu9pEgQuUmkt1FINCCvpV0SHYrLpf7LaF7pZPem1N2lgDySnazt4ag7ZDaWL0XMBiTKGmNxkk185HeST2PSg==}
'@next/swc-linux-x64-musl@15.3.0-canary.9':
resolution: {integrity: sha512-b+V+36WIopplWQI2/xOIqzuRGCRGTDLVe2luhhtjcwewRqUujktGnphHW5zRcEVD9nNwwPCisxC01XLL3geggg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.2.0-canary.67':
resolution: {integrity: sha512-DM+ysK87Q10MkoxgZeFOZsRx4Yt1WtynDVZoogdEjikfxZrMLCEo22O2uFVNh2E0kHCdE89K3dODO9rQz9EjAw==}
'@next/swc-win32-arm64-msvc@15.3.0-canary.9':
resolution: {integrity: sha512-6YbKTAP1Z+dnFtEoPQc4NuQ9J3VIN0vc8gHmZHBl5qfBQgF9f4DfBwcTrXMXEKIFVkQN4YMZU83v+2DSzT+7FQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.2.0-canary.67':
resolution: {integrity: sha512-gI3Hk/6YrFXMJn018ZjZo832Pxrsj2DpyXbLc9Vxs4wOZ0x3ChVk2yhFA/SJZY7yhdD3vwG9Srdn8gfCuO4xHg==}
'@next/swc-win32-x64-msvc@15.3.0-canary.9':
resolution: {integrity: sha512-Ujf4+i1memQV3Qk0EjY00C4bzumV6jOZze9kCdi4PnpPjzEefTj88CFGR7ACmYgu1qDHOKaZQxR08MALy/yvIw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@ -406,7 +413,7 @@ packages:
'@types/react-dom@19.0.4':
resolution: {integrity: sha512-4fSQ8vWFkg+TGhePfUzVmat3eC14TXYSsiiDSLI0dVLsrm9gZFABjPy/Qu6TKgl1tq1Bu1yDsuQgY3A3DOjCcg==}
peerDependencies:
'@types/react': ^19.0.0
'@types/react': 19.0.10
'@types/react@19.0.10':
resolution: {integrity: sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==}
@ -553,8 +560,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
next@15.2.0-canary.67:
resolution: {integrity: sha512-hKZjcmngKiY/HzHXNN0SGXR9Xio6pirraWu8GqRD24pMiGueY0ykxTbOp0SrqgHDsgWGTOxy3hXCriGiyyVb8w==}
next@15.3.0-canary.9:
resolution: {integrity: sha512-R9+FanTpLPN4cez/lJurj/kedcOERPCQebl/F5kevPSzCQzp8Dj/LCv6L10wTqBH3zBgqepp0eytzsVrjW8VjA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
@ -724,6 +731,9 @@ packages:
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
zod@3.24.2:
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
snapshots:
'@alloc/quick-lru@5.2.0': {}
@ -846,30 +856,30 @@ snapshots:
'@img/sharp-win32-x64@0.33.5':
optional: true
'@next/env@15.2.0-canary.67': {}
'@next/env@15.3.0-canary.9': {}
'@next/swc-darwin-arm64@15.2.0-canary.67':
'@next/swc-darwin-arm64@15.3.0-canary.9':
optional: true
'@next/swc-darwin-x64@15.2.0-canary.67':
'@next/swc-darwin-x64@15.3.0-canary.9':
optional: true
'@next/swc-linux-arm64-gnu@15.2.0-canary.67':
'@next/swc-linux-arm64-gnu@15.3.0-canary.9':
optional: true
'@next/swc-linux-arm64-musl@15.2.0-canary.67':
'@next/swc-linux-arm64-musl@15.3.0-canary.9':
optional: true
'@next/swc-linux-x64-gnu@15.2.0-canary.67':
'@next/swc-linux-x64-gnu@15.3.0-canary.9':
optional: true
'@next/swc-linux-x64-musl@15.2.0-canary.67':
'@next/swc-linux-x64-musl@15.3.0-canary.9':
optional: true
'@next/swc-win32-arm64-msvc@15.2.0-canary.67':
'@next/swc-win32-arm64-msvc@15.3.0-canary.9':
optional: true
'@next/swc-win32-x64-msvc@15.2.0-canary.67':
'@next/swc-win32-x64-msvc@15.3.0-canary.9':
optional: true
'@react-aria/focus@3.19.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
@ -1059,9 +1069,9 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.2.1
geist@1.3.1(next@15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0)):
geist@1.3.1(next@15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)):
dependencies:
next: 15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
graceful-fs@4.2.11: {}
@ -1123,9 +1133,9 @@ snapshots:
nanoid@3.3.8: {}
next@15.2.0-canary.67(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next@15.3.0-canary.9(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.0-canary.67
'@next/env': 15.3.0-canary.9
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
@ -1135,14 +1145,14 @@ snapshots:
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.2.0-canary.67
'@next/swc-darwin-x64': 15.2.0-canary.67
'@next/swc-linux-arm64-gnu': 15.2.0-canary.67
'@next/swc-linux-arm64-musl': 15.2.0-canary.67
'@next/swc-linux-x64-gnu': 15.2.0-canary.67
'@next/swc-linux-x64-musl': 15.2.0-canary.67
'@next/swc-win32-arm64-msvc': 15.2.0-canary.67
'@next/swc-win32-x64-msvc': 15.2.0-canary.67
'@next/swc-darwin-arm64': 15.3.0-canary.9
'@next/swc-darwin-x64': 15.3.0-canary.9
'@next/swc-linux-arm64-gnu': 15.3.0-canary.9
'@next/swc-linux-arm64-musl': 15.3.0-canary.9
'@next/swc-linux-x64-gnu': 15.3.0-canary.9
'@next/swc-linux-x64-musl': 15.3.0-canary.9
'@next/swc-win32-arm64-msvc': 15.3.0-canary.9
'@next/swc-win32-x64-msvc': 15.3.0-canary.9
sharp: 0.33.5
transitivePeerDependencies:
- '@babel/core'
@ -1244,3 +1254,5 @@ snapshots:
undici-types@6.20.0: {}
util-deprecate@1.0.2: {}
zod@3.24.2: {}

View File

@ -21,7 +21,10 @@
{
"name": "next"
}
]
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]