Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/strong-wings-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
5 changes: 3 additions & 2 deletions server/public/admin-analytics.html
Original file line number Diff line number Diff line change
Expand Up @@ -468,8 +468,9 @@ <h2>Full Breakdown (Company Type × Revenue Tier)</h2>
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';

Expand Down
27 changes: 25 additions & 2 deletions server/src/billing/stripe-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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');
}
}

Expand Down
24 changes: 24 additions & 0 deletions server/src/db/migrations/168_fix_product_revenue_null_names.sql
Original file line number Diff line number Diff line change
@@ -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.';
48 changes: 36 additions & 12 deletions server/src/db/org-filters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
)
)`;

Expand All @@ -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)
)
)
)
)`;

Expand Down Expand Up @@ -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)
)
)
)
)`;

Expand All @@ -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)
)
)
)
)`;

Expand Down
15 changes: 13 additions & 2 deletions server/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -3099,6 +3100,7 @@ export class HTTPServer {
});

if ('deleted' in customer && customer.deleted) {
customersSkipped++;
continue;
}

Expand Down Expand Up @@ -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
}
}
Expand All @@ -3168,6 +3177,7 @@ export class HTTPServer {
processed: imported,
subscriptionsSynced,
subscriptionsFailed,
customersSkipped,
}, 'Revenue backfill completed');

res.json({
Expand All @@ -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');
Expand Down