Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions __tests__/api/gifts-public-create.test.ts
Original file line number Diff line number Diff line change
@@ -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: "[email protected]",
}),
});

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();
});
});
23 changes: 23 additions & 0 deletions __tests__/api/gifts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,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": "[email protected]",
},
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",
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/gifts/[giftId]/confirm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
20 changes: 20 additions & 0 deletions src/app/api/gifts/public/[giftId]/confirm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 4 additions & 3 deletions src/app/api/gifts/public/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
validateFutureDatetime,
sanitizeInput,
} 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";
Expand Down Expand Up @@ -44,7 +45,7 @@ export async function POST(request: NextRequest) {
const {
recipientId,
amount,
currency = "USDC",
currency = "NGN",
unlockDatetime,
hideAmount,
message,
Expand Down Expand Up @@ -78,9 +79,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 },
);
}

Expand Down
4 changes: 1 addition & 3 deletions src/app/api/gifts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -102,7 +100,7 @@ export async function POST(request: NextRequest) {
senderId: userId,
recipientId: recipient,
amount,
currency: currency.toUpperCase(),
currency,
message: sanitizedMessage,
template: sanitizedTemplate,
coverImageId: sanitizedCoverImageId,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,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",
Expand Down
15 changes: 12 additions & 3 deletions src/lib/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { supportedCurrencyCodes } from "@/lib/db/schema";

export const validateEmail = (email: string): boolean => {
const emailRegex =
Expand All @@ -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();
};
Expand Down Expand Up @@ -106,7 +115,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(),
Expand Down
4 changes: 3 additions & 1 deletion src/server/db/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { integer, real, sqliteTable, text, index } from "drizzle-orm/sqlite-core";

export const supportedCurrencyCodes = ["NGN", "USD"] as const;

export const users = sqliteTable("User", {
id: text("id").primaryKey(),
email: text("email").notNull(),
Expand Down Expand Up @@ -42,7 +44,7 @@ export const gifts = sqliteTable("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(),
Expand Down
18 changes: 15 additions & 3 deletions src/server/services/transactionService.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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) {
Expand All @@ -33,15 +43,17 @@ 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)
await db
.insert(wallets)
.values({
userId: recipientId,
currency,
currency: normalizedCurrency,
balance: amount,
})
.onConflictDoUpdate({
Expand Down