* feat: add seat tracking infrastructure for monthly proration Add seat change logging infrastructure with operationId for idempotency. This PR adds the foundation for monthly proration billing by tracking seat additions and removals, gated behind the monthly-proration feature flag. - Add operationId field to SeatChangeLog for idempotency - Update SeatChangeLogRepository to support upsert with operationId - Add feature flag guard in SeatChangeTrackingService - Integrate seat tracking in team member invites - Integrate seat tracking in bulk user deletions - Integrate seat tracking in team service operations - Integrate seat tracking in DSYNC user creation When monthly-proration feature flag is disabled, seat logging is skipped and behavior remains unchanged. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: add monthly proration processing Add monthly proration billing processing that works on top of the seat tracking infrastructure. This PR implements the core proration logic, webhook handlers, and integration with Stripe billing. - Enhance MonthlyProrationService to process seat change logs - Add payment webhook handlers (invoice.payment_succeeded, invoice.payment_failed) - Update subscription webhook to sync billing period on renewals - Update TeamBillingService to skip real-time updates when proration enabled - Enhance StripeBillingService with proration capabilities - Add Tasker enhancements for processing queues - Update team creation/upgrade routes Depends on: feat/monthly-proration-seat-tracking Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix: remove unused logger from SeatChangeTrackingService * fix: description for calculation * fix null check on trial * chore: no more prisma calls * add feature flag check * fix stub --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
120 lines
3.4 KiB
TypeScript
120 lines
3.4 KiB
TypeScript
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
|
|
import process from "node:process";
|
|
import { configure } from "@trigger.dev/sdk";
|
|
import { ENABLE_ASYNC_TASKER } from "../constants";
|
|
import { redactError } from "../redactError";
|
|
import type { ILogger } from "./types";
|
|
|
|
const isAsyncTaskerEnabled =
|
|
ENABLE_ASYNC_TASKER &&
|
|
process.env.TRIGGER_SECRET_KEY &&
|
|
process.env.TRIGGER_API_URL;
|
|
|
|
export abstract class Tasker<T> {
|
|
protected readonly asyncTasker: T;
|
|
protected readonly syncTasker: T;
|
|
protected readonly logger: ILogger;
|
|
|
|
constructor(dependencies: {
|
|
asyncTasker: T;
|
|
syncTasker: T;
|
|
logger: ILogger;
|
|
}) {
|
|
this.logger = dependencies.logger;
|
|
|
|
if (!isAsyncTaskerEnabled) {
|
|
if (
|
|
ENABLE_ASYNC_TASKER &&
|
|
(!process.env.TRIGGER_SECRET_KEY || !process.env.TRIGGER_API_URL)
|
|
) {
|
|
this.logger.info(
|
|
"Missing env variables TRIGGER_SECRET_KEY or TRIGGER_API_URL, falling back to Sync tasker."
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isAsyncTaskerEnabled) {
|
|
configure({
|
|
accessToken: process.env.TRIGGER_SECRET_KEY,
|
|
baseURL: process.env.TRIGGER_API_URL,
|
|
});
|
|
}
|
|
|
|
this.asyncTasker = isAsyncTaskerEnabled
|
|
? dependencies.asyncTasker
|
|
: dependencies.syncTasker;
|
|
this.syncTasker = dependencies.syncTasker;
|
|
}
|
|
|
|
public async dispatch<K extends keyof T>(
|
|
taskName: K,
|
|
...args: T[K] extends (...args: any[]) => any ? Parameters<T[K]> : never
|
|
): Promise<
|
|
T[K] extends (...args: any[]) => any ? Awaited<ReturnType<T[K]>> : never
|
|
> {
|
|
this.logger.info(`Safely Dispatching task '${String(taskName)}'`, { args });
|
|
return this._safeDispatch(taskName, ...args);
|
|
}
|
|
|
|
private async _safeDispatch<K extends keyof T>(
|
|
taskName: K,
|
|
...args: T[K] extends (...args: any[]) => any ? Parameters<T[K]> : never
|
|
): Promise<
|
|
T[K] extends (...args: any[]) => any ? Awaited<ReturnType<T[K]>> : never
|
|
> {
|
|
try {
|
|
this.logger.info(
|
|
`${isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker"} '${String(
|
|
taskName
|
|
)}' dispatched.`
|
|
);
|
|
const method = this.asyncTasker[taskName] as (...args: any[]) => any;
|
|
return await method.apply(this.asyncTasker, args);
|
|
} catch (err) {
|
|
const taskerLabel = isAsyncTaskerEnabled ? "AsyncTasker" : "SyncTasker";
|
|
const baseUrlInfo = isAsyncTaskerEnabled
|
|
? ` (baseURL: ${process.env.TRIGGER_API_URL ?? "unknown"})`
|
|
: "";
|
|
this.logger.error(
|
|
`${taskerLabel} failed for '${String(taskName)}'.${baseUrlInfo}`,
|
|
this.getErrorDetails(err)
|
|
);
|
|
|
|
if (this.asyncTasker === this.syncTasker) {
|
|
throw err;
|
|
}
|
|
|
|
this.logger.warn(
|
|
`Trying again with SyncTasker for '${String(taskName)}'.`
|
|
);
|
|
|
|
try {
|
|
const fallbackMethod = this.syncTasker[taskName] as (
|
|
...args: any[]
|
|
) => any;
|
|
return await fallbackMethod.apply(this.syncTasker, args);
|
|
} catch (err) {
|
|
this.logger.error(
|
|
`SyncTasker failed for '${String(taskName)}'.`,
|
|
this.getErrorDetails(err)
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
private getErrorDetails(err: unknown) {
|
|
const redactedError = redactError(err);
|
|
if (redactedError instanceof Error) {
|
|
return {
|
|
name: redactedError.name,
|
|
message: redactedError.message,
|
|
stack: redactedError.stack,
|
|
};
|
|
}
|
|
|
|
return { message: String(redactedError) };
|
|
}
|
|
}
|