feat(FN-281): add payment_success and payment_failed PostHog events (+1 more)
Some checks failed
Sync dev → Gitea / Mirror dev to Gitea (push) Has been cancelled

Commits merged:
- fix(FN-281): make result search param truly optional to fix typecheck
- feat(FN-281): add payment_success and payment_failed PostHog events

Files changed:
apps/api/package.json                              |    1 +
 apps/api/src/app.module.ts                         |    2 +
 apps/api/src/payments/payments.service.spec.ts     |   99 +-
 apps/api/src/payments/payments.service.ts          |   40 +
 apps/api/src/posthog/posthog.module.ts             |    9 +
 apps/api/src/posthog/posthog.service.ts            |   64 ++
 .../web/src/components/payment/payment-content.tsx |   24 +-
 apps/web/src/routeTree.gen.ts                      | 1110 ++++++++++----------
 apps/web/src/routes/dashboard/subscription/pay.tsx |   24 +-
 packages/config/src/index.ts                       |    4 +
 pnpm-lock.yaml                                     |   30 +
 11 files changed, 827 insertions(+), 580 deletions(-)

Fusion-Task-Id: FN-281
This commit is contained in:
Fusion
2026-05-13 06:30:48 +00:00
parent 7707443be6
commit d855b564bf
11 changed files with 827 additions and 580 deletions

View File

@@ -60,6 +60,7 @@
"ioredis": "^5.4.0",
"openai": "^6.37.0",
"postgres": "^3.4.0",
"posthog-node": "^5.34.1",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.0",
"undici": "^7.22.0",

View File

@@ -28,6 +28,7 @@ import { JobsModule } from "./jobs/jobs.module";
import { PartsModule } from "./parts/parts.module";
import { PaymentsModule } from "./payments/payments.module";
import { PlansModule } from "./plans/plans.module";
import { PostHogModule } from "./posthog/posthog.module";
import { RedisModule } from "./redis/redis.module";
import { ReferralsModule } from "./referrals/referrals.module";
import { StorageModule } from "./storage/storage.module";
@@ -81,6 +82,7 @@ import { VehiclesModule } from "./vehicles/vehicles.module";
AnalyticsModule,
CatalogModule,
ChangelogModule,
PostHogModule,
],
controllers: [HealthController],
providers: [

View File

@@ -54,15 +54,20 @@ function createService(
const storageService = {
upload: vi.fn().mockResolvedValue("https://storage.test/receipt.pdf"),
};
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,
);
return { service, db, configService, subscriptionsService, storageService };
return { service, db, configService, subscriptionsService, storageService, posthogService };
}
describe("PaymentsService", () => {
@@ -109,46 +114,75 @@ describe("PaymentsService", () => {
});
describe("handleIyzicoCallback", () => {
it("should activate subscription on success", async () => {
it("should activate subscription on success and capture payment_success", 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" }]),
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 { service, subscriptionsService } = createService(db);
const { service, subscriptionsService, posthogService } = createService(db);
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "success");
expect(result.status).toBe("completed");
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
expect(posthogService.captureForUser).toHaveBeenCalledWith(
"u1",
"payment_success",
expect.objectContaining({
method: "iyzico",
payment_id: "pay-1",
}),
);
});
it("should set failed status on failure callback", async () => {
it("should set failed status on failure callback and capture payment_failed", 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" }]),
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 { service, subscriptionsService } = createService(db);
const { service, subscriptionsService, posthogService } = createService(db);
const result = await service.handleIyzicoCallback("pay-1", "iyz-123", "failure");
expect(result.status).toBe("failed");
expect(subscriptionsService.activateSubscription).not.toHaveBeenCalled();
expect(posthogService.captureForUser).toHaveBeenCalledWith(
"u1",
"payment_failed",
expect.objectContaining({
method: "iyzico",
payment_id: "pay-1",
reason: "failure",
}),
);
});
it("should throw NotFoundException when payment not found", async () => {
@@ -264,23 +298,35 @@ describe("PaymentsService", () => {
});
describe("approveEft", () => {
it("should approve and activate subscription", async () => {
it("should approve, activate subscription, and capture payment_success", 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", method: "eft" }]),
limit: vi
.fn()
.mockReturnValue([
{ id: "pay-1", subscriptionId: "sub-1", method: "eft", userId: "u1", amount: 20000 },
]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const { service, subscriptionsService } = createService(db);
const { service, subscriptionsService, posthogService } = createService(db);
const result = await service.approveEft("pay-1", "Looks good");
expect(result.status).toBe("completed");
expect(subscriptionsService.activateSubscription).toHaveBeenCalledWith("sub-1");
expect(posthogService.captureForUser).toHaveBeenCalledWith(
"u1",
"payment_success",
expect.objectContaining({
method: "eft",
payment_id: "pay-1",
}),
);
});
it("should throw NotFoundException when payment not found", async () => {
@@ -311,17 +357,38 @@ describe("PaymentsService", () => {
});
describe("rejectEft", () => {
it("should reject and return failed status", async () => {
it("should reject and capture payment_failed", async () => {
const payment = {
id: "pay-1",
subscriptionId: "sub-1",
userId: "u1",
amount: 20000,
method: "eft",
};
const db = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnValue([payment]),
}),
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(undefined),
}),
};
const { service } = createService(db);
const { service, posthogService } = createService(db);
const result = await service.rejectEft("pay-1", "Bad receipt");
expect(result.status).toBe("failed");
expect(posthogService.captureForUser).toHaveBeenCalledWith(
"u1",
"payment_failed",
expect.objectContaining({
method: "eft",
payment_id: "pay-1",
reason: "Bad receipt",
}),
);
});
});

View File

@@ -3,6 +3,7 @@ import { ConfigService } from "@nestjs/config";
import { and, desc, eq } from "drizzle-orm";
import { DATABASE, type Database } from "../database/database.provider";
import { payments, plans, userSubscriptions } from "../database/schema/core";
import { PostHogService } from "../posthog/posthog.service";
import { StorageService } from "../storage/storage.service";
import { SubscriptionsService } from "../subscriptions/subscriptions.service";
@@ -22,6 +23,7 @@ export class PaymentsService {
private configService: ConfigService,
private subscriptionsService: SubscriptionsService,
private storageService: StorageService,
private posthog: PostHogService,
) {}
private async resolvePlanId(planKey: string): Promise<string> {
@@ -117,6 +119,21 @@ export class PaymentsService {
if (newStatus === "completed") {
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
this.posthog.captureForUser(payment.userId, "payment_success", {
method: "iyzico",
payment_id: paymentId,
iyzico_payment_id: iyzicoPaymentId,
subscription_id: payment.subscriptionId,
amount: Number(payment.amount),
});
} else {
this.posthog.captureForUser(payment.userId, "payment_failed", {
method: "iyzico",
payment_id: paymentId,
iyzico_payment_id: iyzicoPaymentId,
subscription_id: payment.subscriptionId,
reason: status,
});
}
return { status: newStatus };
@@ -194,15 +211,38 @@ export class PaymentsService {
await this.subscriptionsService.activateSubscription(payment.subscriptionId);
this.posthog.captureForUser(payment.userId, "payment_success", {
method: "eft",
payment_id: paymentId,
subscription_id: payment.subscriptionId,
amount: Number(payment.amount),
});
return { status: "completed" };
}
async rejectEft(paymentId: string, adminNote?: string) {
const [payment] = await this.db
.select()
.from(payments)
.where(eq(payments.id, paymentId))
.limit(1);
await this.db
.update(payments)
.set({ status: "failed", adminNote, updatedAt: new Date() })
.where(eq(payments.id, paymentId));
if (payment) {
this.posthog.captureForUser(payment.userId, "payment_failed", {
method: "eft",
payment_id: paymentId,
subscription_id: payment.subscriptionId,
amount: Number(payment.amount),
reason: adminNote ?? "rejected_by_admin",
});
}
return { status: "failed" };
}

View File

@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PostHogService } from "./posthog.service";
@Global()
@Module({
providers: [PostHogService],
exports: [PostHogService],
})
export class PostHogModule {}

View File

@@ -0,0 +1,64 @@
import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { PostHog as PostHogClient } from "posthog-node";
@Injectable()
export class PostHogService implements OnModuleDestroy {
private readonly logger = new Logger(PostHogService.name);
private client: PostHogClient | null = null;
private enabled = false;
constructor(private configService: ConfigService) {
const apiKey = this.configService.get<string>("POSTHOG_API_KEY");
if (apiKey) {
this.enabled = true;
// Lazily import posthog-node to avoid requiring it when not configured
this.initClient(apiKey);
} else {
this.logger.warn("PostHog API key not configured — server-side analytics disabled");
}
}
private async initClient(apiKey: string): Promise<void> {
try {
const { PostHog } = await import("posthog-node");
const host = this.configService.get<string>("POSTHOG_HOST") ?? "https://t.sase.tr";
this.client = new PostHog(apiKey, { host, flushAt: 1, flushInterval: 1000 });
this.logger.log("PostHog server-side client initialized");
} catch (err) {
this.logger.error("Failed to initialize PostHog client", (err as Error).stack);
this.enabled = false;
}
}
async onModuleDestroy(): Promise<void> {
if (this.client) {
try {
await this.client.shutdown();
} catch {
// Ignore shutdown errors
}
}
}
/**
* Capture a PostHog event. Fire-and-forget — analytics loss is acceptable.
* Uses distinctId from frontend identity when provided; otherwise uses "server".
*/
capture(event: string, properties?: Record<string, unknown>, distinctId = "server"): void {
if (!this.enabled || !this.client) return;
try {
this.client.capture({ distinctId, event, properties });
} catch (err) {
this.logger.warn(`PostHog capture failed for event "${event}"`, (err as Error).message);
}
}
/**
* Associate event with a specific user by their ID.
*/
captureForUser(userId: string, event: string, properties?: Record<string, unknown>): void {
this.capture(event, { ...properties, $user_id: userId }, userId);
}
}