Use the billing org id when updating and checking usage

This commit is contained in:
Owen
2026-02-17 15:09:42 -08:00
parent b8c3cc751a
commit b71f582329

View File

@@ -10,7 +10,8 @@ import {
limits, limits,
Usage, Usage,
Limit, Limit,
Transaction Transaction,
orgs
} from "@server/db"; } from "@server/db";
import { FeatureId, getFeatureMeterId } from "./features"; import { FeatureId, getFeatureMeterId } from "./features";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -37,10 +38,10 @@ export function noop() {
} }
export class UsageService { export class UsageService {
private bucketName: string | undefined; // private bucketName: string | undefined;
private events: StripeEvent[] = []; // private events: StripeEvent[] = [];
private lastUploadTime: number = Date.now(); // private lastUploadTime: number = Date.now();
private isUploading: boolean = false; // private isUploading: boolean = false;
constructor() { constructor() {
if (noop()) { if (noop()) {
@@ -91,6 +92,8 @@ export class UsageService {
return null; return null;
} }
let orgIdToUse = await this.getBillingOrg(orgId, transaction);
// Truncate value to 11 decimal places // Truncate value to 11 decimal places
value = this.truncateValue(value); value = this.truncateValue(value);
@@ -100,20 +103,20 @@ export class UsageService {
while (attempt <= maxRetries) { while (attempt <= maxRetries) {
try { try {
// Get subscription data for this org (with caching) // // Get subscription data for this org (with caching)
const customerId = await this.getCustomerId(orgId, featureId); // const customerId = await this.getCustomerId(orgIdToUse, featureId);
if (!customerId) { // if (!customerId) {
logger.warn( // logger.warn(
`No subscription data found for org ${orgId} and feature ${featureId}` // `No subscription data found for org ${orgIdToUse} and feature ${featureId}`
); // );
return null; // return null;
} // }
let usage; let usage;
if (transaction) { if (transaction) {
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgId, orgIdToUse,
featureId, featureId,
value, value,
transaction transaction
@@ -121,7 +124,7 @@ export class UsageService {
} else { } else {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
usage = await this.internalAddUsage( usage = await this.internalAddUsage(
orgId, orgIdToUse,
featureId, featureId,
value, value,
trx trx
@@ -150,7 +153,7 @@ export class UsageService {
const delay = baseDelay + jitter; const delay = baseDelay + jitter;
logger.warn( logger.warn(
`Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms` `Deadlock detected for ${orgIdToUse}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`
); );
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
@@ -158,7 +161,7 @@ export class UsageService {
} }
logger.error( logger.error(
`Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`, `Failed to add usage for ${orgIdToUse}/${featureId} after ${attempt} attempts:`,
error error
); );
break; break;
@@ -169,7 +172,7 @@ export class UsageService {
} }
private async internalAddUsage( private async internalAddUsage(
orgId: string, orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
featureId: FeatureId, featureId: FeatureId,
value: number, value: number,
trx: Transaction trx: Transaction
@@ -221,17 +224,20 @@ export class UsageService {
if (noop()) { if (noop()) {
return; return;
} }
let orgIdToUse = await this.getBillingOrg(orgId);
try { try {
if (!customerId) { // if (!customerId) {
customerId = // customerId =
(await this.getCustomerId(orgId, featureId)) || undefined; // (await this.getCustomerId(orgIdToUse, featureId)) || undefined;
if (!customerId) { // if (!customerId) {
logger.warn( // logger.warn(
`No subscription data found for org ${orgId} and feature ${featureId}` // `No subscription data found for org ${orgIdToUse} and feature ${featureId}`
); // );
return; // return;
} // }
} // }
// Truncate value to 11 decimal places if provided // Truncate value to 11 decimal places if provided
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
@@ -242,7 +248,7 @@ export class UsageService {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Get existing meter record // Get existing meter record
const usageId = `${orgId}-${featureId}`; const usageId = `${orgIdToUse}-${featureId}`;
// Get current usage record // Get current usage record
[currentUsage] = await trx [currentUsage] = await trx
.select() .select()
@@ -264,7 +270,7 @@ export class UsageService {
await trx.insert(usage).values({ await trx.insert(usage).values({
usageId, usageId,
featureId, featureId,
orgId, orgId: orgIdToUse,
meterId, meterId,
instantaneousValue: value || 0, instantaneousValue: value || 0,
latestValue: value || 0, latestValue: value || 0,
@@ -278,7 +284,7 @@ export class UsageService {
// } // }
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to update count usage for ${orgId}/${featureId}:`, `Failed to update count usage for ${orgIdToUse}/${featureId}:`,
error error
); );
} }
@@ -288,7 +294,9 @@ export class UsageService {
orgId: string, orgId: string,
featureId: FeatureId featureId: FeatureId
): Promise<string | null> { ): Promise<string | null> {
const cacheKey = `customer_${orgId}_${featureId}`; let orgIdToUse = await this.getBillingOrg(orgId);
const cacheKey = `customer_${orgIdToUse}_${featureId}`;
const cached = cache.get<string>(cacheKey); const cached = cache.get<string>(cacheKey);
if (cached) { if (cached) {
@@ -302,7 +310,7 @@ export class UsageService {
customerId: customers.customerId customerId: customers.customerId
}) })
.from(customers) .from(customers)
.where(eq(customers.orgId, orgId)) .where(eq(customers.orgId, orgIdToUse))
.limit(1); .limit(1);
if (!customer) { if (!customer) {
@@ -317,112 +325,13 @@ export class UsageService {
return customerId; return customerId;
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to get subscription data for ${orgId}/${featureId}:`, `Failed to get subscription data for ${orgIdToUse}/${featureId}:`,
error error
); );
return null; return null;
} }
} }
private async logStripeEvent(
featureId: FeatureId,
value: number,
customerId: string
): Promise<void> {
// Truncate value to 11 decimal places before sending to Stripe
const truncatedValue = this.truncateValue(value);
const event: StripeEvent = {
identifier: uuidv4(),
timestamp: Math.floor(new Date().getTime() / 1000),
event_name: featureId,
payload: {
value: truncatedValue,
stripe_customer_id: customerId
}
};
this.addEventToMemory(event);
await this.checkAndUploadEvents();
}
private addEventToMemory(event: StripeEvent): void {
if (!this.bucketName) {
logger.warn(
"S3 bucket name is not configured, skipping event storage."
);
return;
}
this.events.push(event);
}
private async checkAndUploadEvents(): Promise<void> {
const now = Date.now();
const timeSinceLastUpload = now - this.lastUploadTime;
// Check if at least 1 minute has passed since last upload
if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
await this.uploadEventsToS3();
}
}
private async uploadEventsToS3(): Promise<void> {
if (!this.bucketName) {
logger.warn(
"S3 bucket name is not configured, skipping S3 upload."
);
return;
}
if (this.events.length === 0) {
return;
}
// Check if already uploading
if (this.isUploading) {
logger.debug("Already uploading events, skipping");
return;
}
this.isUploading = true;
try {
// Take a snapshot of current events and clear the array
const eventsToUpload = [...this.events];
this.events = [];
this.lastUploadTime = Date.now();
const fileName = this.generateEventFileName();
const fileContent = JSON.stringify(eventsToUpload, null, 2);
// Upload to S3
const uploadCommand = new PutObjectCommand({
Bucket: this.bucketName,
Key: fileName,
Body: fileContent,
ContentType: "application/json"
});
await s3Client.send(uploadCommand);
logger.info(
`Uploaded ${fileName} to S3 with ${eventsToUpload.length} events`
);
} catch (error) {
logger.error("Failed to upload events to S3:", error);
// Note: Events are lost if upload fails. In a production system,
// you might want to add the events back to the array or implement retry logic
} finally {
this.isUploading = false;
}
}
private generateEventFileName(): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const uuid = uuidv4().substring(0, 8);
return `events-${timestamp}-${uuid}.json`;
}
public async getUsage( public async getUsage(
orgId: string, orgId: string,
featureId: FeatureId, featureId: FeatureId,
@@ -432,7 +341,9 @@ export class UsageService {
return null; return null;
} }
const usageId = `${orgId}-${featureId}`; let orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`;
try { try {
const [result] = await trx const [result] = await trx
@@ -444,7 +355,7 @@ export class UsageService {
if (!result) { if (!result) {
// Lets create one if it doesn't exist using upsert to handle race conditions // Lets create one if it doesn't exist using upsert to handle race conditions
logger.info( logger.info(
`Creating new usage record for ${orgId}/${featureId}` `Creating new usage record for ${orgIdToUse}/${featureId}`
); );
const meterId = getFeatureMeterId(featureId); const meterId = getFeatureMeterId(featureId);
@@ -454,7 +365,7 @@ export class UsageService {
.values({ .values({
usageId, usageId,
featureId, featureId,
orgId, orgId: orgIdToUse,
meterId, meterId,
latestValue: 0, latestValue: 0,
updatedAt: Math.floor(Date.now() / 1000) updatedAt: Math.floor(Date.now() / 1000)
@@ -476,7 +387,7 @@ export class UsageService {
} catch (insertError) { } catch (insertError) {
// Fallback: try to fetch existing record in case of any insert issues // Fallback: try to fetch existing record in case of any insert issues
logger.warn( logger.warn(
`Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`, `Insert failed for ${orgIdToUse}/${featureId}, attempting to fetch existing record:`,
insertError insertError
); );
const [existingUsage] = await trx const [existingUsage] = await trx
@@ -491,19 +402,41 @@ export class UsageService {
return result; return result;
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to get usage for ${orgId}/${featureId}:`, `Failed to get usage for ${orgIdToUse}/${featureId}:`,
error error
); );
throw error; throw error;
} }
} }
public async forceUpload(): Promise<void> { public async getBillingOrg(
if (this.events.length > 0) { orgId: string,
// Force upload regardless of time trx: Transaction | typeof db = db
this.lastUploadTime = 0; // Reset to force upload ): Promise<string> {
await this.uploadEventsToS3(); let orgIdToUse = orgId;
// get the org
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId))
.limit(1);
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
} }
if (!org.isBillingOrg) {
if (org.billingOrgId) {
orgIdToUse = org.billingOrgId;
} else {
throw new Error(
`Organization ${orgId} is not a billing org and does not have a billingOrgId set`
);
}
}
return orgIdToUse;
} }
public async checkLimitSet( public async checkLimitSet(
@@ -515,6 +448,9 @@ export class UsageService {
if (noop()) { if (noop()) {
return false; return false;
} }
let orgIdToUse = await this.getBillingOrg(orgId, trx);
// This method should check the current usage against the limits set for the organization // This method should check the current usage against the limits set for the organization
// and kick out all of the sites on the org // and kick out all of the sites on the org
let hasExceededLimits = false; let hasExceededLimits = false;
@@ -528,7 +464,7 @@ export class UsageService {
.from(limits) .from(limits)
.where( .where(
and( and(
eq(limits.orgId, orgId), eq(limits.orgId, orgIdToUse),
eq(limits.featureId, featureId) eq(limits.featureId, featureId)
) )
); );
@@ -537,11 +473,11 @@ export class UsageService {
orgLimits = await trx orgLimits = await trx
.select() .select()
.from(limits) .from(limits)
.where(eq(limits.orgId, orgId)); .where(eq(limits.orgId, orgIdToUse));
} }
if (orgLimits.length === 0) { if (orgLimits.length === 0) {
logger.debug(`No limits set for org ${orgId}`); logger.debug(`No limits set for org ${orgIdToUse}`);
return false; return false;
} }
@@ -552,7 +488,7 @@ export class UsageService {
currentUsage = usage; currentUsage = usage;
} else { } else {
currentUsage = await this.getUsage( currentUsage = await this.getUsage(
orgId, orgIdToUse,
limit.featureId as FeatureId, limit.featureId as FeatureId,
trx trx
); );
@@ -563,10 +499,10 @@ export class UsageService {
currentUsage?.latestValue || currentUsage?.latestValue ||
0; 0;
logger.debug( logger.debug(
`Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}` `Current usage for org ${orgIdToUse} on feature ${limit.featureId}: ${usageValue}`
); );
logger.debug( logger.debug(
`Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}` `Limit for org ${orgIdToUse} on feature ${limit.featureId}: ${limit.value}`
); );
if ( if (
currentUsage && currentUsage &&
@@ -574,7 +510,7 @@ export class UsageService {
usageValue > limit.value usageValue > limit.value
) { ) {
logger.debug( logger.debug(
`Org ${orgId} has exceeded limit for ${limit.featureId}: ` + `Org ${orgIdToUse} has exceeded limit for ${limit.featureId}: ` +
`${usageValue} > ${limit.value}` `${usageValue} > ${limit.value}`
); );
hasExceededLimits = true; hasExceededLimits = true;
@@ -582,11 +518,119 @@ export class UsageService {
} }
} }
} catch (error) { } catch (error) {
logger.error(`Error checking limits for org ${orgId}:`, error); logger.error(`Error checking limits for org ${orgIdToUse}:`, error);
} }
return hasExceededLimits; return hasExceededLimits;
} }
// private async logStripeEvent(
// featureId: FeatureId,
// value: number,
// customerId: string
// ): Promise<void> {
// // Truncate value to 11 decimal places before sending to Stripe
// const truncatedValue = this.truncateValue(value);
// const event: StripeEvent = {
// identifier: uuidv4(),
// timestamp: Math.floor(new Date().getTime() / 1000),
// event_name: featureId,
// payload: {
// value: truncatedValue,
// stripe_customer_id: customerId
// }
// };
// this.addEventToMemory(event);
// await this.checkAndUploadEvents();
// }
// private addEventToMemory(event: StripeEvent): void {
// if (!this.bucketName) {
// logger.warn(
// "S3 bucket name is not configured, skipping event storage."
// );
// return;
// }
// this.events.push(event);
// }
// private async checkAndUploadEvents(): Promise<void> {
// const now = Date.now();
// const timeSinceLastUpload = now - this.lastUploadTime;
// // Check if at least 1 minute has passed since last upload
// if (timeSinceLastUpload >= 60000 && this.events.length > 0) {
// await this.uploadEventsToS3();
// }
// }
// private async uploadEventsToS3(): Promise<void> {
// if (!this.bucketName) {
// logger.warn(
// "S3 bucket name is not configured, skipping S3 upload."
// );
// return;
// }
// if (this.events.length === 0) {
// return;
// }
// // Check if already uploading
// if (this.isUploading) {
// logger.debug("Already uploading events, skipping");
// return;
// }
// this.isUploading = true;
// try {
// // Take a snapshot of current events and clear the array
// const eventsToUpload = [...this.events];
// this.events = [];
// this.lastUploadTime = Date.now();
// const fileName = this.generateEventFileName();
// const fileContent = JSON.stringify(eventsToUpload, null, 2);
// // Upload to S3
// const uploadCommand = new PutObjectCommand({
// Bucket: this.bucketName,
// Key: fileName,
// Body: fileContent,
// ContentType: "application/json"
// });
// await s3Client.send(uploadCommand);
// logger.info(
// `Uploaded ${fileName} to S3 with ${eventsToUpload.length} events`
// );
// } catch (error) {
// logger.error("Failed to upload events to S3:", error);
// // Note: Events are lost if upload fails. In a production system,
// // you might want to add the events back to the array or implement retry logic
// } finally {
// this.isUploading = false;
// }
// }
// private generateEventFileName(): string {
// const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
// const uuid = uuidv4().substring(0, 8);
// return `events-${timestamp}-${uuid}.json`;
// }
// public async forceUpload(): Promise<void> {
// if (this.events.length > 0) {
// // Force upload regardless of time
// this.lastUploadTime = 0; // Reset to force upload
// await this.uploadEventsToS3();
// }
// }
} }
export const usageService = new UsageService(); export const usageService = new UsageService();