diff --git a/apps/web/app/(ee)/api/commissions/export/route.ts b/apps/web/app/(ee)/api/commissions/export/route.ts index f899530520e..b6439d4d28c 100644 --- a/apps/web/app/(ee)/api/commissions/export/route.ts +++ b/apps/web/app/(ee)/api/commissions/export/route.ts @@ -51,6 +51,7 @@ export const GET = withWorkspace( const formattedCommissions = formatCommissionsForExport( commissions, columns, + filters.timezone, ); return new Response(convertToCSV(formattedCommissions), { diff --git a/apps/web/app/(ee)/api/cron/export/commissions/route.ts b/apps/web/app/(ee)/api/cron/export/commissions/route.ts index d7d230a8ce2..26ca91e9d36 100644 --- a/apps/web/app/(ee)/api/cron/export/commissions/route.ts +++ b/apps/web/app/(ee)/api/cron/export/commissions/route.ts @@ -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); diff --git a/apps/web/lib/api/commissions/format-commissions-for-export.ts b/apps/web/lib/api/commissions/format-commissions-for-export.ts index 4e5efcc29ae..495671d2c99 100644 --- a/apps/web/lib/api/commissions/format-commissions-for-export.ts +++ b/apps/web/lib/api/commissions/format-commissions-for-export.ts @@ -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"; @@ -29,10 +32,6 @@ const COLUMN_TYPE_SCHEMAS = { .nullable() .default("") .transform((value) => value || ""), - date: z - .date() - .nullable() - .transform((date) => (date ? date.toISOString() : "")), string: z .string() .nullable() @@ -40,10 +39,24 @@ const COLUMN_TYPE_SCHEMAS = { .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>, columns: string[], + timezone?: string, ): Record[] { const sortedColumns = [...columns].sort( (a, b) => @@ -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); diff --git a/apps/web/tests/commissions/format-commissions-for-export.test.ts b/apps/web/tests/commissions/format-commissions-for-export.test.ts new file mode 100644 index 00000000000..e4ad4a00e83 --- /dev/null +++ b/apps/web/tests/commissions/format-commissions-for-export.test.ts @@ -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(""); + }); +});