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');