Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/components/dialog/content/setting/UsageLogsTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,14 @@ import {
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
import { useTopupTrackerStore } from '@/stores/topupTrackerStore'

const events = ref<AuditLog[]>([])
const loading = ref(true)
const error = ref<string | null>(null)

const customerEventService = useCustomerEventsService()
const topupTracker = useTopupTrackerStore()

const pagination = ref({
page: 1,
Expand Down Expand Up @@ -159,6 +161,8 @@ const loadEvents = async () => {
if (response.totalPages) {
pagination.value.totalPages = response.totalPages
}

void topupTracker.reconcileWithEvents(response.events)
} else {
error.value = customerEventService.error.value || 'Failed to load events'
}
Expand Down
7 changes: 5 additions & 2 deletions src/composables/auth/useFirebaseAuthActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useTopupTrackerStore } from '@/stores/topupTrackerStore'
import { usdToMicros } from '@/utils/formatUtil'

/**
Expand Down Expand Up @@ -98,7 +99,7 @@ export const useFirebaseAuthActions = () => {
)
}

// Go to Stripe checkout page
useTopupTrackerStore().startTopup(amount)
window.open(response.checkout_url, '_blank')
}, reportError)

Expand All @@ -115,7 +116,9 @@ export const useFirebaseAuthActions = () => {
}, reportError)

const fetchBalance = wrapWithErrorHandlingAsync(async () => {
return await authStore.fetchBalance()
const result = await authStore.fetchBalance()
void useTopupTrackerStore().reconcileByFetchingEvents()
return result
}, reportError)

const signInWithGoogle = (errorHandler = reportError) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { OverridedMixpanel } from 'mixpanel-browser'

import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
Expand Down Expand Up @@ -282,6 +283,27 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName)
}

trackAddApiCreditButtonClicked(): void {
this.trackEvent(TelemetryEvents.ADD_API_CREDIT_BUTTON_CLICKED)
}

trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}

trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = {
credit_amount: amount
}
this.trackEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}

trackApiCreditTopupSucceeded(): void {
this.trackEvent(TelemetryEvents.API_CREDIT_TOPUP_SUCCEEDED)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
if (this.isOnboardingMode) {
// During onboarding, track basic run button click without workflow context
Expand Down
17 changes: 17 additions & 0 deletions src/platform/telemetry/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ export interface TemplateMetadata {
template_license?: string
}

/**
* Credit topup metadata
*/
export interface CreditTopupMetadata {
credit_amount: number
}

/**
* Workflow import metadata
*/
Expand Down Expand Up @@ -169,6 +176,10 @@ export interface TelemetryProvider {

// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackMonthlySubscriptionSucceeded(): void
trackAddApiCreditButtonClicked(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackApiCreditTopupSucceeded(): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void

// Survey flow events
Expand Down Expand Up @@ -221,6 +232,11 @@ export const TelemetryEvents = {
RUN_BUTTON_CLICKED: 'app:run_button_click',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
ADD_API_CREDIT_BUTTON_CLICKED: 'app:add_api_credit_button_clicked',
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
API_CREDIT_TOPUP_SUCCEEDED: 'app:api_credit_topup_succeeded',

// Onboarding Survey
USER_SURVEY_OPENED: 'app:user_survey_opened',
Expand Down Expand Up @@ -267,6 +283,7 @@ export type TelemetryEventProperties =
| RunButtonProperties
| ExecutionErrorMetadata
| ExecutionSuccessMetadata
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
| TemplateLibraryClosedMetadata
Expand Down
4 changes: 3 additions & 1 deletion src/services/customerEventsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,16 @@ export const useCustomerEventsService = () => {
return null
}

return executeRequest<CustomerEventsResponse>(
const result = await executeRequest<CustomerEventsResponse>(
() =>
customerApiClient.get('/customers/events', {
params: { page, limit },
headers: authHeaders
}),
{ errorContext, routeSpecificErrors }
)

return result
}

return {
Expand Down
155 changes: 155 additions & 0 deletions src/stores/topupTrackerStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'

import { useTelemetry } from '@/platform/telemetry'
import type { AuditLog } from '@/services/customerEventsService'
import {
EventType,
useCustomerEventsService
} from '@/services/customerEventsService'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'

type PendingTopupRecord = {
startedAtIso: string
amountUsd?: number
expectedCents?: number
}

const storageKeyForUser = (userId: string) => `topupTracker:pending:${userId}`

export const useTopupTrackerStore = defineStore('topupTracker', () => {
const telemetry = useTelemetry()
const authStore = useFirebaseAuthStore()
const pendingTopup = ref<PendingTopupRecord | null>(null)
const storageListenerInitialized = ref(false)

const loadFromStorage = () => {
const userId = authStore.userId
if (!userId) return
try {
const rawValue = localStorage.getItem(storageKeyForUser(userId))
if (!rawValue) return
const parsedValue = JSON.parse(rawValue) as PendingTopupRecord
pendingTopup.value = parsedValue
} catch {
pendingTopup.value = null
}
}

const persistToStorage = () => {
const userId = authStore.userId
if (!userId) return
if (pendingTopup.value) {
localStorage.setItem(
storageKeyForUser(userId),
JSON.stringify(pendingTopup.value)
)
} else {
localStorage.removeItem(storageKeyForUser(userId))
}
}

const initializeStorageSynchronization = () => {
if (storageListenerInitialized.value) return
storageListenerInitialized.value = true
loadFromStorage()
window.addEventListener('storage', (e: StorageEvent) => {
const userId = authStore.userId
if (!userId) return
if (e.key === storageKeyForUser(userId)) {
loadFromStorage()
}
})

watch(
() => authStore.userId,
(newUserId, oldUserId) => {
if (newUserId && newUserId !== oldUserId) {
loadFromStorage()
return
}
if (!newUserId && oldUserId) {
pendingTopup.value = null
}
}
)
}

const startTopup = (amountUsd: number) => {
const userId = authStore.userId
if (!userId) return
const expectedCents = Math.round(amountUsd * 100)
pendingTopup.value = {
startedAtIso: new Date().toISOString(),
amountUsd,
expectedCents
}
persistToStorage()
}

const clearTopup = () => {
pendingTopup.value = null
persistToStorage()
}

const reconcileWithEvents = async (
events: AuditLog[] | undefined | null
): Promise<boolean> => {
if (!events || events.length === 0) return false
if (!pendingTopup.value) return false

const startedAt = new Date(pendingTopup.value.startedAtIso)
if (Number.isNaN(+startedAt)) {
clearTopup()
return false
}

const withinWindow = (createdAt: string) => {
const created = new Date(createdAt)
if (Number.isNaN(+created)) return false
const maxAgeMs = 1000 * 60 * 60 * 24
return (
created >= startedAt &&
created.getTime() - startedAt.getTime() <= maxAgeMs
)
}

let matched = events.filter((e) => {
if (e.event_type !== EventType.CREDIT_ADDED) return false
if (!e.createdAt || !withinWindow(e.createdAt)) return false
return true
})

if (pendingTopup.value.expectedCents != null) {
matched = matched.filter((e) =>
typeof e.params?.amount === 'number'
? e.params.amount === pendingTopup.value?.expectedCents
: true
)
}

if (matched.length === 0) return false

telemetry?.trackApiCreditTopupSucceeded()
await authStore.fetchBalance().catch(() => {})
clearTopup()
return true
}

const reconcileByFetchingEvents = async (): Promise<boolean> => {
const service = useCustomerEventsService()
const response = await service.getMyEvents({ page: 1, limit: 10 })
Comment on lines +139 to +141

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid using useCustomerEventsService outside component context

The new reconcileByFetchingEvents action instantiates useCustomerEventsService(), but that composable calls useI18n() internally. useI18n requires an active Vue component instance, which is not present when reconcileByFetchingEvents runs (it is invoked from authActions.fetchBalance() and other event handlers after setup). This will throw getCurrentInstance()/useI18n errors as soon as fetchBalance is called, breaking balance refresh and the top‑up reconciliation flow. Consider moving the service call into a component/composable scope or refactoring the service to avoid useI18n when called from Pinia stores.

Useful? React with 👍 / 👎.

if (!response) return false
return await reconcileWithEvents(response.events)
}

initializeStorageSynchronization()

return {
pendingTopup,
startTopup,
clearTopup,
reconcileWithEvents,
reconcileByFetchingEvents
}
})
Loading