Add Workspace Created and Payment Received ClickHouse events (#20277)
## Summary - Adds two new workspace audit events for the AARRR funnel tracked in ClickHouse - **Workspace Created**: emitted in `signUpOnNewWorkspace` after the transaction commits, capturing every new workspace creation - **Payment Received**: emitted in `processInvoicePaid` on every Stripe `invoice.paid` webhook, with `stripeInvoiceId`, `amountPaid`, and `billingReason` properties. First payment per workspace can be derived at query time via `min(timestamp)` grouped by `workspaceId` ## Test plan - [x] Verify `Workspace Created` event appears in ClickHouse after signing up on a new workspace - [x] Verify `Payment Received` event appears in ClickHouse after a Stripe `invoice.paid` webhook fires - [x] Confirm no event is emitted if the billing customer cannot be resolved from `stripeCustomerId` - [x] Run existing `SignInUpService` unit tests pass with the new `AuditService` mock Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -38,6 +38,14 @@ import {
|
||||
type WEBHOOK_RESPONSE_EVENT,
|
||||
type WebhookResponseTrackEvent,
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/webhook/webhook-response';
|
||||
import {
|
||||
type PAYMENT_RECEIVED_EVENT,
|
||||
type PaymentReceivedTrackEvent,
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received';
|
||||
import {
|
||||
type WORKSPACE_CREATED_EVENT,
|
||||
type WorkspaceCreatedTrackEvent,
|
||||
} from 'src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created';
|
||||
import {
|
||||
type WORKSPACE_ENTITY_CREATED_EVENT,
|
||||
type WorkspaceEntityCreatedTrackEvent,
|
||||
@@ -55,7 +63,9 @@ export type TrackEventName =
|
||||
| typeof OBJECT_RECORD_UPDATED_EVENT
|
||||
| typeof OBJECT_RECORD_DELETED_EVENT
|
||||
| typeof OBJECT_RECORD_UPSERTED_EVENT
|
||||
| typeof USER_SIGNUP_EVENT;
|
||||
| typeof USER_SIGNUP_EVENT
|
||||
| typeof WORKSPACE_CREATED_EVENT
|
||||
| typeof PAYMENT_RECEIVED_EVENT;
|
||||
|
||||
// Map event names to their corresponding event types
|
||||
export interface TrackEvents {
|
||||
@@ -70,6 +80,8 @@ export interface TrackEvents {
|
||||
[OBJECT_RECORD_CREATED_EVENT]: ObjectRecordCreatedTrackEvent;
|
||||
[OBJECT_RECORD_UPDATED_EVENT]: ObjectRecordUpdatedTrackEvent;
|
||||
[OBJECT_RECORD_UPSERTED_EVENT]: ObjectRecordUpsertedTrackEvent;
|
||||
[WORKSPACE_CREATED_EVENT]: WorkspaceCreatedTrackEvent;
|
||||
[PAYMENT_RECEIVED_EVENT]: PaymentReceivedTrackEvent;
|
||||
}
|
||||
|
||||
export type TrackEventProperties<T extends TrackEventName> =
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { registerEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
|
||||
|
||||
export const PAYMENT_RECEIVED_EVENT = 'Payment Received' as const;
|
||||
export const paymentReceivedSchema = z.strictObject({
|
||||
event: z.literal(PAYMENT_RECEIVED_EVENT),
|
||||
properties: z.strictObject({
|
||||
amountPaid: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type PaymentReceivedTrackEvent = z.infer<typeof paymentReceivedSchema>;
|
||||
|
||||
registerEvent(PAYMENT_RECEIVED_EVENT, paymentReceivedSchema);
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { registerEvent } from 'src/engine/core-modules/audit/utils/events/workspace-event/track';
|
||||
|
||||
export const WORKSPACE_CREATED_EVENT = 'Workspace Created' as const;
|
||||
export const workspaceCreatedSchema = z.strictObject({
|
||||
event: z.literal(WORKSPACE_CREATED_EVENT),
|
||||
properties: z.strictObject({}),
|
||||
});
|
||||
|
||||
export type WorkspaceCreatedTrackEvent = z.infer<typeof workspaceCreatedSchema>;
|
||||
|
||||
registerEvent(WORKSPACE_CREATED_EVENT, workspaceCreatedSchema);
|
||||
+5
@@ -100,6 +100,11 @@ const createSignInUpServiceForTests = () => {
|
||||
{
|
||||
isValid: jest.fn().mockReturnValue(false),
|
||||
} as any,
|
||||
{
|
||||
createContext: jest.fn().mockReturnValue({
|
||||
insertWorkspaceEvent: jest.fn(),
|
||||
}),
|
||||
} as any,
|
||||
{
|
||||
createQueryRunner: jest.fn(() => queryRunnerMock),
|
||||
} as any,
|
||||
|
||||
@@ -9,6 +9,8 @@ import { Repository, type DataSource, type QueryRunner } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { USER_SIGNUP_EVENT_NAME } from 'src/engine/api/graphql/workspace-query-runner/constants/user-signup-event-name.constants';
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { WORKSPACE_CREATED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created';
|
||||
import { type AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import {
|
||||
@@ -67,6 +69,7 @@ export class SignInUpService {
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly fileCorePictureService: FileCorePictureService,
|
||||
private readonly enterprisePlanService: EnterprisePlanService,
|
||||
private readonly auditService: AuditService,
|
||||
@InjectDataSource()
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
@@ -594,6 +597,10 @@ export class SignInUpService {
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
this.auditService
|
||||
.createContext({ workspaceId })
|
||||
.insertWorkspaceEvent(WORKSPACE_CREATED_EVENT, {});
|
||||
|
||||
return { user, workspace };
|
||||
} catch (error) {
|
||||
if (queryRunner.isTransactionActive) {
|
||||
|
||||
+2
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
|
||||
import { BillingWebhookController } from 'src/engine/core-modules/billing-webhook/billing-webhook.controller';
|
||||
import { BillingWebhookAlertService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-alert.service';
|
||||
import { BillingWebhookCreditGrantService } from 'src/engine/core-modules/billing-webhook/services/billing-webhook-credit-grant.service';
|
||||
@@ -32,6 +33,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuditModule,
|
||||
FeatureFlagModule,
|
||||
StripeModule,
|
||||
MessageQueueModule,
|
||||
|
||||
+17
-10
@@ -8,6 +8,8 @@ import { type Repository } from 'typeorm';
|
||||
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
|
||||
import { PAYMENT_RECEIVED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received';
|
||||
import { getSubscriptionIdFromInvoice } from 'src/engine/core-modules/billing-webhook/utils/get-subscription-id-from-invoice.util';
|
||||
import {
|
||||
BillingException,
|
||||
@@ -41,6 +43,7 @@ export class BillingWebhookInvoiceService {
|
||||
private readonly billingCreditRolloverService: BillingCreditRolloverService,
|
||||
private readonly meteredCreditService: MeteredCreditService,
|
||||
private readonly stripeInvoiceService: StripeInvoiceService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
async processStripeEvent(
|
||||
@@ -170,7 +173,19 @@ export class BillingWebhookInvoiceService {
|
||||
paidInvoicePeriodEnd,
|
||||
);
|
||||
|
||||
await this.delaySuspendedWorkspaceCleanup(stripeCustomerId);
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne({
|
||||
where: { stripeCustomerId },
|
||||
});
|
||||
|
||||
if (isDefined(billingCustomer)) {
|
||||
await this.delaySuspendedWorkspaceCleanup(billingCustomer);
|
||||
|
||||
this.auditService
|
||||
.createContext({ workspaceId: billingCustomer.workspaceId })
|
||||
.insertWorkspaceEvent(PAYMENT_RECEIVED_EVENT, {
|
||||
amountPaid: data.object.amount_paid,
|
||||
});
|
||||
}
|
||||
|
||||
return { stripeSubscriptionId };
|
||||
}
|
||||
@@ -204,16 +219,8 @@ export class BillingWebhookInvoiceService {
|
||||
}
|
||||
|
||||
private async delaySuspendedWorkspaceCleanup(
|
||||
stripeCustomerId: string,
|
||||
billingCustomer: BillingCustomerEntity,
|
||||
): Promise<void> {
|
||||
const billingCustomer = await this.billingCustomerRepository.findOne({
|
||||
where: { stripeCustomerId },
|
||||
});
|
||||
|
||||
if (!isDefined(billingCustomer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = await this.workspaceRepository.findOne({
|
||||
where: {
|
||||
id: billingCustomer.workspaceId,
|
||||
|
||||
Reference in New Issue
Block a user