From 4d9a566a1372086c89df2eb6eefc710621e007c6 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 31 Oct 2025 21:57:59 -0700 Subject: [PATCH] Implement topupTrackerStore (cherry picked from commit fbf2aeb54f6c82afcdb4717f49bca28d9d869ab5) --- .../dialog/content/setting/UsageLogsTable.vue | 4 + .../auth/useFirebaseAuthActions.ts | 7 +- src/stores/topupTrackerStore.ts | 155 ++++++++++++++++++ 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/stores/topupTrackerStore.ts diff --git a/src/components/dialog/content/setting/UsageLogsTable.vue b/src/components/dialog/content/setting/UsageLogsTable.vue index ba70658700..4936bbf8d6 100644 --- a/src/components/dialog/content/setting/UsageLogsTable.vue +++ b/src/components/dialog/content/setting/UsageLogsTable.vue @@ -101,12 +101,14 @@ import { EventType, useCustomerEventsService } from '@/services/customerEventsService' +import { useTopupTrackerStore } from '@/stores/topupTrackerStore' const events = ref([]) const loading = ref(true) const error = ref(null) const customerEventService = useCustomerEventsService() +const topupTracker = useTopupTrackerStore() const pagination = ref({ page: 1, @@ -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' } diff --git a/src/composables/auth/useFirebaseAuthActions.ts b/src/composables/auth/useFirebaseAuthActions.ts index 1e80416332..61307ca983 100644 --- a/src/composables/auth/useFirebaseAuthActions.ts +++ b/src/composables/auth/useFirebaseAuthActions.ts @@ -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' /** @@ -98,7 +99,7 @@ export const useFirebaseAuthActions = () => { ) } - // Go to Stripe checkout page + useTopupTrackerStore().startTopup(amount) window.open(response.checkout_url, '_blank') }, reportError) @@ -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) => diff --git a/src/stores/topupTrackerStore.ts b/src/stores/topupTrackerStore.ts new file mode 100644 index 0000000000..3094e9681e --- /dev/null +++ b/src/stores/topupTrackerStore.ts @@ -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(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 => { + 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 => { + const service = useCustomerEventsService() + const response = await service.getMyEvents({ page: 1, limit: 10 }) + if (!response) return false + return await reconcileWithEvents(response.events) + } + + initializeStorageSynchronization() + + return { + pendingTopup, + startTopup, + clearTopup, + reconcileWithEvents, + reconcileByFetchingEvents + } +})