wip: zen lite
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
CREATE TABLE `lite` (
|
||||
`id` varchar(30) NOT NULL,
|
||||
`workspace_id` varchar(30) NOT NULL,
|
||||
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
|
||||
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
`time_deleted` timestamp(3),
|
||||
`user_id` varchar(30) NOT NULL,
|
||||
`rolling_usage` bigint,
|
||||
`weekly_usage` bigint,
|
||||
`monthly_usage` bigint,
|
||||
`time_rolling_updated` timestamp(3),
|
||||
`time_weekly_updated` timestamp(3),
|
||||
`time_monthly_updated` timestamp(3),
|
||||
CONSTRAINT `PRIMARY` PRIMARY KEY(`workspace_id`,`id`),
|
||||
CONSTRAINT `workspace_user_id` UNIQUE INDEX(`workspace_id`,`user_id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `billing` ADD `lite_subscription_id` varchar(28);--> statement-breakpoint
|
||||
ALTER TABLE `billing` ADD `lite` json;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,10 @@
|
||||
import { Database, eq, and, sql, inArray, isNull, count } from "../src/drizzle/index.js"
|
||||
import { BillingTable, SubscriptionPlan } from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, BlackPlans } from "../src/schema/billing.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
|
||||
const plan = process.argv[2] as (typeof SubscriptionPlan)[number]
|
||||
if (!SubscriptionPlan.includes(plan)) {
|
||||
const plan = process.argv[2] as (typeof BlackPlans)[number]
|
||||
if (!BlackPlans.includes(plan)) {
|
||||
console.error("Usage: bun foo.ts <count>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { Database, and, eq, sql } from "../src/drizzle/index.js"
|
||||
import { AuthTable } from "../src/schema/auth.sql.js"
|
||||
import { UserTable } from "../src/schema/user.sql.js"
|
||||
import {
|
||||
BillingTable,
|
||||
PaymentTable,
|
||||
SubscriptionTable,
|
||||
SubscriptionPlan,
|
||||
UsageTable,
|
||||
} from "../src/schema/billing.sql.js"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js"
|
||||
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
|
||||
import { BlackData } from "../src/black.js"
|
||||
import { centsToMicroCents } from "../src/util/price.js"
|
||||
@@ -235,7 +229,7 @@ function formatRetryTime(seconds: number) {
|
||||
|
||||
function getSubscriptionStatus(row: {
|
||||
subscription: {
|
||||
plan: (typeof SubscriptionPlan)[number]
|
||||
plan: (typeof BlackPlans)[number]
|
||||
} | null
|
||||
timeSubscriptionCreated: Date | null
|
||||
fixedUsage: number | null
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Stripe } from "stripe"
|
||||
import { Database, eq, sql } from "./drizzle"
|
||||
import { BillingTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
||||
import { Actor } from "./actor"
|
||||
import { fn } from "./util/fn"
|
||||
import { z } from "zod"
|
||||
@@ -9,6 +9,7 @@ import { Identifier } from "./identifier"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { User } from "./user"
|
||||
import { BlackData } from "./black"
|
||||
import { LiteData } from "./lite"
|
||||
|
||||
export namespace Billing {
|
||||
export const ITEM_CREDIT_NAME = "opencode credits"
|
||||
@@ -233,6 +234,56 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const generateLiteCheckoutUrl = fn(
|
||||
z.object({
|
||||
successUrl: z.string(),
|
||||
cancelUrl: z.string(),
|
||||
}),
|
||||
async (input) => {
|
||||
const user = Actor.assert("user")
|
||||
const { successUrl, cancelUrl } = input
|
||||
|
||||
const email = await User.getAuthEmail(user.properties.userID)
|
||||
const billing = await Billing.get()
|
||||
|
||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||
|
||||
const session = await Billing.stripe().checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
billing_address_collection: "required",
|
||||
line_items: [{ price: LiteData.priceID(), quantity: 1 }],
|
||||
...(billing.customerID
|
||||
? {
|
||||
customer: billing.customerID,
|
||||
customer_update: {
|
||||
name: "auto",
|
||||
address: "auto",
|
||||
},
|
||||
}
|
||||
: {
|
||||
customer_email: email!,
|
||||
}),
|
||||
currency: "usd",
|
||||
payment_method_types: ["card"],
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
subscription_data: {
|
||||
metadata: {
|
||||
workspaceID: Actor.workspace(),
|
||||
userID: user.properties.userID,
|
||||
type: "lite",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return session.url
|
||||
},
|
||||
)
|
||||
|
||||
export const generateSessionUrl = fn(
|
||||
z.object({
|
||||
returnUrl: z.string(),
|
||||
@@ -271,7 +322,7 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const subscribe = fn(
|
||||
export const subscribeBlack = fn(
|
||||
z.object({
|
||||
seats: z.number(),
|
||||
coupon: z.string().optional(),
|
||||
@@ -336,7 +387,7 @@ export namespace Billing {
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribe = fn(
|
||||
export const unsubscribeBlack = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
@@ -360,4 +411,29 @@ export namespace Billing {
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const unsubscribeLite = fn(
|
||||
z.object({
|
||||
subscriptionID: z.string(),
|
||||
}),
|
||||
async ({ subscriptionID }) => {
|
||||
const workspaceID = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ workspaceID: BillingTable.workspaceID })
|
||||
.from(BillingTable)
|
||||
.where(eq(BillingTable.liteSubscriptionID, subscriptionID))
|
||||
.then((rows) => rows[0]?.workspaceID),
|
||||
)
|
||||
if (!workspaceID) throw new Error("Workspace ID not found for subscription")
|
||||
|
||||
await Database.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(BillingTable)
|
||||
.set({ liteSubscriptionID: null, lite: null })
|
||||
.where(eq(BillingTable.workspaceID, workspaceID))
|
||||
|
||||
await tx.delete(LiteTable).where(eq(LiteTable.workspaceID, workspaceID))
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { SubscriptionPlan } from "./schema/billing.sql"
|
||||
import { BlackPlans } from "./schema/billing.sql"
|
||||
|
||||
export namespace BlackData {
|
||||
const Schema = z.object({
|
||||
@@ -28,7 +28,7 @@ export namespace BlackData {
|
||||
|
||||
export const getLimits = fn(
|
||||
z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
const json = JSON.parse(Resource.ZEN_BLACK_LIMITS.value)
|
||||
@@ -36,9 +36,11 @@ export namespace BlackData {
|
||||
},
|
||||
)
|
||||
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_BLACK_PRICE.product)
|
||||
|
||||
export const planToPriceID = fn(
|
||||
z.object({
|
||||
plan: z.enum(SubscriptionPlan),
|
||||
plan: z.enum(BlackPlans),
|
||||
}),
|
||||
({ plan }) => {
|
||||
if (plan === "200") return Resource.ZEN_BLACK_PRICE.plan200
|
||||
|
||||
@@ -8,6 +8,7 @@ export namespace Identifier {
|
||||
benchmark: "ben",
|
||||
billing: "bil",
|
||||
key: "key",
|
||||
lite: "lit",
|
||||
model: "mod",
|
||||
payment: "pay",
|
||||
provider: "prv",
|
||||
|
||||
@@ -4,9 +4,10 @@ import { Resource } from "@opencode-ai/console-resource"
|
||||
|
||||
export namespace LiteData {
|
||||
const Schema = z.object({
|
||||
fixedLimit: z.number().int(),
|
||||
rollingLimit: z.number().int(),
|
||||
rollingWindow: z.number().int(),
|
||||
weeklyLimit: z.number().int(),
|
||||
monthlyLimit: z.number().int(),
|
||||
})
|
||||
|
||||
export const validate = fn(Schema, (input) => {
|
||||
@@ -18,11 +19,7 @@ export namespace LiteData {
|
||||
return Schema.parse(json)
|
||||
})
|
||||
|
||||
export const planToPriceID = fn(z.void(), () => {
|
||||
return Resource.ZEN_LITE_PRICE.price
|
||||
})
|
||||
|
||||
export const priceIDToPlan = fn(z.void(), () => {
|
||||
return "lite"
|
||||
})
|
||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
||||
export const planName = fn(z.void(), () => "lite")
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex,
|
||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||
import { workspaceIndexes } from "./workspace.sql"
|
||||
|
||||
export const SubscriptionPlan = ["20", "100", "200"] as const
|
||||
export const BlackPlans = ["20", "100", "200"] as const
|
||||
export const BillingTable = mysqlTable(
|
||||
"billing",
|
||||
{
|
||||
@@ -25,14 +25,18 @@ export const BillingTable = mysqlTable(
|
||||
subscription: json("subscription").$type<{
|
||||
status: "subscribed"
|
||||
seats: number
|
||||
plan: "20" | "100" | "200"
|
||||
plan: (typeof BlackPlans)[number]
|
||||
useBalance?: boolean
|
||||
coupon?: string
|
||||
}>(),
|
||||
subscriptionID: varchar("subscription_id", { length: 28 }),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", SubscriptionPlan),
|
||||
subscriptionPlan: mysqlEnum("subscription_plan", BlackPlans),
|
||||
timeSubscriptionBooked: utc("time_subscription_booked"),
|
||||
timeSubscriptionSelected: utc("time_subscription_selected"),
|
||||
liteSubscriptionID: varchar("lite_subscription_id", { length: 28 }),
|
||||
lite: json("lite").$type<{
|
||||
useBalance?: boolean
|
||||
}>(),
|
||||
},
|
||||
(table) => [
|
||||
...workspaceIndexes(table),
|
||||
@@ -55,6 +59,22 @@ export const SubscriptionTable = mysqlTable(
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const LiteTable = mysqlTable(
|
||||
"lite",
|
||||
{
|
||||
...workspaceColumns,
|
||||
...timestamps,
|
||||
userID: ulid("user_id").notNull(),
|
||||
rollingUsage: bigint("rolling_usage", { mode: "number" }),
|
||||
weeklyUsage: bigint("weekly_usage", { mode: "number" }),
|
||||
monthlyUsage: bigint("monthly_usage", { mode: "number" }),
|
||||
timeRollingUpdated: utc("time_rolling_updated"),
|
||||
timeWeeklyUpdated: utc("time_weekly_updated"),
|
||||
timeMonthlyUpdated: utc("time_monthly_updated"),
|
||||
},
|
||||
(table) => [...workspaceIndexes(table), uniqueIndex("workspace_user_id").on(table.workspaceID, table.userID)],
|
||||
)
|
||||
|
||||
export const PaymentTable = mysqlTable(
|
||||
"payment",
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod"
|
||||
import { fn } from "./util/fn"
|
||||
import { centsToMicroCents } from "./util/price"
|
||||
import { getWeekBounds } from "./util/date"
|
||||
import { getWeekBounds, getMonthlyBounds } from "./util/date"
|
||||
|
||||
export namespace Subscription {
|
||||
export const analyzeRollingUsage = fn(
|
||||
@@ -29,7 +29,7 @@ export namespace Subscription {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((windowEnd.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.ceil(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / rollingLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -61,7 +61,7 @@ export namespace Subscription {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((week.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.ceil(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,4 +72,38 @@ export namespace Subscription {
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export const analyzeMonthlyUsage = fn(
|
||||
z.object({
|
||||
limit: z.number().int(),
|
||||
usage: z.number().int(),
|
||||
timeUpdated: z.date(),
|
||||
timeSubscribed: z.date(),
|
||||
}),
|
||||
({ limit, usage, timeUpdated, timeSubscribed }) => {
|
||||
const now = new Date()
|
||||
const month = getMonthlyBounds(now, timeSubscribed)
|
||||
const fixedLimitInMicroCents = centsToMicroCents(limit * 100)
|
||||
if (timeUpdated < month.start) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 0,
|
||||
}
|
||||
}
|
||||
if (usage < fixedLimitInMicroCents) {
|
||||
return {
|
||||
status: "ok" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: Math.floor(Math.min(100, (usage / fixedLimitInMicroCents) * 100)),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "rate-limited" as const,
|
||||
resetInSec: Math.ceil((month.end.getTime() - now.getTime()) / 1000),
|
||||
usagePercent: 100,
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getWeekBounds } from "./date"
|
||||
|
||||
describe("util.date.getWeekBounds", () => {
|
||||
test("returns a Monday-based week for Sunday dates", () => {
|
||||
const date = new Date("2026-01-18T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("returns a seven day window", () => {
|
||||
const date = new Date("2026-01-14T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
const span = bounds.end.getTime() - bounds.start.getTime()
|
||||
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
@@ -7,3 +7,32 @@ export function getWeekBounds(date: Date) {
|
||||
end.setUTCDate(start.getUTCDate() + 7)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export function getMonthlyBounds(now: Date, subscribed: Date) {
|
||||
const day = subscribed.getUTCDate()
|
||||
const hh = subscribed.getUTCHours()
|
||||
const mm = subscribed.getUTCMinutes()
|
||||
const ss = subscribed.getUTCSeconds()
|
||||
const ms = subscribed.getUTCMilliseconds()
|
||||
|
||||
function anchor(year: number, month: number) {
|
||||
const max = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(year, month, Math.min(day, max), hh, mm, ss, ms))
|
||||
}
|
||||
|
||||
function shift(year: number, month: number, delta: number) {
|
||||
const total = year * 12 + month + delta
|
||||
return [Math.floor(total / 12), ((total % 12) + 12) % 12] as const
|
||||
}
|
||||
|
||||
let y = now.getUTCFullYear()
|
||||
let m = now.getUTCMonth()
|
||||
let start = anchor(y, m)
|
||||
if (start > now) {
|
||||
;[y, m] = shift(y, m, -1)
|
||||
start = anchor(y, m)
|
||||
}
|
||||
const [ny, nm] = shift(y, m, 1)
|
||||
const end = anchor(ny, nm)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
76
packages/console/core/test/date.test.ts
Normal file
76
packages/console/core/test/date.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getWeekBounds, getMonthlyBounds } from "../src/util/date"
|
||||
|
||||
describe("util.date.getWeekBounds", () => {
|
||||
test("returns a Monday-based week for Sunday dates", () => {
|
||||
const date = new Date("2026-01-18T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-12T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-01-19T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("returns a seven day window", () => {
|
||||
const date = new Date("2026-01-14T12:00:00Z")
|
||||
const bounds = getWeekBounds(date)
|
||||
|
||||
const span = bounds.end.getTime() - bounds.start.getTime()
|
||||
expect(span).toBe(7 * 24 * 60 * 60 * 1000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("util.date.getMonthlyBounds", () => {
|
||||
test("resets on subscription day mid-month", () => {
|
||||
const now = new Date("2026-03-20T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-04-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("before subscription day in current month uses previous month anchor", () => {
|
||||
const now = new Date("2026-03-10T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-02-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("clamps day for short months", () => {
|
||||
const now = new Date("2026-03-01T10:00:00Z")
|
||||
const subscribed = new Date("2026-01-31T12:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-02-28T12:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-03-31T12:00:00.000Z")
|
||||
})
|
||||
|
||||
test("handles subscription on the 1st", () => {
|
||||
const now = new Date("2026-04-15T00:00:00Z")
|
||||
const subscribed = new Date("2026-01-01T00:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-04-01T00:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-05-01T00:00:00.000Z")
|
||||
})
|
||||
|
||||
test("exactly on the reset boundary uses current period", () => {
|
||||
const now = new Date("2026-03-15T08:00:00Z")
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-03-15T08:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-04-15T08:00:00.000Z")
|
||||
})
|
||||
|
||||
test("february to march with day 30 subscription", () => {
|
||||
const now = new Date("2026-02-15T06:00:00Z")
|
||||
const subscribed = new Date("2025-12-30T06:00:00Z")
|
||||
const bounds = getMonthlyBounds(now, subscribed)
|
||||
|
||||
expect(bounds.start.toISOString()).toBe("2026-01-30T06:00:00.000Z")
|
||||
expect(bounds.end.toISOString()).toBe("2026-02-28T06:00:00.000Z")
|
||||
})
|
||||
})
|
||||
106
packages/console/core/test/subscription.test.ts
Normal file
106
packages/console/core/test/subscription.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, test, setSystemTime, afterEach } from "bun:test"
|
||||
import { Subscription } from "../src/subscription"
|
||||
import { centsToMicroCents } from "../src/util/price"
|
||||
|
||||
afterEach(() => {
|
||||
setSystemTime()
|
||||
})
|
||||
|
||||
describe("Subscription.analyzeMonthlyUsage", () => {
|
||||
const subscribed = new Date("2026-01-15T08:00:00Z")
|
||||
|
||||
test("returns ok with 0% when usage was last updated before current period", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: centsToMicroCents(500),
|
||||
timeUpdated: new Date("2026-02-10T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
// reset should be seconds until 2026-04-15T08:00:00Z
|
||||
const expected = Math.ceil(
|
||||
(new Date("2026-04-15T08:00:00Z").getTime() - new Date("2026-03-20T10:00:00Z").getTime()) / 1000,
|
||||
)
|
||||
expect(result.resetInSec).toBe(expected)
|
||||
})
|
||||
|
||||
test("returns ok with usage percent when under limit", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10 // $10
|
||||
const half = centsToMicroCents(10 * 100) / 2
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: half,
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(50)
|
||||
})
|
||||
|
||||
test("returns rate-limited when at or over limit", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: centsToMicroCents(limit * 100),
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("rate-limited")
|
||||
expect(result.usagePercent).toBe(100)
|
||||
})
|
||||
|
||||
test("resets usage when crossing monthly boundary", () => {
|
||||
// subscribed on 15th, now is April 16th — period is Apr 15 to May 15
|
||||
// timeUpdated is March 20 (previous period)
|
||||
setSystemTime(new Date("2026-04-16T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: centsToMicroCents(10 * 100),
|
||||
timeUpdated: new Date("2026-03-20T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
})
|
||||
|
||||
test("caps usage percent at 100", () => {
|
||||
setSystemTime(new Date("2026-03-20T10:00:00Z"))
|
||||
const limit = 10
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit,
|
||||
usage: centsToMicroCents(limit * 100) - 1,
|
||||
timeUpdated: new Date("2026-03-18T00:00:00Z"),
|
||||
timeSubscribed: subscribed,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
test("handles subscription day 31 in short month", () => {
|
||||
const sub31 = new Date("2026-01-31T12:00:00Z")
|
||||
// now is March 1 — period should be Feb 28 to Mar 31
|
||||
setSystemTime(new Date("2026-03-01T10:00:00Z"))
|
||||
const result = Subscription.analyzeMonthlyUsage({
|
||||
limit: 10,
|
||||
usage: 0,
|
||||
timeUpdated: new Date("2026-03-01T09:00:00Z"),
|
||||
timeSubscribed: sub31,
|
||||
})
|
||||
|
||||
expect(result.status).toBe("ok")
|
||||
expect(result.usagePercent).toBe(0)
|
||||
const expected = Math.ceil(
|
||||
(new Date("2026-03-31T12:00:00Z").getTime() - new Date("2026-03-01T10:00:00Z").getTime()) / 1000,
|
||||
)
|
||||
expect(result.resetInSec).toBe(expected)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user