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
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:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user