fix: simply billing

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-05 09:03:03 +01:00
parent bbd30ca6e0
commit d9e3883d11
53 changed files with 1543 additions and 1240 deletions

View File

@@ -261,6 +261,62 @@ export class Query<T = any> {
return this;
}
/**
* Safe version of fill that only applies WITH FILL if the date range is valid
* Prevents ClickHouse errors when TO value is less than FROM value
*/
safeFill(
from: string | Date | Expression,
to: string | Date | Expression,
step: string | Expression,
): this {
// Check if the date range is valid
const isValid = this.isValidDateRange(from, to);
if (isValid) {
return this.fill(from, to, step);
}
// Skip fill if date range is invalid
return this;
}
private isValidDateRange(
from: string | Date | Expression,
to: string | Date | Expression,
): boolean {
try {
// If either is an Expression, assume it's valid (can't easily parse)
if (from instanceof Expression || to instanceof Expression) {
return true;
}
let fromDate: Date;
let toDate: Date;
if (from instanceof Date) {
fromDate = from;
} else if (typeof from === 'string') {
// Try parsing various date formats
fromDate = new Date(from);
} else {
return true; // Can't determine, assume valid
}
if (to instanceof Date) {
toDate = to;
} else if (typeof to === 'string') {
toDate = new Date(to);
} else {
return true; // Can't determine, assume valid
}
// Check if dates are valid and to is after from
return !isNaN(fromDate.getTime()) && !isNaN(toDate.getTime()) && toDate > fromDate;
} catch {
// If any error, assume valid to avoid breaking existing functionality
return true;
}
}
private escapeDate(value: string | Date): string {
if (value instanceof Date) {
return sqlstring.escape(clix.datetime(value));

View File

@@ -117,6 +117,22 @@ const getPrismaClient = () => {
return new Date(Date.now() + 1000 * 60 * 60 * 24);
},
},
isActive: {
needs: {
subscriptionStatus: true,
subscriptionEndsAt: true,
subscriptionCanceledAt: true,
},
compute(org) {
return (
org.subscriptionStatus === 'active' &&
org.subscriptionEndsAt &&
org.subscriptionEndsAt > new Date() &&
!isCanceled(org) &&
!isWillBeCanceled(org)
);
},
},
isTrial: {
needs: { subscriptionStatus: true, subscriptionEndsAt: true },
compute(org) {

View File

@@ -11,6 +11,21 @@ import type {
import { TABLE_NAMES, formatClickhouseDate } from '../clickhouse/client';
import { createSqlBuilder } from '../sql-builder';
/**
* Helper function to check if endDate is after startDate
* This prevents ClickHouse errors when WITH FILL TO value is less than FROM value
*/
function isValidDateRange(startDate: string, endDate: string): boolean {
try {
const start = DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss');
const end = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss');
return end > start;
} catch {
// If date parsing fails, assume invalid range
return false;
}
}
export function transformPropertyKey(property: string) {
const propertyPatterns = ['properties', 'profile.properties'];
const match = propertyPatterns.find((pattern) =>
@@ -103,29 +118,43 @@ export function getChartSql({
}
sb.select.count = 'count(*) as count';
// Only apply WITH FILL if the date range is valid (endDate > startDate)
const hasValidDateRange = isValidDateRange(startDate, endDate);
switch (interval) {
case 'minute': {
sb.fill = `FROM toStartOfMinute(toDateTime('${startDate}')) TO toStartOfMinute(toDateTime('${endDate}')) STEP toIntervalMinute(1)`;
if (hasValidDateRange) {
sb.fill = `FROM toStartOfMinute(toDateTime('${startDate}')) TO toStartOfMinute(toDateTime('${endDate}')) STEP toIntervalMinute(1)`;
}
sb.select.date = 'toStartOfMinute(created_at) as date';
break;
}
case 'hour': {
sb.fill = `FROM toStartOfHour(toDateTime('${startDate}')) TO toStartOfHour(toDateTime('${endDate}')) STEP toIntervalHour(1)`;
if (hasValidDateRange) {
sb.fill = `FROM toStartOfHour(toDateTime('${startDate}')) TO toStartOfHour(toDateTime('${endDate}')) STEP toIntervalHour(1)`;
}
sb.select.date = 'toStartOfHour(created_at) as date';
break;
}
case 'day': {
sb.fill = `FROM toStartOfDay(toDateTime('${startDate}')) TO toStartOfDay(toDateTime('${endDate}')) STEP toIntervalDay(1)`;
if (hasValidDateRange) {
sb.fill = `FROM toStartOfDay(toDateTime('${startDate}')) TO toStartOfDay(toDateTime('${endDate}')) STEP toIntervalDay(1)`;
}
sb.select.date = 'toStartOfDay(created_at) as date';
break;
}
case 'week': {
sb.fill = `FROM toStartOfWeek(toDateTime('${startDate}'), 1, '${timezone}') TO toStartOfWeek(toDateTime('${endDate}'), 1, '${timezone}') STEP toIntervalWeek(1)`;
if (hasValidDateRange) {
sb.fill = `FROM toStartOfWeek(toDateTime('${startDate}'), 1, '${timezone}') TO toStartOfWeek(toDateTime('${endDate}'), 1, '${timezone}') STEP toIntervalWeek(1)`;
}
sb.select.date = `toStartOfWeek(created_at, 1, '${timezone}') as date`;
break;
}
case 'month': {
sb.fill = `FROM toStartOfMonth(toDateTime('${startDate}'), '${timezone}') TO toStartOfMonth(toDateTime('${endDate}'), '${timezone}') STEP toIntervalMonth(1)`;
if (hasValidDateRange) {
sb.fill = `FROM toStartOfMonth(toDateTime('${startDate}'), '${timezone}') TO toStartOfMonth(toDateTime('${endDate}'), '${timezone}') STEP toIntervalMonth(1)`;
}
sb.select.date = `toStartOfMonth(created_at, '${timezone}') as date`;
break;
}

View File

@@ -7,6 +7,27 @@ import { db } from '../prisma-client';
import { createSqlBuilder } from '../sql-builder';
import { getOrganizationAccess, getProjectAccess } from './access.service';
import { type IServiceProject, getProjectById } from './project.service';
/**
* Helper function to check if endDate is after startDate
* This prevents ClickHouse errors when WITH FILL TO value is less than FROM value
*/
function isValidDateRange(startDate: string, endDate: string): boolean {
try {
const start = DateTime.fromFormat(startDate, 'yyyy-MM-dd HH:mm:ss');
const end = DateTime.fromFormat(endDate, 'yyyy-MM-dd HH:mm:ss');
return end > start;
} catch {
// Try alternative format
try {
const start = new Date(startDate);
const end = new Date(endDate);
return !isNaN(start.getTime()) && !isNaN(end.getTime()) && end > start;
} catch {
return false;
}
}
}
export type IServiceOrganization = Awaited<
ReturnType<typeof db.organization.findUniqueOrThrow>
>;
@@ -239,7 +260,19 @@ export async function getOrganizationBillingEventsCountSerie(
sb.select.count = 'COUNT(*) AS count';
sb.select.day = `toDate(toStartOf${interval.slice(0, 1).toUpperCase() + interval.slice(1)}(created_at)) AS ${interval}`;
sb.groupBy.day = interval;
sb.orderBy.day = `${interval} WITH FILL FROM toDate(${sqlstring.escape(formatClickhouseDate(startDate, true))}) TO toDate(${sqlstring.escape(formatClickhouseDate(endDate, true))}) STEP INTERVAL 1 ${interval.toUpperCase()}`;
// Only apply WITH FILL if the date range is valid (endDate > startDate)
const hasValidDateRange = isValidDateRange(
formatClickhouseDate(startDate, true),
formatClickhouseDate(endDate, true)
);
if (hasValidDateRange) {
sb.orderBy.day = `${interval} WITH FILL FROM toDate(${sqlstring.escape(formatClickhouseDate(startDate, true))}) TO toDate(${sqlstring.escape(formatClickhouseDate(endDate, true))}) STEP INTERVAL 1 ${interval.toUpperCase()}`;
} else {
sb.orderBy.day = `${interval} ASC`;
}
sb.where.projectIds = `project_id IN (${organization.projects.map((project) => sqlstring.escape(project.id)).join(',')})`;
sb.where.createdAt = `${interval} BETWEEN ${sqlstring.escape(formatClickhouseDate(startDate, true))} AND ${sqlstring.escape(formatClickhouseDate(endDate, true))}`;

View File

@@ -261,7 +261,7 @@ export class OverviewService {
.rawWhere(this.getRawWhereClause('events', filters))
.groupBy(['date', 'ds.bounce_rate'])
.orderBy('date', 'ASC')
.fill(
.safeFill(
clix.toStartOf(
clix.datetime(
startDate,
@@ -342,7 +342,7 @@ export class OverviewService {
.having('sum(sign)', '>', 0)
.rollup()
.orderBy('date', 'ASC')
.fill(
.safeFill(
clix.toStartOf(
clix.datetime(
startDate,