From eddd2c979aff5a7f402c81ff621043e55cbe1fde Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 7 May 2026 09:57:30 +0200 Subject: [PATCH] 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 --- .../core-modules/audit/types/events.type.ts | 14 +++++++++- .../billing/payment-received.ts | 15 +++++++++++ .../workspace/workspace-created.ts | 13 +++++++++ .../auth/services/sign-in-up.service.spec.ts | 5 ++++ .../auth/services/sign-in-up.service.ts | 7 +++++ .../billing-webhook/billing-webhook.module.ts | 2 ++ .../billing-webhook-invoice.service.ts | 27 ++++++++++++------- 7 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received.ts create mode 100644 packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created.ts diff --git a/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts b/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts index 91fc285373..1a85aafe79 100644 --- a/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts +++ b/packages/twenty-server/src/engine/core-modules/audit/types/events.type.ts @@ -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 = diff --git a/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received.ts b/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received.ts new file mode 100644 index 0000000000..31c90b390c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/billing/payment-received.ts @@ -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; + +registerEvent(PAYMENT_RECEIVED_EVENT, paymentReceivedSchema); diff --git a/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created.ts b/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created.ts new file mode 100644 index 0000000000..42fba30ad6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/audit/utils/events/workspace-event/workspace/workspace-created.ts @@ -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; + +registerEvent(WORKSPACE_CREATED_EVENT, workspaceCreatedSchema); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts index 6290bd5fb9..bc48cdc787 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.spec.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts index 5d33841516..0a6bef8389 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/sign-in-up.service.ts @@ -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) { diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts index 883543e0a8..1bd60cc0e7 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/billing-webhook.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts index 9e453f0cce..e5ebff1aa9 100644 --- a/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts +++ b/packages/twenty-server/src/engine/core-modules/billing-webhook/services/billing-webhook-invoice.service.ts @@ -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 { - const billingCustomer = await this.billingCustomerRepository.findOne({ - where: { stripeCustomerId }, - }); - - if (!isDefined(billingCustomer)) { - return; - } - const workspace = await this.workspaceRepository.findOne({ where: { id: billingCustomer.workspaceId,