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
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:
@@ -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",
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
9
apps/api/src/posthog/posthog.module.ts
Normal file
9
apps/api/src/posthog/posthog.module.ts
Normal 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 {}
|
||||
64
apps/api/src/posthog/posthog.service.ts
Normal file
64
apps/api/src/posthog/posthog.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user