diff --git a/__tests__/api/gifts-public-create.test.ts b/__tests__/api/gifts-public-create.test.ts new file mode 100644 index 0000000..083e6f3 --- /dev/null +++ b/__tests__/api/gifts-public-create.test.ts @@ -0,0 +1,55 @@ +import { NextRequest } from "next/server"; +import { POST } from "@/app/api/gifts/public/route"; +import { db } from "@/lib/db"; + +jest.mock("@/lib/db", () => ({ + db: { + query: { + users: { + findFirst: jest.fn(), + }, + gifts: { + findFirst: jest.fn(), + }, + }, + insert: jest.fn(), + }, +})); + +jest.mock("@/lib/rate-limiter", () => ({ + isRateLimited: jest.fn(() => false), +})); + +jest.mock("@/lib/honeypot", () => ({ + validateHoneypot: jest.fn(() => true), +})); + +describe("POST /api/gifts/public", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return 400 for an unsupported currency", async () => { + const request = new NextRequest("http://localhost/api/gifts/public", { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ + recipientId: "recipient-123", + amount: 500, + currency: "USDC", + senderName: "Sender", + senderEmail: "sender@example.com", + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Unsupported currency. Accepted: NGN, USD"); + expect(db.query.users.findFirst).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/api/gifts.test.ts b/__tests__/api/gifts.test.ts index 7e3b871..f098eae 100644 --- a/__tests__/api/gifts.test.ts +++ b/__tests__/api/gifts.test.ts @@ -126,6 +126,29 @@ describe("POST /api/gifts", () => { expect(response.status).toBe(422); }); + it("should return 400 for an unsupported currency", async () => { + const request = new NextRequest("http://localhost/api/gifts", { + method: "POST", + headers: { + "content-type": "application/json", + "x-user-id": "sender-123", + "x-user-email": "sender@example.com", + }, + body: JSON.stringify({ + recipient: "550e8400-e29b-41d4-a716-446655440000", + amount: 500, + currency: "USDC", + }), + }); + + const response = await POST(request); + const data = await response.json(); + + expect(response.status).toBe(400); + expect(data.success).toBe(false); + expect(data.error).toBe("Unsupported currency. Accepted: NGN, USD"); + }); + it("should return 401 if not authenticated", async () => { const request = new NextRequest("http://localhost/api/gifts", { method: "POST", diff --git a/src/app/api/gifts/[giftId]/confirm/route.ts b/src/app/api/gifts/[giftId]/confirm/route.ts index 8e15a1d..449e9bd 100644 --- a/src/app/api/gifts/[giftId]/confirm/route.ts +++ b/src/app/api/gifts/[giftId]/confirm/route.ts @@ -3,6 +3,7 @@ import { gifts, wallets } from "@/lib/db/schema"; import { notifyGiftCompleted } from "@/server/services/notificationService"; import { verifyPayment as verifyPaystackPayment, isPaymentSuccessful as isPaystackPaymentSuccessful } from "@/lib/paystack/api"; import { verifyPayment as verifyStripePayment, isPaymentSuccessful as isStripePaymentSuccessful } from "@/lib/stripe/client"; +import { validateCurrency } from "@/lib/validation"; import crypto from "crypto"; import { and, eq, sql } from "drizzle-orm"; import { NextRequest, NextResponse } from "next/server"; @@ -73,6 +74,16 @@ export async function POST( ); } + if (!validateCurrency(gift.currency)) { + return NextResponse.json( + { + success: false, + error: "Unsupported currency. Accepted: NGN, USD", + }, + { status: 400 }, + ); + } + // Verify payment before proceeding with on-chain operations const giftData = gift as any; if (giftData.paymentReference && giftData.paymentProvider) { diff --git a/src/app/api/gifts/public/[giftId]/confirm/route.ts b/src/app/api/gifts/public/[giftId]/confirm/route.ts index 6030a4a..8d30bfc 100644 --- a/src/app/api/gifts/public/[giftId]/confirm/route.ts +++ b/src/app/api/gifts/public/[giftId]/confirm/route.ts @@ -4,6 +4,7 @@ import { gifts } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { processGiftTransaction } from "@/server/services/transactionService"; import { notifyGiftConfirmed } from "@/server/services/notificationService"; +import { validateCurrency } from "@/lib/validation"; import { sendGiftCompletionToSender, sendGiftNotificationToRecipient, @@ -57,6 +58,16 @@ export async function POST( ); } + if (!validateCurrency(gift.currency)) { + return NextResponse.json( + { + success: false, + error: "Unsupported currency. Accepted: NGN, USD", + }, + { status: 400 }, + ); + } + // Verify payment before proceeding with on-chain operations if (gift.paymentReference && gift.paymentProvider) { try { @@ -183,6 +194,15 @@ export async function POST( ); } catch (error) { console.error("[GIFT_CONFIRM_ERROR]", error); + if ( + error instanceof Error && + error.message === "Unsupported currency. Accepted: NGN, USD" + ) { + return NextResponse.json( + { success: false, error: error.message }, + { status: 400 }, + ); + } if ( error instanceof Error && error.message.includes("Insufficient balance") diff --git a/src/app/api/gifts/public/route.ts b/src/app/api/gifts/public/route.ts index 03e7fc1..0ce86b3 100644 --- a/src/app/api/gifts/public/route.ts +++ b/src/app/api/gifts/public/route.ts @@ -10,6 +10,7 @@ import { sanitizeInput, convertToUTCDate, } from "@/lib/validation"; +import { supportedCurrencyCodes } from "@/lib/db/schema"; import { isRateLimited } from "@/lib/rate-limiter"; import { validateHoneypot } from "@/lib/honeypot"; import { generateUniqueSlug } from "@/lib/slug"; @@ -46,7 +47,7 @@ export async function POST(request: NextRequest) { const { recipientId, amount, - currency = "USDC", + currency = "NGN", unlockDatetime, hideAmount, message, @@ -80,9 +81,9 @@ export async function POST(request: NextRequest) { return NextResponse.json( { success: false, - error: "Unsupported currency. Accepted: USD, EUR, GBP, NGN, USDC", + error: `Unsupported currency. Accepted: ${supportedCurrencyCodes.join(", ")}`, }, - { status: 422 }, + { status: 400 }, ); } diff --git a/src/app/api/gifts/route.ts b/src/app/api/gifts/route.ts index 93daaf9..c9ba788 100644 --- a/src/app/api/gifts/route.ts +++ b/src/app/api/gifts/route.ts @@ -3,8 +3,6 @@ import { db } from "@/lib/db"; import { users, gifts } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { - validateAmount, - validateCurrency, sanitizeInput, validateMessage, validateUnlockAt, @@ -106,9 +104,7 @@ export async function POST(request: NextRequest) { senderId: userId, recipientId: recipient, amount, - fee, - totalAmount, - currency: currency.toUpperCase(), + currency, message: sanitizedMessage, template: sanitizedTemplate, coverImageId: sanitizedCoverImageId, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 4d2bd39..d5e5795 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -13,6 +13,9 @@ import { uuid, } from "drizzle-orm/pg-core"; +export const supportedCurrencyCodes = ["NGN", "USD"] as const; +export type SupportedCurrencyCode = (typeof supportedCurrencyCodes)[number]; + // Enums export const userStatusEnum = pgEnum("user_status", [ "unverified", diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 6da9d15..fa2c4f0 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { supportedCurrencyCodes } from "@/lib/db/schema"; export const validateEmail = (email: string): boolean => { const emailRegex = @@ -21,10 +22,18 @@ export const validateAmount = (amount: number): boolean => { }; export const validateCurrency = (currency: string): boolean => { - const supportedCurrencies = ["USD", "EUR", "GBP", "NGN", "USDC"]; // Add more as needed - return supportedCurrencies.includes(currency.toUpperCase()); + return supportedCurrencyCodes.includes( + currency.toUpperCase() as (typeof supportedCurrencyCodes)[number], + ); }; +export const CurrencySchema = z + .string() + .refine(validateCurrency, { + message: `Unsupported currency. Accepted: ${supportedCurrencyCodes.join(", ")}`, + }) + .transform((value) => value.toUpperCase()); + export const validateFutureDatetime = (date: Date): boolean => { return !isNaN(date.getTime()) && date.getTime() > Date.now(); }; @@ -159,7 +168,7 @@ export const validateMessage = (message: string | null | undefined): boolean => export const CreateGiftSchema = z.object({ recipient: z.string().uuid("Invalid recipient ID"), amount: z.number().min(500, "Gift amount needs to be above the minimum threshold"), - currency: z.string().default("USDC"), + currency: CurrencySchema.default("NGN"), message: z.string().max(500, "Message cannot exceed 500 characters").optional().nullable(), template: z.string().optional().nullable(), coverImageId: z.union([z.string(), z.number()]).optional().nullable(), diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 6ac1e15..ef7e181 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -1,6 +1,8 @@ import { boolean, index, integer, pgTable, real, text } from "drizzle-orm/pg-core"; -export const users = pgTable("User", { +export const supportedCurrencyCodes = ["NGN", "USD"] as const; + +export const users = sqliteTable("User", { id: text("id").primaryKey(), email: text("email").notNull(), passwordHash: text("passwordHash").notNull(), @@ -42,7 +44,7 @@ export const gifts = pgTable("Gift", { senderId: text("senderId"), recipientId: text("recipientId").notNull(), amount: real("amount").notNull(), - currency: text("currency").notNull().default("USDC"), + currency: text("currency").notNull().default("NGN"), message: text("message"), template: text("template"), status: text("status").notNull(), diff --git a/src/server/services/transactionService.ts b/src/server/services/transactionService.ts index ae10ffe..a8adbcd 100644 --- a/src/server/services/transactionService.ts +++ b/src/server/services/transactionService.ts @@ -1,5 +1,6 @@ import { db } from "@/lib/db"; import { wallets } from "@/lib/db/schema"; +import { validateCurrency } from "@/lib/validation"; import { eq, and, sql } from "drizzle-orm"; import crypto from "crypto"; @@ -14,12 +15,21 @@ export async function processGiftTransaction( params: ProcessGiftTransactionParams, ) { const { senderId, recipientId, amount, currency } = params; + const normalizedCurrency = currency.toUpperCase(); + + if (!validateCurrency(normalizedCurrency)) { + throw new Error("Unsupported currency. Accepted: NGN, USD"); + } + const transactionId = `txn_${crypto.randomUUID()}`; // If sender is authenticated, deduct from their wallet if (senderId) { const senderWallet = await db.query.wallets.findFirst({ - where: and(eq(wallets.userId, senderId), eq(wallets.currency, currency)), + where: and( + eq(wallets.userId, senderId), + eq(wallets.currency, normalizedCurrency), + ), }); if (!senderWallet || senderWallet.balance < amount) { @@ -33,7 +43,9 @@ export async function processGiftTransaction( balance: sql`${wallets.balance} - ${amount}`, updatedAt: new Date(), }) - .where(and(eq(wallets.userId, senderId), eq(wallets.currency, currency))); + .where( + and(eq(wallets.userId, senderId), eq(wallets.currency, normalizedCurrency)), + ); } // Upsert recipient wallet (add) @@ -41,7 +53,7 @@ export async function processGiftTransaction( .insert(wallets) .values({ userId: recipientId, - currency, + currency: normalizedCurrency, balance: amount, }) .onConflictDoUpdate({