company/report/definition.ts (ReportDefinition) and company/report/report.ts (RunReport, ReportRow, and the supporting filter/groupBy/metric types). This is an ad-hoc/saved aggregation-query builderover a fixed allowlist of resource types — not a set of canned reports. There is no persisted "Report" or result entity: running a ReportDefinition re-executes its saved filter/groupBy/metrics/sort shape against live data every time via RunReport, rather than returning a cached snapshot.
report.ts defines REPORT_RESOURCES, a const allowlist mirroring the backend registry (per the code comment — the backend is the source of truth and rejects anything not listed there). ReportResourceName = keyof typeof REPORT_RESOURCES:
| Resource | dimensionFields | dateFields | metricFields |
|---|---|---|---|
checkouts | id, companyID, memberID, soldByMemberID, payingMemberID, status, createdAt, updatedAt, resolvedAt, canceledAt | createdAt, updatedAt, resolvedAt, canceledAt | amount, subtotalAmount |
invoices | id, checkoutID, companyID, status, currency, billingReason, periodStart, periodEnd, dueDate, paidAt, createdAt, updatedAt | periodStart, periodEnd, dueDate, paidAt, createdAt, updatedAt | amountDue, amountPaid, amountRemaining |
payments | id, companyID, checkoutID, invoiceID, status, currency, paymentMethodType, cardBrand, paidAt, refundedAt, createdAt, updatedAt | paidAt, refundedAt, createdAt, updatedAt | amount, amountRefunded |
account_credit_transactions | id, companyID, memberID, source, checkoutID, invoiceID, createdByUserID, createdAt | createdAt | amount, balanceAfter |
payroll_run_lines | id, companyID, payrollRunID, staffID, rateID, createdAt | createdAt | regularUnits, overtimeUnits, regularAmount, overtimeAmount, bonusAmount, totalAmount |
ReportResourceConfig splits each resource's fields into three roles: dimensionFields (safe to filter/group-by as-is), dateFields (a subset of dimension fields that are also safe to bucket via ReportGroupBy.bucket), and metricFields (safe to aggregate via ReportMetric). invoices and payments correspond to the Payment domain class — "the class is named Paymentfor backward compatibility, it represents what the API calls an invoice"; payments here is the Stripe payment/charge record, distinct from the invoices resource. checkouts corresponds to Checkout; payroll_run_lines to PayrollRunLine; account_credit_transactions to AccountCreditTransaction.
Member, Visit, Credit, Booking, Class/ClassInstance, Product, Subscription, CompanyEvent, StaffTimeBlock — has no entry in REPORT_RESOURCES and cannot be targeted by RunReport/ReportDefinition. The code doesn't explain why these five were chosen; treat the table above as the complete, current list rather than inferring a pattern.interface RunReportRequest<Field extends string = string> {
resource: ReportResourceName;
filter?: QueryRequest<Field>["filter"];
logic?: "and" | "or";
groupBy?: ReportGroupBy<Field>[];
metrics: ReportMetric<Field>[];
sort?: ReportSort;
limit?: number;
offset?: number;
includeDeleted?: boolean;
}
interface RunSavedReportRequest {
reportDefinitionID: string;
limit?: number;
offset?: number;
includeDeleted?: boolean;
}RunSavedReportRequest runs a previously saved ReportDefinition instead of sending the full ad-hoc shape; its limit/offset/includeDeletedoverride the saved definition's values when provided (the definition itself carries no limit/offset/includeDeleted — those three are per-run-only, never persisted).
type ReportRow = Record<string, string | number | null>;Each row is dynamically keyed, not a fixed interface — column names are constructed positionally from the request's own groupBy/metrics arrays (per the doc comment on ReportRow):
| Column | Shape |
|---|---|
| Group-by columns | g{n}_{field}, or g{n}_{field}_{bucket} when the group-by entry has a bucket — n is the entry's index in the groupBy array sent |
| Metric columns | m{n}_{agg}_{field}, or m{n}_{agg}_all for a field: "*" count — n is the entry's index in the metrics array sent |
E.g. { groupBy: [{ field: "status" }], metrics: [{ field: "amount", agg: "sum" }, { field: "*", agg: "count" }] } produces rows shaped like { g0_status: "resolved", m0_sum_amount: 4820, m1_count_all: 12 }.
async function RunReport<Field extends string = string>(
company: Company,
request: RunReportRequest<Field> | RunSavedReportRequest,
): Promise<SimpleResult<ReportRow[]>>POST /company/{companyID}/report — server-side filter/groupBy/metric aggregation over an allowlisted resource (see REPORT_RESOURCES above for what each resource supports). Accepts either shape in the union: a RunReportRequest (ad-hoc — specifies resource plus the full filter/groupBy/metrics/sort shape) or a RunSavedReportRequest ({ reportDefinitionID, limit?, offset?, includeDeleted? } — re-runs a saved ReportDefinition by id). The backend distinguishes the two by the presence of reportDefinitionID vs. resource/metrics.
Company/ReportDefinition instance methods per the SDK-wide convention rather than importing RunReport directly, except where no saved definition exists yet (the ad-hoc case has no instance to call it from).definition.ts. A saved query config — the filter/groupBy/metrics/sort shape plus name/resource, persisted so it can be re-run via Run() without resending the full shape each time. DataStructure<ReportDefinitionData>, endpoint /company/{companyID}/report/definitions.
interface ReportDefinitionShape {
filter?: QueryRequest["filter"];
logic?: "and" | "or";
groupBy?: ReportGroupBy[];
metrics: ReportMetric[];
sort?: ReportSort;
}
interface LazyReportDefinitionData {
id: string;
companyID: string;
name: string;
resource: ReportResourceName;
definition: ReportDefinitionShape;
createdByUserID?: string | null;
}
interface ReportDefinitionData extends LazyReportDefinitionData {
createdAt: Date;
updatedAt: Date;
}ReportDefinitionShape matches RunReportRequest minus resource (which lives alongside definition on the row, not nested inside it) and minus limit/offset/includeDeleted (per-run-only, as noted above).
definition is a JSON column — per the code comment, mysql2 auto-parses it back into an object on read, so LazyReportDefinitionData and the full ReportDefinitionData carry the exact same definitionshape; there's no separate lazy/full split for this field the way most other resources trim detail fields out of their lazy shape.CreateReportDefinitionData is the create payload: ReportDefinitionShape & { name: string; resource: ReportResourceName } — i.e. name/resourceflattened alongside the shape's own fields, not wrapped in a nested definition key.
new ReportDefinition(id: string, company: Company);
new ReportDefinition(data: LazyReportDefinitionData | ReportDefinitionData, company: Company, lazy?: boolean);ReportDefinition.create(company: Company, definitions: CreateReportDefinitionData[]): Promise<EventData<string[]>>POST /company/{companyID}/report/definitions with the array body.
ReportDefinition.query(company: Company, data: QueryRequest): Promise<SimpleResult<ReportDefinition[]>>POST /company/{companyID}/report/definitions/query — standard query shape; wraps each row in new ReportDefinition(d, company, lazy) (lazy defaults to true when data.lazy is undefined).
definition.save(): Promise<SimpleResult<EventData>>PATCH /company/{companyID}/report/definitions with [{ id, name, resource, ...definition }] — flattens the nested definition object back out to top-level fields before sending. Per the code comment, the backend rebuilds the definition JSON column from top-level filter/logic/groupBy/metrics/sort fields on PATCH and does not accept a nested definition object back — this is the inverse of what fetch()/query()return, so don't assume save() can just re-send the cached row shape unmodified.definition.delete(): Promise<SimpleResult<EventData>>DELETE /company/{companyID}/report/definitions?ids={id}.
definition.Run(opts?: { limit?: number; offset?: number; includeDeleted?: boolean }): Promise<SimpleResult<ReportRow[]>>Executes the saved definition by delegating to RunReport(this.company, { reportDefinitionID: this.id, ...opts }) — i.e. it always sends a RunSavedReportRequest, never re-sends the definition's own filter/groupBy/metrics/sort shape (the backend re-derives that server-side from the saved row). opts.limit/opts.offset/opts.includeDeletedoverride the saved definition's values for this run only; omitting optsentirely runs with the backend's defaults for those three. Returns fresh ReportRow[] on every call — nothing is cached or persisted between runs.
import { ReportDefinition } from "zensile-sdk/company/report/definition";
// Load (or construct) the saved definition, then execute it
const definition = new ReportDefinition(reportDefinitionID, company);
const result = await definition.Run({ limit: 50 });
if (result.status === 200) {
for (const row of result.data ?? []) {
console.log(row); // e.g. { g0_status: "resolved", m0_sum_amount: 4820 }
}
}import { RunReport } from "zensile-sdk/company/report/report";
const result = await RunReport(company, {
resource: "checkouts",
filter: [{ field: "status", condition: "=", value: "resolved" }],
groupBy: [{ field: "createdAt", bucket: "month" }],
metrics: [
{ field: "amount", agg: "sum" },
{ field: "*", agg: "count" },
],
sort: { on: "metric", index: 0, order: "desc" },
limit: 12,
});
if (result.status === 200) {
// rows shaped like { g0_createdAt_month: "2026-07", m0_sum_amount: 12300, m1_count_all: 41 }
console.log(result.data);
}Saving the same shape as a reusable ReportDefinition instead of re-sending it ad hoc every time:
import { ReportDefinition } from "zensile-sdk/company/report/definition";
const created = await ReportDefinition.create(company, [
{
name: "Monthly checkout revenue",
resource: "checkouts",
filter: [{ field: "status", condition: "=", value: "resolved" }],
groupBy: [{ field: "createdAt", bucket: "month" }],
metrics: [{ field: "amount", agg: "sum" }],
},
]);