feat(FN-337): add @Public() to iyzico callback, HMAC signature verification, and idempotency guard (+1 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- chore(FN-337): verify all quality gates — lint, tests (22/22), typecheck, build pass
- feat(FN-337): add @Public() to iyzico callback, HMAC signature verification, and idempotency guard

Files changed:
apps/api/src/payments/payments.controller.ts   |  12 +-
 apps/api/src/payments/payments.service.spec.ts | 187 ++++++++++++++++++++++++-
 apps/api/src/payments/payments.service.ts      |  43 +++++-
 3 files changed, 234 insertions(+), 8 deletions(-)

Fusion-Task-Id: FN-337
This commit is contained in:
Fusion
2026-05-13 22:46:26 +00:00
parent 3e3d66c919
commit f8f629d39c
3 changed files with 234 additions and 8 deletions

View File

@@ -3,6 +3,7 @@ import {
Body,
Controller,
Get,
Headers,
Param,
Patch,
Post,
@@ -12,6 +13,7 @@ import {
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { Public } from "../common/decorators/public.decorator";
import { Roles } from "../common/decorators/roles.decorator";
import { RolesGuard } from "../common/guards/roles.guard";
import { PaymentsService } from "./payments.service";
@@ -34,13 +36,21 @@ export class PaymentsController {
}
@Post("iyzico/callback")
@Public()
async iyzicoCallback(
@Body() body: { paymentId: string; iyzicoPaymentId: string; status: string },
@Body() body: {
paymentId: string;
iyzicoPaymentId: string;
status: string;
signature?: string;
},
@Headers("x-iyzico-signature") headerSignature?: string,
) {
return this.paymentsService.handleIyzicoCallback(
body.paymentId,
body.iyzicoPaymentId,
body.status,
headerSignature ?? body.signature,
);
}

View File

@@ -1,7 +1,12 @@
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { createHmac } from "node:crypto";
import { BadRequestException, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PaymentsService } from "./payments.service";
function makeSignature(secret: string, paymentId: string, iyzicoPaymentId: string): string {
return createHmac("sha256", secret).update(`${paymentId}:${iyzicoPaymentId}`).digest("hex");
}
function createMockDb(overrides: Record<string, unknown> = {}) {
function chainable(terminalValue: unknown) {
const chain: Record<string, unknown> = {};
@@ -146,7 +151,12 @@ describe("PaymentsService", () => {
};
const { service, subscriptionsService, posthogService } = createService(db);
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "success");
const result = await service.handleIyzicoCallback(
"pay-1",
"iyz-123",
"success",
makeSignature("test-value", "pay-1", "iyz-123"),
);
expect(result.status).toBe("completed");
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
expect(posthogService.captureForUser).toHaveBeenCalledWith(
@@ -184,7 +194,12 @@ describe("PaymentsService", () => {
};
const { service, subscriptionsService, posthogService } = createService(db);
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "failure");
const result = await service.handleIyzicoCallback(
"pay-1",
"iyz-123",
"failure",
makeSignature("test-value", "pay-1", "iyz-123"),
);
expect(result.status).toBe("failed");
expect(subscriptionsService.activateSubscription).not.toHaveBeenCalled();
expect(posthogService.captureForUser).toHaveBeenCalledWith(
@@ -208,9 +223,171 @@ describe("PaymentsService", () => {
};
const { service } = createService(db);
await expect(service.handleIyzicoCallback("nonexistent", "iyz-1", "success")).rejects.toThrow(
NotFoundException,
await expect(
service.handleIyzicoCallback(
"nonexistent",
"iyz-1",
"success",
makeSignature("test-value", "nonexistent", "iyz-1"),
),
).rejects.toThrow(NotFoundException);
});
it("should return early for already-completed payment without side effects", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([
{
id: "pay-1",
subscriptionId: "sub-1",
status: "completed",
userId: "u1",
amount: 20000,
},
]),
}),
};
const { service, subscriptionsService, posthogService } = createService(db);
const result = await service.handleIyzicoCallback(
"pay-1",
"iyz-1",
"success",
makeSignature("test-value", "pay-1", "iyz-1"),
);
expect(result).toEqual({ status: "completed" });
expect(subscriptionsService.activateSubscription).not.toHaveBeenCalled();
expect(posthogService.captureForUser).not.toHaveBeenCalled();
});
it("should return early for already-failed payment without side effects", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([
{
id: "pay-1",
subscriptionId: "sub-1",
status: "failed",
userId: "u1",
amount: 20000,
},
]),
}),
};
const { service, subscriptionsService, posthogService } = createService(db);
const result = await service.handleIyzicoCallback(
"pay-1",
"iyz-1",
"success",
makeSignature("test-value", "pay-1", "iyz-1"),
);
expect(result).toEqual({ status: "failed" });
expect(subscriptionsService.activateSubscription).not.toHaveBeenCalled();
expect(posthogService.captureForUser).not.toHaveBeenCalled();
});
it("should throw UnauthorizedException when signature is missing and secret is configured", async () => {
const { service } = createService();
await expect(service.handleIyzicoCallback("pay-1", "iyz-123", "success")).rejects.toThrow(
UnauthorizedException,
);
});
it("should throw UnauthorizedException when signature is invalid", async () => {
const { service } = createService();
await expect(
service.handleIyzicoCallback("pay-1", "iyz-123", "success", "invalid-hex"),
).rejects.toThrow(UnauthorizedException);
});
it("should process callback when valid signature is provided", async () => {
const selectChain = (rows: unknown[]) => ({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue(rows),
});
const db = {
select: vi
.fn()
.mockReturnValueOnce(
selectChain([
{
id: "pay-1",
subscriptionId: "sub-1",
status: "pending",
userId: "u1",
amount: 20000,
currency: "TRY",
},
]),
)
.mockReturnValueOnce(
selectChain([{ id: "sub-1", planId: "plan-1", billingPeriod: "monthly" }]),
)
.mockReturnValueOnce(selectChain([{ id: "plan-1", brandCount: 1, name: "Brand 1" }])),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const { service, subscriptionsService } = createService(db);
const result = await service.handleIyzicoCallback(
"pay-1",
"iyz-123",
"success",
makeSignature("test-value", "pay-1", "iyz-123"),
);
expect(result).toEqual({ status: "completed" });
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
});
it("should process callback without signature when secret is not configured", async () => {
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([
{
id: "pay-1",
subscriptionId: "sub-1",
status: "pending",
userId: "u1",
amount: 20000,
},
]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const configService = { get: vi.fn().mockReturnValue(undefined) };
const subscriptionsService = {
activateSubscription: vi.fn().mockResolvedValue(undefined),
create: vi.fn().mockResolvedValue({ id: "sub-1", planId: "plan-1", status: "pending" }),
addBrandsToSubscription: vi.fn().mockResolvedValue(undefined),
};
const storageService = { upload: vi.fn() };
const posthogService = { capture: vi.fn(), captureForUser: vi.fn() };
const service = new PaymentsService(
db as any,
configService as any,
subscriptionsService as any,
storageService as any,
posthogService as any,
);
const result = await service.handleIyzicoCallback("pay-1", "iyz-1", "success");
expect(result.status).toBe("completed");
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
});
});

View File

@@ -1,4 +1,12 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { createHmac, timingSafeEqual } from "node:crypto";
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { and, desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
@@ -104,7 +112,31 @@ export class PaymentsService {
};
}
async handleIyzicoCallback(paymentId: string, iyzicoPaymentId: string, status: string) {
async handleIyzicoCallback(
paymentId: string,
iyzicoPaymentId: string,
status: string,
signature?: string,
) {
const secretKey = this.configService.get<string>("iyzico.secretKey");
if (secretKey) {
if (!signature) {
throw new UnauthorizedException("iyzico callback signature missing");
}
const expected = createHmac("sha256", secretKey)
.update(`${paymentId}:${iyzicoPaymentId}`)
.digest("hex");
const expectedBuf = Buffer.from(expected, "utf8");
const actualBuf = Buffer.from(signature, "utf8");
if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) {
throw new UnauthorizedException("iyzico callback signature invalid");
}
} else {
this.logger.warn(
"IYZICO_SECRET_KEY not configured — skipping callback signature verification",
);
}
const [payment] = await this.db
.select()
.from(payments)
@@ -113,6 +145,13 @@ export class PaymentsService {
if (!payment) throw new NotFoundException("Ödeme bulunamadı");
if (payment.status === "completed" || payment.status === "failed") {
this.logger.log(
`iyzico callback for payment ${paymentId} already processed (status: ${payment.status}) — skipping`,
);
return { status: payment.status };
}
const newStatus = status === "success" ? "completed" : "failed";
await this.db