Skip to content
Merged
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
55 changes: 40 additions & 15 deletions apps/api/src/billing/http-schemas/usage.schema.spec.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,35 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { GetUsageHistoryQuerySchema } from "./usage.schema";

describe("Usage Schema", () => {
describe("GetUsageHistoryQuerySchema", () => {
const address = "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6";

it("derives startDate as 30 days before the provided endDate", () => {
it("defaults startDate so the window covers the 30 days ending at endDate", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-31" });

expect(result.startDate).toBe("2024-01-01");
expect(result.startDate).toBe("2024-01-02");
expect(result.endDate).toBe("2024-01-31");
});

it("derives startDate across month and year boundaries", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-01-15" });

expect(result.startDate).toBe("2023-12-16");
expect(result.startDate).toBe("2023-12-17");
expect(result.endDate).toBe("2024-01-15");
});

it("computes startDate in UTC regardless of the process timezone", () => {
const originalTimezone = process.env.TZ;
process.env.TZ = "America/New_York";
vi.stubEnv("TZ", "America/New_York");

try {
const result = GetUsageHistoryQuerySchema.parse({ address, endDate: "2024-11-15" });

expect(result.startDate).toBe("2024-10-16");
expect(result.startDate).toBe("2024-10-17");
expect(result.endDate).toBe("2024-11-15");
} finally {
process.env.TZ = originalTimezone;
vi.unstubAllEnvs();
}
});

Expand All @@ -41,23 +40,49 @@ describe("Usage Schema", () => {
expect(result.endDate).toBe("2024-01-31");
});

it("defaults endDate to today and spans a 30-day window when both dates are omitted", () => {
const result = GetUsageHistoryQuerySchema.parse({ address });
it("defaults endDate to today and covers 30 days when both dates are omitted", () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2024-11-15T23:59:59.999Z"));

try {
const result = GetUsageHistoryQuerySchema.parse({ address });

expect(result.endDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
const spanInDays = (Date.parse(result.endDate) - Date.parse(result.startDate)) / (24 * 60 * 60 * 1000);
expect(spanInDays).toBe(30);
expect(result.endDate).toBe("2024-11-15");
expect(result.startDate).toBe("2024-10-17");
} finally {
vi.useRealTimers();
}
});

it("accepts a single-day range", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-31", endDate: "2024-01-31" });

expect(result.startDate).toBe("2024-01-31");
expect(result.endDate).toBe("2024-01-31");
});

it("accepts a range of exactly 366 days", () => {
const result = GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-01", endDate: "2024-12-31" });

expect(result.startDate).toBe("2024-01-01");
expect(result.endDate).toBe("2024-12-31");
});

it("rejects a range wider than 366 days", () => {
expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2023-01-01", endDate: "2024-12-31" })).toThrow(
"Date range cannot exceed 366 days and startDate must be before endDate"
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});

it("rejects a range of 367 days", () => {
expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-01-01", endDate: "2025-01-01" })).toThrow(
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});

it("rejects a startDate after the endDate", () => {
expect(() => GetUsageHistoryQuerySchema.parse({ address, startDate: "2024-02-01", endDate: "2024-01-01" })).toThrow(
"Date range cannot exceed 366 days and startDate must be before endDate"
"Date range cannot exceed 366 days and startDate must not be after endDate"
);
});
});
Expand Down
53 changes: 33 additions & 20 deletions apps/api/src/billing/http-schemas/usage.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,57 @@ import { z } from "@hono/zod-openapi";

import { AkashAddressSchema } from "@src/utils/schema";

const DEFAULT_USAGE_WINDOW_DAYS = 30;
const MAX_USAGE_WINDOW_DAYS = 366;
const MS_PER_DAY = 24 * 60 * 60 * 1000;

function toIsoDate(date: Date) {
return date.toISOString().split("T")[0];
}

function startOfInclusiveWindow(endDate: string, windowDays: number) {
const date = new Date(`${endDate}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() - (windowDays - 1));
return toIsoDate(date);
}

function countInclusiveDays(startDate: string, endDate: string) {
return (Date.parse(endDate) - Date.parse(startDate)) / MS_PER_DAY + 1;
}

export const GetUsageHistoryQuerySchema = z
.object({
address: AkashAddressSchema.openapi({
description: "The wallet address to get billing and usage data for",
example: "akash18andxgtd6r08zzfpcdqg9pdr6smks7gv76tyt6"
}),
startDate: z.string().date().optional().openapi({
description: "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
example: "2024-01-01"
}),
startDate: z
.string()
.date()
.optional()
.openapi({
description: `Start date (YYYY-MM-DD), inclusive. Defaults to a ${DEFAULT_USAGE_WINDOW_DAYS}-day window ending at endDate`,
example: "2024-01-01"
}),
endDate: z.string().date().optional().openapi({
description: "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
description: "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
example: "2024-01-31"
})
})
.transform(data => {
const endDate = data.endDate ?? new Date().toISOString().split("T")[0];
const endDate = data.endDate ?? toIsoDate(new Date());
const startDate = data.startDate ?? startOfInclusiveWindow(endDate, DEFAULT_USAGE_WINDOW_DAYS);

if (data.startDate) {
return { ...data, startDate: data.startDate, endDate };
}

const startDate = new Date(`${endDate}T00:00:00.000Z`);
startDate.setUTCDate(startDate.getUTCDate() - 30);

return { ...data, startDate: startDate.toISOString().split("T")[0], endDate };
return { ...data, startDate, endDate };
})
.refine(
data => {
const end = new Date(data.endDate);
const start = new Date(data.startDate);

const daysDiff = Math.ceil((end.getTime() - start.getTime()) / (24 * 60 * 60 * 1000));
const windowDays = countInclusiveDays(data.startDate, data.endDate);

return start <= end && daysDiff <= 366;
return windowDays >= 1 && windowDays <= MAX_USAGE_WINDOW_DAYS;
},
{
message: "Date range cannot exceed 366 days and startDate must be before endDate"
message: `Date range cannot exceed ${MAX_USAGE_WINDOW_DAYS} days and startDate must not be after endDate`
}
);

Expand Down
6 changes: 4 additions & 2 deletions apps/api/src/billing/routes/usage/usage.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const getUsageHistoryRoute = createRoute({
}
},
400: {
description: "Invalid address format"
description:
"Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
});
Expand All @@ -50,7 +51,8 @@ const getUsageHistoryStatsRoute = createRoute({
}
},
400: {
description: "Invalid address format"
description:
"Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
});
Expand Down
12 changes: 6 additions & 6 deletions apps/api/swagger/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1777,7 +1777,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01"
},
"required": false,
Expand All @@ -1788,7 +1788,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31"
},
"required": false,
Expand Down Expand Up @@ -1875,7 +1875,7 @@
}
},
"400": {
"description": "Invalid address format"
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
}
Expand All @@ -1902,7 +1902,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01"
},
"required": false,
Expand All @@ -1913,7 +1913,7 @@
"schema": {
"type": "string",
"format": "date",
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31"
},
"required": false,
Expand Down Expand Up @@ -1961,7 +1961,7 @@
}
},
"400": {
"description": "Invalid address format"
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days"
}
}
}
Expand Down
12 changes: 6 additions & 6 deletions apps/api/test/functional/__snapshots__/docs.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -14213,7 +14213,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "startDate",
"required": false,
"schema": {
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01",
"format": "date",
"type": "string",
Expand All @@ -14224,7 +14224,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "endDate",
"required": false,
"schema": {
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31",
"format": "date",
"type": "string",
Expand Down Expand Up @@ -14310,7 +14310,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"description": "Returns billing and usage data",
},
"400": {
"description": "Invalid address format",
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days",
},
},
"security": [],
Expand Down Expand Up @@ -14338,7 +14338,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "startDate",
"required": false,
"schema": {
"description": "Start date (YYYY-MM-DD). Defaults to 30 days before endDate",
"description": "Start date (YYYY-MM-DD), inclusive. Defaults to a 30-day window ending at endDate",
"example": "2024-01-01",
"format": "date",
"type": "string",
Expand All @@ -14349,7 +14349,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"name": "endDate",
"required": false,
"schema": {
"description": "End date (YYYY-MM-DD). Defaults to today by UTC 23:59:59",
"description": "End date (YYYY-MM-DD), inclusive. Defaults to today (UTC)",
"example": "2024-01-31",
"format": "date",
"type": "string",
Expand Down Expand Up @@ -14396,7 +14396,7 @@ exports[`API Docs > GET /v1/doc > returns docs with all routes expected 1`] = `
"description": "Returns usage stats data",
},
"400": {
"description": "Invalid address format",
"description": "Invalid address format or invalid date range: dates must be YYYY-MM-DD, startDate must not be after endDate and the range cannot exceed 366 days",
},
},
"security": [],
Expand Down
Loading