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);
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
FileText,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Brand {
|
||||
id: string;
|
||||
@@ -36,6 +36,7 @@ interface PaymentContentProps {
|
||||
planKey: string;
|
||||
period: "monthly" | "yearly";
|
||||
brandIds: string[];
|
||||
result?: string;
|
||||
}
|
||||
|
||||
const planConfig: Record<string, { priceMonthly: number; priceYearly: number }> = {
|
||||
@@ -54,7 +55,7 @@ const bankDetails = {
|
||||
|
||||
type Step = "summary" | "payment" | "confirmation";
|
||||
|
||||
export function PaymentContent({ planKey, period, brandIds }: PaymentContentProps) {
|
||||
export function PaymentContent({ planKey, period, brandIds, result }: PaymentContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -65,6 +66,18 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [eftPaymentId, setEftPaymentId] = useState<string | null>(null);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when result changes
|
||||
useEffect(() => {
|
||||
if (!result) return;
|
||||
if (result === "success") {
|
||||
capture("payment_success", { method: "iyzico", plan: planKey, period, amount: totalAmount });
|
||||
setStep("confirmation");
|
||||
} else {
|
||||
capture("payment_failed", { method: "iyzico", plan: planKey, period, reason: result });
|
||||
toast.error(t("payment.paymentFailed"));
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const config = planConfig[planKey];
|
||||
const totalAmount = config
|
||||
? period === "monthly"
|
||||
@@ -119,6 +132,13 @@ export function PaymentContent({ planKey, period, brandIds }: PaymentContentProp
|
||||
return api.upload<{ success: boolean }>(`/payments/eft/${eftPaymentId}/receipt`, formData);
|
||||
},
|
||||
onSuccess: () => {
|
||||
capture("payment_success", {
|
||||
method: "eft",
|
||||
plan: planKey,
|
||||
period,
|
||||
amount: totalAmount,
|
||||
payment_id: eftPaymentId,
|
||||
});
|
||||
toast.success(t("payment.receiptUploaded"));
|
||||
setStep("confirmation");
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,16 +9,26 @@ const PaymentContent = lazy(() =>
|
||||
);
|
||||
|
||||
export const Route = createFileRoute("/dashboard/subscription/pay")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
plan: (search.plan as string) || "",
|
||||
period: ((search.period as string) || "monthly") as "monthly" | "yearly",
|
||||
brands: (search.brands as string) || "",
|
||||
}),
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
const params: {
|
||||
plan: string;
|
||||
period: "monthly" | "yearly";
|
||||
brands: string;
|
||||
result?: string;
|
||||
} = {
|
||||
plan: (search.plan as string) || "",
|
||||
period: ((search.period as string) || "monthly") as "monthly" | "yearly",
|
||||
brands: (search.brands as string) || "",
|
||||
};
|
||||
const result = search.result as string | undefined;
|
||||
if (result) params.result = result;
|
||||
return params;
|
||||
},
|
||||
component: PaymentPage,
|
||||
});
|
||||
|
||||
function PaymentPage() {
|
||||
const { plan, period, brands } = Route.useSearch();
|
||||
const { plan, period, brands, result } = Route.useSearch();
|
||||
const brandIds = brands.split(",").filter(Boolean);
|
||||
|
||||
return (
|
||||
@@ -37,7 +47,7 @@ function PaymentPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PaymentContent planKey={plan} period={period} brandIds={brandIds} />
|
||||
<PaymentContent planKey={plan} period={period} brandIds={brandIds} result={result} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user