Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
21 changes: 21 additions & 0 deletions prisma/migrations/20260830000000_add_email_otp_model/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- AlterTable
ALTER TABLE "Merchant" DROP COLUMN "emailOtp",
DROP COLUMN "emailOtpExpiresAt";

-- CreateTable
CREATE TABLE "EmailOtp" (
"id" TEXT NOT NULL,
"merchantId" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"usedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "EmailOtp_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "EmailOtp_merchantId_createdAt_idx" ON "EmailOtp"("merchantId", "createdAt");

-- AddForeignKey
ALTER TABLE "EmailOtp" ADD CONSTRAINT "EmailOtp_merchantId_fkey" FOREIGN KEY ("merchantId") REFERENCES "Merchant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
19 changes: 17 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,6 @@ model Merchant {
verified Boolean @default(false)
emailVerified Boolean @default(false)
registered Boolean @default(false)
emailOtp String?
emailOtpExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

Expand All @@ -92,6 +90,23 @@ model Merchant {
analytics MerchantAnalytics[]
subscriptionPlans SubscriptionPlan[]
transactions Transaction[]
emailOtps EmailOtp[]
}

// One row per generated email verification code, shaped like AuthNonce: the code
// is stored only as a bcrypt hash, and a row is spent by stamping usedAt rather
// than being overwritten. Keeping history (instead of a single mutable field on
// Merchant) is what lets resendEmailOtp rate-limit by querying for a recent row.
model EmailOtp {
id String @id @default(uuid())
merchantId String
merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade)
codeHash String
expiresAt DateTime
usedAt DateTime?
createdAt DateTime @default(now())

@@index([merchantId, createdAt])
}

model AuthNonce {
Expand Down
17 changes: 6 additions & 11 deletions src/services/merchant.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,9 @@ import type {
MerchantListSortBy,
MerchantListSortDir,
} from '../utils/merchant.validation.js';
import { generateOtp, hashOtp } from './otp.services.js';
import { sendOtp } from './email.service.js';
import { issueEmailOtp } from './otp.services.js';
import { Keypair } from '@stellar/stellar-sdk';

const OTP_EXPIRY_MS = 10 * 60 * 1000;

interface MerchantData {
merchantId: number;
email?: string;
Expand Down Expand Up @@ -105,10 +102,6 @@ export const registerMerchant = async (merchantId: string, data: RegisterMerchan
throw new AppError(409, 'Email already registered');
}

const code = generateOtp();
const emailOtp = await hashOtp(code);
const emailOtpExpiresAt = new Date(Date.now() + OTP_EXPIRY_MS);

const updatedMerchant = await prisma.merchant.update({
where: { id: merchantId },
data: {
Expand All @@ -121,13 +114,15 @@ export const registerMerchant = async (merchantId: string, data: RegisterMerchan
logo: data.logo?.trim() ?? null,
emailVerified: false,
registered: true,
emailOtp,
emailOtpExpiresAt,
},
});

try {
await sendOtp(normalizedEmail, code, data.firstName.trim());
await issueEmailOtp({
id: updatedMerchant.id,
email: normalizedEmail,
firstName: data.firstName.trim(),
});
} catch (err) {
console.error('Failed to send OTP email after registration', err);
}
Expand Down
62 changes: 36 additions & 26 deletions src/services/otp.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,62 +20,68 @@ export const hashOtp = async (code: string): Promise<string> => bcrypt.hash(code
export const verifyOtpHash = async (code: string, hash: string): Promise<boolean> =>
bcrypt.compare(code, hash);

const getLastOtpSentAt = (expiresAt: Date): Date => new Date(expiresAt.getTime() - OTP_EXPIRY_MS);

/**
* Generates a 6-digit OTP, stores its bcrypt hash with a 10-minute expiry,
* and sends the code to the merchant's email.
* Generates a 6-digit OTP, stores its bcrypt hash as a new EmailOtp row with a
* 10-minute expiry, and sends the code to the merchant's email. Previous codes
* are left untouched; verification always uses the most recent one.
*/
export const issueEmailOtp = async (merchant: {
id: string;
email: string;
firstName: string | null;
}): Promise<void> => {
const code = generateOtp();
const emailOtp = await hashOtp(code);
const emailOtpExpiresAt = new Date(Date.now() + OTP_EXPIRY_MS);
const codeHash = await hashOtp(code);

await prisma.merchant.update({
where: { id: merchant.id },
data: { emailOtp, emailOtpExpiresAt },
await prisma.emailOtp.create({
data: {
merchantId: merchant.id,
codeHash,
expiresAt: new Date(Date.now() + OTP_EXPIRY_MS),
},
});

await sendOtp(merchant.email, code, merchant.firstName?.trim() || 'there');
};

/**
* Validates the submitted OTP against the stored hash and marks the email verified.
* Validates the submitted OTP against the merchant's most recent unused code and
* marks the email verified. The matched row is stamped usedAt so it cannot be
* replayed.
*/
export const verifyEmailOtp = async (merchantId: string, code: string) => {
const merchant = await prisma.merchant.findUnique({
where: { id: merchantId },
const otp = await prisma.emailOtp.findFirst({
where: { merchantId, usedAt: null },
orderBy: { createdAt: 'desc' },
});

if (!merchant?.emailOtp || !merchant.emailOtpExpiresAt) {
if (!otp) {
throw new AppError(400, 'Invalid verification code');
}

if (merchant.emailOtpExpiresAt.getTime() < Date.now()) {
if (otp.expiresAt.getTime() < Date.now()) {
throw new AppError(400, 'Code expired');
}

const isValid = await verifyOtpHash(code, merchant.emailOtp);
const isValid = await verifyOtpHash(code, otp.codeHash);
if (!isValid) {
throw new AppError(400, 'Invalid verification code');
}

await prisma.emailOtp.update({
where: { id: otp.id },
data: { usedAt: new Date() },
});

return prisma.merchant.update({
where: { id: merchantId },
data: {
emailVerified: true,
emailOtp: null,
emailOtpExpiresAt: null,
},
data: { emailVerified: true },
});
};

/**
* Re-generates and re-sends the email OTP, rate-limited to one request per minute.
* Re-generates and re-sends the email OTP, rate-limited to one request per
* minute by checking for an EmailOtp row created within the cooldown window.
*/
export const resendEmailOtp = async (merchantId: string): Promise<void> => {
const merchant = await prisma.merchant.findUnique({
Expand All @@ -94,11 +100,15 @@ export const resendEmailOtp = async (merchantId: string): Promise<void> => {
throw new AppError(400, 'Email already verified');
}

if (merchant.emailOtpExpiresAt) {
const lastSentAt = getLastOtpSentAt(merchant.emailOtpExpiresAt);
if (Date.now() - lastSentAt.getTime() < OTP_RESEND_COOLDOWN_MS) {
throw new AppError(429, 'Please wait before requesting a new code');
}
const recentOtp = await prisma.emailOtp.findFirst({
where: {
merchantId,
createdAt: { gt: new Date(Date.now() - OTP_RESEND_COOLDOWN_MS) },
},
});

if (recentOtp) {
throw new AppError(429, 'Please wait before requesting a new code');
}

await issueEmailOtp({
Expand Down
4 changes: 1 addition & 3 deletions tests/integration/admin.merchant.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,6 @@ const merchant = {
verified: false,
emailVerified: true,
registered: true,
emailOtp: null,
emailOtpExpiresAt: null,
createdAt: new Date('2026-06-27T12:00:00.000Z'),
updatedAt: new Date('2026-06-27T12:00:00.000Z'),
};
Expand Down Expand Up @@ -75,7 +73,7 @@ describe('GET /api/v1/admin/merchants', () => {
expect(response.body.pagination).toEqual({ limit: 20, offset: 0, total: 1 });
expect(response.body.data).toHaveLength(1);
expect(response.body.data[0].id).toBe('merchant-1');
// sanitizeMerchant keeps the OTP columns out of an admin response too.
// sanitizeMerchant is an allow-list, so internal columns never reach an admin response.
expect(response.body.data[0]).not.toHaveProperty('emailOtp');
expect(prismaMock.merchant.findMany).toHaveBeenCalledWith({
where: {},
Expand Down
2 changes: 0 additions & 2 deletions tests/integration/api-key.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,6 @@ const merchant = {
verified: false,
emailVerified: true,
registered: true,
emailOtp: null,
emailOtpExpiresAt: null,
createdAt: new Date('2026-06-27T12:00:00.000Z'),
updatedAt: new Date('2026-06-27T12:00:00.000Z'),
};
Expand Down
Loading