Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/app/(ee)/api/commissions/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const GET = withWorkspace(
const formattedCommissions = formatCommissionsForExport(
commissions,
columns,
filters.timezone,
);

return new Response(convertToCSV(formattedCommissions), {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/app/(ee)/api/cron/export/commissions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ export async function POST(req: Request) {
for await (const { commissions } of fetchCommissionsBatch(
commissionsFilters,
)) {
allCommissions.push(...formatCommissionsForExport(commissions, columns));
allCommissions.push(
...formatCommissionsForExport(commissions, columns, filters.timezone),
);
}

const csvData = convertToCSV(allCommissions);
Expand Down
27 changes: 22 additions & 5 deletions apps/web/lib/api/commissions/format-commissions-for-export.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { sanitizeTimezone } from "@/lib/analytics/utils/sanitize-timezone";
import { formatMoneyCentsForExport } from "@/lib/api/utils/format-money-cents-for-export";
import { COMMISSION_EXPORT_COLUMNS } from "@/lib/zod/schemas/commissions";
import { tz } from "@date-fns/tz";
import { format } from "date-fns";
import * as z from "zod/v4";
import { getCommissions } from "./get-commissions";

Expand Down Expand Up @@ -29,21 +32,31 @@ const COLUMN_TYPE_SCHEMAS = {
.nullable()
.default("")
.transform((value) => value || ""),
date: z
.date()
.nullable()
.transform((date) => (date ? date.toISOString() : "")),
string: z
.string()
.nullable()
.default("")
.transform((value) => value || ""),
};

// Wall-clock time in the export timezone. toISOString() is UTC, so a commission
// at 2026-10-08 06:59 in Asia/Shanghai was exported as 2026-10-07 22:59:59.
function exportDateSchema(timezone?: string) {
const zone = tz(sanitizeTimezone(timezone));

return z
.date()
.nullable()
.transform((date) =>
date ? format(date, "yyyy-MM-dd HH:mm:ss", { in: zone }) : "",
);
}

// Formats commissions for CSV export with proper column ordering and type coercion
export function formatCommissionsForExport(
commissions: Awaited<ReturnType<typeof getCommissions>>,
columns: string[],
timezone?: string,
): Record<string, any>[] {
const sortedColumns = [...columns].sort(
(a, b) =>
Expand Down Expand Up @@ -93,7 +106,11 @@ export function formatCommissionsForExport(
}

columnSchemas[column] =
COLUMN_TYPE_SCHEMAS[columnInfo.type as keyof typeof COLUMN_TYPE_SCHEMAS];
columnInfo.type === "date"
? exportDateSchema(timezone)
: COLUMN_TYPE_SCHEMAS[
columnInfo.type as keyof typeof COLUMN_TYPE_SCHEMAS
];
}

return z.array(z.object(columnSchemas)).parse(formattedCommissions);
Expand Down
77 changes: 77 additions & 0 deletions apps/web/tests/commissions/format-commissions-for-export.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { formatCommissionsForExport } from "@/lib/api/commissions/format-commissions-for-export";
import { describe, expect, test } from "vitest";

// 2026-10-07 22:59:59 UTC is 2026-10-08 06:59:59 in China (UTC+8), which is the
// case from the commissions export: the table showed Oct 8 and the CSV showed Oct 7.
const earlyMorningInChina = new Date("2026-10-07T22:59:59.000Z");
const paidAt = new Date("2026-10-09T01:02:03.000Z");

function commissionWithDates() {
return {
id: "cm_1",
currency: "usd",
amount: 499,
earnings: 99,
createdAt: earlyMorningInChina,
updatedAt: earlyMorningInChina,
payout: { paidAt },
customer: {
name: "Ada",
email: "ada@example.com",
externalId: null,
stripeCustomerId: null,
},
partner: { name: "Melody", email: "melody@example.com" },
programEnrollment: { tenantId: null },
} as any;
}

describe("formatCommissionsForExport", () => {
test("formats date columns in the export timezone", () => {
const [row] = formatCommissionsForExport(
[commissionWithDates()],
["createdAt", "updatedAt", "paidAt"],
"Asia/Shanghai",
);

expect(row).toEqual({
createdAt: "2026-10-08 06:59:59",
updatedAt: "2026-10-08 06:59:59",
paidAt: "2026-10-09 09:02:03",
});
});

test("formats dates behind UTC in that timezone", () => {
const [row] = formatCommissionsForExport(
[commissionWithDates()],
["createdAt"],
"America/Los_Angeles",
);

// October is PDT (UTC-7).
expect(row.createdAt).toBe("2026-10-07 15:59:59");
});

test("falls back to UTC when timezone is missing or invalid", () => {
const columns = ["createdAt"];
const commissions = [commissionWithDates()];

expect(formatCommissionsForExport(commissions, columns)[0].createdAt).toBe(
"2026-10-07 22:59:59",
);
expect(
formatCommissionsForExport(commissions, columns, "Not/AZone")[0]
.createdAt,
).toBe("2026-10-07 22:59:59");
});

test("leaves an empty paid date blank", () => {
const [row] = formatCommissionsForExport(
[{ ...commissionWithDates(), payout: null }],
["paidAt"],
"Asia/Shanghai",
);

expect(row.paidAt).toBe("");
});
});
Loading