diff --git a/.changeset/strong-wings-cough.md b/.changeset/strong-wings-cough.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/strong-wings-cough.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/server/public/admin-analytics.html b/server/public/admin-analytics.html index a583ec78..bf8155e3 100644 --- a/server/public/admin-analytics.html +++ b/server/public/admin-analytics.html @@ -468,8 +468,9 @@

Full Breakdown (Company Type × Revenue Tier)

const result = await response.json(); const subsSyncedMsg = result.subscriptions_synced ? ` Synced ${result.subscriptions_synced} subscriptions.` : ''; - const subsFailedMsg = result.subscriptions_failed ? ` (${result.subscriptions_failed} failed)` : ''; - messageEl.textContent = `Sync complete: Found ${result.invoices_found} invoices and ${result.refunds_found} refunds. Imported ${result.imported} new records, ${result.skipped} already existed.${subsSyncedMsg}${subsFailedMsg}`; + const subsSkippedMsg = result.customers_skipped ? ` Skipped ${result.customers_skipped} deleted customers.` : ''; + const subsFailedMsg = result.subscriptions_failed ? ` (${result.subscriptions_failed} errors)` : ''; + messageEl.textContent = `Sync complete: Found ${result.invoices_found} invoices and ${result.refunds_found} refunds. Processed ${result.processed} records.${subsSyncedMsg}${subsSkippedMsg}${subsFailedMsg}`; messageEl.style.display = 'block'; messageEl.className = 'success-message'; diff --git a/server/src/billing/stripe-client.ts b/server/src/billing/stripe-client.ts index aed0c756..9271a3af 100644 --- a/server/src/billing/stripe-client.ts +++ b/server/src/billing/stripe-client.ts @@ -637,9 +637,10 @@ export async function fetchAllPaidInvoices( const productObj = await stripe.products.retrieve(product); cachedProduct = { id: productObj.id, name: productObj.name }; productCache.set(product, cachedProduct); - } catch { + } catch (productFetchError) { + // Log the failure so we can diagnose why product names are missing + logger.warn({ productId: product, invoiceId: invoice.id, err: productFetchError }, 'Failed to fetch product for invoice'); // Use description as fallback, don't cache failures - // Leave cachedProduct as undefined to use fallback below } } productName = cachedProduct?.name || primaryLine.description || null; @@ -649,6 +650,28 @@ export async function fetchAllPaidInvoices( // Cache the expanded product object productCache.set(product.id, { id: product.id, name: product.name }); } + } else { + // Log when there's no price on the line item + logger.warn({ invoiceId: invoice.id, lineItemId: primaryLine.id }, 'Invoice line item has no price'); + } + } else { + // Log when invoice has no line items + logger.warn({ invoiceId: invoice.id }, 'Invoice has no line items'); + } + + // Try additional fallbacks for product name + if (!productName) { + // Try invoice description (for manually created invoices) + if (invoice.description) { + productName = invoice.description; + } + // Try subscription metadata product_name (if set during checkout) + else if (typeof invoice.subscription === 'object' && invoice.subscription?.metadata?.product_name) { + productName = invoice.subscription.metadata.product_name; + } + // Log if we still couldn't get a product name for a paid invoice + else if (invoice.amount_paid > 0) { + logger.warn({ invoiceId: invoice.id, productId, hasDescription: !!primaryLine?.description }, 'Invoice has no product name after all fallbacks'); } } diff --git a/server/src/db/migrations/168_fix_product_revenue_null_names.sql b/server/src/db/migrations/168_fix_product_revenue_null_names.sql new file mode 100644 index 00000000..5faae5b7 --- /dev/null +++ b/server/src/db/migrations/168_fix_product_revenue_null_names.sql @@ -0,0 +1,24 @@ +-- Migration: Fix product_revenue view to handle NULL product names +-- Previously, the view filtered out records where product_name IS NULL, +-- which caused "No product data yet" to display when product names weren't captured. +-- This fix uses COALESCE to provide a fallback name. + +DROP VIEW IF EXISTS product_revenue; + +-- Recreate product_revenue view with fallback for NULL product names +CREATE VIEW product_revenue AS +SELECT + COALESCE(re.product_name, 'Unlabeled Product') AS product_name, + re.product_id, + COUNT(DISTINCT re.workos_organization_id) AS customer_count, + SUM(re.amount_paid) / 100.0 AS total_revenue, + AVG(re.amount_paid) / 100.0 AS avg_revenue_per_customer, + MIN(re.paid_at) AS first_sale, + MAX(re.paid_at) AS last_sale +FROM revenue_events re +WHERE re.paid_at IS NOT NULL + AND re.amount_paid > 0 +GROUP BY COALESCE(re.product_name, 'Unlabeled Product'), re.product_id +ORDER BY total_revenue DESC; + +COMMENT ON VIEW product_revenue IS 'Revenue breakdown by product/SKU, aggregated from revenue_events. Uses "Unlabeled Product" for records without product names.'; diff --git a/server/src/db/org-filters.ts b/server/src/db/org-filters.ts index a8ebf2ba..a9b68d42 100644 --- a/server/src/db/org-filters.ts +++ b/server/src/db/org-filters.ts @@ -34,11 +34,17 @@ export const HAS_USER = `( ) OR EXISTS ( SELECT 1 FROM slack_user_mappings sm - WHERE organizations.email_domain IS NOT NULL - AND sm.pending_organization_id IS NULL - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(organizations.email_domain) + WHERE sm.pending_organization_id IS NULL AND sm.slack_is_bot = false AND sm.slack_is_deleted = false + AND ( + (organizations.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(organizations.email_domain)) + OR EXISTS ( + SELECT 1 FROM organization_domains od + WHERE od.workos_organization_id = organizations.workos_organization_id + AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + ) + ) ) )`; @@ -61,12 +67,18 @@ export const HAS_ENGAGED_USER = `( OR EXISTS ( SELECT 1 FROM slack_user_mappings sm JOIN slack_activity_daily sad ON sad.slack_user_id = sm.slack_user_id - WHERE organizations.email_domain IS NOT NULL - AND sm.pending_organization_id IS NULL - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(organizations.email_domain) + WHERE sm.pending_organization_id IS NULL AND sm.slack_is_bot = false AND sm.slack_is_deleted = false AND sad.activity_date >= CURRENT_DATE - INTERVAL '30 days' + AND ( + (organizations.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(organizations.email_domain)) + OR EXISTS ( + SELECT 1 FROM organization_domains od + WHERE od.workos_organization_id = organizations.workos_organization_id + AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + ) + ) ) )`; @@ -100,11 +112,17 @@ export const HAS_USER_ALIASED = `( ) OR EXISTS ( SELECT 1 FROM slack_user_mappings sm - WHERE o.email_domain IS NOT NULL - AND sm.pending_organization_id IS NULL - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(o.email_domain) + WHERE sm.pending_organization_id IS NULL AND sm.slack_is_bot = false AND sm.slack_is_deleted = false + AND ( + (o.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(o.email_domain)) + OR EXISTS ( + SELECT 1 FROM organization_domains od + WHERE od.workos_organization_id = o.workos_organization_id + AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + ) + ) ) )`; @@ -127,12 +145,18 @@ export const HAS_ENGAGED_USER_ALIASED = `( OR EXISTS ( SELECT 1 FROM slack_user_mappings sm JOIN slack_activity_daily sad ON sad.slack_user_id = sm.slack_user_id - WHERE o.email_domain IS NOT NULL - AND sm.pending_organization_id IS NULL - AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(o.email_domain) + WHERE sm.pending_organization_id IS NULL AND sm.slack_is_bot = false AND sm.slack_is_deleted = false AND sad.activity_date >= CURRENT_DATE - INTERVAL '30 days' + AND ( + (o.email_domain IS NOT NULL AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(o.email_domain)) + OR EXISTS ( + SELECT 1 FROM organization_domains od + WHERE od.workos_organization_id = o.workos_organization_id + AND LOWER(SPLIT_PART(sm.slack_email, '@', 2)) = LOWER(od.domain) + ) + ) ) )`; diff --git a/server/src/http.ts b/server/src/http.ts index 6826965f..417fc390 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -3090,6 +3090,7 @@ export class HTTPServer { // This populates subscription_amount, subscription_interval, subscription_current_period_end let subscriptionsSynced = 0; let subscriptionsFailed = 0; + let customersSkipped = 0; // Deleted or missing customers if (stripe) { for (const [customerId, workosOrgId] of customerOrgMap) { try { @@ -3099,6 +3100,7 @@ export class HTTPServer { }); if ('deleted' in customer && customer.deleted) { + customersSkipped++; continue; } @@ -3155,8 +3157,15 @@ export class HTTPServer { subscriptionsSynced++; logger.debug({ workosOrgId, customerId, amount, interval }, 'Synced subscription data'); } catch (subError) { - subscriptionsFailed++; - logger.error({ err: subError, customerId, workosOrgId }, 'Failed to sync subscription for customer'); + // Handle Stripe "resource_missing" errors (deleted customers) gracefully + // Use Stripe's error type for better type safety + if (subError instanceof Stripe.errors.StripeInvalidRequestError && subError.code === 'resource_missing') { + customersSkipped++; + logger.debug({ customerId, workosOrgId }, 'Skipped missing/deleted Stripe customer'); + } else { + subscriptionsFailed++; + logger.error({ err: subError, customerId, workosOrgId }, 'Failed to sync subscription for customer'); + } // Continue with other customers } } @@ -3168,6 +3177,7 @@ export class HTTPServer { processed: imported, subscriptionsSynced, subscriptionsFailed, + customersSkipped, }, 'Revenue backfill completed'); res.json({ @@ -3178,6 +3188,7 @@ export class HTTPServer { processed: imported, subscriptions_synced: subscriptionsSynced, subscriptions_failed: subscriptionsFailed, + customers_skipped: customersSkipped, }); } catch (error) { logger.error({ err: error }, 'Error during revenue backfill');