Inhaltsverzeichnis
- Github Repo and Example Page with Final Result
- Here is a complete list of all previous stories so you can follow it from scratch.
- NPM Packages
- Small change on Prisma Schema
- New Stripe API Routes
- ENV Variables
- Helper Library
- Adapting Manageoperations API
- Main Component CreateStorageAccountform_Appservice
- We added two new imports
- New Const Definitions
- New checkPaymentStatus function
- New Functions related to the introduced modal
- New Modal Logic at the end
- Complete code of the main component
- UI Changes
- Cloudapp-dev, and before you leave us
Github Repo and Example Page with Final Result
More details and the GitHub repo with the entire code
Here is a complete list of all previous stories so you can follow it from scratch.
Next.js 14 - Building a Saas solution on Azure (long-running processes) - Part 1
Next.js 14 - Building a Saas solution in Azure (authentication) - Part 2
Automated creation of Azure resources via CLI in Next.js - Part 3
Next.js 14 — Building a SaaS Solution on Azure (Storage Accounts etc.) — Part 4
NPM Packages
I am using these new packages:
"@stripe/stripe-js": "^4.7.0",
"@types/stripe-v3": "^3.1.33",
"stripe": "^17.1.0",Small change on Prisma Schema
Below is my Prisma schema -> /Prisma/schema_storageaccounts.prisma
I added the field “updatedAt” to the model “operations,” so I have a clear overview of the last changes.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x"]
output = "./generated/client_storageaccount"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL_STORAGEACCOUNTS")
}
model ResourceGroup {
id Int @id @default(autoincrement()) // Primary key
userId String @default("0") // The user ID
name String @unique // Name of the resource group, unique
createdAt DateTime @default(now()) // Creation date
storageAccounts StorageAccount[] // Relation to StorageAccount model
}
model StorageAccount {
id Int @id @default(autoincrement()) // Primary key
userId String // The user ID
storageAccountName String // Name of the storage account
resourceGroupId Int // Foreign key to ResourceGroup
accessKey String? // Access key for the storage account
createdAt DateTime @default(now()) // Creation date
containers Container[] // Relation to Container model
resourceGroup ResourceGroup @relation(fields: [resourceGroupId], references: [id], onDelete: Cascade) // Relation to ResourceGroup
@@unique([userId, storageAccountName]) // Unique constraint to ensure no duplicate storage accounts for a user
}
model Container {
id Int @id @default(autoincrement()) // Primary key
containerName String // Name of the container
storageAccountId Int // Foreign key to StorageAccount
createdAt DateTime @default(now()) // Creation date
storageAccount StorageAccount @relation(fields: [storageAccountId], references: [id], onDelete: Cascade) // Relation to StorageAccount
@@unique([storageAccountId, containerName]) // Unique constraint to ensure no duplicate container names within a storage account
}
model Operations {
id Int @id @default(autoincrement()) // Primary key
resourcegroup String // Name of the RGGroup
creation String @default("NoOps") // Creation of the resource group
deletion String @default("NoOps") // Deletion of the resource group
createdAt DateTime @default(now()) // Creation date
updatedAt DateTime @updatedAt // Last update date
}New Stripe API Routes
We need new API Routes to communicate with Stripe. The first one is /src/app/API/stripe/check-payment-status, which checks the payment's status.
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
export async function GET(req: NextRequest) {
const sessionId = req.cookies.get("session_id")?.value; // Assuming session ID is stored in cookies
if (!sessionId) {
return NextResponse.json({ paid: false });
}
try {
const session = await stripe.checkout.sessions.retrieve(sessionId);
return NextResponse.json({ paid: session.payment_status === "paid" });
} catch (error) {
console.error("Error retrieving session from Stripe:", error);
return NextResponse.json({ paid: false });
}
}The next one is the create-checkout-session route, which we need to create the session and where we define the “success_url” and “cancel_url” for the redirect after the payment. In this API Route, we set a Session Cookie as well, for which we will need to check the payment status after a reload of the page.
// /app/api/stripe/create-checkout-session/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
export async function POST(req: NextRequest) {
try {
const session = await stripe.checkout.sessions.create({
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "Azure Storage Account Subscription",
},
unit_amount: 5000, // $50
},
quantity: 1,
},
],
mode: "payment",
success_url: `${req.headers.get(
"origin"
)}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${req.headers.get("origin")}/dashboard`,
});
// Set the session_id cookie
const response = NextResponse.json({ id: session.id });
response.cookies.set("session_id", session.id, {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
});
return response;
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}Last but not least we create a new “success” API Route
// /app/api/stripe/success/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { getToken } from "next-auth/jwt";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
export async function GET(req: NextRequest) {
const sessionId = req.nextUrl.searchParams.get("session_id");
if (!sessionId) {
return NextResponse.json(
{ error: "Session ID is required" },
{ status: 400 }
);
}
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status === "paid") {
const authtoken = await getToken({ req });
if (!authtoken || authtoken.role !== "Admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Logic to create a storage account after payment.
// Access session data and create Azure storage account.
return NextResponse.json({
message: "Payment successful, storage account creation is now possible!",
});
} else {
return NextResponse.json(
{ error: "Payment not verified" },
{ status: 400 }
);
}
}ENV Variables
As you can see in the API routes, we need new Env variables. I highly recommend to define production and test variables, you can distinguish them looking at the first part of the key “sk_live” vs “sk_test”.
# Stripe Prod
STRIPE_SECRET_KEY=sk_live_xxxxxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
# Stripe Test
STRIPE_SECRET_KEY=sk_test_xxxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxHelper Library
With the env variables in place, we can create a new helper library.
// /lib/get-stripe.ts
import { loadStripe, Stripe } from "@stripe/stripe-js";
let stripePromise: Promise<Stripe | null>;
const getStripe = () => {
if (!stripePromise) {
stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
}
return stripePromise;
};
export default getStripe;Adapting Manageoperations API
As mentioned in the Prisma schema section, I added a new field in the “operations” model. Therefore, we also have to change the logic for the “manage operations” API route. (src/app/api/azure/resources/manageoperations)
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
import prisma_storageaccount from "@/lib/prisma_storageaccounts";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const authtoken = await getToken({ req });
const isAdmin = authtoken?.role === "Admin";
if (!authtoken || !isAdmin) {
return NextResponse.json(
{ error: "Unauthorized Operations API" },
{ status: 401 }
);
}
try {
const { resourceGroupName, operation, value } = await req.json();
if (operation == "") {
return NextResponse.json(
{ error: "Invalid request. Operation is missing" },
{ status: 400 }
);
}
const creationvalue = operation === "create" ? value : "NoOps"; // If the operation is create, set the creation value to the value, otherwise set it to null
const deleteionvalue = operation === "delete" ? value : "NoOps"; // If the operation is delete, set the deletion value to the value, otherwise set it to null
const operationGetResult = await prisma_storageaccount.operations.findFirst(
{
where: {
resourcegroup: resourceGroupName,
},
}
);
// If value is status, return the operation result
if (value === "status") {
return NextResponse.json({ data: operationGetResult }, { status: 200 });
}
let operationResult: any = "";
if (!operationGetResult) {
operationResult = await prisma_storageaccount.operations.create({
data: {
resourcegroup: resourceGroupName,
creation: creationvalue,
deletion: deleteionvalue,
},
});
} else {
if (operation === "create") {
operationResult = await prisma_storageaccount.operations.update({
where: {
id: operationGetResult.id,
},
data: {
creation: creationvalue,
updatedAt: new Date(),
},
});
} else if (operation === "delete") {
operationResult = await prisma_storageaccount.operations.update({
where: {
id: operationGetResult.id,
},
data: {
deletion: deleteionvalue,
updatedAt: new Date(),
},
});
}
}
return NextResponse.json({ message: operationResult }, { status: 200 });
} catch (err) {
console.error("Error processing request:", err);
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
}Main Component CreateStorageAccountform_Appservice
The biggest changes happened in our main component -> src/components/user/createstorageaccountform_appservice.components.tsx
We added two new imports
import getStripe from "@/lib/get-stripe";
import { useRouter, useSearchParams } from "next/navigation";New Const Definitions
const router = useRouter();
const searchParams = useSearchParams();
const session_id = searchParams.get("session_id");
const [paid, setPaid] = useState(false); // Tracks payment status
const [showModal, setShowModal] = useState(false); // Tracks modal visibilityNew checkPaymentStatus function
// Function to check if the user has already paid
const checkPaymentStatus = async () => {
const res = await fetch("/api/stripe/check-payment-status");
const data = await res.json();
console.log("Payment status:", data.paid);
setPaid(data.paid);
};Changes on handleDeleteStorageAccount and handleCreateStorageAccount.
New Functions related to the introduced modal
// Show confirmation modal before deleting
const handleShowModal = () => {
setShowModal(true);
};
// Confirm and proceed to delete storage account
const confirmDeleteStorageAccount = () => {
setShowModal(false);
handleDeleteStorageAccount();
};
// Close modal without deleting
const closeModal = () => {
setShowModal(false);
};New Modal Logic at the end
{/* Confirmation Modal */}
{showModal && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-75 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full p-6 relative">
{/* Modal Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-200">
Confirm Deletion
</h3>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Modal Body */}
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to delete the storage account? This action
is irreversible.
</p>
{/* Modal Footer */}
<div className="flex justify-end space-x-3">
<button
onClick={confirmDeleteStorageAccount}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:ring-4 focus:ring-red-200 dark:focus:ring-red-900"
>
Yes, Delete
</button>
<button
onClick={closeModal}
className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 focus:ring-4 focus:ring-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:focus:ring-gray-800"
>
Cancel
</button>
</div>
</div>
</div>
)}Complete code of the main component
Below is the complete code. You can also get it from the Github repo.
"use client";
import React, { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import LoadingButton from "@/components/tools/loadingbutton/loadingbutton.component";
import FileUploader from "@/components/azure/storageaccounts/fileuploader.component";
import getStripe from "@/lib/get-stripe";
import { useRouter, useSearchParams } from "next/navigation";
// Utility function to generate a random alphanumeric string
const generateRandomName = (length: number) => {
const characters = "abcdefghijklmnopqrstuvwxyz0123456789";
return Array.from({ length }, () =>
characters.charAt(Math.floor(Math.random() * characters.length))
).join("");
};
const CreateStorageAccountFormAppService: React.FC = () => {
const { data: session } = useSession();
const user_az_id = session?.user?.sub;
const user_email = session?.user?.email;
const token = session?.accessToken;
const router = useRouter();
const searchParams = useSearchParams();
const session_id = searchParams.get("session_id");
const [resourceGroupName, setResourceGroupName] = useState(
generateRandomName(24)
);
const [accountName, setAccountName] = useState(generateRandomName(24));
const [location, setLocation] = useState("westeurope");
const [tags, setTags] = useState({
env: "production",
user: user_az_id || "",
});
const [containerName, setContainerName] = useState("mycontainer");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [storageAccountName, setStorageAccountName] = useState("");
const [accessKey, setAccessKey] = useState("");
const [containers, setContainers] = useState<string[]>([]);
const [hasStorageAccount, setHasStorageAccount] = useState(false);
const [paid, setPaid] = useState(false); // Tracks payment status
const [azureOps, setAzureOps] = useState(""); // Tracks long-running operations (e.g., "create", "delete")
const [showModal, setShowModal] = useState(false); // Tracks modal visibility
// Function to check if the user has already paid
const checkPaymentStatus = async () => {
const res = await fetch("/api/stripe/check-payment-status");
const data = await res.json();
console.log("Payment status:", data.paid);
setPaid(data.paid);
};
// Function to check if the user already has a storage account
const checkIfUserHasStorageAccount = async () => {
if (!user_az_id) return;
try {
const response = await fetch(
"/api/azure/storageaccount/check-storage-account",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: user_az_id }),
}
);
if (response.ok) {
const data = await response.json();
setHasStorageAccount(data.storageAccountName !== "");
setStorageAccountName(data.storageAccountName);
setAccessKey(data.accessKey);
setContainerName(data.containerName || "mycontainer");
setResourceGroupName(data.resourceGroupName);
}
} catch (error) {
console.error("Error checking storage account existence:", error);
}
};
// Function to fetch the operation status (for long-running processes like creation or deletion)
// Funktion zum Abrufen des Werts aus der Datenbank
const fetchData = async () => {
try {
const operation = azureOps;
const value = "status";
// console.log("Operation: ", operation);
const responseOps = await fetch("/api/azure/resources/manageoperations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
resourceGroupName,
operation,
value,
}),
});
const data = await responseOps.json();
// console.log("FetchData: ", data);
// console.log("resourceGroupName: ", resourceGroupName);
if (azureOps === "create") {
// console.log("Data: ", data.data.creation);
if (data.data.creation === "Success") {
setMessage("Storage account created successfully!");
setHasStorageAccount(true); // Update the state to reflect the creation
setLoading(false);
}
}
if (azureOps === "delete") {
// console.log("Data: ", data.data.deletion);
if (data.data.deletion === "Success") {
setMessage("Storage account successfully deleted!");
setHasStorageAccount(false); // Update the state to reflect the creation
setLoading(false);
}
}
} catch (error) {
console.error("Fehler beim Abrufen der Daten:", error);
}
};
// Handle Stripe Payment
const handlePayment = async () => {
const res = await fetch("/api/stripe/create-checkout-session", {
method: "POST",
});
const { id } = await res.json();
const stripe = (await getStripe()) as any;
const { error } = await stripe.redirectToCheckout({ sessionId: id });
if (error) console.error("Stripe checkout failed:", error);
else router.push("/dashboard");
};
// Create Storage Account after payment
const handleCreateStorageAccount = async () => {
setLoading(true);
setMessage("");
setAzureOps("create");
setContainers([]);
try {
let randomName = resourceGroupName;
const newTags = { env: "production", user: user_az_id };
// Check if Resource Group Name is empty
if (resourceGroupName === "") {
// console.error("Resource Group Name is empty");
randomName = generateRandomName(24); // Generate a new name
setResourceGroupName(randomName); // Update the state, but it may not reflect immediately
}
// Creating Records in Operationstable
const operation = "create";
const value = "pending";
// Call the function to create a record in the operation table
const responseOps = await fetch("/api/azure/resources/manageoperations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
resourceGroupName: randomName, // Use the `randomName` directly
tags: newTags,
operation,
value,
}),
});
const response = await fetch(
"/api/azure/resources/createstorageaccountappservice",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
resourceGroupName: randomName,
accountName,
location,
tags: newTags,
containerName,
user_az_id,
user_email,
}),
}
);
if (response.ok) {
const successData = await response.text();
// setMessage(`Success: ${successData}`);
checkIfUserHasStorageAccount(); // Refresh account state
} else {
const errorData = await response.text();
setMessage(`Error: ${errorData}`);
}
} catch (error: any) {
setMessage(`Error: ${error.message}`);
} finally {
setLoading(false);
}
};
// Delete storage account
const handleDeleteStorageAccount = async () => {
setLoading(true);
setMessage("");
setAzureOps("delete");
try {
// Creating Records in Operationstable
const operation = "delete";
const value = "pending";
// Call the function to create a record in the operation table
const responseOps = await fetch("/api/azure/resources/manageoperations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
resourceGroupName,
operation,
value,
}),
});
const response = await fetch(
"/api/azure/resources/deleteresourcegroupappservice",
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
tagKey: "user",
tagValue: user_az_id,
user_email,
resourceGroupName,
}),
}
);
if (response.ok) {
const successData = await response.text();
// setMessage(`Success: ${successData}`);
checkIfUserHasStorageAccount(); // Refresh account state
} else {
const errorData = await response.text();
setMessage(`Error: ${errorData}`);
}
} catch (error: any) {
setMessage(`Error: ${error.message}`);
} finally {
setLoading(false);
}
};
// Show confirmation modal before deleting
const handleShowModal = () => {
setShowModal(true);
};
// Confirm and proceed to delete storage account
const confirmDeleteStorageAccount = () => {
setShowModal(false);
handleDeleteStorageAccount();
};
// Close modal without deleting
const closeModal = () => {
setShowModal(false);
};
useEffect(() => {
checkIfUserHasStorageAccount();
checkPaymentStatus();
if (session_id) {
fetch(`/api/stripe/success?session_id=${session_id}`)
.then((res) => res.json())
.then((data) => {
console.log(data.message); // Success message
setMessage(data.message);
});
}
}, [session]);
// Überwacht Datenbankwertänderungen
useEffect(() => {
// Datenbankabfrage alle 5 Sekunden
if (loading === false) return; // Nur abfragen, wenn der Ladezustand true ist
const intervalId = setInterval(fetchData, 5000);
return () => clearInterval(intervalId); // Bereinigen, wenn die Komponente unmontiert wird
}, [loading === true]);
return (
<div className="max-w-lg p-6 mx-auto mt-6 bg-gray-400 rounded-lg shadow-md">
<h2 className="mb-4 text-2xl font-bold text-center">
{hasStorageAccount
? "You already have a storage account"
: "Create Storage Account"}
</h2>
{!hasStorageAccount && !paid && (
<button
onClick={handlePayment}
className="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition mb-4"
>
Proceed to Payment
</button>
)}
{!hasStorageAccount && paid && (
<LoadingButton
loading={loading}
onClick={handleCreateStorageAccount}
startlabel="Create Storage Account"
loadinglabel="Creating ..."
/>
)}
{hasStorageAccount && (
<>
<LoadingButton
loading={loading}
onClick={handleShowModal}
startlabel="Deleting Storage Account"
loadinglabel="Deleting ..."
/>
<div className="flex justify-center items-center min-h-screen">
<FileUploader
storageAccountName={storageAccountName}
accessKey={accessKey}
containerName={containerName}
/>
</div>
</>
)}
{message && (
<p className="mt-4 text-sm font-medium text-center">{message}</p>
)}
{containers.length > 0 && (
<div className="mt-6">
<h3 className="mb-2 text-lg font-semibold">Containers:</h3>
<ul className="list-disc list-inside">
{containers.map((container) => (
<li className="text-gray-600" key={container}>
{container}
</li>
))}
</ul>
</div>
)}
{/* Confirmation Modal */}
{showModal && (
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-75 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-md w-full p-6 relative">
{/* Modal Header */}
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-gray-800 dark:text-gray-200">
Confirm Deletion
</h3>
<button
onClick={closeModal}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Modal Body */}
<p className="text-gray-700 dark:text-gray-300 mb-6">
Are you sure you want to delete the storage account? This action
is irreversible.
</p>
{/* Modal Footer */}
<div className="flex justify-end space-x-3">
<button
onClick={confirmDeleteStorageAccount}
className="bg-red-600 text-white px-4 py-2 rounded-md hover:bg-red-700 focus:ring-4 focus:ring-red-200 dark:focus:ring-red-900"
>
Yes, Delete
</button>
<button
onClick={closeModal}
className="bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 focus:ring-4 focus:ring-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 dark:focus:ring-gray-800"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default CreateStorageAccountFormAppService;UI Changes
With this change, the logged-in user will see this UI.

After a click on “Proceed to Payment” the user is redirected to Stripe (you can use test credit card data -> 4242 4242 4242 4242 - 01/28–123)

After the successful payment, the user will be redirected to “/dashboard,” and now the button changed to “Create Storage Account.”

Cloudapp-dev, and before you leave us
Thank you for reading until the end. Before you go:




