Integrate Razorpay checkout flow
This commit is contained in:
parent
79ac24217a
commit
ab054884eb
5 changed files with 257 additions and 129 deletions
|
|
@ -2,6 +2,7 @@ import { For, Show, createSignal, onMount } from "solid-js";
|
|||
import { Portal } from "solid-js/web";
|
||||
import { BTN_GHOST, BTN_ORANGE, BTN_PRIMARY, CARD, ORANGE, NAVY, PKG_CARD } from "~/components/DashboardShell";
|
||||
import { PROFESSIONAL_ROLE_SET, ROLE_PREFIXES, type RoleKey } from "./RoleDashboardShared";
|
||||
import { openRazorpayCheckout } from "~/lib/razorpay";
|
||||
|
||||
const API = "/api/gateway";
|
||||
|
||||
|
|
@ -102,8 +103,6 @@ export default function CreditsPage(props: Props) {
|
|||
step: "form",
|
||||
error: "",
|
||||
});
|
||||
|
||||
// Mock card form state
|
||||
const [cardNumber, setCardNumber] = createSignal("");
|
||||
const [cardExpiry, setCardExpiry] = createSignal("");
|
||||
const [cardCvv, setCardCvv] = createSignal("");
|
||||
|
|
@ -356,24 +355,9 @@ export default function CreditsPage(props: Props) {
|
|||
|
||||
if (!pkg) return;
|
||||
|
||||
// Validate card fields
|
||||
if (cardNumber().replace(/\s/g, "").length < 16) {
|
||||
setCheckout((c) => ({ ...c, error: "Please enter a valid card number" }));
|
||||
return;
|
||||
}
|
||||
if (!cardExpiry() || cardExpiry().length < 5) {
|
||||
setCheckout((c) => ({ ...c, error: "Please enter expiry date (MM/YY)" }));
|
||||
return;
|
||||
}
|
||||
if (cardCvv().length < 3) {
|
||||
setCheckout((c) => ({ ...c, error: "Please enter valid CVV" }));
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckout((c) => ({ ...c, step: "processing", error: "" }));
|
||||
|
||||
try {
|
||||
// Step 1: Create order
|
||||
const orderRes = await apiFetch("/api/payments/create-order", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
|
|
@ -393,24 +377,25 @@ export default function CreditsPage(props: Props) {
|
|||
return;
|
||||
}
|
||||
|
||||
const orderId = orderData.order_id;
|
||||
const paymentResult = await openRazorpayCheckout({
|
||||
amount: orderData.amount,
|
||||
currency: orderData.currency,
|
||||
orderId: orderData.order_id,
|
||||
description: `${pkg.name} - ${pkg.tracecoins_amount} Tracecoins`,
|
||||
});
|
||||
|
||||
// Step 2: Simulate payment processing (mock)
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Step 3: Verify payment
|
||||
const verifyRes = await apiFetch("/api/payments/verify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
order_id: orderId,
|
||||
payment_id: "pay_mock_" + Date.now(),
|
||||
signature: "mock_signature",
|
||||
razorpay_order_id: paymentResult.razorpay_order_id,
|
||||
razorpay_payment_id: paymentResult.razorpay_payment_id,
|
||||
razorpay_signature: paymentResult.razorpay_signature,
|
||||
}),
|
||||
});
|
||||
const verifyData = await verifyRes.json().catch(() => ({}));
|
||||
|
||||
if (verifyRes.ok && verifyData.verified) {
|
||||
setCheckout((c) => ({ ...c, step: "success", orderId }));
|
||||
setCheckout((c) => ({ ...c, step: "success", orderId: paymentResult.razorpay_order_id }));
|
||||
setMsg(
|
||||
`Payment successful! ${pkg.tracecoins_amount} Tracecoins have been added to your wallet.`
|
||||
);
|
||||
|
|
@ -655,52 +640,36 @@ export default function CreditsPage(props: Props) {
|
|||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Mock Notice */}
|
||||
{/* Razorpay Notice */}
|
||||
<div
|
||||
style={{
|
||||
background: "#FEF3C7",
|
||||
border: "1px solid #FCD34D",
|
||||
background: "#FFF7ED",
|
||||
border: "1px solid #FDBA74",
|
||||
"border-radius": "8px",
|
||||
padding: "10px",
|
||||
"font-size": "12px",
|
||||
color: "#92400E",
|
||||
padding: "12px",
|
||||
"font-size": "13px",
|
||||
color: "#9A3412",
|
||||
}}
|
||||
>
|
||||
<strong>Mock Payment:</strong> Use any values. This is a simulation.
|
||||
Razorpay Standard Checkout will open in a secure modal. Cancelled or failed
|
||||
payments will not be credited.
|
||||
</div>
|
||||
|
||||
{/* Card Number */}
|
||||
<div>
|
||||
<label
|
||||
<Show when={Boolean(aiPkg)}>
|
||||
<div
|
||||
style={{
|
||||
display: "block",
|
||||
background: "#FEF3C7",
|
||||
border: "1px solid #FCD34D",
|
||||
"border-radius": "8px",
|
||||
padding: "10px",
|
||||
"font-size": "12px",
|
||||
"font-weight": "600",
|
||||
color: "#374151",
|
||||
"margin-bottom": "6px",
|
||||
color: "#92400E",
|
||||
}}
|
||||
>
|
||||
Card Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardNumber()}
|
||||
onInput={(e) => setCardNumber(formatCardNumber(e.currentTarget.value))}
|
||||
placeholder="1234 5678 9012 3456"
|
||||
maxLength="19"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "8px",
|
||||
"font-size": "14px",
|
||||
"box-sizing": "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
AI credit purchases still use the existing internal test payment flow in this
|
||||
screen.
|
||||
</div>
|
||||
|
||||
{/* Expiry & CVV */}
|
||||
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "12px" }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
|
|
@ -711,14 +680,14 @@ export default function CreditsPage(props: Props) {
|
|||
"margin-bottom": "6px",
|
||||
}}
|
||||
>
|
||||
Expiry (MM/YY)
|
||||
Card Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardExpiry()}
|
||||
onInput={(e) => setCardExpiry(formatExpiry(e.currentTarget.value))}
|
||||
placeholder="MM/YY"
|
||||
maxLength="5"
|
||||
value={cardNumber()}
|
||||
onInput={(e) => setCardNumber(formatCardNumber(e.currentTarget.value))}
|
||||
placeholder="1234 5678 9012 3456"
|
||||
maxLength="19"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
|
|
@ -729,6 +698,68 @@ export default function CreditsPage(props: Props) {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", "grid-template-columns": "1fr 1fr", gap: "12px" }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
"font-size": "12px",
|
||||
"font-weight": "600",
|
||||
color: "#374151",
|
||||
"margin-bottom": "6px",
|
||||
}}
|
||||
>
|
||||
Expiry (MM/YY)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardExpiry()}
|
||||
onInput={(e) => setCardExpiry(formatExpiry(e.currentTarget.value))}
|
||||
placeholder="MM/YY"
|
||||
maxLength="5"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "8px",
|
||||
"font-size": "14px",
|
||||
"box-sizing": "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
"font-size": "12px",
|
||||
"font-weight": "600",
|
||||
color: "#374151",
|
||||
"margin-bottom": "6px",
|
||||
}}
|
||||
>
|
||||
CVV
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardCvv()}
|
||||
onInput={(e) =>
|
||||
setCardCvv(e.currentTarget.value.replace(/\D/g, "").slice(0, 4))
|
||||
}
|
||||
placeholder="123"
|
||||
maxLength="4"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "8px",
|
||||
"font-size": "14px",
|
||||
"box-sizing": "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
|
|
@ -739,16 +770,13 @@ export default function CreditsPage(props: Props) {
|
|||
"margin-bottom": "6px",
|
||||
}}
|
||||
>
|
||||
CVV
|
||||
Cardholder Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardCvv()}
|
||||
onInput={(e) =>
|
||||
setCardCvv(e.currentTarget.value.replace(/\D/g, "").slice(0, 4))
|
||||
}
|
||||
placeholder="123"
|
||||
maxLength="4"
|
||||
value={cardName()}
|
||||
onInput={(e) => setCardName(e.currentTarget.value)}
|
||||
placeholder="John Doe"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
|
|
@ -759,36 +787,7 @@ export default function CreditsPage(props: Props) {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cardholder Name */}
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
"font-size": "12px",
|
||||
"font-weight": "600",
|
||||
color: "#374151",
|
||||
"margin-bottom": "6px",
|
||||
}}
|
||||
>
|
||||
Cardholder Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={cardName()}
|
||||
onInput={(e) => setCardName(e.currentTarget.value)}
|
||||
placeholder="John Doe"
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px",
|
||||
border: "1px solid #E5E7EB",
|
||||
"border-radius": "8px",
|
||||
"font-size": "14px",
|
||||
"box-sizing": "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Pay Button */}
|
||||
<button
|
||||
|
|
@ -812,7 +811,7 @@ export default function CreditsPage(props: Props) {
|
|||
"text-align": "center",
|
||||
}}
|
||||
>
|
||||
Secure payment powered by Nxtgauge
|
||||
Secure payment powered by Razorpay
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
|
|
|||
37
src/global.d.ts
vendored
37
src/global.d.ts
vendored
|
|
@ -1,2 +1,39 @@
|
|||
/// <reference types="vinxi/types/client" />
|
||||
/// <reference types="vinxi/types/server" />
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Razorpay?: new (options: {
|
||||
key: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
order_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
handler: (response: {
|
||||
razorpay_payment_id: string;
|
||||
razorpay_order_id: string;
|
||||
razorpay_signature: string;
|
||||
}) => void;
|
||||
modal?: {
|
||||
ondismiss?: () => void;
|
||||
};
|
||||
prefill?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
};
|
||||
theme?: {
|
||||
color?: string;
|
||||
};
|
||||
}) => {
|
||||
on: (
|
||||
event: "payment.failed",
|
||||
handler: (event: { error?: { description?: string } }) => void
|
||||
) => void;
|
||||
open: () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -141,17 +141,29 @@ export async function fetchInvoices(rolePrefix: string, page = 1, limit = 20): P
|
|||
return apiFetch(`/api/${rolePrefix}/wallet/invoices?page=${page}&limit=${limit}`);
|
||||
}
|
||||
|
||||
export async function createPaymentOrder(amount: number, packageId?: string): Promise<any> {
|
||||
export async function createPaymentOrder(
|
||||
amount: number,
|
||||
packageId?: string,
|
||||
currency = "INR"
|
||||
): Promise<any> {
|
||||
return apiFetch('/api/payments/create-order', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amount, package_id: packageId }),
|
||||
body: JSON.stringify({ amount, currency, package_id: packageId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function verifyPayment(orderId: string, paymentId: string): Promise<any> {
|
||||
export async function verifyPayment(
|
||||
orderId: string,
|
||||
paymentId: string,
|
||||
signature: string
|
||||
): Promise<any> {
|
||||
return apiFetch('/api/payments/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ order_id: orderId, payment_id: paymentId }),
|
||||
body: JSON.stringify({
|
||||
razorpay_order_id: orderId,
|
||||
razorpay_payment_id: paymentId,
|
||||
razorpay_signature: signature,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
92
src/lib/razorpay.ts
Normal file
92
src/lib/razorpay.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
type RazorpaySuccessPayload = {
|
||||
razorpay_payment_id: string;
|
||||
razorpay_order_id: string;
|
||||
razorpay_signature: string;
|
||||
};
|
||||
|
||||
type RazorpayCheckoutOptions = {
|
||||
amount: number;
|
||||
currency: string;
|
||||
orderId: string;
|
||||
description: string;
|
||||
prefill?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
contact?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const RAZORPAY_SCRIPT_ID = "razorpay-checkout-js";
|
||||
const RAZORPAY_SCRIPT_SRC = "https://checkout.razorpay.com/v1/checkout.js";
|
||||
|
||||
function getRazorpayKeyId() {
|
||||
const keyId = import.meta.env.VITE_RAZORPAY_KEY_ID;
|
||||
if (!keyId) {
|
||||
throw new Error("Razorpay public key is not configured.");
|
||||
}
|
||||
return keyId;
|
||||
}
|
||||
|
||||
export async function loadRazorpayCheckout(): Promise<void> {
|
||||
if (typeof window === "undefined") {
|
||||
throw new Error("Razorpay checkout is only available in the browser.");
|
||||
}
|
||||
|
||||
if (window.Razorpay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = document.getElementById(RAZORPAY_SCRIPT_ID) as HTMLScriptElement | null;
|
||||
if (existing) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
existing.addEventListener("load", () => resolve(), { once: true });
|
||||
existing.addEventListener("error", () => reject(new Error("Failed to load Razorpay checkout.")), {
|
||||
once: true,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.id = RAZORPAY_SCRIPT_ID;
|
||||
script.src = RAZORPAY_SCRIPT_SRC;
|
||||
script.async = true;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("Failed to load Razorpay checkout."));
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
export async function openRazorpayCheckout(
|
||||
options: RazorpayCheckoutOptions
|
||||
): Promise<RazorpaySuccessPayload> {
|
||||
await loadRazorpayCheckout();
|
||||
|
||||
return new Promise<RazorpaySuccessPayload>((resolve, reject) => {
|
||||
const instance = new window.Razorpay({
|
||||
key: getRazorpayKeyId(),
|
||||
amount: options.amount,
|
||||
currency: options.currency,
|
||||
order_id: options.orderId,
|
||||
name: "Nxtgauge",
|
||||
description: options.description,
|
||||
handler: (response: RazorpaySuccessPayload) => resolve(response),
|
||||
modal: {
|
||||
ondismiss: () => reject(new Error("Payment cancelled.")),
|
||||
},
|
||||
prefill: options.prefill,
|
||||
theme: {
|
||||
color: "#FF5E13",
|
||||
},
|
||||
});
|
||||
|
||||
instance.on("payment.failed", (event: { error?: { description?: string } }) => {
|
||||
reject(new Error(event.error?.description || "Payment failed."));
|
||||
});
|
||||
|
||||
instance.open();
|
||||
});
|
||||
}
|
||||
|
||||
export type { RazorpaySuccessPayload };
|
||||
|
|
@ -2,6 +2,7 @@ import { createResource, createSignal, Show, For } from "solid-js";
|
|||
import { useNavigate } from "@solidjs/router";
|
||||
import DashboardLayout from "~/components/DashboardLayout";
|
||||
import { api } from "~/lib/api";
|
||||
import { openRazorpayCheckout } from "~/lib/razorpay";
|
||||
|
||||
interface Package {
|
||||
id: string;
|
||||
|
|
@ -55,8 +56,7 @@ export default function BuyTracecoinsPage() {
|
|||
setError("");
|
||||
|
||||
try {
|
||||
// Step 1: Create order via Beeceptor
|
||||
const orderRes = await api.post("/payments/order", {
|
||||
const orderRes = await api.post("/payments/create-order", {
|
||||
package_id: pkg.id,
|
||||
amount: pkg.price_inr,
|
||||
currency: "INR",
|
||||
|
|
@ -64,29 +64,17 @@ export default function BuyTracecoinsPage() {
|
|||
|
||||
const { order_id, amount, currency } = orderRes.data;
|
||||
|
||||
// Step 2: Simulate Razorpay payment with Beeceptor
|
||||
const paymentRes = await fetch("https://nxtgauge.free.beeceptor.com/payment", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
order_id,
|
||||
amount,
|
||||
currency,
|
||||
status: "captured",
|
||||
}),
|
||||
const paymentData = await openRazorpayCheckout({
|
||||
amount,
|
||||
currency,
|
||||
orderId: order_id,
|
||||
description: `${pkg.name} - ${pkg.tracecoins_amount} Tracecoins`,
|
||||
});
|
||||
|
||||
const paymentData = await paymentRes.json();
|
||||
|
||||
if (paymentData.status !== "captured") {
|
||||
throw new Error("Payment failed");
|
||||
}
|
||||
|
||||
// Step 3: Verify payment with our backend
|
||||
await api.post("/payments/verify", {
|
||||
order_id,
|
||||
payment_id: paymentData.payment_id || "pay_test_" + Date.now(),
|
||||
signature: "test_signature",
|
||||
razorpay_order_id: paymentData.razorpay_order_id,
|
||||
razorpay_payment_id: paymentData.razorpay_payment_id,
|
||||
razorpay_signature: paymentData.razorpay_signature,
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue